From 0942313aaaae5ad0024c7ffe0d6c04740bb940ff Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Jan 2026 16:58:43 +0100 Subject: [PATCH 01/10] package/finit: bump to v4.15 https://github.com/finit-project/finit/releases/tag/4.15 Signed-off-by: Joachim Wiberg --- package/finit/finit.hash | 2 +- package/finit/finit.mk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/finit/finit.hash b/package/finit/finit.hash index a3e9478b..eb1240f1 100644 --- a/package/finit/finit.hash +++ b/package/finit/finit.hash @@ -1,5 +1,5 @@ # From https://github.com/troglobit/finit/releases/ -sha256 7ccbcead4e3e6734c81a8c5445f4a27738f19a4ab367d702513a201db9b618c7 finit-4.15-rc1.tar.gz +sha256 0e4774ccb8933ed92287e6c18d27cb463222dcc1f50a3607e27bbe5fd150ece0 finit-4.15.tar.gz # Locally calculated sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE diff --git a/package/finit/finit.mk b/package/finit/finit.mk index 1bf387c8..890dc069 100644 --- a/package/finit/finit.mk +++ b/package/finit/finit.mk @@ -4,7 +4,7 @@ # ################################################################################ -FINIT_VERSION = 4.15-rc1 +FINIT_VERSION = 4.15 FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION) FINIT_LICENSE = MIT FINIT_LICENSE_FILES = LICENSE From 11c796b3cbbbf05744d2dcad22549066ca9a8c98 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Jan 2026 14:48:48 +0100 Subject: [PATCH 02/10] board/common: reduce overhead in /etc/fstab Finit is capable of setting up all basic file systems, even /run but Infix relies on it allowing executing scripts (dagger and container) so leave that for now. Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/fstab | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/board/common/rootfs/etc/fstab b/board/common/rootfs/etc/fstab index 70d11aac..97ecd868 100644 --- a/board/common/rootfs/etc/fstab +++ b/board/common/rootfs/etc/fstab @@ -1,16 +1,8 @@ # Virtual filesystems -devtmpfs /dev devtmpfs defaults 0 0 -mkdir#-p /dev/pts helper none 0 0 -devpts /dev/pts devpts mode=620,ptmxmode=0666 0 0 -mkdir#-p /dev/shm helper none 0 0 -tmpfs /dev/shm tmpfs mode=0777 0 0 -proc /proc proc defaults 0 0 -tmpfs /tmp tmpfs mode=1777,nosuid,nodev 0 0 -tmpfs /run tmpfs mode=0755,nosuid,nodev 0 0 -tmpfs /media tmpfs mode=1755,nosuid,nodev 0 0 -sysfs /sys sysfs defaults 0 0 -debugfs /sys/kernel/debug debugfs nofail 0 0 -cfgfs /config configfs nofail,noauto 0 0 +tmpfs /run tmpfs mode=0755,nosuid,nodev 0 0 +tmpfs /media tmpfs mode=1755,nosuid,nodev 0 0 +debugfs /sys/kernel/debug debugfs nofail 0 0 +cfgfs /config configfs nofail,noauto 0 0 # The chosen backing storage for the overlays placed on /cfg, /etc, # /home, /root, and /var, are determined dynamically by /usr/libexec/infix/mnt From e0ea554b7b4b16577e8ddd9a2b1bcf94c3b8d1e2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 2 Jan 2026 19:27:52 +0100 Subject: [PATCH 03/10] statd: update SimpleTable with flexible columns and fix spacing This patch adds support for flexible columns. When a column is marked as flexible it means it can be stretched when printing the table to an optional minimal table width. Multiple columns can be marked and when this occurs the padding is applied equally to all columns. Also, make sure to only add 2 char padding between columns, not always at the end, or we will require larger terminal than necessary. Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 92 +++++++++++++++++++---- test/case/statd/system/cli/show-services | 4 +- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index c67a4e0f..61524531 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -283,17 +283,20 @@ class PadFirewall: class Column: """Column definition for SimpleTable""" - def __init__(self, name, align='left', formatter=None): + def __init__(self, name, align='left', formatter=None, flexible=False): self.name = name self.align = align self.formatter = formatter + self.flexible = flexible class SimpleTable: """Simple table formatter that handles ANSI colors correctly and calculates dynamic column widths""" - def __init__(self, columns): + def __init__(self, columns, min_width=None): self.columns = columns self.rows = [] + self.min_width = min_width + self._column_widths = None # Cache calculated widths @staticmethod def visible_width(text): @@ -308,12 +311,58 @@ class SimpleTable: raise ValueError(f"Expected {len(self.columns)} values, got {len(values)}") self.rows.append(values) + def width(self): + """Calculate and return total table width""" + if self._column_widths is None: + self._column_widths = self._calculate_column_widths() + + # Sum column widths + 2-char separator between columns + total = sum(self._column_widths) + if self._column_widths: + # Separators only between columns, not after the last one + total += (len(self._column_widths) - 1) * 2 + + return total + + def adjust_padding(self, width): + """Distribute padding to width evenly across flexible columns""" + if self._column_widths is None: + self._column_widths = self._calculate_column_widths() + + current_width = self.width() + extra_width = width - current_width + + if extra_width <= 0: + return # Already at or above target + + # Find flexible columns + flex_indices = [i for i, col in enumerate(self.columns) if col.flexible] + + if not flex_indices: + return # No flexible columns to expand + + # Distribute evenly + per_column = extra_width // len(flex_indices) + remainder = extra_width % len(flex_indices) + + for i, idx in enumerate(flex_indices): + self._column_widths[idx] += per_column + # Give remainder to first columns + if i < remainder: + self._column_widths[idx] += 1 + def print(self, styled=True): """Calculate widths and print complete table""" - column_widths = self._calculate_column_widths() - print(self._format_header(column_widths, styled)) + if self._column_widths is None: + self._column_widths = self._calculate_column_widths() + + # Apply minimum width if specified + if self.min_width: + self.adjust_padding(self.min_width) + + print(self._format_header(self._column_widths, styled)) for row_data in self.rows: - print(self._format_row(row_data, column_widths)) + print(self._format_row(row_data, self._column_widths)) def _calculate_column_widths(self): """Calculate maximum width needed for each column""" @@ -332,24 +381,29 @@ class SimpleTable: header_parts = [] for i, column in enumerate(self.columns): width = column_widths[i] - if column.align == 'right': - header_parts.append(f"{column.name:>{width}} ") - else: - header_parts.append(f"{column.name:{width}} ") + # Add separator " " only between columns, not after the last one + is_last = (i == len(self.columns) - 1) + separator = "" if is_last else " " - header_str = ''.join(header_parts).rstrip() + if column.align == 'right': + header_parts.append(f"{column.name:>{width}}{separator}") + else: + header_parts.append(f"{column.name:{width}}{separator}") + + header_str = ''.join(header_parts) return Decore.invert(header_str) if styled else header_str def _format_row(self, row_data, column_widths): """Format a single data row""" row_parts = [] for i, (value, column) in enumerate(zip(row_data, self.columns)): - formatted_value = self._format_column_value(value, column, column_widths[i]) + is_last = (i == len(self.columns) - 1) + formatted_value = self._format_column_value(value, column, column_widths[i], is_last) row_parts.append(formatted_value) - return ''.join(row_parts).rstrip() + return ''.join(row_parts) - def _format_column_value(self, value, column, width): + def _format_column_value(self, value, column, width, is_last=False): """Format a single column value with proper alignment""" if column.formatter: value = column.formatter(value) @@ -357,12 +411,18 @@ class SimpleTable: value_str = str(value) visible_len = self.visible_width(value_str) - if column.align == 'right': + # Add separator " " only between columns, not after the last one + separator = "" if is_last else " " + + # Don't pad the last column to avoid trailing spaces + if is_last: + return value_str + elif column.align == 'right': padding = width - visible_len - return ' ' * max(0, padding) + value_str + ' ' + return ' ' * max(0, padding) + value_str + separator else: padding = width - visible_len - return value_str + ' ' * max(0, padding) + ' ' + return value_str + ' ' * max(0, padding) + separator class Decore(): diff --git a/test/case/statd/system/cli/show-services b/test/case/statd/system/cli/show-services index 322c4b78..35c6bd67 100644 --- a/test/case/statd/system/cli/show-services +++ b/test/case/statd/system/cli/show-services @@ -1,4 +1,4 @@ -NAME STATUS PID MEM UP RST DESCRIPTION +NAME STATUS PID MEM UP RST DESCRIPTION  udevd running 1185 23m 0 Device event daemon (udev) dbus running 2248 23m 0 D-Bus message bus daemon confd running 3039 23m 0 Configuration daemon @@ -7,7 +7,7 @@ dnsmasq running 2249 23m 0 DHCP/DNS proxy tty:hvc0 running 3559 23m 0 Getty on hvc0 iitod running 2340 23m 0 LED daemon klishd running 3560 23m 0 CLI backend daemon -mdns-alias running 3617 23m 0 mDNS alias advertiser +mdns-alias running 3617 23m 0 mDNS alias advertiser mstpd stopped 0 Spanning Tree daemon rauc running 3564 23m 0 Software update service resolvconf done 2 Update DNS configuration From db6fb82a2b538e57461e04997aff5d030421596e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 2 Jan 2026 19:31:24 +0100 Subject: [PATCH 04/10] statd: add new class Canvas for rendering unified SimpleTables Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 132 ++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 61524531..737ec303 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -425,6 +425,138 @@ class SimpleTable: return value_str + ' ' * max(0, padding) + separator +class Canvas: + """Multi-item rendering canvas for unified width alignment across tables and content. + + Canvas coordinates the display of multiple SimpleTable instances and other content + types (text, titles, spacing, pre-rendered content) with consistent width alignment. + This creates professional-looking output where all table headers and content align + to the same width, with flexible columns distributing extra space evenly. + + Purpose: + - Achieve visual alignment across heterogeneous content + - Minimize performance overhead with single-pass data traversal + - Support mixed content: tables, text, titles, matrices, spacing + + How it works: + 1. Buffer items in sequence (add_text, add_table, add_title, etc.) + 2. Calculate maximum width from all SimpleTable instances + 3. Apply flex padding to narrower tables via SimpleTable.adjust_padding() + 4. Render all items sequentially with unified width + + Interaction with SimpleTable: + - Calls SimpleTable.width() to determine natural table width + - Calls SimpleTable.adjust_padding(max_width) to expand flexible columns + - SimpleTable columns marked with flexible=True distribute extra width evenly + - Preserves table alignment (left/right) and formatting + + Performance: + - Single data traversal: tables built once, widths calculated once + - Memory over CPU: buffers all items before rendering + - Optimized for embedded systems (ARM Cortex-A7) + + Example: + See show_firewall() for a complete usage example demonstrating: + - Mixed content types (status text, matrix, tables) + - Multiple tables with different flexible columns + - Centered matrix using get_max_width() + - Proper spacing and titles + + Args: + min_width: Optional minimum width for all tables (default: None) + """ + + def __init__(self, min_width=None): + self.min_width = min_width + self.items = [] # List of (type, content) tuples + + def add_text(self, text): + """Add a plain text line""" + self.items.append(('text', text)) + + def add_title(self, text): + """Add a section title (will be centered to canvas width)""" + self.items.append(('title', text)) + + def add_raw(self, text): + """Add pre-rendered multi-line text (like zone matrix)""" + self.items.append(('raw', text)) + + def add_spacing(self, lines=1): + """Add blank lines for spacing""" + self.items.append(('spacing', lines)) + + def add_table(self, table): + """Add a SimpleTable instance""" + if not isinstance(table, SimpleTable): + raise ValueError("Expected SimpleTable instance") + self.items.append(('table', table)) + + def insert(self, index, item_type, content): + """Insert an item at a specific position""" + self.items.insert(index, (item_type, content)) + + def insert_text(self, index, text): + """Insert a plain text line at position""" + self.insert(index, 'text', text) + + def insert_title(self, index, text): + """Insert a section title at position""" + self.insert(index, 'title', text) + + def insert_raw(self, index, text): + """Insert pre-rendered text at position""" + self.insert(index, 'raw', text) + + def insert_spacing(self, index, lines=1): + """Insert blank lines at position""" + self.insert(index, 'spacing', lines) + + def insert_table(self, index, table): + """Insert a SimpleTable at position""" + if not isinstance(table, SimpleTable): + raise ValueError("Expected SimpleTable instance") + self.insert(index, 'table', table) + + def get_max_width(self): + """Calculate and return the maximum width from all tables""" + max_width = self.min_width or 0 + + for item_type, content in self.items: + if item_type == 'table': + max_width = max(max_width, content.width()) + + return max_width + + def render(self): + """Calculate widths, apply padding, and output all items""" + # First pass: find maximum width from all tables + max_width = self.get_max_width() + + # Second pass: apply padding to all tables with flexible columns + for item_type, content in self.items: + if item_type == 'table': + content.adjust_padding(max_width) + + # Third pass: render each item + for i, (item_type, content) in enumerate(self.items): + if item_type == 'text': + print(content) + elif item_type == 'title': + # Title with underline matching canvas width + underline = "─" * max_width + print(underline) + print(Decore.bold(content)) + elif item_type == 'raw': + # Pre-rendered content (like matrix) + print(content) + elif item_type == 'spacing': + for _ in range(content): + print() + elif item_type == 'table': + content.print() + + class Decore(): @staticmethod def decorate(sgr, txt, restore="0"): From 3934838663a7496c655ee41ba8d13f9ac0343a3e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 2 Jan 2026 19:32:03 +0100 Subject: [PATCH 05/10] statd: refactor firewall formatter to use Canvas + SimleTable Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 480 ++++++++++++---------- 1 file changed, 266 insertions(+), 214 deletions(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 737ec303..9d011c19 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -245,42 +245,6 @@ def format_uptime_seconds(seconds): return cls.mount + cls.size + cls.used + cls.avail + cls.percent -class PadFirewall: - zone_locked = 2 - zone_name = 21 - zone_type = 6 - zone_data = 34 - zone_services = 38 - - zone_flow_to = 20 - zone_flow_action = 14 - zone_flow_policy = 20 - zone_flow_services = 45 - - policy_locked = 2 - policy_name = 21 - policy_action = 9 - policy_ingress = 33 - policy_egress = 35 - - service_name = 20 - service_ports = 69 - - # Firewall log display formatting - log_time = 15 # ISO format: MM-DD HH:MM:SS - log_action = 6 # REJECT/DROP + small buffer - log_iif = 11 # Input interface + small buffer - log_src = 26 # IPv6 addresses (shortened) or IPv4 - log_dst = 26 # IPv6 addresses (shortened) or IPv4 - log_proto = 5 # TCP/UDP/ICMP + small buffer - log_port = 5 # Port numbers + small buffer - - @classmethod - def table_width(cls): - """Table width for zones/policies tables, used to center matrix""" - return cls.zone_locked + cls.zone_name + cls.zone_type + cls.zone_data \ - + cls.zone_services - class Column: """Column definition for SimpleTable""" def __init__(self, name, align='left', formatter=None, flexible=False): @@ -2370,26 +2334,26 @@ def parse_firewall_log_line(line): return parsed -def show_firewall_logs(limit=10): - """Show recent firewall log entries, tail -N equivalent""" +def firewall_log_table(limit=None): + """Create firewall log table (returns None if no logs available)""" try: - hdr = (f"{'TIME':<{PadFirewall.log_time}} " - f"{'ACTION':>{PadFirewall.log_action}} " - f"{'IIF':>{PadFirewall.log_iif}} " - f"{'SOURCE':<{PadFirewall.log_src}} " - f"{'DEST':<{PadFirewall.log_dst}} " - f"{'PROTO':<{PadFirewall.log_proto}} " - f"{'PORT':>{PadFirewall.log_port}}") - - Decore.title(f"Log (last {limit})", len(hdr)) - with open('/var/log/firewall.log', 'r', encoding='utf-8') as f: lines = deque(f, maxlen=limit) if not lines: - raise FileNotFoundError + return None + + # Create table with column definitions - mark flexible columns + log_table = SimpleTable([ + Column('TIME'), + Column('ACTION', 'right'), + Column('IIF', 'right'), + Column('SOURCE', flexible=True), + Column('DEST', flexible=True), + Column('PROTO'), + Column('PORT', 'right') + ]) - print(Decore.invert(hdr)) for line in lines: parsed = parse_firewall_log_line(line) if not parsed: @@ -2406,26 +2370,30 @@ def show_firewall_logs(limit=10): dt = datetime.strptime(ts, "%b %d %H:%M:%S") time_str = dt.strftime("%b %d %H:%M:%S") except Exception: - time_str = parsed['timestamp'][:PadFirewall.log_time-1] + time_str = parsed['timestamp'][:14] # Truncate long timestamps if parsed['action'] == 'REJECT': - action_color = Decore.red + action_str = Decore.red(parsed['action']) else: - action_color = Decore.yellow - action = action_color(parsed['action']) + action_str = Decore.yellow(parsed['action']) - print(f"{time_str:<{PadFirewall.log_time}} " - f"{action:>{PadFirewall.log_action + 10}} " - f"{parsed['in_iface']:>{PadFirewall.log_iif}} " - f"{parsed['src']:<{PadFirewall.log_src}} " - f"{parsed['dst']:<{PadFirewall.log_dst}} " - f"{parsed['proto']:<{PadFirewall.log_proto}} " - f"{parsed['dpt']:>{PadFirewall.log_port}}") + log_table.row(time_str, action_str, parsed['in_iface'], + parsed['src'], parsed['dst'], parsed['proto'], + parsed['dpt']) - except FileNotFoundError: + return log_table + + except (FileNotFoundError, Exception): + return None + + +def show_firewall_logs(limit=None): + """Show recent firewall log entries, tail -N equivalent (legacy)""" + log_table = firewall_log_table(limit) + if log_table: + log_table.print() + else: print("No logs found (may be disabled or no denied traffic)") - except Exception as e: - print(f"Error reading firewall logs: {e}") def show_firewall(json): @@ -2445,6 +2413,9 @@ def show_firewall(json): print("Firewall disabled.") return + # Create Canvas instance + canvas = Canvas() + # Build firewall status with contextual alerts lockdown_state = fw.get('lockdown', False) logging_enabled = fw.get('logging', 'off') != 'off' @@ -2455,22 +2426,54 @@ def show_firewall(json): elif logging_enabled: firewall_status += f" [ {Decore.bold_yellow('MONITORING')} ]" - # Adjust 20 + 8, where 8 is len(bold) - print(f"{Decore.bold('Firewall'):<28}: {firewall_status}") + # Add status information + canvas.add_text(f"{Decore.bold('Firewall'):<28}: {firewall_status}") lockdown_display = "active" if lockdown_state else "inactive" - print(f"{Decore.bold('Lockdown mode'):<28}: {lockdown_display}") + canvas.add_text(f"{Decore.bold('Lockdown mode'):<28}: {lockdown_display}") + canvas.add_text(f"{Decore.bold('Default zone'):<28}: {fw.get('default', 'unknown')}") + canvas.add_text(f"{Decore.bold('Log denied traffic'):<28}: {fw.get('logging', 'off')}") - print(f"{Decore.bold('Default zone'):<28}: {fw.get('default', 'unknown')}") - print(f"{Decore.bold('Log denied traffic'):<28}: {fw.get('logging', 'off')}") + canvas.add_spacing() - show_firewall_matrix(fw) - show_firewall_zone(json) - show_firewall_policy(json) + # Create tables + zone_table = firewall_zone_table(json) + policy_table = firewall_policy_table(json) - # Add firewall logs at the bottom if logging is enabled - if fw.get('logging', 'off') != 'off': - show_firewall_logs() + # Add zone table + if zone_table: + canvas.add_title("Zones") + canvas.add_table(zone_table) + canvas.add_spacing() + + # Add policy table + if policy_table: + canvas.add_title("Policies") + canvas.add_table(policy_table) + canvas.add_spacing() + + # Add firewall logs if logging is enabled + if logging_enabled: + log_table = firewall_log_table(limit=10) + if log_table: + canvas.add_title("Log (last 10)") + canvas.add_table(log_table) + canvas.add_spacing() + + # Get max width for matrix centering + max_width = canvas.get_max_width() + + # Render matrix centered to max table width + matrix_text = firewall_matrix(fw, width=max_width) + if matrix_text: + # Insert matrix after status lines and first spacing (index 5) + # Status: 4 lines + 1 spacing = index 5 + canvas.insert_title(5, "Zone Matrix") + canvas.insert_raw(6, matrix_text) + canvas.insert_spacing(7) + + # Render the canvas + canvas.render() def build_policy_map(policies): @@ -2719,19 +2722,17 @@ def traffic_flow(from_zone, to_zone, policy_map, zones, policies, cell_width): return make_cell("✗", Decore.red_bg) -def show_firewall_matrix(fw): - """Renders visual zone-to-zone traffic flow matrix. +def firewall_matrix(fw, width=None): + """Render zone-to-zone traffic flow matrix as a multi-line string. Args: - fw: Firewall config dict with zones/policies - - Algorithm: - 1. Collect zones with interfaces/networks + implicit HOST - 2. Build policy lookup map via build_policy_map() - 3. Generate matrix cells using traffic_flow() logic - 4. Render with box-drawing chars and colored symbols + fw: Firewall config dict with zones/policies + width: Optional target width for centering (from Canvas) Symbols: ✓=allow, ✗=deny, ⚠=conditional, —=n/a + + Returns: + Multi-line string containing the rendered matrix, or None if < 2 zones """ zones = fw.get('zone', []) policies = fw.get('policy', []) @@ -2779,21 +2780,20 @@ def show_firewall_matrix(fw): for zone in zone_names: hdr += f" {zone:^{col_width}} │" - # Calculate centering relative to zones/policies table width + # Build matrix rows + lines = [] + + # Calculate centering if width is provided matrix_width = len(top_border) - target_width = PadFirewall.table_width() - padding = max(0, (target_width - matrix_width) // 2) - indent = " " * padding + if width and width > matrix_width: + padding = (width - matrix_width) // 2 + indent = " " * padding + else: + indent = "" - # Center the title underline to match table width - title_padding = max(0, (target_width - len("Zone Matrix")) // 2) - title_underline = "─" * target_width - - print(title_underline) - print(f"{'':<{title_padding}}{Decore.bold('Zone Matrix')}") - print(f"{indent}{top_border}") - print(f"{indent}{hdr}") - print(f"{indent}{middle_border}") + lines.append(indent + top_border) + lines.append(indent + hdr) + lines.append(indent + middle_border) for from_zone in zone_names: row = f"│ {from_zone:>{left_col_width}} │" @@ -2802,24 +2802,103 @@ def show_firewall_matrix(fw): symbol = traffic_flow(from_zone, to_zone, policy_map, zones, policies, col_width) row += f"{symbol}│" - print(f"{indent}{row}") - print(f"{indent}{bottom_border}") + lines.append(indent + row) + lines.append(indent + bottom_border) - # Center the legend - define parts first for length calculation + # Add legend legend_data = [ ("✓ Allow", Decore.green_bg), ("✗ Deny", Decore.red_bg), ("⚠ Conditional", Decore.yellow_bg) ] - # Calculate visible length, then colorize the parts - visible_parts = [f" {text} " for text, _ in legend_data] - visible_legend = " ".join(visible_parts) colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data] legend = " ".join(colorized_parts) - # Depending on taste and number of zones, but +1 works for me - legend_padding = max(0, (target_width - len(visible_legend)) // 2) + 1 - print(f"{' ' * legend_padding}{legend}") + + if width: + # Calculate legend centering + # Use visible width (without ANSI codes) for calculation + visible_legend = " ".join([f" {text} " for text, _ in legend_data]) + legend_padding = max(0, (width - len(visible_legend)) // 2) + lines.append(" " * legend_padding + legend) + else: + lines.append(indent + legend) + + return "\n".join(lines) + + +def show_firewall_matrix(json): + """Renders visual zone-to-zone traffic flow matrix.""" + fw = json.get('infix-firewall:firewall', {}) + if fw: + print(firewall_matrix(fw)) + + +def firewall_zone_table(json): + """Create firewall zones table (returns SimpleTable or None)""" + fw = json.get('infix-firewall:firewall', {}) + zones = fw.get('zone', []) + + if not zones: + return None + + # Create zones table with flexible columns + zone_table = SimpleTable([ + Column(''), # Lock icon + Column('NAME', flexible=True), + Column('TYPE'), + Column('DATA', flexible=True), + Column('ALLOWED HOST SERVICES', flexible=True) + ]) + + for zone in zones: + name = zone.get('name', '') + action = zone.get('action', 'reject') + interface_list = zone.get('interface', []) + network_list = zone.get('network', []) + port_forwards = zone.get('port-forward', []) + services = zone.get('service', []) + + if action == 'accept': + services_display = "ANY" + elif services: + services_display = ", ".join(services) + else: + services_display = "(none)" + + immutable = zone.get('immutable', False) + locked = "⚷" if immutable else " " + + # Build configuration strings + config_lines = [] + + # Interfaces + if interface_list: + interfaces = compress_interface_list(interface_list) + config_lines.append(("iif", interfaces)) + else: + config_lines.append(("iif", "(none)")) + + # Networks + if network_list: + networks = ", ".join(network_list) + config_lines.append(("net", networks)) + + # Port forwards + if port_forwards: + pf_display = format_port_forwards(port_forwards) + config_lines.append(("fwd", pf_display)) + + # Add first line with zone name and services + if config_lines: + first_type, first_data = config_lines[0] + zone_table.row(locked, name, first_type, first_data, services_display) + + # Add additional configuration lines as separate rows + for config_type, config_data in config_lines[1:]: + zone_table.row('', '', config_type, config_data, '') + + return zone_table def show_firewall_zone(json, zone_name=None): @@ -2902,12 +2981,13 @@ def show_firewall_zone(json, zone_name=None): to_display = f"{to_addr}:{to_port_str}" print(f"{' - ' + from_port:<18} → {to_display}") - hdr = (f"{'TO ZONE':<{PadFirewall.zone_flow_to}}" - f"{'ACTION':<{PadFirewall.zone_flow_action}}" - f"{'POLICY':<{PadFirewall.zone_flow_policy}}" - f"{'SERVICES':<{PadFirewall.zone_flow_services}}") - Decore.title(f"Traffic Flows: {zone_name} →", len(hdr)) - print(Decore.invert(hdr)) + # Create traffic flows table + flow_table = SimpleTable([ + Column('TO ZONE'), + Column('ACTION'), + Column('POLICY'), + Column('SERVICES') + ]) # Add HOST zone first current_zone = next((z for z in zones if z.get('name') == zone_name), None) @@ -2926,10 +3006,7 @@ def show_firewall_zone(json, zone_name=None): host_action = "✗ deny" host_services = "(none)" - print(f"{'HOST':<{PadFirewall.zone_flow_to}}" - f"{host_action:<{PadFirewall.zone_flow_action}}" - f"{'(services)':<{PadFirewall.zone_flow_policy}}" - f"{host_services}") + flow_table.row('HOST', host_action, '(services)', host_services) # Add other zones for other_zone in zones: @@ -2960,75 +3037,48 @@ def show_firewall_zone(json, zone_name=None): services_display = "(none)" break - print(f"{other_name:<{PadFirewall.zone_flow_to}}" - f"{action:<{PadFirewall.zone_flow_action}}" - f"{policy_name:<{PadFirewall.zone_flow_policy}}" - f"{services_display}") + flow_table.row(other_name, action, policy_name, services_display) + + Decore.title(f"Traffic Flows: {zone_name} →") + flow_table.print() else: - hdr = (f"{'':<{PadFirewall.zone_locked}}" - f"{'NAME':<{PadFirewall.zone_name}}" - f"{'TYPE':<{PadFirewall.zone_type}}" - f"{'DATA':<{PadFirewall.zone_data}}" - f"{'ALLOWED HOST SERVICES':<{PadFirewall.zone_services}}") - Decore.title("Zones", len(hdr)) - print(Decore.invert(hdr)) + # Use helper to create and display zones table + zone_table = firewall_zone_table(json) + if zone_table: + zone_table.min_width = 72 + zone_table.print() - for zone in zones: - name = zone.get('name', '') - action = zone.get('action', 'reject') - interface_list = zone.get('interface', []) - network_list = zone.get('network', []) - port_forwards = zone.get('port-forward', []) - services = zone.get('service', []) - if action == 'accept': - services_display = "ANY" - elif services: - services_display = ", ".join(services) - else: - services_display = "(none)" +def firewall_policy_table(json): + """Create firewall policies table (returns SimpleTable or None)""" + fw = json.get('infix-firewall:firewall', {}) + policies = fw.get('policy', []) - immutable = zone.get('immutable', False) - locked = "⚷" if immutable else " " + if not policies: + return None - # Build configuration strings - config_lines = [] + # Create policies table with flexible columns + policy_table = SimpleTable([ + Column(''), # Lock icon + Column('NAME', flexible=True), + Column('ACTION'), + Column('INGRESS', flexible=True), + Column('EGRESS', flexible=True) + ]) - # Interfaces - if interface_list: - interfaces = compress_interface_list(interface_list) - config_lines.append(("iif", interfaces)) - else: - config_lines.append(("iif", "(none)")) + sorted_policies = sorted(policies, key=lambda p: p.get('priority', 32767)) + for policy in sorted_policies: + name = policy.get('name', '') + ingress = ", ".join(policy.get('ingress', [])) + egress = ", ".join(policy.get('egress', [])) + action = policy.get('action', 'reject') - # Networks - if network_list: - networks = ", ".join(network_list) - config_lines.append(("net", networks)) + immutable = policy.get('immutable', False) + locked = "⚷" if immutable else " " - # Port forwards - if port_forwards: - pf_display = format_port_forwards(port_forwards) - config_lines.append(("fwd", pf_display)) + policy_table.row(locked, name, action, ingress, egress) - # Print first line with zone name and services - if config_lines: - first_type, first_data = config_lines[0] - print(f"{locked:<{PadFirewall.zone_locked}}" - f"{name:<{PadFirewall.zone_name}}" - f"{first_type:<{PadFirewall.zone_type}}" - f"{first_data:<{PadFirewall.zone_data}}" - f"{services_display}") - - # Print additional configuration lines - for config_type, config_data in config_lines[1:]: - print(f"{'':<{PadFirewall.zone_locked}}" - f"{'':<{PadFirewall.zone_name}}" - f"{config_type:<{PadFirewall.zone_type}}" - f"{config_data}") - - # if zone != zones[-1]: # Don't add line after last zone - # print() + return policy_table def show_firewall_policy(json, policy_name=None): @@ -3095,35 +3145,11 @@ def show_firewall_policy(json, policy_name=None): else: print(f"{' - ' + action:<6} {family} (unknown type)") else: - hdr = (f"{'':<{PadFirewall.policy_locked}}" - f"{'NAME':<{PadFirewall.policy_name}}" - f"{'ACTION':<{PadFirewall.policy_action}}" - f"{'INGRESS':<{PadFirewall.policy_ingress}}" - f"{'EGRESS':<{PadFirewall.policy_egress}}") - Decore.title("Policies", len(hdr)) - print(Decore.invert(hdr)) - - sorted_policies = sorted(policies, key=lambda p: p.get('priority', 32767)) - for policy in sorted_policies: - name = policy.get('name', '') - ingress = ", ".join(policy.get('ingress', [])) - egress = ", ".join(policy.get('egress', [])) - action = policy.get('action', 'reject') - - # Check for custom filters - # custom = policy.get('custom', {}) - # custom_filters = custom.get('filter', []) - # if custom_filters: - # name += f" ({len(custom_filters)} filter(s))" - - immutable = policy.get('immutable', False) - locked = "⚷" if immutable else " " - - print(f"{locked:<{PadFirewall.policy_locked}}" - f"{name:<{PadFirewall.policy_name}}" - f"{action:<{PadFirewall.policy_action}}" - f"{ingress:<{PadFirewall.policy_ingress}}" - f"{egress:<{PadFirewall.policy_egress}}") + # Use helper to create and display policies table + policy_table = firewall_policy_table(json) + if policy_table: + policy_table.min_width = 72 + policy_table.print() def format_port_list(ports): @@ -3145,6 +3171,28 @@ def format_port_list(ports): return ", ".join(formatted) +def firewall_service_table(json): + """Create firewall services table (returns SimpleTable or None)""" + fw = json.get('infix-firewall:firewall', {}) + services = fw.get('service', []) + + if not services: + return None + + # Create services table with flexible columns + service_table = SimpleTable([ + Column('NAME'), + Column('PORTS', flexible=True) + ]) + + for service in services: + name = service.get('name', '') + ports = format_port_list(service.get('port', [])) + service_table.row(name, ports) + + return service_table + + def show_firewall_service(json, name=None): """Show firewall services table or specific service details""" fw = json.get('infix-firewall:firewall', {}) @@ -3163,15 +3211,11 @@ def show_firewall_service(json, name=None): print(f"{'ports':<20}: {ports}") print(format_description('description', description)) else: - hdr = (f"{'NAME':<{PadFirewall.service_name}}" - f"{'PORTS':<{PadFirewall.service_ports}}") - print(Decore.invert(hdr)) - for service in services: - name = service.get('name', '') - ports = format_port_list(service.get('port', [])) - - print(f"{name:<{PadFirewall.service_name}}" - f"{ports:<{PadFirewall.service_ports}}") + # Use helper to create and display services table + service_table = firewall_service_table(json) + if service_table: + service_table.min_width = 72 + service_table.print() def show_ospf(json_data): @@ -4029,13 +4073,17 @@ def main(): .add_argument('-n', '--name', help='Interface name') subparsers.add_parser('show-lldp', help='Show LLDP neighbors') + subparsers.add_parser('show-firewall', help='Show firewall overview') + subparsers.add_parser('show-firewall-matrix', help='Show firewall matrix') subparsers.add_parser('show-firewall-zone', help='Show firewall zones') \ .add_argument('name', nargs='?', help='Zone name') subparsers.add_parser('show-firewall-policy', help='Show firewall policies') \ .add_argument('name', nargs='?', help='Policy name') subparsers.add_parser('show-firewall-service', help='Show firewall services') \ .add_argument('name', nargs='?', help='Service name') + subparsers.add_parser('show-firewall-log', help='Show firewall log') \ + .add_argument('limit', nargs='?', help='Last N lines, default: all') subparsers.add_parser('show-ntp', help='Show NTP sources') @@ -4082,12 +4130,16 @@ def main(): show_lldp(json_data) elif args.command == "show-firewall": show_firewall(json_data) + elif args.command == "show-firewall-matrix": + show_firewall_matrix(json_data) elif args.command == "show-firewall-zone": show_firewall_zone(json_data, args.name) elif args.command == "show-firewall-policy": show_firewall_policy(json_data, args.name) elif args.command == "show-firewall-service": show_firewall_service(json_data, args.name) + elif args.command == "show-firewall-log": + show_firewall_logs(args.limit) elif args.command == "show-ntp": show_ntp(json_data) elif args.command == "show-bfd": From 25a7050c2389ac54e109a9f6571ce5490ecd2044 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Jan 2026 14:46:44 +0100 Subject: [PATCH 06/10] test: fix container resource-limit value The constaint was supposed to be 50% of one CPU core, or 500 millicores as per Kubernetes nomenclature. Signed-off-by: Joachim Wiberg --- test/case/containers/basic/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/case/containers/basic/test.py b/test/case/containers/basic/test.py index 80f6ed90..3d969a59 100755 --- a/test/case/containers/basic/test.py +++ b/test/case/containers/basic/test.py @@ -49,7 +49,7 @@ with infamy.Test() as test: }, "resource-limit": { "memory": 512, # 512 KiB - "cpu": 50000 # 50% of one CPU + "cpu": 500 # 50% of one CPU (0.5 cores) } } ] @@ -70,7 +70,7 @@ with infamy.Test() as test: limits = web.get("resource-limit", {}) assert limits.get("memory") == 512, "Memory limit not set correctly" - assert limits.get("cpu") == 50000, "CPU limit not set correctly" + assert limits.get("cpu") == 500, "CPU limit not set correctly" rusage = web.get("resource-usage", {}) assert rusage is not None, "Resource usage data not available" From 162e6d6662fe843560883b75de6103326a99cfa2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Jan 2026 14:45:01 +0100 Subject: [PATCH 07/10] 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": From 203f81df674e9520d2925bb27c723c3a8fb112f4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 2 Jan 2026 19:32:33 +0100 Subject: [PATCH 08/10] cli: show firewall, add subcommands for log and matrix The original 'show firewall log' just did the same as the 'show log firewall' command. This new implementation allows showing pretty-printed firewall log which is admittedly easier on the eyes. Also, add 'show firewall matrix' to just show the zone matrix overview. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 423c31a1..bb3d6a1e 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -638,10 +638,18 @@ + + + - doas -u $USER cat /log/firewall.log |pager +G + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-log $KLISH_PARAM_limit |pager +G + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-matrix + + From 84b1f1ad12204e5594d5d1b07727826cf4ee71e5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 22 Dec 2025 13:18:28 +0100 Subject: [PATCH 09/10] doc: update, new resource limits section for containers Signed-off-by: Joachim Wiberg --- doc/container.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/doc/container.md b/doc/container.md index e9f135df..02a0b795 100644 --- a/doc/container.md +++ b/doc/container.md @@ -488,6 +488,46 @@ time and analysis of your container application to figure out which capabilities you need. +Resource Limits +--------------- + +Containers can be configured with resource limits to control their memory +and CPU usage. This helps prevent containers from consuming excessive system +resources and ensures fair resource allocation across multiple containers. + +### Configuring Resource Limits + +Resource limits are set per container and include: + + - **Memory:** Maximum memory usage in kibibytes (KiB) + - **CPU:** Maximum CPU usage in millicores (1000 millicores = 1 CPU core) + +Example configuration limiting a container to 512 MiB of memory and 1.5 CPU cores: + + admin@example:/> configure + admin@example:/config/> edit container web + admin@example:/config/container/web/> edit resource-limit + admin@example:/config/container/web/resource-limit/> set memory 524288 + admin@example:/config/container/web/resource-limit/> set cpu 1500 + admin@example:/config/container/web/resource-limit/> leave + +Common CPU limit examples: + + - `500` = 0.5 cores (50% of one core) + - `1000` = 1.0 cores (one full core) + - `2000` = 2.0 cores (two full cores) + +### Monitoring Resource Usage + +Runtime resource usage statistics are available in the operational datastore: + + admin@example:/> show container web + ... + +Use `show container usage` to see resource consumption across all containers, +including memory, CPU, block I/O, network I/O, and process counts. + + Networking and Containers ------------------------- From 1030bae3150c80c1b17d2bb1e7b8e79047d60c80 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 22 Dec 2025 13:19:08 +0100 Subject: [PATCH 10/10] doc: update ChangeLog, container resource limits + usage Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index bc0e97e2..6935fea3 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -17,10 +17,15 @@ All notable changes to the project are documented in this file. can now be enabled per category (bfd, packet, ism, nsm, default-information, nssa). All debug options are disabled by default to prevent log flooding in production environments. See the documentation for usage examples +- Add support for configurable container resource limits, memory and CPU. + Resource usage is available through the operational datastore, where the + currently active resource limits in the container runtime are also available - Add support for "routing interfaces", issue #647. Lists interfaces with IP forwarding. Inspect from CLI using `show interface`, look for `⇅` flag - Add operational data journal to statd with hierarchical time-based retention policy, keeping snapshots from every 5 minutes (recent) to yearly (historical) +- Add support data collection script, useful when troubleshooting issues on + deployed systems. Gathers system information, logs, and more. Issue #1287 ### Fixes