diff --git a/board/common/rootfs/etc/finit.d/available/container@.conf b/board/common/rootfs/etc/finit.d/available/container@.conf index 4e6b813cc..5814c14a2 100644 --- a/board/common/rootfs/etc/finit.d/available/container@.conf +++ b/board/common/rootfs/etc/finit.d/available/container@.conf @@ -5,4 +5,5 @@ # 'podman load', must not have a timeout. sysv log:prio:local1,tag:%i kill:30 pid:!/run/container:%i.pid \ pre:0,/usr/sbin/container cleanup:0,/usr/sbin/container \ + cgroup.system,delegate \ [2345] :%i container -n %i -- container %i diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index e407c1abb..35df8a2fa 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -411,10 +411,20 @@ create() logging="--log-driver syslog" fi + # Build resource limit arguments + resource="" + if [ -n "$memory" ]; then + resource="$resource --memory=$memory" + fi + if [ -n "$cpu_limit" ]; then + resource="$resource --cpu-quota=$cpu_limit" + fi + # When we get here we've already fetched, or pulled, the image - args="$args --read-only --replace --quiet --cgroup-parent=containers $caps" + args="$args --read-only --replace --quiet $caps" + args="$args --cgroups=enabled --cgroupns=host --cgroup-parent=system/container@$name" args="$args --restart=$restart --systemd=false --tz=local $privileged" - args="$args $vol $mount $hostname $entrypoint $env $port $logging" + args="$args $vol $mount $hostname $entrypoint $env $port $logging $resource" pidfile=/run/container:${name}.pid [ -n "$quiet" ] || log "---------------------------------------" @@ -716,6 +726,8 @@ options: --log-path PATH Path for k8s-file log pipe -m, --mount HOST:DEST Bind mount a read-only file inside a container --manual Do not start container automatically after creation + --memory BYTES Memory limit in bytes (supports K/M/G suffix) + --cpu-limit LIMIT CPU limit in millicores (1000m = 100% of 1 core) -n, --name NAME Alternative way of supplying name to start/stop/restart --privileged Give container extended privileges -p, --publish PORT Publish ports when creating container @@ -836,6 +848,14 @@ while [ "$1" != "" ]; do --manual) manual=true ;; + --memory) + shift + memory="$1" + ;; + --cpu-limit) + shift + cpu_limit="$1" + ;; -n | --name) shift name="$1" diff --git a/package/finit/0001-Increase-MAX_ID_LEN-to-support-longer-service-identi.patch b/package/finit/0001-Increase-MAX_ID_LEN-to-support-longer-service-identi.patch deleted file mode 100644 index 98bc19b81..000000000 --- a/package/finit/0001-Increase-MAX_ID_LEN-to-support-longer-service-identi.patch +++ /dev/null @@ -1,35 +0,0 @@ -From 927acdbd7e19b1a9a0065ebdb88b663ec6626b88 Mon Sep 17 00:00:00 2001 -From: Joachim Wiberg -Date: Tue, 14 Oct 2025 11:49:57 +0200 -Subject: [PATCH] Increase MAX_ID_LEN to support longer service identifiers -Organization: Wires - -Allow service IDs up to 64 characters to support SHA-256 hashes, -UUIDs, and other long unique identifiers. This increases memory -usage by ~98 bytes per service instance, which is negligible for -typical deployments. - -The IDENT column width in initctl output adapts dynamically based -on actual ID lengths in use, so short IDs remain unaffected. - -Signed-off-by: Joachim Wiberg ---- - src/svc.h | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/src/svc.h b/src/svc.h -index 74cc3ca..1a7e6f2 100644 ---- a/src/svc.h -+++ b/src/svc.h -@@ -94,7 +94,7 @@ typedef enum { - SVC_NOTIFY_S6, - } svc_notify_t; - --#define MAX_ID_LEN 16 -+#define MAX_ID_LEN 65 - #define MAX_ARG_LEN 64 - #define MAX_CMD_LEN 256 - #define MAX_IDENT_LEN (MAX_ARG_LEN + MAX_ID_LEN + 1) --- -2.43.0 - diff --git a/package/finit/finit.hash b/package/finit/finit.hash index 04d754dec..a3e9478bb 100644 --- a/package/finit/finit.hash +++ b/package/finit/finit.hash @@ -1,5 +1,5 @@ # From https://github.com/troglobit/finit/releases/ -sha256 7c128119129324050ff7e5b56d0f33fa152fe254d035c0d0c6f72dc75d6786f3 finit-4.14.tar.gz +sha256 7ccbcead4e3e6734c81a8c5445f4a27738f19a4ab367d702513a201db9b618c7 finit-4.15-rc1.tar.gz # Locally calculated sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE diff --git a/package/finit/finit.mk b/package/finit/finit.mk index 2e3b2be0f..1bf387c8c 100644 --- a/package/finit/finit.mk +++ b/package/finit/finit.mk @@ -4,7 +4,7 @@ # ################################################################################ -FINIT_VERSION = 4.14 +FINIT_VERSION = 4.15-rc1 FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION) FINIT_LICENSE = MIT FINIT_LICENSE_FILES = LICENSE diff --git a/src/confd/src/containers.c b/src/confd/src/containers.c index 57cb7f34c..59eae7fbd 100644 --- a/src/confd/src/containers.c +++ b/src/confd/src/containers.c @@ -294,6 +294,25 @@ static int add(const char *name, struct lyd_node *cif) fprintf(fp, " --checksum sha512:%s", string); } + /* Add resource limits for Podman to enforce via cgroups */ + node = lydx_get_descendant(lyd_child(cif), "resource-limit", NULL); + if (node) { + struct lyd_node *mem_node, *cpu_node; + + /* Memory limit in KiB, Podman accepts with 'k' suffix */ + mem_node = lydx_get_descendant(lyd_child(node), "memory", NULL); + if (mem_node) + fprintf(fp, " --memory %sk", lyd_get_value(mem_node)); + + /* CPU limit in millicores, convert to quota (microseconds per 100ms) */ + cpu_node = lydx_get_descendant(lyd_child(node), "cpu", NULL); + if (cpu_node) { + uint32_t millicores = strtoul(lyd_get_value(cpu_node), NULL, 10); + uint32_t quota = millicores * 100; /* 1000m → 100000µs, 2000m → 200000µs */ + fprintf(fp, " --cpu-limit %u", quota); + } + } + fprintf(fp, " create %s %s", name, image); if ((string = lydx_get_cattr(cif, "command"))) diff --git a/src/confd/yang/confd/infix-containers.yang b/src/confd/yang/confd/infix-containers.yang index 07746d0eb..a1ddd09ee 100644 --- a/src/confd/yang/confd/infix-containers.yang +++ b/src/confd/yang/confd/infix-containers.yang @@ -22,6 +22,13 @@ module infix-containers { prefix infix-sys; } + revision 2025-12-09 { + description "Add resource management: + - Add resource-limit container with memory and cpu configuration. + - Add resource-usage operational data for live resource usage statistics."; + reference "internal"; + } + revision 2025-10-12 { description "Two major changes: - Add dedicated 'ident' type for container and volume names. @@ -341,6 +348,86 @@ module infix-containers { } } + container resource-limit { + description "Resource limits for the container."; + + leaf memory { + description "Maximum memory limit in kibibytes, default: unlimited."; + type uint64; + units "KiB"; + } + + leaf cpu { + description "CPU limit in millicores, default: unlimited. + + Millicores represent thousandths of a CPU core: + 500 = 0.5 cores (50% of one core) + 1000 = 1.0 cores (one full core) + 2000 = 2.0 cores (two full cores) + 3500 = 3.5 cores + + This is converted to cgroup cpu.quota internally."; + type uint32; + units "millicores"; + } + } + + container resource-usage { + description "Runtime container resource usage statistics."; + config false; + + leaf memory { + description "Used memory in kibibytes."; + type uint64; + units "KiB"; + } + + leaf cpu { + description "CPU usage percentage."; + type decimal64 { + fraction-digits 2; + } + units "percent"; + } + + container block-io { + description "Block I/O statistics"; + + leaf read { + description "Block I/O read in kibibytes."; + type uint64; + units "KiB"; + } + + leaf write { + description "Block I/O write in kibibytes."; + type uint64; + units "KiB"; + } + } + + container net-io { + description "Network I/O statistics"; + + leaf received { + description "Network I/O received in kibibytes."; + type uint64; + units "KiB"; + } + + leaf sent { + description "Network I/O sent in kibibytes."; + type uint64; + units "KiB"; + } + } + + leaf pids { + description "Number of processes/threads."; + type uint32; + } + } + list mount { description "Files, content, and directories to mount inside container."; key name; diff --git a/src/confd/yang/confd/infix-containers@2025-10-12.yang b/src/confd/yang/confd/infix-containers@2025-12-09.yang similarity index 100% rename from src/confd/yang/confd/infix-containers@2025-10-12.yang rename to src/confd/yang/confd/infix-containers@2025-12-09.yang diff --git a/src/confd/yang/containers.inc b/src/confd/yang/containers.inc index 11dfc5bc5..6f13f9713 100644 --- a/src/confd/yang/containers.inc +++ b/src/confd/yang/containers.inc @@ -1,5 +1,5 @@ # -*- sh -*- MODULES=( "infix-interfaces -e containers" - "infix-containers@2025-10-12.yang" + "infix-containers@2025-12-09.yang" ) diff --git a/src/statd/python/yanger/infix_containers.py b/src/statd/python/yanger/infix_containers.py index 8ef96e77d..9461e8ef8 100644 --- a/src/statd/python/yanger/infix_containers.py +++ b/src/statd/python/yanger/infix_containers.py @@ -1,3 +1,11 @@ +"""Operational data provider for infix-containers YANG model. + +Collects container status, network info, resource limits from cgroups, +and runtime statistics via podman commands. +""" +import os +import re + from .common import LOG from .host import HOST @@ -46,6 +54,165 @@ def network(ps, inspect): return net +def parse_size_kib(size_str): + """Parse size string like '1.5MB' or '512kB' to KiB (kibibytes).""" + if not size_str: + return 0 + + size_str = size_str.strip().upper() + + # Extract numeric part and unit + match = re.match(r'([0-9.]+)\s*([KMGT]?I?B)?', size_str) + if not match: + return 0 + + value = float(match.group(1)) + unit = match.group(2) if match.group(2) else 'B' + + # Convert to KiB (kibibytes) + multipliers = { + 'B': 1/1024, + 'KB': 1000/1024, 'KIB': 1, + 'MB': (1000**2)/1024, 'MIB': 1024, + 'GB': (1000**3)/1024, 'GIB': 1024**2, + 'TB': (1000**4)/1024, 'TIB': 1024**3, + } + + return int(value * multipliers.get(unit, 1)) + + +def parse_cgroup_memory(mem_str): + """Parse cgroup memory.max value (bytes) to KiB.""" + if not mem_str or mem_str == "max": + return 0 + try: + mem_bytes = int(mem_str) + return mem_bytes // 1024 + except ValueError: + return 0 + + +def parse_cgroup_cpu(cpu_str): + """Parse cgroup cpu.max value to millicores.""" + if not cpu_str: + return 0 + parts = cpu_str.split() + if len(parts) != 2 or parts[0] == "max": + return 0 + try: + quota = int(parts[0]) + period = int(parts[1]) + # Convert to millicores: (quota/period) * 1000 + return (quota * 1000) // period + except ValueError: + return 0 + + +def read_cgroup_limits(inspect): + """Read resource limits from cgroup files for a container.""" + if not inspect or not isinstance(inspect, dict): + return None + + cgroup_path = inspect.get("State", {}).get("CgroupPath") + if not cgroup_path: + return None + + cgroup_base = f"/sys/fs/cgroup{cgroup_path}" + mem_val = 0 + cpu_val = 0 + + try: + # Read memory limit (in bytes, convert to KiB) + mem_max_path = os.path.join(cgroup_base, "memory.max") + if os.path.exists(mem_max_path): + with open(mem_max_path, 'r') as f: + mem_str = f.read().strip() + mem_val = parse_cgroup_memory(mem_str) + + # Read CPU limit (quota and period in microseconds, convert to millicores) + cpu_max_path = os.path.join(cgroup_base, "cpu.max") + if os.path.exists(cpu_max_path): + with open(cpu_max_path, 'r') as f: + cpu_str = f.read().strip() + cpu_val = parse_cgroup_cpu(cpu_str) + except Exception as e: + LOG.error(f"failed reading cgroup limits: {e}") + return None + + if mem_val > 0 or cpu_val > 0: + result = {} + if mem_val > 0: + result["memory"] = f"{mem_val}" + if cpu_val > 0: + result["cpu"] = cpu_val + return result + + return None + + +def resource_stats(name): + """Get resource usage stats for a running container using podman stats.""" + cmd = ['podman', 'stats', '--no-stream', '--format', 'json', '--no-reset', name] + try: + stats = HOST.run_json(cmd, default=[]) + if not stats or len(stats) == 0: + return None + + stat = stats[0] + rusage = {} + + # Memory usage - parse used memory, convert to KiB + # Encode as string for uint64 compatibility + mem_usage_str = stat.get("mem_usage", "") + if "/" in mem_usage_str: + mem_used_str = mem_usage_str.split("/")[0].strip() + mem_used_kib = parse_size_kib(mem_used_str) + rusage["memory"] = f"{mem_used_kib}" + + # CPU percentage - format as decimal64 with 2 fractional digits + cpu_perc = stat.get("cpu_percent", "0%").rstrip("%") + try: + rusage["cpu"] = "{:.2f}".format(float(cpu_perc)) + except (ValueError, TypeError): + pass + + block_io = stat.get("block_io", "0B / 0B") + if "/" in block_io: + block_read_str, block_write_str = block_io.split("/") + block_read_kib = parse_size_kib(block_read_str.strip()) + block_write_kib = parse_size_kib(block_write_str.strip()) + + rusage["block-io"] = {} + if block_read_kib > 0: + rusage["block-io"]["read"] = f"{block_read_kib}" + if block_write_kib > 0: + rusage["block-io"]["write"] = f"{block_write_kib}" + + net_io = stat.get("net_io", "0B / 0B") + if "/" in net_io: + net_rx_str, net_tx_str = net_io.split("/") + net_rx_kib = parse_size_kib(net_rx_str.strip()) + net_tx_kib = parse_size_kib(net_tx_str.strip()) + + rusage["net-io"] = {} + if net_rx_kib > 0: + rusage["net-io"]["received"] = f"{net_rx_kib}" + if net_tx_kib > 0: + rusage["net-io"]["sent"] = f"{net_tx_kib}" + + pids = stat.get("pids", "0") + try: + rusage["pids"] = int(pids) + except (ValueError, TypeError): + pass + + return rusage if rusage else None + + except Exception as e: + LOG.error(f"failed getting stats for {name}: {e}") + return None + + def container(ps): out = { "name": ps["Names"][0], @@ -70,6 +237,15 @@ def container(ps): if net: out["network"] = net + limits = read_cgroup_limits(inspect) + if limits: + out["resource-limit"] = limits + + if out["running"]: + rusage = resource_stats(out["name"]) + if rusage: + out["resource-usage"] = rusage + return out diff --git a/test/case/containers/basic/test.adoc b/test/case/containers/basic/test.adoc index 48ce26d7e..e4e543625 100644 --- a/test/case/containers/basic/test.adoc +++ b/test/case/containers/basic/test.adoc @@ -21,6 +21,7 @@ image::topology.svg[Container basic topology, align=center, scaledwidth=75%] . Create container 'web' from bundled OCI image . Verify container 'web' has started . Verify container 'web' is reachable on http://container-host.local:91 +. Verify resource constraints and usage are available . Stop container 'web' . Verify container 'web' is stopped . Restart container 'web' diff --git a/test/case/containers/basic/test.py b/test/case/containers/basic/test.py index 09e048f48..80f6ed90c 100755 --- a/test/case/containers/basic/test.py +++ b/test/case/containers/basic/test.py @@ -11,10 +11,10 @@ from infamy.util import until, curl -def _verify(server): +def _verify(server, silent=False): # TODO: Should really use mDNS here.... url = f"http://[{server}]:91/index.html" - response = curl(url) + response = curl(url, silent=silent) return response is not None and "It works" in response @@ -46,6 +46,10 @@ def _verify(server): "command": "/usr/sbin/httpd -f -v -p 91", "network": { "host": True + }, + "resource-limit": { + "memory": 512, # 512 KiB + "cpu": 50000 # 50% of one CPU } } ] @@ -57,7 +61,23 @@ def _verify(server): until(lambda: c.running(NAME), attempts=60) with test.step("Verify container 'web' is reachable on http://container-host.local:91"): - until(lambda: _verify(addr), attempts=10) + until(lambda: _verify(addr, silent=True), attempts=10) + + with test.step("Verify resource constraints and usage are available"): + data = target.get_data("/infix-containers:containers") + containers = data.get("containers", {}).get("container", []) + web = next((c for c in containers if c["name"] == NAME), None) + + 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" + + rusage = web.get("resource-usage", {}) + assert rusage is not None, "Resource usage data not available" + + mem_used = rusage.get("memory") + assert mem_used is not None, "Memory usage not reported" + print(f"Container using {mem_used} KiB memory") with test.step("Stop container 'web'"): c = infamy.Container(target) @@ -71,5 +91,5 @@ def _verify(server): with test.step("Verify container 'web' is reachable on http://container-host.local:91"): # Wait for it to restart and respond, or fail - until(lambda: _verify(addr), attempts=60) + until(lambda: _verify(addr, silent=True), attempts=60) test.succeed() diff --git a/test/infamy/util.py b/test/infamy/util.py index 4a7c80e76..174cae4ab 100644 --- a/test/infamy/util.py +++ b/test/infamy/util.py @@ -99,12 +99,13 @@ def warn(msg): RST = "\033[0m" print(f"{YELLOW}warn - {msg}{RST}") -def curl(url, timeout=10): +def curl(url, timeout=10, silent=False): """Fetch a URL and return its response body as a UTF-8 string. Args: url (str): The full URL to fetch. timeout (int): Request timeout in seconds. + silent (bool): If True, suppress warning on failure (for retry scenarios). Returns: str | None: Response body as text, or None if the request failed. @@ -115,5 +116,6 @@ def curl(url, timeout=10): with urllib.request.urlopen(url, timeout=timeout) as response: return response.read().decode('utf-8', errors='replace') except (urllib.error.URLError, ConnectionResetError, UnicodeEncodeError) as e: - print(f"[WARN] curl: failed to fetch {url}: {e}") + if not silent: + print(f"[WARN] curl: failed to fetch {url}: {e}") return "" \ No newline at end of file