From 162e6d6662fe843560883b75de6103326a99cfa2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Jan 2026 14:45:01 +0100 Subject: [PATCH] cli: user operational data for show container [name] Leverage the operational data to present a more human-friendly view of containers in the CLI. Replacing the podman script wrapper. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/containers.xml | 17 +- src/show/show.py | 59 +++++++ src/statd/python/cli_pretty/cli_pretty.py | 206 +++++++++++++++++++++- 3 files changed, 273 insertions(+), 9 deletions(-) diff --git a/src/klish-plugin-infix/xml/containers.xml b/src/klish-plugin-infix/xml/containers.xml index 4d697c8a..4b73f607 100644 --- a/src/klish-plugin-infix/xml/containers.xml +++ b/src/klish-plugin-infix/xml/containers.xml @@ -186,14 +186,11 @@ - - - doas container show |pager - - + + - doas container -a show + show container @@ -231,7 +228,15 @@ + + + + + + show container $KLISH_PARAM_name |pager + + diff --git a/src/show/show.py b/src/show/show.py index 84990168..204d79a4 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -173,6 +173,64 @@ def services(args: List[str]) -> None: cli_pretty(data, f"show-services") +def container(args: List[str]) -> None: + """Handle show container [name] + + Arguments: + (none) - Show all containers in table format + name - Show detailed view of specific container + """ + data = run_sysrepocfg("/infix-containers:containers") + if not data: + print("No container data retrieved.") + return + + # Fetch interface data for bridge resolution (both table and detailed views) + # Fetch operational interface data + iface_oper = run_sysrepocfg("/ietf-interfaces:interfaces") + + # Also fetch config data for veth peer information (not in operational) + try: + result = subprocess.run([ + "sysrepocfg", "-f", "json", "-X", "-d", "running", "-x", "/ietf-interfaces:interfaces" + ], capture_output=True, text=True, check=True) + iface_config = json.loads(result.stdout) + + # Merge config veth peer info into operational data + if iface_oper and iface_config: + oper_ifaces = iface_oper.get('ietf-interfaces:interfaces', {}).get('interface', []) + config_ifaces = iface_config.get('ietf-interfaces:interfaces', {}).get('interface', []) + + # Create a map of config interfaces + config_map = {iface['name']: iface for iface in config_ifaces} + + # Merge veth peer info from config into operational + for oper_iface in oper_ifaces: + name = oper_iface.get('name') + if name in config_map: + config_iface = config_map[name] + # Add veth peer if it exists in config but not in operational + if 'infix-interfaces:veth' in config_iface and 'infix-interfaces:veth' not in oper_iface: + oper_iface['infix-interfaces:veth'] = config_iface['infix-interfaces:veth'] + + data.update(iface_oper) + except (subprocess.CalledProcessError, json.JSONDecodeError): + # If config fetch fails, just use operational data + if iface_oper: + data.update(iface_oper) + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + + if len(args) == 0 or not args[0]: + cli_pretty(data, "show-container") + elif len(args) == 1: + name = args[0] + cli_pretty(data, "show-container-detail", name) + else: + print("Too many arguments provided. Expected: show container [name]") + def bfd(args: List[str]) -> None: """Handle show bfd [subcommand] [peer] [brief] @@ -486,6 +544,7 @@ def execute_command(command: str, args: List[str]): command_mapping = { 'bfd': bfd, 'boot-order': boot_order, + 'container': container, 'dhcp': dhcp, 'hardware': hardware, 'interface': interface, diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 9d011c19..926dd7b1 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -579,14 +579,14 @@ class Decore(): return Decore.decorate("43", txt, "49") @staticmethod - def title(txt, len=None, bold=True): + def title(txt, width=None, bold=True): """Print section header with horizontal bar line above it Args: txt: The header text to display - len: Length of horizontal bar line (defaults to len(txt)) + width: Length of horizontal bar line (defaults to len(txt)) bold: Whether to make the text bold """ - length = len if len is not None else len(txt) + length = width if width is not None else len(txt) underline = "─" * length print(underline) if bold: @@ -2084,6 +2084,198 @@ def show_hardware(json): sensor.print() +def resolve_container_network(network, all_ifaces): + """Resolve container network to bridge names or network type. + + Args: + network: Container network dict from operational data + all_ifaces: List of all interface dicts + + Returns: + str: Network description (bridge name, "host", interface name, or "-") + """ + if network.get('host'): + return "host" + + if 'interface' not in network: + return "-" + + interfaces = network.get('interface', []) + if not interfaces: + return "-" + + network_names = [] + for iface in interfaces: + iface_name = iface.get('name', '') + iface_data = next((i for i in all_ifaces if i.get('name') == iface_name), None) + + bridge_found = False + if iface_data: + # Check if this is a VETH with a peer (host-side veth) + veth = iface_data.get('infix-interfaces:veth', {}) + peer_name = veth.get('peer') + + if peer_name: + # Find the peer interface and check if it's a bridge port + peer_data = next((i for i in all_ifaces if i.get('name') == peer_name), None) + if peer_data: + bridge_port = peer_data.get('infix-interfaces:bridge-port', {}) + bridge_name = bridge_port.get('bridge') + if bridge_name: + network_names.append(bridge_name) + bridge_found = True + + # If not found via veth peer, try reverse lookup + if not bridge_found: + for host_iface in all_ifaces: + container_net = host_iface.get('infix-interfaces:container-network', {}) + containers_list = container_net.get('containers', []) + + if iface_name in containers_list: + bridge_port = host_iface.get('infix-interfaces:bridge-port', {}) + bridge_name = bridge_port.get('bridge') + + if bridge_name: + network_names.append(bridge_name) + bridge_found = True + break + + if not bridge_found: + network_names.append(iface_name) + + return ', '.join(network_names) + + +def show_container(json): + """Display container table view with resource usage.""" + containers_data = json.get("infix-containers:containers", {}) + containers = containers_data.get("container", []) + + if not containers: + print("No containers configured.") + return + + # Get interface data for network resolution + all_ifaces_data = json.get('ietf-interfaces:interfaces', {}) + all_ifaces = all_ifaces_data.get('interface', []) + + # Create table with column definitions + container_table = SimpleTable([ + Column('NAME'), + Column('STATUS'), + Column('NETWORK'), + Column('MEMORY (KiB)', 'right'), + Column('CPU%', 'right') + ]) + + for container in containers: + name = container.get("name", "") + status = container.get("status", "") + + # Get network information + network = container.get('network', {}) + network_str = resolve_container_network(network, all_ifaces) + + # Get resource limits and usage + resource_limit = container.get("resource-limit", {}) + resource_usage = container.get("resource-usage", {}) + + mem_limit = resource_limit.get("memory") + mem_usage = resource_usage.get("memory") + cpu_usage = resource_usage.get("cpu", "0.0") + + # Format memory display + if mem_usage is not None and mem_limit is not None: + memory_str = f"{mem_usage}/{mem_limit}" + elif mem_usage is not None: + memory_str = str(mem_usage) + else: + memory_str = "-" + + # Format CPU display + cpu_str = str(cpu_usage) + + # Color code status like in show_services + if status in ('running', 'active'): + status_str = Decore.green(status) + elif status in ('exited', 'stopped', 'created'): + status_str = Decore.yellow(status) + elif status in ('error', 'dead'): + status_str = Decore.red(status) + else: + status_str = status + + container_table.row(name, status_str, network_str, memory_str, cpu_str) + + container_table.print() + + +def show_container_detail(json, name): + """Display detailed container view with full resource information.""" + containers_data = json.get("infix-containers:containers", {}) + containers = containers_data.get("container", []) + + container = None + for c in containers: + if c.get("name") == name: + container = c + break + + if not container: + print(f"Container '{name}' not found.") + return + + print(f"Name : {container.get('name', '-')}") + print(f"Container ID : {container.get('id', '-')}") + print(f"Image : {container.get('image', '-')}") + print(f"Image ID : {container.get('image-id', '')}") + + command = container.get('command') + if command: + print(f"Command : {command}") + + # Get network information + network = container.get('network', {}) + all_ifaces_data = json.get('ietf-interfaces:interfaces', {}) + all_ifaces = all_ifaces_data.get('interface', []) + network_str = resolve_container_network(network, all_ifaces) + print(f"Network : {network_str}") + + print(f"Status : {container.get('status', '-')}") + print(f"Running : {'yes' if container.get('running') else 'no'}") + + resource_limit = container.get("resource-limit", {}) + resource_usage = container.get("resource-usage", {}) + + pids = resource_usage.get("pids") + if pids is not None: + print(f"Processes : {pids}") + + mem_limit = resource_limit.get("memory") + if mem_limit is not None: + print(f"Memory Limit : {mem_limit} KiB") + + mem_usage = resource_usage.get("memory") + if mem_usage is not None: + mem_usage_int = int(mem_usage) + if mem_limit: + mem_limit_int = int(mem_limit) + percent = (mem_usage_int / mem_limit_int) * 100 + print(f"Memory Usage : {mem_usage_int} KiB ({percent:.1f}%)") + else: + print(f"Memory Usage : {mem_usage_int} KiB") + + cpu_limit = resource_limit.get("cpu") + if cpu_limit is not None: + cpu_limit_int = int(cpu_limit) + cores = cpu_limit_int / 1000.0 + print(f"CPU Limit : {cpu_limit_int} millicores ({cores:.1f} cores)") + + cpu_usage = resource_usage.get("cpu") + if cpu_usage is not None: + print(f"CPU Usage : {cpu_usage}%") + + def show_ntp(json): if not json.get("ietf-system:system-state"): print("NTP client not enabled.") @@ -4067,6 +4259,10 @@ def main(): subparsers.add_parser('show-dhcp-server', help='Show DHCP server') \ .add_argument("-s", "--stats", action="store_true", help="Show server statistics") + subparsers.add_parser('show-container', help='Show containers table') + subparsers.add_parser('show-container-detail', help='Show container details') \ + .add_argument('name', help='Container name') + subparsers.add_parser('show-hardware', help='Show USB ports') subparsers.add_parser('show-interfaces', help='Show interfaces') \ @@ -4122,6 +4318,10 @@ def main(): show_bridge_stp(json_data) elif args.command == "show-dhcp-server": show_dhcp_server(json_data, args.stats) + elif args.command == "show-container": + show_container(json_data) + elif args.command == "show-container-detail": + show_container_detail(json_data, args.name) elif args.command == "show-hardware": show_hardware(json_data) elif args.command == "show-interfaces":