From 6c742b5cda5b46997008f0f744c825e32998e1b8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 14 Oct 2025 11:52:56 +0200 Subject: [PATCH 01/26] package/finit: backport support for longer service identifiers Fixes #1146 Signed-off-by: Joachim Wiberg --- ...LEN-to-support-longer-service-identi.patch | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 package/finit/0001-Increase-MAX_ID_LEN-to-support-longer-service-identi.patch 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 new file mode 100644 index 00000000..98bc19b8 --- /dev/null +++ b/package/finit/0001-Increase-MAX_ID_LEN-to-support-longer-service-identi.patch @@ -0,0 +1,35 @@ +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 + From a880939a2ef2df208adc28be083b85873ba94c0e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 9 Sep 2025 17:37:43 +0200 Subject: [PATCH 02/26] confd: add new container and volume name type This rectifies an omission from the initial yang model. Not all charachters are supported in container and volume names. E.g., simply attempting to create a volume or container with a space in the name causes this error message from podman: podman: Error: running volume create option: names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*: invalid argument In addition to the regexp, the new 'ident' type also enforces a minimum and maximum length. Sure, technically a single char is allowed, but let's be reasonable, and who in their right mind wants an identifier > 64 chars? We have description for that. Signed-off-by: Joachim Wiberg --- src/confd/yang/confd/infix-containers.yang | 22 ++++++++++++++----- ....yang => infix-containers@2025-09-09.yang} | 0 src/confd/yang/containers.inc | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) rename src/confd/yang/confd/{infix-containers@2025-06-25.yang => infix-containers@2025-09-09.yang} (100%) diff --git a/src/confd/yang/confd/infix-containers.yang b/src/confd/yang/confd/infix-containers.yang index 9226bc24..b4c90ada 100644 --- a/src/confd/yang/confd/infix-containers.yang +++ b/src/confd/yang/confd/infix-containers.yang @@ -22,6 +22,11 @@ module infix-containers { prefix infix-sys; } + revision 2025-09-09 { + description "Add dedicated 'ident' type for container and volume names."; + reference "internal"; + } + revision 2025-06-25 { description "Add file mode option to content mounts, allows creating scripts."; reference "internal"; @@ -62,6 +67,14 @@ module infix-containers { * Typedefs */ + typedef ident { + description "Container name or volume identifiers may not contain spaces."; + type string { + length "2..64"; + pattern '[a-zA-Z0-9][a-zA-Z0-9_.-]+'; + } + } + typedef mount-type { type enumeration { enum bind { @@ -128,7 +141,7 @@ module infix-containers { leaf name { description "Name of the container"; - type string; + type ident; } leaf id { @@ -344,7 +357,7 @@ module infix-containers { For persistent writable directories, *volumes* may be a better fit for your container and easier to set up."; - type string; + type ident; } leaf type { @@ -436,10 +449,7 @@ module infix-containers { with the contents of the container's file system on first use. Hence, upgrading the container image will not update the volume if the image has new/removed files at 'path'."; - type string { - pattern '[a-zA-Z_][a-zA-Z0-9_]*'; - length "1..64"; - } + type ident; } leaf target { diff --git a/src/confd/yang/confd/infix-containers@2025-06-25.yang b/src/confd/yang/confd/infix-containers@2025-09-09.yang similarity index 100% rename from src/confd/yang/confd/infix-containers@2025-06-25.yang rename to src/confd/yang/confd/infix-containers@2025-09-09.yang diff --git a/src/confd/yang/containers.inc b/src/confd/yang/containers.inc index 4b2e3f30..b9199119 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-06-25.yang" + "infix-containers@2025-09-09.yang" ) From e5da7f04730ec5b756743b8e75594c23a54091a2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 9 Sep 2025 18:08:32 +0200 Subject: [PATCH 03/26] confd: create container script even if disabled Not only great for debugging, but also allows users to start their containers manually in another way. But yeah, mostly for debug. Signed-off-by: Joachim Wiberg --- src/confd/src/infix-containers.c | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/confd/src/infix-containers.c b/src/confd/src/infix-containers.c index 22736992..2a108109 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -38,16 +38,6 @@ static int add(const char *name, struct lyd_node *cif) char script[strlen(name) + 5]; FILE *fp, *ap; - /* - * If running already, disable the service, keeping the created - * container and any volumes for later if the user re-enables - * it again. - */ - if (!lydx_is_enabled(cif, "enabled")) { - systemf("initctl -bnq disable container@%s.conf", name); - return 0; - } - snprintf(script, sizeof(script), "%s.sh", name); fp = fopenf("w", "%s/%s", _PATH_CONT, script); if (!fp) { @@ -256,9 +246,9 @@ static int add(const char *name, struct lyd_node *cif) fchmod(fileno(fp), 0700); fclose(fp); - /* Enable, or update, container -- both trigger container setup. */ - systemf("initctl -bnq enable container@%s.conf", name); systemf("initctl -bnq touch container@%s.conf", name); + systemf("initctl -bnq %s container@%s.conf", lydx_is_enabled(cif, "enabled") + ? "enable" : "disable", name); return 0; } From af7c2d780113b68356413ae5d72463e044ace30a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 9 Sep 2025 18:05:37 +0200 Subject: [PATCH 04/26] container: increase podman stop timeout The default timeout for 'podman stop foo' is 10 seconds, which for heavily loaded systems with intricate shutdown process is *waaaay* too short. Increase it to the container script default 30s, which coincidentally is also the container@.conf template's kill delay. Fixes #1149 Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index fe68c85a..79033cf9 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -375,6 +375,9 @@ stop() # Real work is done by wrap() courtesy of finit sysv emulation } +# When called by Finit: `initctl stop foo`, the container script +# is called as `container -n $foo stop`. For the stop command +# we want to bypass the default 10 second timeout. wrap() { name=$1 @@ -385,21 +388,28 @@ wrap() # The setup phase may run forever in the background trying to fetch # the image. It saves its PID in /run/containers/${name}.pid - if [ "$cmd" = "stop" ] && [ -f "$pidfile" ]; then - pid=$(cat "$pidfile") + if [ "$cmd" = "stop" ]; then + if [ -f "$pidfile" ]; then + pid=$(cat "$pidfile") - # Check if setup is still running ... - if kill -0 "$pid" 2>/dev/null; then - kill "$pid" - wait "$pid" 2>/dev/null - fi + # Check if setup is still running ... + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + wait "$pid" 2>/dev/null + fi - rm -f "$pidfile" - return 0 + rm -f "$pidfile" + return 0 + fi + + args="-i --timeout $timeout" + else + args= fi # Skip "echo $name" from podman start in log - podman "$cmd" "$name" >/dev/null + # shellcheck disable=SC2086 + podman "$cmd" $args "$name" >/dev/null } # Removes network $1 from all containers From c6f3e75aa4db410b4dac7ed03cb60736301487ca Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 9 Sep 2025 18:52:23 +0200 Subject: [PATCH 05/26] container: only retry remote images on network changes Fixes #1148 Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index 79033cf9..d39ad217 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -790,19 +790,35 @@ case $cmd in pidfile=$(pidfn "${name}") echo $$ > "$pidfile" - while ! "$script"; do - log "${name}: setup failed, waiting for network changes ..." + # Try setup once first + if ! "$script"; then + # Get image transport to decide if retries make sense + image=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null || echo "") + case "$image" in + oci:* | oci-archive:* | docker-archive:* | docker-daemon:* | dir:* | containers-storage:* | ostree:* | sif:* | /*) + # Local transport - exit immediately on failure + log "${name}: setup failed for local image $image" + rm -f "$pidfile" + exit 1 + ;; + *) + # Remote transport (docker://, ftp://, http://, https://, etc.) - retry on network changes + while ! "$script"; do + log "${name}: setup failed, waiting for network changes ..." - # Timeout and retry after 60 seconds, on SIGTERM, or when - # any network event is caught. - timeout -s TERM -k 1 60 sh -c \ - 'ip monitor address route 2>/dev/null | head -n1 >/dev/null' || true + # Timeout and retry after 60 seconds, on SIGTERM, or when + # any network event is caught. + timeout -s TERM -k 1 60 sh -c \ + 'ip monitor address route 2>/dev/null | head -n1 >/dev/null' || true - # On IP address/route changes, wait a few seconds more to ensure - # the system has ample time to react and set things up for us. - log "${name}: retrying ..." - sleep 2 - done + # On IP address/route changes, wait a few seconds more to ensure + # the system has ample time to react and set things up for us. + log "${name}: retrying ..." + sleep 2 + done + ;; + esac + fi rm -f "$pidfile" cnt=$(podman image prune -f | wc -l) From 68bb01545c31c04117e81f63e5039e32273dcc4a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 26 Sep 2025 11:20:35 +0200 Subject: [PATCH 06/26] container: optimize startup of preexisting containers This commit adds metadata to track loaded OCI archives to allow skipping 'delete + load' of OCI images when restarting either the container or the system as a whole. The sha256 of all loaded OCI archives is stored in a sidecar file in our downloads directory. Then we verify the checksum of the OCI archives against their same-named sidecar to determine if the OCI archive is already loaded or not. Additionally, the instance using the image is labled with metadata to detect changes in the container configuration. This in turn allow skipping the delete + create phase also of the instance. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 98 ++++++++++++++++++++++++++ src/confd/src/infix-containers.c | 48 ++++++++++++- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index d39ad217..c3a5bda4 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -230,6 +230,18 @@ load_archive() err 1 "failed tagging image as $tag" fi + # Save archive SHA256 to sidecar file for local archives to enable optimization + if [ -f "$file" ]; then + img_sha256=$(sha256sum "$file" | cut -f1 -d' ') + archive_basename=$(basename "$file") + sha_file="$DOWNLOADS/${archive_basename}.sha256" + + mkdir -p "$DOWNLOADS" + + echo "$img_sha256" > "$sha_file" + log "Saved archive checksum to $sha_file" + fi + # Clean up after ourselves if [ -n "$extracted" ]; then log "Cleaning up extracted $dir" @@ -295,6 +307,20 @@ create() args="$args --network=none" fi + # Add optimization labels for meta-sha256 and config checksum + script="/run/containers/${name}.sh" + if [ -f "$script" ]; then + # Extract meta-sha256 from script if present + meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) + if [ -n "$meta_sha" ]; then + args="$args --label meta-sha256=$meta_sha" + fi + + # Add config checksum label + config_sha=$(sha256sum "$script" | cut -f1 -d' ') + args="$args --label config-sha256=$config_sha" + fi + # shellcheck disable=SC2048 log "podman create --name $name --conmon-pidfile=$pidfile $args $image $*" if podman create --name "$name" --conmon-pidfile="$pidfile" $args "$image" $*; then @@ -785,6 +811,78 @@ case $cmd in script=/run/containers/${name}.sh [ -x "$script" ] || err 1 "setup: $script does not exist or is not executable." + # Early exit optimization: check if container is already up-to-date + meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) + if [ -n "$meta_sha" ]; then + # Local image optimization: check if archive SHA matches stored sidecar file + # Extract the image path from the script + img=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null) + if [ -n "$img" ]; then + # Determine the archive file path + case "$img" in + oci-archive:*) + archive_path="${img#oci-archive:}" + ;; + *) + archive_path="$img" + ;; + esac + + # Handle relative paths - check BUILTIN and DOWNLOADS + if [ ! -e "$archive_path" ]; then + if [ -e "$DOWNLOADS/$(basename "$archive_path")" ]; then + archive_path="$DOWNLOADS/$(basename "$archive_path")" + elif [ -e "$BUILTIN/$(basename "$archive_path")" ]; then + archive_path="$BUILTIN/$(basename "$archive_path")" + fi + fi + + # Check if the archive exists and compare SHA with sidecar file + if [ -f "$archive_path" ]; then + archive_basename=$(basename "$archive_path") + sha_file="$DOWNLOADS/${archive_basename}.sha256" + + # Check if sidecar file exists + if [ -f "$sha_file" ]; then + stored_sha=$(cat "$sha_file" 2>/dev/null) + current_sha=$(sha256sum "$archive_path" | cut -f1 -d' ') + + # If SHA matches, check container instance + if [ "$stored_sha" = "$current_sha" ]; then + if podman container exists "$name"; then + # Check if container has matching labels + config_sha=$(sha256sum "$script" | cut -f1 -d' ') + container_meta=$(podman inspect "$name" --format '{{index .Config.Labels "meta-sha256"}}' 2>/dev/null) + container_config=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) + + if [ "$container_meta" = "$meta_sha" ] && [ "$container_config" = "$config_sha" ]; then + log "Container $name is up-to-date (archive and config unchanged), skipping setup" + exit 0 + fi + fi + else + # Archive changed (e.g., rootfs upgrade) - need to reload + log "Archive SHA changed, will reload image and recreate container" + fi + fi + fi + fi + else + # Remote image optimization: check config-sha256 only + if podman container exists "$name"; then + # Get current config checksum from script + config_sha=$(sha256sum "$script" | cut -f1 -d' ') + + # Get container's stored config checksum + container_config=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) + + if [ -n "$container_config" ] && [ "$container_config" = "$config_sha" ]; then + log "Container $name config unchanged, skipping setup" + exit 0 + fi + fi + fi + # Save our PID in case we get stuck here and someone wants to # stop us, e.g., due to reconfiguration or reboot. pidfile=$(pidfn "${name}") diff --git a/src/confd/src/infix-containers.c b/src/confd/src/infix-containers.c index 2a108109..b4e37a89 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -22,6 +22,30 @@ #define _PATH_CONT "/run/containers" +/* + * Check if image is a local archive and return the offset to the file path. + * Returns 0 if not a recognized local archive format. + */ +static int archive_offset(const char *image) +{ + static const struct { + const char *prefix; + int offset; + } prefixes[] = { + { "docker-archive:", 15 }, + { "oci-archive:", 12 }, + { NULL, 0 } + }; + int i; + + for (i = 0; prefixes[i].prefix; i++) { + if (!strncmp(image, prefixes[i].prefix, prefixes[i].offset)) + return prefixes[i].offset; + } + + return 0; +} + /* * Create a setup/create/upgrade script and instantiate a new instance * that Finit will start when all networking and other dependencies are @@ -37,6 +61,7 @@ static int add(const char *name, struct lyd_node *cif) struct lyd_node *node, *nets, *caps; char script[strlen(name) + 5]; FILE *fp, *ap; + int offset; snprintf(script, sizeof(script), "%s.sh", name); fp = fopenf("w", "%s/%s", _PATH_CONT, script); @@ -58,9 +83,26 @@ static int add(const char *name, struct lyd_node *cif) image = lydx_get_cattr(cif, "image"); fprintf(fp, "#!/bin/sh\n" "# meta-name: %s\n" - "# meta-image: %s\n" - "container --quiet delete %s >/dev/null\n" - "container --quiet", name, image, name); + "# meta-image: %s\n", name, image); + + offset = archive_offset(image); + if (offset) { + const char *path = image + offset; + char sha256[65] = { 0 }; + FILE *pp; + + pp = popenf("r", "sha256sum %s | cut -f1 -d' '", path); + if (pp) { + if (fgets(sha256, sizeof(sha256), pp)) { + chomp(sha256); + fprintf(fp, "# meta-sha256: %s\n", sha256); + } + pclose(pp); + } + } + + fprintf(fp, "container --quiet delete %s >/dev/null\n" + "container --quiet", name); LYX_LIST_FOR_EACH(lyd_child(cif), node, "dns") fprintf(fp, " --dns %s", lyd_get_value(node)); From 0eddd1ba6412595aa437e606480cb6802ac7bf36 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 9 Sep 2025 17:51:29 +0200 Subject: [PATCH 07/26] container: refactor cleanup on instance removal This commit reverts 477f7ae and bb19d06, which intended to fix an issue with lingering old images, see #1098. However, as detailed in #1147, this caused severe side effects while working with multiple larger containers. Basically, the prune operation of one container removed images of other containers that are just being created in parallel. Instead of using the podman prune command we can use the meta datain the start script to pinpoint exactly which image(s) to remove, including any downloaded OCI archives when the container instance is removed. Fixes #1147 Signed-off-by: Joachim Wiberg --- .../etc/finit.d/available/container@.conf | 9 +- board/common/rootfs/usr/sbin/container | 125 ++++++++++++++++-- src/confd/src/core.c | 6 +- src/confd/src/infix-containers.c | 26 ++++ 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/board/common/rootfs/etc/finit.d/available/container@.conf b/board/common/rootfs/etc/finit.d/available/container@.conf index 7a494c60..4e6b813c 100644 --- a/board/common/rootfs/etc/finit.d/available/container@.conf +++ b/board/common/rootfs/etc/finit.d/available/container@.conf @@ -1,7 +1,8 @@ # Start a container instance (%i) and redirect logs to /log/container -# Give podman enough time to properly shut down the container, kill:30 -# The pre:script, which is responsibe for fetching a remote image, must -# not have a timeout. The cleanup should take no longer than a minute. +# Give podman enough time to properly shut down the container, kill:30, +# which is also matched in the container script (podman default is 10!) +# The pre:script, responsibe for fetching a remote image and calling on +# '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:60,/usr/sbin/container \ + pre:0,/usr/sbin/container cleanup:0,/usr/sbin/container \ [2345] :%i container -n %i -- container %i diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index c3a5bda4..bbebb43b 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -6,19 +6,24 @@ # NOTE: when creating/deleting containers, remember 'initctl reload' to # activate the changes! In confd this is already handled. # +CLEANUP=/var/lib/containers/cleanup DOWNLOADS=/var/lib/containers/oci BUILTIN=/lib/oci BASEDIR=/var/tmp container=$0 checksum="" extracted= -timeout=30 +timeout=30 # NOTE: matched with container@.conf dir="" all="" env="" port="" force= +# Variable shared across subshells +export meta_sha="" + + log() { logger -I $PPID -t container -p local1.notice -- "$*" @@ -118,6 +123,7 @@ load_archive() { uri=$1 tag=$2 + tmp=$3 img=$(basename "$uri") # Supported transports for load and create @@ -230,7 +236,8 @@ load_archive() err 1 "failed tagging image as $tag" fi - # Save archive SHA256 to sidecar file for local archives to enable optimization + # Save archive SHA256 to sidecar file to enable optimization + # This applies to both local archives and downloaded remote archives if [ -f "$file" ]; then img_sha256=$(sha256sum "$file" | cut -f1 -d' ') archive_basename=$(basename "$file") @@ -239,6 +246,10 @@ load_archive() mkdir -p "$DOWNLOADS" echo "$img_sha256" > "$sha_file" + if [ -n "$tmp" ]; then + echo "$img_sha256" > "$tmp" + fi + log "Saved archive checksum to $sha_file" fi @@ -273,10 +284,16 @@ create() # Unpack and load docker-archive/oci/oci-archive, returning image # name, or return docker:// URL for download. - if ! image=$(load_archive "$image"); then + sha_file=$(mktemp -u) + if ! image=$(load_archive "$image" "" "$sha_file"); then exit 1 fi + if [ -f "$sha_file" ]; then + meta_sha=$(cat "$sha_file" 2>/dev/null || echo "") + rm "$sha_file" + fi + if [ -z "$logging" ]; then logging="--log-driver syslog" fi @@ -310,8 +327,10 @@ create() # Add optimization labels for meta-sha256 and config checksum script="/run/containers/${name}.sh" if [ -f "$script" ]; then - # Extract meta-sha256 from script if present - meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) + if [ -z "$meta_sha" ]; then + # Extract meta-sha256 from script if present + meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) + fi if [ -n "$meta_sha" ]; then args="$args --label meta-sha256=$meta_sha" fi @@ -357,11 +376,90 @@ delete() sleep 1 done + # NOTE: -v does not remove *named volumes* podman rm -vif "$name" >/dev/null 2>&1 [ -n "$quiet" ] || log "Container $name has been removed." +} - cnt=$(podman image prune -af | wc -l) - log "Pruned $cnt image(s)" +# Called by Finit cleanup:script when a container service is removed. +# Processes all container instance IDs found in $CLEANUP// directory +# and removes them. This handles the case where a container with the same +# name but different configuration replaces an old one. +# +# Note: Volumes are NOT automatically removed to prevent accidental data loss. +# Use 'admin-exec container prune' to clean up unused volumes manually. +cleanup() +{ + if [ -z "$name" ]; then + log "cleanup: missing container name" + return 1 + fi + + cleanup_dir="$CLEANUP/$name" + if [ ! -d "$cleanup_dir" ]; then + log "cleanup: no cleanup directory for $name, nothing to do" + return 0 + fi + + log "Cleaning up container instances for: $name" + + # Remove all container instances by ID from the cleanup directory + for id_file in "$cleanup_dir"/*; do + [ -f "$id_file" ] || continue + cid=$(basename "$id_file") + + # Extract image name and meta-sha256 from the container instance labels + img=$(podman inspect "$cid" 2>/dev/null | jq -r '.[].ImageName' 2>/dev/null || echo "") + sha=$(podman inspect "$cid" --format '{{index .Config.Labels "meta-sha256"}}' 2>/dev/null || echo "") + + log "Removing container instance with ID: ${cid:0:12}..." + podman rm -vif "$cid" >/dev/null 2>&1 + rm -f "$id_file" + + # Clean up the image if it exists and is not used by other containers + if [ -n "$img" ] && [ "$img" != "null" ]; then + if ! podman ps -a --format "{{.Image}}" | grep -q "^${img}$"; then + log "Removing unused image: $img" + podman rmi "$img" 2>/dev/null || true + + # Also remove archive and sidecar file from $DOWNLOADS for remote images + if [ -n "$sha" ] && [ -d "$DOWNLOADS" ]; then + # Search for matching sidecar file containing this SHA + for sha_file in "$DOWNLOADS"/*.sha256; do + [ -f "$sha_file" ] || continue + stored_sha=$(cat "$sha_file" 2>/dev/null || echo "") + if [ "$stored_sha" = "$sha" ]; then + # Found matching sidecar - derive archive filename + archive="${sha_file%.sha256}" + + if [ -f "$archive" ]; then + # Safety check: verify archive SHA matches before removing + archive_sha=$(sha256sum "$archive" 2>/dev/null | awk '{print $1}') + if [ "$archive_sha" = "$sha" ]; then + log "Removing archive: $archive" + rm -f "$archive" + else + log "Warning: archive SHA mismatch for $archive, not removing" + fi + fi + + log "Removing sidecar file: $sha_file" + rm -f "$sha_file" + break + fi + done + fi + else + log "Image $img still in use by other containers, not removing" + fi + fi + done + + # Remove the cleanup directory for this container, and parent if empty + rmdir "$cleanup_dir" 2>/dev/null + rmdir "$CLEANUP" 2>/dev/null + + exit 0 } waitfor() @@ -466,7 +564,7 @@ netrestart() done } -cleanup() +atexit() { pidfile=$(pidfn "$name") @@ -674,7 +772,7 @@ if [ -n "$cmd" ]; then shift fi -trap cleanup INT HUP TERM +trap atexit INT HUP TERM case $cmd in # Does not work atm., cannot attach to TTY because @@ -682,6 +780,9 @@ case $cmd in # attach) # podman attach "$1" # ;; + cleanup) # Hidden from public view, use remove or flush commands instead + cleanup "$@" + ;; create) [ -n "$quiet" ] || log "Got create args: $*" create "$@" @@ -920,7 +1021,7 @@ case $cmd in rm -f "$pidfile" cnt=$(podman image prune -f | wc -l) - log "setup: pruned $cnt image(s)" + log "Pruned $cnt image(s)" ;; shell) if [ -z "$name" ]; then @@ -1060,8 +1161,8 @@ case $cmd in ;; cleanup) # Called as cleanup-script from Finit service - log "Calling $container -n $SERVICE_ID delete" - exec $container -q -n "$SERVICE_ID" delete + log "Calling $container -n $SERVICE_ID cleanup" + exec $container -q -n "$SERVICE_ID" cleanup ;; *) false diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 49fb59fe..719d6228 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -82,8 +82,12 @@ int core_post_hook(sr_session_ctx_t *session, uint32_t sub_id, const char *modul return SR_ERR_OK; } - if (systemf("initctl -b reload")) + if (systemf("initctl -b reload")) { + EMERG("initctl reload: failed applying new configuration!"); return SR_ERR_SYS; + } + + AUDIT("The new configuration has been applied."); return SR_ERR_OK; } diff --git a/src/confd/src/infix-containers.c b/src/confd/src/infix-containers.c index b4e37a89..61d777ea 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -21,6 +21,7 @@ #define CFG_XPATH "/infix-containers:containers" #define _PATH_CONT "/run/containers" +#define _PATH_CLEAN "/var/lib/containers/cleanup" /* * Check if image is a local archive and return the offset to the file path. @@ -302,9 +303,34 @@ static int add(const char *name, struct lyd_node *cif) */ static int del(const char *name) { + char prune_dir[sizeof(_PATH_CLEAN) + strlen(name) + 3]; + char buf[256]; + FILE *pp; + erasef("%s/%s.sh", _PATH_CONT, name); systemf("initctl -bnq disable container@%s.conf", name); + /* Schedule a cleanup job for this container as soon as it has stopped */ + snprintf(prune_dir, sizeof(prune_dir), "%s/%s", _PATH_CLEAN, name); + systemf("mkdir -p %s", prune_dir); + + /* Finit cleanup:script runs when container is deleted, it will remove any image by-ID */ + pp = popenf("r", "podman inspect %s 2>/dev/null | jq -r '.[].Id' 2>/dev/null", name); + if (!pp) { + /* Nothing to do, if we can't get the Id we cannot safely remove anything */ + ERROR("Cannot find any container instance named '%s' to delete", name); + rmdir(prune_dir); + return SR_ERR_OK; + } + + if (fgets(buf, sizeof(buf), pp)) { + chomp(buf); + if (strlen(buf) > 2) + touchf("%s/%s", prune_dir, buf); + } + + pclose(pp); + return SR_ERR_OK; } From 5b7325261a993ecc172a27609b4e21acb09e0b4b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 10 Oct 2025 17:40:24 +0200 Subject: [PATCH 08/26] container: use 'nice' for podman load and create When loading big OCI images at boot the `podman load` process completely monopolizes all cores of an Arm Cortex-A72. It blocks on I/O, sure, but with 'nice' we can get some attention at least to more critical services at boot. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index bbebb43b..1ee8ae7a 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -211,7 +211,7 @@ load_archive() fi [ -n "$quiet" ] || log "Loading OCI image $dir ..." - output=$(podman load -qi "$dir") + output=$(nice podman load -qi "$dir") # Extract image ID from podman load output: # "Loaded image: sha256:cd9d0aaf81be..." @@ -342,7 +342,7 @@ create() # shellcheck disable=SC2048 log "podman create --name $name --conmon-pidfile=$pidfile $args $image $*" - if podman create --name "$name" --conmon-pidfile="$pidfile" $args "$image" $*; then + if nice podman create --name "$name" --conmon-pidfile="$pidfile" $args "$image" $*; then [ -n "$quiet" ] || log "Successfully created container $name from $image" [ -n "$manual" ] || start "$name" From 7fbc2eba4f3629550af17d3a3d23e2f488b785ca Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 10:18:49 +0200 Subject: [PATCH 09/26] container: make 'container remove' cli command slightly more useful Usually, when your system is up and running properly, you want to clean up anything unused from your previous experiments. This change alllows that by calling the interactive 'podman image prune -a -f' command from the CLI command 'container remove all' Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 13 ++++++++++--- src/klish-plugin-infix/xml/containers.xml | 9 +++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index 1ee8ae7a..a8be65d3 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -584,7 +584,7 @@ usage: container [opt] cmd [arg] options: - -a, --all Show all, of something + -a, --all Show all, do all, remove all, of something --dns NAMESERVER Set nameserver(s) when creating a container --dns-search LIST Set host lookup search list when creating container --cap-add CAP Add capability to unprivileged container @@ -624,7 +624,7 @@ commands: list [image | oci] List names (only) of containers, images, or OCI archives load [NAME | URL] NM Load OCI tarball fileNAME or URL to image NM locate Find container that currently owns '--net IFNAME' - remove IMAGE Remove an (unused) container image + remove [IMAGE] Remove an image, or prune unused images including OCI archives restart [network] NAME Restart a (crashed) container or container(s) using network run NAME [CMD] Run a container interactively, with an optional command save IMAGE FILE Save a container image to an OCI tarball FILE[.tar.gz] @@ -876,7 +876,14 @@ case $cmd in podman pull "$@" ;; remove) - podman rmi $all $force -i "$1" + if [ -n "$all" ]; then + log "Removing all OCI archives from $DOWNLOADS directory ..." + find "${DOWNLOADS:?}" -mindepth 1 -exec rm -rf {} + + log "Removing all unused container images ..." + podman image prune $all $force + else + podman rmi $force -i "$1" + fi ;; run) img=$1 diff --git a/src/klish-plugin-infix/xml/containers.xml b/src/klish-plugin-infix/xml/containers.xml index 23becbae..4d697c8a 100644 --- a/src/klish-plugin-infix/xml/containers.xml +++ b/src/klish-plugin-infix/xml/containers.xml @@ -102,9 +102,14 @@ - + + + - doas container remove $KLISH_PARAM_name + if [ -n "$KLISH_PARAM_name" ]; then + all=-a + fi + doas container $all remove $KLISH_PARAM_name From f53d26bf34b543e70e2f1f609747389869bf9d79 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 10:34:49 +0200 Subject: [PATCH 10/26] container: upgrade fixes for mutable images Container instances that run with mutable images, e.g., tagged with `:latest` or similar non-versioned tags, can be upgraded without changing the config. This commit fixes two issues found with this support: - force container image re-fetch on upgrade, even if the file exists locally - surgically remove old image from container store after upgrade Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 119 +++++++++++++----- .../infix_containers/container_volume/test.py | 2 +- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index a8be65d3..77d9f9d5 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -80,10 +80,15 @@ fetch() cd "$DOWNLOADS" || return if [ -e "$file" ]; then - log "$file already available." - if check "$file"; then - echo "$dst" - return 0 + if [ -n "$force" ]; then + log "Force flag set, removing cached $file and re-downloading." + rm -f "$file" + else + log "$file already available." + if check "$file"; then + echo "$dst" + return 0 + fi fi fi @@ -140,7 +145,8 @@ load_archive() fi ;; *) # docker://*, docker-archive:*, or URL - if podman image exists "$img"; then + # Skip existence check if force flag is set (e.g., for upgrade command) + if [ -z "$force" ] && podman image exists "$img"; then echo "$img" return 0 fi @@ -594,7 +600,7 @@ options: -d, --detach Detach a container started with 'run IMG [CMD]' -e, --env FILE Environment variables when creating container --entrypoint Disable container image's ENTRYPOINT, run cmd + arg - -f, --force Force operation, e.g. remove + -f, --force Force operation, e.g. remove, or force image re-fetch -h, --help Show this help text --hostname NAME Set hostname when creating container --net NETWORK Network interface(s) when creating or finding container @@ -1120,32 +1126,89 @@ case $cmd in podman stats -i 2 ;; upgrade) - # Start script used to initially create container - script=/run/containers/${1}.sh - - # Find container image - img=$(podman inspect "$1" | jq -r .[].ImageName) - if [ -z "$img" ]; then - echo "No such container ($1), or invalid ImageName. Cannot upgrade." - exit 1; + if [ -z "$name" ]; then + name="$1" fi + script=/run/containers/${name}.sh - # Likely an OCI archive, or local directory, assume user has updated image. - if echo "$img" | grep -Eq '^localhost/'; then - file=$(awk '/^# meta-image:/ {print $3}' "$script") - echo ">> Upgrading container $1 using $file ..." - else - printf ">> Stopping ... " - podman stop "$1" - printf ">> " - podman pull "$img" || (echo "Failed fetching $img, check your network (settings)."; exit 1) - echo ">> Starting $1 ..." - fi - if ! "$script"; then - echo ">> Failed recreating container $1" + # Verify container exists + if ! podman container exists "$name"; then + echo "No such container: $name" exit 1 fi - echo ">> Done." + + # Check if container uses a mutable tag (e.g., :latest) + # Get the actual image name from the running container + image_name=$(podman inspect "$name" | jq -r '.[].ImageName') + if [ -z "$image_name" ]; then + echo "Cannot determine ImageName for container $name" + exit 1 + fi + + # Check if image uses :latest or other mutable tag + # Images with immutable digests (@sha256:...) don't benefit from upgrade + if echo "$image_name" | grep -qE '@sha256:'; then + echo "Container $name uses an immutable image digest ($image_name)" + echo "Upgrade will have no effect. Update the configuration to use a mutable tag." + exit 1 + fi + + # Get image URI from the container script + img=$(awk '/^# meta-image:/ {print $3}' "$script") + if [ -z "$img" ]; then + echo "Cannot determine image for container $name" + exit 1 + fi + + echo ">> Upgrading container $name from image $img ..." + + # Capture the current image ID before upgrade so we can remove it after + old_image_id=$(podman inspect "$name" --format '{{.ImageID}}' 2>/dev/null) + + # Stop container using proper command (goes through Finit) + printf ">> Stopping container ... " + container stop "$name" + echo "done" + + # Set force flag to ensure fresh pull/fetch of image + force="-f" + + # For remote images, force re-pull + case "$img" in + docker://* | ftp://* | http://* | https://*) + printf ">> Pulling latest image ... " + if ! load_archive "$img" >/dev/null 2>&1; then + echo "failed" + echo "Failed fetching $img, check your network settings." + exit 1 + fi + echo "done" + ;; + *) + # Local archives - user must update the file manually + echo ">> Using local image $img (ensure file is updated) ..." + ;; + esac + + # Recreate container by running the script + echo ">> Recreating container ..." + if ! "$script"; then + echo ">> Failed recreating container $name" + exit 1 + fi + + # Remove the old image if it's not used by any other containers + if [ -n "$old_image_id" ]; then + # Check if the old image is still in use by any containers + if ! podman ps -a --format '{{.ImageID}}' | grep -q "^${old_image_id}$"; then + log "Removing old image $old_image_id" + podman rmi "$old_image_id" 2>/dev/null || true + else + log "Old image $old_image_id still in use by other containers, keeping it" + fi + fi + + echo ">> Container $name upgraded successfully." ;; volume) cmd=$1 diff --git a/test/case/infix_containers/container_volume/test.py b/test/case/infix_containers/container_volume/test.py index 95be8fe0..896b8ea8 100755 --- a/test/case/infix_containers/container_volume/test.py +++ b/test/case/infix_containers/container_volume/test.py @@ -57,7 +57,7 @@ with infamy.Test() as test: with test.step("Upgrade container"): out = tgtssh.runsh(f"sudo container upgrade {NAME}") - if ">> Done." not in out.stdout: + if f">> Container {NAME} upgraded successfully." not in out.stdout: msg = f"Failed upgrading container {NAME}:\n" \ f"STDOUT:\n{out.stdout}\n" \ f"STDERR:\n{out.stderr}" From da5b4bcffff0660afd8e17078aa6d0d4ec3118f7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 11:02:47 +0200 Subject: [PATCH 11/26] container: remove old image when upgrading at startup One unique feature of Infix OS is that you can embed your OCI archive in the rootfs.squashfs. This way your container instance does not need to download anything from the network. To upgrade you drop in a new image and rebuild Infix, when the new Infix boots the container script 'setup' command will recognize that the OCI archive has changed and will reload it into the container store. This patch is an improvement of the way too generic 'podman image prune' command used previously. Instead of looking for any dangling image, we now surgically remove the old image. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index 77d9f9d5..9579b251 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -997,6 +997,12 @@ case $cmd in fi fi + # Capture old image ID before recreation (for surgical cleanup after) + old_image_id="" + if podman container exists "$name"; then + old_image_id=$(podman inspect "$name" --format '{{.ImageID}}' 2>/dev/null) + fi + # Save our PID in case we get stuck here and someone wants to # stop us, e.g., due to reconfiguration or reboot. pidfile=$(pidfn "${name}") @@ -1033,8 +1039,17 @@ case $cmd in fi rm -f "$pidfile" - cnt=$(podman image prune -f | wc -l) - log "Pruned $cnt image(s)" + + # Remove the old image if it's not used by any other containers + if [ -n "$old_image_id" ]; then + # Check if the old image is still in use by any containers + if ! podman ps -a --format '{{.ImageID}}' | grep -q "^${old_image_id}$"; then + log "Removing old image $old_image_id" + podman rmi "$old_image_id" 2>/dev/null || true + else + log "Old image $old_image_id still in use by other containers, keeping it" + fi + fi ;; shell) if [ -z "$name" ]; then From ea2c4be237c9ed2c784fe4da15e2cd6d44340ea5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 21 Oct 2025 13:55:02 +0200 Subject: [PATCH 12/26] container: refactor and cleanup per review comments Shell script: - Factor out big portions of code into more logical helper functions - Simplify calling setup script by checking for remote image first - Simplify meta/sha up-to-date handling and clarify terminology - Consistent use of -f instead of -e in file-exists checks - Fix unsafe use of 'mktemp -u' C code: - Clarify meta/sha terminology: rename meta-sha256 -> meta-image-sha256 - Refactor weird archive_offset() function to local_path() helper - Factor out helper function calc_sha() - Check len of sha256 >= 64 Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/sbin/container | 280 ++++++++++++++----------- src/confd/src/infix-containers.c | 73 ++++--- 2 files changed, 199 insertions(+), 154 deletions(-) diff --git a/board/common/rootfs/usr/sbin/container b/board/common/rootfs/usr/sbin/container index 9579b251..e407c1ab 100755 --- a/board/common/rootfs/usr/sbin/container +++ b/board/common/rootfs/usr/sbin/container @@ -6,6 +6,11 @@ # NOTE: when creating/deleting containers, remember 'initctl reload' to # activate the changes! In confd this is already handled. # +# TODO: this script should be refactored to take a .cfg file instead for +# each container. For details, see this pull request discussion: +# https://github.com/kernelkit/infix/pull/1151#discussion_r2444851292 +# +# shellcheck disable=SC1001 CLEANUP=/var/lib/containers/cleanup DOWNLOADS=/var/lib/containers/oci BUILTIN=/lib/oci @@ -23,6 +28,10 @@ force= # Variable shared across subshells export meta_sha="" +dbg() +{ + logger -I $PPID -t container -p local1.debug -- "$*" +} log() { @@ -49,6 +58,104 @@ pidfn() echo "/run/containers/${1}.pid" } +calc_sha() +{ + sha256sum "$1" 2>/dev/null | awk '{print $1}' +} + +# Check image transport, return 0 if remote, 1 if local +is_remote() +{ + image=$(awk '/^# meta-image:/ {print $3}' "$1" 2>/dev/null || echo "") + echo "$image" + + case "$image" in + oci\:* | oci-archive\:* | docker-archive\:* | docker-daemon\:* | \ + dir\:* | containers-storage\:* | ostree\:* | sif\:* | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +# Check loaded image in container store vs archive, as well as verifying +# the container instance vs. the configuration. +# +# This is an optimization to cut down startup times. +is_uptodate() +{ + img_sha=$(awk '/^# meta-image-sha256:/ {print $3}' "$script" 2>/dev/null) + if [ -n "$img_sha" ]; then + # Local image optimization: check if archive SHA matches stored sidecar file + img=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null) + if [ -n "$img" ]; then + case "$img" in + docker-archive\:*) + archive_path="${img#docker-archive:}" + ;; + oci-archive\:*) + archive_path="${img#oci-archive:}" + ;; + *) + archive_path="$img" + ;; + esac + + # Handle relative paths - check BUILTIN and DOWNLOADS + if [ ! -f "$archive_path" ]; then + if [ -f "$DOWNLOADS/$(basename "$archive_path")" ]; then + archive_path="$DOWNLOADS/$(basename "$archive_path")" + elif [ -f "$BUILTIN/$(basename "$archive_path")" ]; then + archive_path="$BUILTIN/$(basename "$archive_path")" + fi + fi + + # Check if the archive exists and compare SHA with sidecar file + if [ -f "$archive_path" ]; then + archive_basename=$(basename "$archive_path") + sha_file="$DOWNLOADS/${archive_basename}.sha256" + + if [ -f "$sha_file" ]; then + stored_sha=$(cat "$sha_file" 2>/dev/null) + current_sha=$(calc_sha "$archive_path") + + # If SHA matches, check container instance + if [ "$stored_sha" = "$current_sha" ]; then + if podman container exists "$name"; then + config_sha=$(calc_sha "$script") + container_sha=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) + container_img_sha=$(podman inspect "$name" --format '{{index .Config.Labels "meta-image-sha256"}}' 2>/dev/null) + + if [ "$container_img_sha" = "$img_sha" ] && [ "$container_sha" = "$config_sha" ]; then + # Unmodified local image and configuration + return 0 + fi + fi + else + # Archive changed (e.g., rootfs upgrade) - need to reload + dbg "Archive SHA changed, will reload image and recreate container" + fi + fi + fi + fi + else + # Remote image optimization: check config-sha256 only + if podman container exists "$name"; then + config_sha=$(calc_sha "$script") + container_sha=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) + + if [ "$container_sha" = "$config_sha" ]; then + # Unmodified local image and configuration + return 0 + fi + fi + fi + + return 1 +} + check() { file=$1 @@ -79,7 +186,7 @@ fetch() dst="$DOWNLOADS/$file" cd "$DOWNLOADS" || return - if [ -e "$file" ]; then + if [ -f "$file" ]; then if [ -n "$force" ]; then log "Force flag set, removing cached $file and re-downloading." rm -f "$file" @@ -161,10 +268,10 @@ load_archive() ;; esac - if [ ! -e "$file" ]; then - if [ -e "$DOWNLOADS/$file" ]; then + if [ ! -f "$file" ]; then + if [ -f "$DOWNLOADS/$file" ]; then file="$DOWNLOADS/$file" - elif [ -e "$BUILTIN/$file" ]; then + elif [ -f "$BUILTIN/$file" ]; then file="$BUILTIN/$file" else err 1 "cannot find OCI archive $file in URI $uri" @@ -245,7 +352,7 @@ load_archive() # Save archive SHA256 to sidecar file to enable optimization # This applies to both local archives and downloaded remote archives if [ -f "$file" ]; then - img_sha256=$(sha256sum "$file" | cut -f1 -d' ') + img_sha256=$(calc_sha "$file") archive_basename=$(basename "$file") sha_file="$DOWNLOADS/${archive_basename}.sha256" @@ -290,13 +397,13 @@ create() # Unpack and load docker-archive/oci/oci-archive, returning image # name, or return docker:// URL for download. - sha_file=$(mktemp -u) + sha_file=$(mktemp) if ! image=$(load_archive "$image" "" "$sha_file"); then exit 1 fi - if [ -f "$sha_file" ]; then - meta_sha=$(cat "$sha_file" 2>/dev/null || echo "") + if [ -s "$sha_file" ]; then + img_sha=$(cat "$sha_file" 2>/dev/null || echo "") rm "$sha_file" fi @@ -330,20 +437,19 @@ create() args="$args --network=none" fi - # Add optimization labels for meta-sha256 and config checksum + # Add optimization labels for meta-image-sha256 and config checksum script="/run/containers/${name}.sh" if [ -f "$script" ]; then - if [ -z "$meta_sha" ]; then - # Extract meta-sha256 from script if present - meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) + if [ -z "$img_sha" ]; then + # Extract meta-image-sha256 from script if present + img_sha=$(awk '/^# meta-image-sha256:/ {print $3}' "$script" 2>/dev/null) fi - if [ -n "$meta_sha" ]; then - args="$args --label meta-sha256=$meta_sha" + if [ -n "$img_sha" ]; then + args="$args --label meta-image-sha256=$img_sha" fi # Add config checksum label - config_sha=$(sha256sum "$script" | cut -f1 -d' ') - args="$args --label config-sha256=$config_sha" + args="$args --label config-sha256=$(calc_sha "$script")" fi # shellcheck disable=SC2048 @@ -414,9 +520,10 @@ cleanup() [ -f "$id_file" ] || continue cid=$(basename "$id_file") - # Extract image name and meta-sha256 from the container instance labels + # Extract image name and meta-image-sha256 from the container instance labels + # These are the image in container store and the trace to an oci-archive.tar.gz img=$(podman inspect "$cid" 2>/dev/null | jq -r '.[].ImageName' 2>/dev/null || echo "") - sha=$(podman inspect "$cid" --format '{{index .Config.Labels "meta-sha256"}}' 2>/dev/null || echo "") + sha=$(podman inspect "$cid" --format '{{index .Config.Labels "meta-image-sha256"}}' 2>/dev/null || echo "") log "Removing container instance with ID: ${cid:0:12}..." podman rm -vif "$cid" >/dev/null 2>&1 @@ -428,20 +535,19 @@ cleanup() log "Removing unused image: $img" podman rmi "$img" 2>/dev/null || true - # Also remove archive and sidecar file from $DOWNLOADS for remote images + # Also remove archive and sidecar checksum file from $DOWNLOADS for remote images + # I.e., $DOWNLOADS/oci-archive.tar.gz and $DOWNLOADS/oci-archive.tar.gz.sha256 if [ -n "$sha" ] && [ -d "$DOWNLOADS" ]; then # Search for matching sidecar file containing this SHA for sha_file in "$DOWNLOADS"/*.sha256; do [ -f "$sha_file" ] || continue stored_sha=$(cat "$sha_file" 2>/dev/null || echo "") if [ "$stored_sha" = "$sha" ]; then - # Found matching sidecar - derive archive filename + # Found matching sidecar - derive archive filename by stripping .sha256 archive="${sha_file%.sha256}" - if [ -f "$archive" ]; then # Safety check: verify archive SHA matches before removing - archive_sha=$(sha256sum "$archive" 2>/dev/null | awk '{print $1}') - if [ "$archive_sha" = "$sha" ]; then + if [ "$(calc_sha "$archive")" = "$sha" ]; then log "Removing archive: $archive" rm -f "$archive" else @@ -508,17 +614,19 @@ stop() # When called by Finit: `initctl stop foo`, the container script # is called as `container -n $foo stop`. For the stop command # we want to bypass the default 10 second timeout. +# +# NOTE: containers have three phases: setup, running, and teardown. +# the setup phase is this script trying to fetch the image. wrap() { name=$1 cmd=$2 + args= pidfile=$(pidfn "$name") - # Containers have three phases: setup, running, and teardown. - - # The setup phase may run forever in the background trying to fetch - # the image. It saves its PID in /run/containers/${name}.pid if [ "$cmd" = "stop" ]; then + # The setup phase may run forever in the background trying to fetch + # the image. It saves its PID in /run/containers/${name}.pid if [ -f "$pidfile" ]; then pid=$(cat "$pidfile") @@ -532,9 +640,8 @@ wrap() return 0 fi + # Only the 'podman stop' command takes -i and --timeout args="-i --timeout $timeout" - else - args= fi # Skip "echo $name" from podman start in log @@ -925,76 +1032,9 @@ case $cmd in script=/run/containers/${name}.sh [ -x "$script" ] || err 1 "setup: $script does not exist or is not executable." - # Early exit optimization: check if container is already up-to-date - meta_sha=$(awk '/^# meta-sha256:/ {print $3}' "$script" 2>/dev/null) - if [ -n "$meta_sha" ]; then - # Local image optimization: check if archive SHA matches stored sidecar file - # Extract the image path from the script - img=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null) - if [ -n "$img" ]; then - # Determine the archive file path - case "$img" in - oci-archive:*) - archive_path="${img#oci-archive:}" - ;; - *) - archive_path="$img" - ;; - esac - - # Handle relative paths - check BUILTIN and DOWNLOADS - if [ ! -e "$archive_path" ]; then - if [ -e "$DOWNLOADS/$(basename "$archive_path")" ]; then - archive_path="$DOWNLOADS/$(basename "$archive_path")" - elif [ -e "$BUILTIN/$(basename "$archive_path")" ]; then - archive_path="$BUILTIN/$(basename "$archive_path")" - fi - fi - - # Check if the archive exists and compare SHA with sidecar file - if [ -f "$archive_path" ]; then - archive_basename=$(basename "$archive_path") - sha_file="$DOWNLOADS/${archive_basename}.sha256" - - # Check if sidecar file exists - if [ -f "$sha_file" ]; then - stored_sha=$(cat "$sha_file" 2>/dev/null) - current_sha=$(sha256sum "$archive_path" | cut -f1 -d' ') - - # If SHA matches, check container instance - if [ "$stored_sha" = "$current_sha" ]; then - if podman container exists "$name"; then - # Check if container has matching labels - config_sha=$(sha256sum "$script" | cut -f1 -d' ') - container_meta=$(podman inspect "$name" --format '{{index .Config.Labels "meta-sha256"}}' 2>/dev/null) - container_config=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) - - if [ "$container_meta" = "$meta_sha" ] && [ "$container_config" = "$config_sha" ]; then - log "Container $name is up-to-date (archive and config unchanged), skipping setup" - exit 0 - fi - fi - else - # Archive changed (e.g., rootfs upgrade) - need to reload - log "Archive SHA changed, will reload image and recreate container" - fi - fi - fi - fi - else - # Remote image optimization: check config-sha256 only - if podman container exists "$name"; then - # Get current config checksum from script - config_sha=$(sha256sum "$script" | cut -f1 -d' ') - - # Get container's stored config checksum - container_config=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) - - if [ -n "$container_config" ] && [ "$container_config" = "$config_sha" ]; then - log "Container $name config unchanged, skipping setup" - exit 0 - fi - fi + if is_uptodate "$script"; then + log "Container $name config unchanged, skipping setup" + exit 0 fi # Capture old image ID before recreation (for surgical cleanup after) @@ -1008,34 +1048,24 @@ case $cmd in pidfile=$(pidfn "${name}") echo $$ > "$pidfile" - # Try setup once first - if ! "$script"; then - # Get image transport to decide if retries make sense - image=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null || echo "") - case "$image" in - oci:* | oci-archive:* | docker-archive:* | docker-daemon:* | dir:* | containers-storage:* | ostree:* | sif:* | /*) - # Local transport - exit immediately on failure - log "${name}: setup failed for local image $image" - rm -f "$pidfile" - exit 1 - ;; - *) - # Remote transport (docker://, ftp://, http://, https://, etc.) - retry on network changes - while ! "$script"; do - log "${name}: setup failed, waiting for network changes ..." + if image=$(is_remote "$script"); then + while ! "$script"; do + log "${name}: setup failed, waiting for network changes ..." - # Timeout and retry after 60 seconds, on SIGTERM, or when - # any network event is caught. - timeout -s TERM -k 1 60 sh -c \ - 'ip monitor address route 2>/dev/null | head -n1 >/dev/null' || true + # Timeout and retry after 60 seconds, on SIGTERM, or when + # any network event is caught. + timeout -s TERM -k 1 60 sh -c \ + 'ip monitor address route 2>/dev/null | head -n1 >/dev/null' || true - # On IP address/route changes, wait a few seconds more to ensure - # the system has ample time to react and set things up for us. - log "${name}: retrying ..." - sleep 2 - done - ;; - esac + # On IP address/route changes, wait a few seconds more to ensure + # the system has ample time to react and set things up for us. + log "${name}: retrying ..." + sleep 2 + done + elif ! "$script"; then + log "${name}: setup failed for local image $image" + rm -f "$pidfile" + exit 1 fi rm -f "$pidfile" diff --git a/src/confd/src/infix-containers.c b/src/confd/src/infix-containers.c index 61d777ea..a62e68f0 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -24,27 +24,42 @@ #define _PATH_CLEAN "/var/lib/containers/cleanup" /* - * Check if image is a local archive and return the offset to the file path. - * Returns 0 if not a recognized local archive format. + * Check if image is a local archive and return the file path. + * Returns NULL if not a recognized local archive format. */ -static int archive_offset(const char *image) +static const char *local_path(const char *image) { - static const struct { - const char *prefix; - int offset; - } prefixes[] = { - { "docker-archive:", 15 }, - { "oci-archive:", 12 }, - { NULL, 0 } + const char *prefixes[] = { + "docker-archive:", + "oci-archive:", + NULL, }; - int i; - for (i = 0; prefixes[i].prefix; i++) { - if (!strncmp(image, prefixes[i].prefix, prefixes[i].offset)) - return prefixes[i].offset; + for (int i = 0; prefixes[i]; i++) { + size_t len = strlen(prefixes[i]); + + if (!strncmp(image, prefixes[i], len)) + return image + len; } - return 0; + return NULL; +} + +static int calc_sha(const char *path, char *sha256, size_t len) +{ + int rc = 1; + FILE *pp; + + pp = popenf("r", "sha256sum \"%s\" 2>/dev/null | awk '{print $1}'", path); + if (pp) { + if (fgets(sha256, len, pp)) { + chomp(sha256); + rc = 0; + } + pclose(pp); + } + + return rc; } /* @@ -61,8 +76,8 @@ static int add(const char *name, struct lyd_node *cif) const char *restart_policy, *string, *image; struct lyd_node *node, *nets, *caps; char script[strlen(name) + 5]; + const char *path; FILE *fp, *ap; - int offset; snprintf(script, sizeof(script), "%s.sh", name); fp = fopenf("w", "%s/%s", _PATH_CONT, script); @@ -82,24 +97,23 @@ static int add(const char *name, struct lyd_node *cif) * a running container instance. */ image = lydx_get_cattr(cif, "image"); + + /* + * TODO: this script should be refactored to be a .cfg file to + * the container helper script, or in the future, even a + * .cfg file to another runtime/engine. For details, see + * https://github.com/kernelkit/infix/pull/1151#discussion_r2444851292 + */ fprintf(fp, "#!/bin/sh\n" "# meta-name: %s\n" "# meta-image: %s\n", name, image); - offset = archive_offset(image); - if (offset) { - const char *path = image + offset; + path = local_path(image); + if (path) { char sha256[65] = { 0 }; - FILE *pp; - pp = popenf("r", "sha256sum %s | cut -f1 -d' '", path); - if (pp) { - if (fgets(sha256, sizeof(sha256), pp)) { - chomp(sha256); - fprintf(fp, "# meta-sha256: %s\n", sha256); - } - pclose(pp); - } + if (!calc_sha(path, sha256, sizeof(sha256))) + fprintf(fp, "# meta-image-sha256: %s\n", sha256); } fprintf(fp, "container --quiet delete %s >/dev/null\n" @@ -325,7 +339,8 @@ static int del(const char *name) if (fgets(buf, sizeof(buf), pp)) { chomp(buf); - if (strlen(buf) > 2) + /* The return sha should be at least 64 chars long (sha256) */ + if (strlen(buf) >= 64) touchf("%s/%s", prune_dir, buf); } From 671fc93ce00e59daab24b8376765cc688c6e4c37 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 23:06:55 +0200 Subject: [PATCH 13/26] confd: add support for upgrade action (rpc) Already supported in the CLI. This makes it official, and quite handy for users that run mutable containers. Signed-off-by: Joachim Wiberg --- src/confd/src/infix-containers.c | 1 + src/confd/yang/confd/infix-containers.yang | 18 ++++++++++++++++-- ...9.yang => infix-containers@2025-10-12.yang} | 0 src/confd/yang/containers.inc | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) rename src/confd/yang/confd/{infix-containers@2025-09-09.yang => infix-containers@2025-10-12.yang} (100%) diff --git a/src/confd/src/infix-containers.c b/src/confd/src/infix-containers.c index a62e68f0..1c579930 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -492,6 +492,7 @@ int infix_containers_init(struct confd *confd) REGISTER_RPC(confd->session, CFG_XPATH "/container/start", action, NULL, &confd->sub); REGISTER_RPC(confd->session, CFG_XPATH "/container/stop", action, NULL, &confd->sub); REGISTER_RPC(confd->session, CFG_XPATH "/container/restart", action, NULL, &confd->sub); + REGISTER_RPC(confd->session, CFG_XPATH "/container/upgrade", action, NULL, &confd->sub); REGISTER_RPC(confd->session, "/infix-containers:oci-load", oci_load, NULL, &confd->sub); return SR_ERR_OK; diff --git a/src/confd/yang/confd/infix-containers.yang b/src/confd/yang/confd/infix-containers.yang index b4c90ada..07746d0e 100644 --- a/src/confd/yang/confd/infix-containers.yang +++ b/src/confd/yang/confd/infix-containers.yang @@ -22,8 +22,10 @@ module infix-containers { prefix infix-sys; } - revision 2025-09-09 { - description "Add dedicated 'ident' type for container and volume names."; + revision 2025-10-12 { + description "Two major changes: + - Add dedicated 'ident' type for container and volume names. + - New upgrade action (RPC) for containers with :latest image."; reference "internal"; } @@ -484,6 +486,18 @@ module infix-containers { action restart { description "Restart a running, or start, a stopped container."; } + + action upgrade { + description "Upgrade container to latest version of its image. + + This action fetches the latest version of the container's image + and recreates the container with the new image. Any named volumes + are preserved. + + Note: This action is primarily for containers using mutable tags + like ':latest'. For containers using immutable tags (e.g., + ':25.06.0'), the upgrade action will have no effect."; + } } } diff --git a/src/confd/yang/confd/infix-containers@2025-09-09.yang b/src/confd/yang/confd/infix-containers@2025-10-12.yang similarity index 100% rename from src/confd/yang/confd/infix-containers@2025-09-09.yang rename to src/confd/yang/confd/infix-containers@2025-10-12.yang diff --git a/src/confd/yang/containers.inc b/src/confd/yang/containers.inc index b9199119..11dfc5bc 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-09-09.yang" + "infix-containers@2025-10-12.yang" ) From 640a47bcebc84f638d27b18ff85b9066560e31d7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 13 Oct 2025 08:19:24 +0200 Subject: [PATCH 14/26] test: add support for 'make V=1 test-spec' to debug Signed-off-by: Joachim Wiberg --- test/test.mk | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/test.mk b/test/test.mk index 78e74a5b..85f3f4bb 100644 --- a/test/test.mk +++ b/test/test.mk @@ -36,11 +36,18 @@ test: test-sh: $(test-dir)/env $(base) $(mode) $(binaries) $(pkg-$(ARCH)) -i /bin/sh +SPEC_DEBUG := +SPEC_Q := @ +ifeq ($(V),1) +SPEC_DEBUG := -d +SPEC_Q := +endif + test-spec: @esc_infix_name="$(echo $(INFIX_NAME) | sed 's/\//\\\//g')"; \ sed 's/{REPLACE}/$(subst ",,$(esc_infix_name)) $(INFIX_VERSION)/' \ $(spec-dir)/Readme.adoc.in > $(spec-dir)/Readme.adoc - @$(spec-dir)/generate_spec.py -s $(test-dir)/case/all.yaml -r $(BR2_EXTERNAL_INFIX_PATH) + $(SPEC_Q)$(spec-dir)/generate_spec.py -s $(test-dir)/case/all.yaml -r $(BR2_EXTERNAL_INFIX_PATH) $(SPEC_DEBUG) @asciidoctor-pdf --failure-level INFO --theme $(spec-dir)/theme.yml \ -a logo="image:$(LOGO)" \ -a pdf-fontsdir=$(spec-dir)/fonts \ From 905df0dab79ee9aa9d54402325ef3815007f608e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 23:08:26 +0200 Subject: [PATCH 15/26] test: verify container upgrade This commit adds four (small) container images to the Infamy test container which are used in the new container upgrade test. The test verifies that a mutable container can be upgraded and that old images are properly cleaned up from the container store. Fixes #624 Signed-off-by: Joachim Wiberg --- test/.env | 2 +- test/case/infix_containers/Readme.adoc | 1 + test/case/infix_containers/all.yaml | 3 + .../case/infix_containers/upgrade/Readme.adoc | 1 + .../case/infix_containers/upgrade/download.sh | 40 ++++ test/case/infix_containers/upgrade/test.adoc | 34 ++++ test/case/infix_containers/upgrade/test.py | 182 ++++++++++++++++++ .../infix_containers/upgrade/topology.dot | 24 +++ .../infix_containers/upgrade/topology.svg | 42 ++++ test/docker/Dockerfile | 16 +- test/docker/README.md | 3 +- 11 files changed, 341 insertions(+), 7 deletions(-) create mode 120000 test/case/infix_containers/upgrade/Readme.adoc create mode 100755 test/case/infix_containers/upgrade/download.sh create mode 100644 test/case/infix_containers/upgrade/test.adoc create mode 100755 test/case/infix_containers/upgrade/test.py create mode 100644 test/case/infix_containers/upgrade/topology.dot create mode 100644 test/case/infix_containers/upgrade/topology.svg diff --git a/test/.env b/test/.env index 962afdf3..b6968427 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.5 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.6 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/case/infix_containers/Readme.adoc b/test/case/infix_containers/Readme.adoc index 726d2c1a..7fe48f87 100644 --- a/test/case/infix_containers/Readme.adoc +++ b/test/case/infix_containers/Readme.adoc @@ -10,6 +10,7 @@ Tests verifying Infix Docker container support: - Connecting containers with VETH pairs to standard Linux bridges - Assigning physical Ethernet interfaces to containers - Container upgrades with persistent volume data + - Container upgrade using RPC with cleanup of old image - Firewall container running in host network mode with full privileges include::container_basic/Readme.adoc[] diff --git a/test/case/infix_containers/all.yaml b/test/case/infix_containers/all.yaml index 53ec2915..41a0a758 100644 --- a/test/case/infix_containers/all.yaml +++ b/test/case/infix_containers/all.yaml @@ -21,6 +21,9 @@ - name: Container Volume Persistence case: container_volume/test.py +- name: Container Upgrade + case: upgrade/test.py + - name: Basic Firewall Container case: container_firewall_basic/test.py diff --git a/test/case/infix_containers/upgrade/Readme.adoc b/test/case/infix_containers/upgrade/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/infix_containers/upgrade/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/infix_containers/upgrade/download.sh b/test/case/infix_containers/upgrade/download.sh new file mode 100755 index 00000000..7bd28b1a --- /dev/null +++ b/test/case/infix_containers/upgrade/download.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Download curios-httpd container images for upgrade testing +# This script is called during Docker image build to pre-populate +# the test container with the necessary OCI archives. + +set -e + +DEST_DIR="${1:-/srv}" +IMAGE_BASE="ghcr.io/kernelkit/curios-httpd" +VERSIONS="24.05.0 24.11.0" +ARCHS="linux/amd64 linux/arm64" + +echo "Downloading curios-httpd images to $DEST_DIR..." +mkdir -p "$DEST_DIR" + +for ver in $VERSIONS; do + for arch in $ARCHS; do + # Create architecture-specific filename + arch_suffix=$(echo "$arch" | sed 's|linux/||') + output="$DEST_DIR/curios-httpd-${ver}-${arch_suffix}.tar" + + echo "Fetching ${IMAGE_BASE}:${ver} for ${arch}..." + skopeo copy --override-arch "${arch#linux/}" \ + "docker://${IMAGE_BASE}:${ver}" \ + "oci-archive:${output}" + + # Check if already gzipped and compress if needed + output_gz="${output}.gz" + if file "$output" | grep -q "gzip compressed"; then + echo "File ${output} is already gzipped, renaming..." + mv "$output" "$output_gz" + else + echo "Compressing ${output}..." + gzip "${output}" + fi + done +done + +echo "Download complete!" +ls -lh "$DEST_DIR" diff --git a/test/case/infix_containers/upgrade/test.adoc b/test/case/infix_containers/upgrade/test.adoc new file mode 100644 index 00000000..59c24940 --- /dev/null +++ b/test/case/infix_containers/upgrade/test.adoc @@ -0,0 +1,34 @@ +=== Container Upgrade + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/upgrade] + +==== Description + +Verify container upgrade functionality by testing the optimization +that skips recreation when the container configuration hasn't changed. + +This test uses two versions of curios-httpd (24.05.0 and 24.11.0) served +over HTTP with :latest tag. The test verifies that: +1. Container starts successfully from the :latest image +2. When the :latest tag points to a new version, upgrade is triggered +3. The container runs the new version after upgrade + +==== Topology + +image::topology.svg[Container Upgrade topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Detect target architecture +. Set up isolated network and file server +. Create symlink for curios-httpd:latest -> 24.05.0 +. Create container 'web' from curios-httpd:latest (24.05.0) +. Verify container 'web' has started with version 24.05.0 +. Update symlink to point to curios-httpd:24.11.0 +. Trigger container upgrade by calling upgrade action +. Wait for container 'web' to complete uprgade +. Verify container 'web' is running new version 24.11.0 +. Verify old image was pruned and disk usage is reasonable + + diff --git a/test/case/infix_containers/upgrade/test.py b/test/case/infix_containers/upgrade/test.py new file mode 100755 index 00000000..7193c87e --- /dev/null +++ b/test/case/infix_containers/upgrade/test.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Container Upgrade + +Verify container upgrade functionality by testing the optimization +that skips recreation when the container configuration hasn't changed. + +This test uses two versions of curios-httpd (24.05.0 and 24.11.0) served +over HTTP with :latest tag. The test verifies that: +1. Container starts successfully from the :latest image +2. When the :latest tag points to a new version, upgrade is triggered +3. The container runs the new version after upgrade +""" +import os +import time +import infamy +import infamy.file_server as srv +from infamy.util import until + +SRVPORT = 8008 +SRVDIR = "/srv" + +with infamy.Test() as test: + NAME = "web" + + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + tgtssh = env.attach("target", "mgmt", "ssh") + + if not target.has_model("infix-containers"): + test.skip() + + _, hport = env.ltop.xlate("host", "data") + _, tport = env.ltop.xlate("target", "data") + + with test.step("Detect target architecture"): + # Query operational datastore for machine architecture + system_state = target.get_data("/ietf-system:system-state") + arch = system_state["system-state"]["platform"]["machine"] + + # Map kernel arch to our image naming + arch_map = { + "x86_64": "amd64", + "aarch64": "arm64", + "armv7l": "arm64", # Fallback for ARM variants + } + image_arch = arch_map.get(arch, "amd64") + print(f"Detected architecture: {arch} -> using {image_arch} images") + + with test.step("Set up isolated network and file server"): + netns = infamy.IsolatedMacVlan(hport).start() + netns.addip("192.168.0.1") + + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + { + "name": tport, + "ipv4": { + "address": [ + { + "ip": "192.168.0.2", + "prefix-length": 24 + } + ] + } + } + ] + } + } + }) + netns.must_reach("192.168.0.2") + + with srv.FileServer(netns, "192.168.0.1", SRVPORT, SRVDIR): + with test.step("Create symlink for curios-httpd:latest -> 24.05.0"): + # Create symlink in the file server directory + old_img = f"{SRVDIR}/curios-httpd-24.05.0-{image_arch}.tar.gz" + new_img = f"{SRVDIR}/curios-httpd-24.11.0-{image_arch}.tar.gz" + latest_link = f"{SRVDIR}/curios-httpd-latest.tar.gz" + + # Remove any existing symlink + if os.path.exists(latest_link): + os.unlink(latest_link) + + # Point to old version + os.symlink(old_img, latest_link) + print(f"Created symlink: {latest_link} -> {old_img}") + + with test.step("Create container 'web' from curios-httpd:latest (24.05.0)"): + target.put_config_dict("infix-containers", { + "containers": { + "container": [ + { + "name": f"{NAME}", + "image": f"http://192.168.0.1:{SRVPORT}/curios-httpd-latest.tar.gz", + "command": "/usr/sbin/httpd -f -v -p 91", + "network": { + "host": True + } + } + ] + } + }) + + with test.step("Verify container 'web' has started with version 24.05.0"): + c = infamy.Container(target) + until(lambda: c.running(NAME), attempts=60) + + # Get initial operational data to capture container ID and image ID + containers_data = target.get_data("/infix-containers:containers") + container = containers_data["containers"]["container"][NAME] + initial_container_id = container["id"] + initial_image_id = container["image-id"] + + print(f"Container started with container-id: {initial_container_id[:12]}...") + print(f" and image-id: {initial_image_id[:12]}...") + + # Get baseline disk usage for /var/lib/containers + result = tgtssh.runsh("doas du -s /var/lib/containers") + initial_disk_usage = int(result.stdout.split()[0]) + print(f"Disk usage after initial creation: {initial_disk_usage} KiB") + + with test.step("Update symlink to point to curios-httpd:24.11.0"): + # Remove old symlink and point to new version + os.unlink(latest_link) + os.symlink(new_img, latest_link) + print(f"Updated symlink: {latest_link} -> {new_img}") + + with test.step("Trigger container upgrade by calling upgrade action"): + c = infamy.Container(target) + c.action(NAME, "upgrade") + + with test.step("Wait for container 'web' to complete uprgade"): + time.sleep(3) + until(lambda: c.running(NAME), attempts=30) + + with test.step("Verify container 'web' is running new version 24.11.0"): + c = infamy.Container(target) + # Wait for upgrade to complete and container to restart + until(lambda: c.running(NAME), attempts=60) + + # Get operational data after upgrade + containers_data = target.get_data("/infix-containers:containers") + container = containers_data["containers"]["container"][NAME] + new_container_id = container["id"] + new_image_id = container["image-id"] + + print(f"After upgrade container-id: {new_container_id[:12]}...") + print(f" image-id: {new_image_id[:12]}...") + + # Verify that both IDs have changed + if new_container_id == initial_container_id: + test.fail("Container ID did not change after upgrade!") + if new_image_id == initial_image_id: + test.fail("Image ID did not change after upgrade!") + + print("✓ Both container ID and image ID changed after upgrade") + + with test.step("Verify old image was pruned and disk usage is reasonable"): + # We expect minimal growth — the new image replaces old + # image, and they are each around 500-600 KiB. We allow for + # some overhead, but should be < 200 KiB. If the old image + # is not pruned properly, we'll see ~600 KiB extra growth. + MAX_ACCEPTABLE_GROWTH = 1000 + + # Get disk usage after upgrade + result = tgtssh.runsh("doas du -s /var/lib/containers") + final_disk_usage = int(result.stdout.split()[0]) + print(f"Disk usage after upgrade: {final_disk_usage} KiB") + + # Calculate the difference + disk_growth = final_disk_usage - initial_disk_usage + print(f"Disk usage growth: {disk_growth} KiB") + + if disk_growth > MAX_ACCEPTABLE_GROWTH: + test.fail(f"Disk usage grew by {disk_growth} KiB (expected < {MAX_ACCEPTABLE_GROWTH} KiB). " + f"Old image may not have been pruned!") + + print(f"✓ Disk usage growth is acceptable ({disk_growth} KiB < {MAX_ACCEPTABLE_GROWTH} KiB)") + + test.succeed() diff --git a/test/case/infix_containers/upgrade/topology.dot b/test/case/infix_containers/upgrade/topology.dot new file mode 100644 index 00000000..ebb673d5 --- /dev/null +++ b/test/case/infix_containers/upgrade/topology.dot @@ -0,0 +1,24 @@ +graph "1x2" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | data }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt | data } | target", + pos="10,12!", + + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color=lightgrey] + host:data -- target:data [color=black] +} diff --git a/test/case/infix_containers/upgrade/topology.svg b/test/case/infix_containers/upgrade/topology.svg new file mode 100644 index 00000000..ff3d246b --- /dev/null +++ b/test/case/infix_containers/upgrade/topology.svg @@ -0,0 +1,42 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + + + + diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 81a07e08..78aa3a8b 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -8,6 +8,7 @@ RUN apk add --no-cache \ e2tools \ ethtool \ fakeroot \ + file \ gcc \ git \ graphviz \ @@ -25,6 +26,7 @@ RUN apk add --no-cache \ qemu-img \ qemu-system-x86_64 \ ruby-mustache \ + skopeo \ socat \ squashfs-tools \ sshpass \ @@ -38,20 +40,24 @@ RUN cd /tmp/mtools-$MTOOL_VERSION && make && make install # Alpine's QEMU package does not bundle this for some reason, copied # from Ubuntu -COPY qemu-ifup /etc +COPY docker/qemu-ifup /etc # Needed to let qeneth find mustache(1) ENV PATH="${PATH}:/usr/lib/ruby/gems/3.2.0/bin" # Install all python packages used by the tests -COPY init-venv.sh /root -COPY pip-requirements.txt /root +COPY docker/init-venv.sh /root +COPY docker/pip-requirements.txt /root # Add bootstrap YANG models, the rest will be downloaded from the device -ADD yang /root/yang +ADD docker/yang /root/yang RUN ~/init-venv.sh ~/pip-requirements.txt -COPY entrypoint.sh /entrypoint.sh +# Download container images for upgrade testing +COPY case/infix_containers/upgrade/download.sh /tmp/download.sh +RUN /tmp/download.sh /srv && rm /tmp/download.sh + +COPY docker/entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD ["/bin/sh"] diff --git a/test/docker/README.md b/test/docker/README.md index 83b09004..c28b084c 100644 --- a/test/docker/README.md +++ b/test/docker/README.md @@ -16,7 +16,8 @@ the image, e.g., with missing Alpine packages. here: in this example we use version 0.4: - docker build -t ghcr.io/kernelkit/infix-test:0.4 . + cd test/ + docker build -f docker/Dockerfile -t ghcr.io/kernelkit/infix-test:0.4 . 3. Update the `test/.env` file to use the new version 4. Verify your new image works properly (remember to remove your `~/.infix/venv`) From 9273f4cd1e33ca6f563d915a65c1c569b104ee94 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 13 Oct 2025 06:43:34 +0200 Subject: [PATCH 16/26] test: remove redundant container_ prefix from test directories Rename test directories in infix_containers/ to remove the redundant 'container_' prefix since they already live under infix_containers/: container_basic -> basic container_bridge -> bridge container_enabled -> enabled container_environment -> environment container_firewall_basic -> firewall_basic container_host_commands -> host_commands container_phys -> phys container_veth -> veth container_volume -> volume Also update references in all.yaml and Readme.adoc files. Signed-off-by: Joachim Wiberg --- test/case/infix_containers/Readme.adoc | 18 +++++++++--------- test/case/infix_containers/all.yaml | 18 +++++++++--------- .../{container_basic => basic}/Readme.adoc | 0 .../{container_basic => basic}/test.adoc | 2 +- .../{container_basic => basic}/test.py | 0 .../{container_basic => basic}/topology.dot | 0 .../{container_basic => basic}/topology.svg | 0 .../{container_bridge => bridge}/Readme.adoc | 0 .../{container_bridge => bridge}/test.adoc | 2 +- .../{container_bridge => bridge}/test.py | 0 .../{container_bridge => bridge}/topology.dot | 0 .../{container_bridge => bridge}/topology.svg | 0 .../container_phys/topology.dot | 1 - .../container_veth/topology.dot | 1 - .../{container_enabled => enabled}/Readme.adoc | 0 .../{container_enabled => enabled}/test.adoc | 2 +- .../{container_enabled => enabled}/test.py | 0 .../topology.dot | 0 .../topology.svg | 0 .../Readme.adoc | 0 .../test.adoc | 2 +- .../test.py | 0 .../topology.dot | 0 .../topology.svg | 0 .../Readme.adoc | 0 .../test.adoc | 2 +- .../test.py | 0 .../topology.dot | 0 .../topology.svg | 0 .../Readme.adoc | 0 .../test.adoc | 2 +- .../test.py | 0 .../topology.dot | 0 .../topology.svg | 0 .../{container_phys => phys}/Readme.adoc | 0 .../{container_phys => phys}/test.adoc | 2 +- .../{container_phys => phys}/test.py | 0 test/case/infix_containers/phys/topology.dot | 1 + .../{container_phys => phys}/topology.svg | 0 .../{container_veth => veth}/Readme.adoc | 0 .../network_diagram.svg | 0 .../{container_veth => veth}/test.adoc | 2 +- .../{container_veth => veth}/test.py | 0 test/case/infix_containers/veth/topology.dot | 1 + .../{container_veth => veth}/topology.svg | 0 .../{container_volume => volume}/Readme.adoc | 0 .../{container_volume => volume}/test.adoc | 2 +- .../{container_volume => volume}/test.py | 0 .../{container_volume => volume}/topology.dot | 0 .../{container_volume => volume}/topology.svg | 0 50 files changed, 29 insertions(+), 29 deletions(-) rename test/case/infix_containers/{container_basic => basic}/Readme.adoc (100%) rename test/case/infix_containers/{container_basic => basic}/test.adoc (97%) rename test/case/infix_containers/{container_basic => basic}/test.py (100%) rename test/case/infix_containers/{container_basic => basic}/topology.dot (100%) rename test/case/infix_containers/{container_basic => basic}/topology.svg (100%) rename test/case/infix_containers/{container_bridge => bridge}/Readme.adoc (100%) rename test/case/infix_containers/{container_bridge => bridge}/test.adoc (97%) rename test/case/infix_containers/{container_bridge => bridge}/test.py (100%) rename test/case/infix_containers/{container_bridge => bridge}/topology.dot (100%) rename test/case/infix_containers/{container_bridge => bridge}/topology.svg (100%) delete mode 120000 test/case/infix_containers/container_phys/topology.dot delete mode 120000 test/case/infix_containers/container_veth/topology.dot rename test/case/infix_containers/{container_enabled => enabled}/Readme.adoc (100%) rename test/case/infix_containers/{container_enabled => enabled}/test.adoc (97%) rename test/case/infix_containers/{container_enabled => enabled}/test.py (100%) rename test/case/infix_containers/{container_enabled => enabled}/topology.dot (100%) rename test/case/infix_containers/{container_enabled => enabled}/topology.svg (100%) rename test/case/infix_containers/{container_environment => environment}/Readme.adoc (100%) rename test/case/infix_containers/{container_environment => environment}/test.adoc (96%) rename test/case/infix_containers/{container_environment => environment}/test.py (100%) rename test/case/infix_containers/{container_environment => environment}/topology.dot (100%) rename test/case/infix_containers/{container_environment => environment}/topology.svg (100%) rename test/case/infix_containers/{container_firewall_basic => firewall_basic}/Readme.adoc (100%) rename test/case/infix_containers/{container_firewall_basic => firewall_basic}/test.adoc (98%) rename test/case/infix_containers/{container_firewall_basic => firewall_basic}/test.py (100%) rename test/case/infix_containers/{container_firewall_basic => firewall_basic}/topology.dot (100%) rename test/case/infix_containers/{container_firewall_basic => firewall_basic}/topology.svg (100%) rename test/case/infix_containers/{container_host_commands => host_commands}/Readme.adoc (100%) rename test/case/infix_containers/{container_host_commands => host_commands}/test.adoc (96%) rename test/case/infix_containers/{container_host_commands => host_commands}/test.py (100%) rename test/case/infix_containers/{container_host_commands => host_commands}/topology.dot (100%) rename test/case/infix_containers/{container_host_commands => host_commands}/topology.svg (100%) rename test/case/infix_containers/{container_phys => phys}/Readme.adoc (100%) rename test/case/infix_containers/{container_phys => phys}/test.adoc (97%) rename test/case/infix_containers/{container_phys => phys}/test.py (100%) create mode 120000 test/case/infix_containers/phys/topology.dot rename test/case/infix_containers/{container_phys => phys}/topology.svg (100%) rename test/case/infix_containers/{container_veth => veth}/Readme.adoc (100%) rename test/case/infix_containers/{container_veth => veth}/network_diagram.svg (100%) rename test/case/infix_containers/{container_veth => veth}/test.adoc (98%) rename test/case/infix_containers/{container_veth => veth}/test.py (100%) create mode 120000 test/case/infix_containers/veth/topology.dot rename test/case/infix_containers/{container_veth => veth}/topology.svg (100%) rename test/case/infix_containers/{container_volume => volume}/Readme.adoc (100%) rename test/case/infix_containers/{container_volume => volume}/test.adoc (97%) rename test/case/infix_containers/{container_volume => volume}/test.py (100%) rename test/case/infix_containers/{container_volume => volume}/topology.dot (100%) rename test/case/infix_containers/{container_volume => volume}/topology.svg (100%) diff --git a/test/case/infix_containers/Readme.adoc b/test/case/infix_containers/Readme.adoc index 7fe48f87..1a4c3a6c 100644 --- a/test/case/infix_containers/Readme.adoc +++ b/test/case/infix_containers/Readme.adoc @@ -13,36 +13,36 @@ Tests verifying Infix Docker container support: - Container upgrade using RPC with cleanup of old image - Firewall container running in host network mode with full privileges -include::container_basic/Readme.adoc[] +include::basic/Readme.adoc[] <<< -include::container_enabled/Readme.adoc[] +include::enabled/Readme.adoc[] <<< -include::container_environment/Readme.adoc[] +include::environment/Readme.adoc[] <<< -include::container_bridge/Readme.adoc[] +include::bridge/Readme.adoc[] <<< -include::container_phys/Readme.adoc[] +include::phys/Readme.adoc[] <<< -include::container_veth/Readme.adoc[] +include::veth/Readme.adoc[] <<< -include::container_volume/Readme.adoc[] +include::volume/Readme.adoc[] <<< -include::container_firewall_basic/Readme.adoc[] +include::firewall_basic/Readme.adoc[] <<< -include::container_host_commands/Readme.adoc[] +include::host_commands/Readme.adoc[] diff --git a/test/case/infix_containers/all.yaml b/test/case/infix_containers/all.yaml index 41a0a758..d50db46a 100644 --- a/test/case/infix_containers/all.yaml +++ b/test/case/infix_containers/all.yaml @@ -1,31 +1,31 @@ --- - name: Container basic - case: container_basic/test.py + case: basic/test.py - name: Container enabled/disabled - case: container_enabled/test.py + case: enabled/test.py - name: Container environment variables - case: container_environment/test.py + case: environment/test.py - name: Container with bridge network - case: container_bridge/test.py + case: bridge/test.py - name: Container with physical interface - case: container_phys/test.py + case: phys/test.py - name: Container with VETH pair - case: container_veth/test.py + case: veth/test.py - name: Container Volume Persistence - case: container_volume/test.py + case: volume/test.py - name: Container Upgrade case: upgrade/test.py - name: Basic Firewall Container - case: container_firewall_basic/test.py + case: firewall_basic/test.py - name: Host Command Execution from Container - case: container_host_commands/test.py + case: host_commands/test.py diff --git a/test/case/infix_containers/container_basic/Readme.adoc b/test/case/infix_containers/basic/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_basic/Readme.adoc rename to test/case/infix_containers/basic/Readme.adoc diff --git a/test/case/infix_containers/container_basic/test.adoc b/test/case/infix_containers/basic/test.adoc similarity index 97% rename from test/case/infix_containers/container_basic/test.adoc rename to test/case/infix_containers/basic/test.adoc index 8205adbb..e76cd18c 100644 --- a/test/case/infix_containers/container_basic/test.adoc +++ b/test/case/infix_containers/basic/test.adoc @@ -1,6 +1,6 @@ === Container basic -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_basic] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/basic] ==== Description diff --git a/test/case/infix_containers/container_basic/test.py b/test/case/infix_containers/basic/test.py similarity index 100% rename from test/case/infix_containers/container_basic/test.py rename to test/case/infix_containers/basic/test.py diff --git a/test/case/infix_containers/container_basic/topology.dot b/test/case/infix_containers/basic/topology.dot similarity index 100% rename from test/case/infix_containers/container_basic/topology.dot rename to test/case/infix_containers/basic/topology.dot diff --git a/test/case/infix_containers/container_basic/topology.svg b/test/case/infix_containers/basic/topology.svg similarity index 100% rename from test/case/infix_containers/container_basic/topology.svg rename to test/case/infix_containers/basic/topology.svg diff --git a/test/case/infix_containers/container_bridge/Readme.adoc b/test/case/infix_containers/bridge/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_bridge/Readme.adoc rename to test/case/infix_containers/bridge/Readme.adoc diff --git a/test/case/infix_containers/container_bridge/test.adoc b/test/case/infix_containers/bridge/test.adoc similarity index 97% rename from test/case/infix_containers/container_bridge/test.adoc rename to test/case/infix_containers/bridge/test.adoc index faf9160f..c50ff86f 100644 --- a/test/case/infix_containers/container_bridge/test.adoc +++ b/test/case/infix_containers/bridge/test.adoc @@ -1,6 +1,6 @@ === Container with bridge network -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_bridge] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/bridge] ==== Description diff --git a/test/case/infix_containers/container_bridge/test.py b/test/case/infix_containers/bridge/test.py similarity index 100% rename from test/case/infix_containers/container_bridge/test.py rename to test/case/infix_containers/bridge/test.py diff --git a/test/case/infix_containers/container_bridge/topology.dot b/test/case/infix_containers/bridge/topology.dot similarity index 100% rename from test/case/infix_containers/container_bridge/topology.dot rename to test/case/infix_containers/bridge/topology.dot diff --git a/test/case/infix_containers/container_bridge/topology.svg b/test/case/infix_containers/bridge/topology.svg similarity index 100% rename from test/case/infix_containers/container_bridge/topology.svg rename to test/case/infix_containers/bridge/topology.svg diff --git a/test/case/infix_containers/container_phys/topology.dot b/test/case/infix_containers/container_phys/topology.dot deleted file mode 120000 index 5130457b..00000000 --- a/test/case/infix_containers/container_phys/topology.dot +++ /dev/null @@ -1 +0,0 @@ -../container_bridge/topology.dot \ No newline at end of file diff --git a/test/case/infix_containers/container_veth/topology.dot b/test/case/infix_containers/container_veth/topology.dot deleted file mode 120000 index 5130457b..00000000 --- a/test/case/infix_containers/container_veth/topology.dot +++ /dev/null @@ -1 +0,0 @@ -../container_bridge/topology.dot \ No newline at end of file diff --git a/test/case/infix_containers/container_enabled/Readme.adoc b/test/case/infix_containers/enabled/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_enabled/Readme.adoc rename to test/case/infix_containers/enabled/Readme.adoc diff --git a/test/case/infix_containers/container_enabled/test.adoc b/test/case/infix_containers/enabled/test.adoc similarity index 97% rename from test/case/infix_containers/container_enabled/test.adoc rename to test/case/infix_containers/enabled/test.adoc index 047b08c8..ea10606a 100644 --- a/test/case/infix_containers/container_enabled/test.adoc +++ b/test/case/infix_containers/enabled/test.adoc @@ -1,6 +1,6 @@ === Container enabled/disabled -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_enabled] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/enabled] ==== Description diff --git a/test/case/infix_containers/container_enabled/test.py b/test/case/infix_containers/enabled/test.py similarity index 100% rename from test/case/infix_containers/container_enabled/test.py rename to test/case/infix_containers/enabled/test.py diff --git a/test/case/infix_containers/container_enabled/topology.dot b/test/case/infix_containers/enabled/topology.dot similarity index 100% rename from test/case/infix_containers/container_enabled/topology.dot rename to test/case/infix_containers/enabled/topology.dot diff --git a/test/case/infix_containers/container_enabled/topology.svg b/test/case/infix_containers/enabled/topology.svg similarity index 100% rename from test/case/infix_containers/container_enabled/topology.svg rename to test/case/infix_containers/enabled/topology.svg diff --git a/test/case/infix_containers/container_environment/Readme.adoc b/test/case/infix_containers/environment/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_environment/Readme.adoc rename to test/case/infix_containers/environment/Readme.adoc diff --git a/test/case/infix_containers/container_environment/test.adoc b/test/case/infix_containers/environment/test.adoc similarity index 96% rename from test/case/infix_containers/container_environment/test.adoc rename to test/case/infix_containers/environment/test.adoc index 5e67da5a..2de6e5c4 100644 --- a/test/case/infix_containers/container_environment/test.adoc +++ b/test/case/infix_containers/environment/test.adoc @@ -1,6 +1,6 @@ === Container environment variables -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_environment] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/environment] ==== Description diff --git a/test/case/infix_containers/container_environment/test.py b/test/case/infix_containers/environment/test.py similarity index 100% rename from test/case/infix_containers/container_environment/test.py rename to test/case/infix_containers/environment/test.py diff --git a/test/case/infix_containers/container_environment/topology.dot b/test/case/infix_containers/environment/topology.dot similarity index 100% rename from test/case/infix_containers/container_environment/topology.dot rename to test/case/infix_containers/environment/topology.dot diff --git a/test/case/infix_containers/container_environment/topology.svg b/test/case/infix_containers/environment/topology.svg similarity index 100% rename from test/case/infix_containers/container_environment/topology.svg rename to test/case/infix_containers/environment/topology.svg diff --git a/test/case/infix_containers/container_firewall_basic/Readme.adoc b/test/case/infix_containers/firewall_basic/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_firewall_basic/Readme.adoc rename to test/case/infix_containers/firewall_basic/Readme.adoc diff --git a/test/case/infix_containers/container_firewall_basic/test.adoc b/test/case/infix_containers/firewall_basic/test.adoc similarity index 98% rename from test/case/infix_containers/container_firewall_basic/test.adoc rename to test/case/infix_containers/firewall_basic/test.adoc index 1f986fa6..3b1d8e33 100644 --- a/test/case/infix_containers/container_firewall_basic/test.adoc +++ b/test/case/infix_containers/firewall_basic/test.adoc @@ -1,6 +1,6 @@ === Basic Firewall Container -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_firewall_basic] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/firewall_basic] ==== Description diff --git a/test/case/infix_containers/container_firewall_basic/test.py b/test/case/infix_containers/firewall_basic/test.py similarity index 100% rename from test/case/infix_containers/container_firewall_basic/test.py rename to test/case/infix_containers/firewall_basic/test.py diff --git a/test/case/infix_containers/container_firewall_basic/topology.dot b/test/case/infix_containers/firewall_basic/topology.dot similarity index 100% rename from test/case/infix_containers/container_firewall_basic/topology.dot rename to test/case/infix_containers/firewall_basic/topology.dot diff --git a/test/case/infix_containers/container_firewall_basic/topology.svg b/test/case/infix_containers/firewall_basic/topology.svg similarity index 100% rename from test/case/infix_containers/container_firewall_basic/topology.svg rename to test/case/infix_containers/firewall_basic/topology.svg diff --git a/test/case/infix_containers/container_host_commands/Readme.adoc b/test/case/infix_containers/host_commands/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_host_commands/Readme.adoc rename to test/case/infix_containers/host_commands/Readme.adoc diff --git a/test/case/infix_containers/container_host_commands/test.adoc b/test/case/infix_containers/host_commands/test.adoc similarity index 96% rename from test/case/infix_containers/container_host_commands/test.adoc rename to test/case/infix_containers/host_commands/test.adoc index d6f88d8a..ac74d43e 100644 --- a/test/case/infix_containers/container_host_commands/test.adoc +++ b/test/case/infix_containers/host_commands/test.adoc @@ -1,6 +1,6 @@ === Host Command Execution from Container -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_host_commands] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/host_commands] ==== Description diff --git a/test/case/infix_containers/container_host_commands/test.py b/test/case/infix_containers/host_commands/test.py similarity index 100% rename from test/case/infix_containers/container_host_commands/test.py rename to test/case/infix_containers/host_commands/test.py diff --git a/test/case/infix_containers/container_host_commands/topology.dot b/test/case/infix_containers/host_commands/topology.dot similarity index 100% rename from test/case/infix_containers/container_host_commands/topology.dot rename to test/case/infix_containers/host_commands/topology.dot diff --git a/test/case/infix_containers/container_host_commands/topology.svg b/test/case/infix_containers/host_commands/topology.svg similarity index 100% rename from test/case/infix_containers/container_host_commands/topology.svg rename to test/case/infix_containers/host_commands/topology.svg diff --git a/test/case/infix_containers/container_phys/Readme.adoc b/test/case/infix_containers/phys/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_phys/Readme.adoc rename to test/case/infix_containers/phys/Readme.adoc diff --git a/test/case/infix_containers/container_phys/test.adoc b/test/case/infix_containers/phys/test.adoc similarity index 97% rename from test/case/infix_containers/container_phys/test.adoc rename to test/case/infix_containers/phys/test.adoc index 977854bc..6d0390f2 100644 --- a/test/case/infix_containers/container_phys/test.adoc +++ b/test/case/infix_containers/phys/test.adoc @@ -1,6 +1,6 @@ === Container with physical interface -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_phys] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/phys] ==== Description diff --git a/test/case/infix_containers/container_phys/test.py b/test/case/infix_containers/phys/test.py similarity index 100% rename from test/case/infix_containers/container_phys/test.py rename to test/case/infix_containers/phys/test.py diff --git a/test/case/infix_containers/phys/topology.dot b/test/case/infix_containers/phys/topology.dot new file mode 120000 index 00000000..1f2e45e2 --- /dev/null +++ b/test/case/infix_containers/phys/topology.dot @@ -0,0 +1 @@ +../bridge/topology.dot \ No newline at end of file diff --git a/test/case/infix_containers/container_phys/topology.svg b/test/case/infix_containers/phys/topology.svg similarity index 100% rename from test/case/infix_containers/container_phys/topology.svg rename to test/case/infix_containers/phys/topology.svg diff --git a/test/case/infix_containers/container_veth/Readme.adoc b/test/case/infix_containers/veth/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_veth/Readme.adoc rename to test/case/infix_containers/veth/Readme.adoc diff --git a/test/case/infix_containers/container_veth/network_diagram.svg b/test/case/infix_containers/veth/network_diagram.svg similarity index 100% rename from test/case/infix_containers/container_veth/network_diagram.svg rename to test/case/infix_containers/veth/network_diagram.svg diff --git a/test/case/infix_containers/container_veth/test.adoc b/test/case/infix_containers/veth/test.adoc similarity index 98% rename from test/case/infix_containers/container_veth/test.adoc rename to test/case/infix_containers/veth/test.adoc index fa4edc70..c5de9312 100644 --- a/test/case/infix_containers/container_veth/test.adoc +++ b/test/case/infix_containers/veth/test.adoc @@ -1,6 +1,6 @@ === Container with VETH pair -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_veth] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/veth] ==== Description diff --git a/test/case/infix_containers/container_veth/test.py b/test/case/infix_containers/veth/test.py similarity index 100% rename from test/case/infix_containers/container_veth/test.py rename to test/case/infix_containers/veth/test.py diff --git a/test/case/infix_containers/veth/topology.dot b/test/case/infix_containers/veth/topology.dot new file mode 120000 index 00000000..1f2e45e2 --- /dev/null +++ b/test/case/infix_containers/veth/topology.dot @@ -0,0 +1 @@ +../bridge/topology.dot \ No newline at end of file diff --git a/test/case/infix_containers/container_veth/topology.svg b/test/case/infix_containers/veth/topology.svg similarity index 100% rename from test/case/infix_containers/container_veth/topology.svg rename to test/case/infix_containers/veth/topology.svg diff --git a/test/case/infix_containers/container_volume/Readme.adoc b/test/case/infix_containers/volume/Readme.adoc similarity index 100% rename from test/case/infix_containers/container_volume/Readme.adoc rename to test/case/infix_containers/volume/Readme.adoc diff --git a/test/case/infix_containers/container_volume/test.adoc b/test/case/infix_containers/volume/test.adoc similarity index 97% rename from test/case/infix_containers/container_volume/test.adoc rename to test/case/infix_containers/volume/test.adoc index e56587a7..b5be4eda 100644 --- a/test/case/infix_containers/container_volume/test.adoc +++ b/test/case/infix_containers/volume/test.adoc @@ -1,6 +1,6 @@ === Container Volume Persistence -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/container_volume] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_containers/volume] ==== Description diff --git a/test/case/infix_containers/container_volume/test.py b/test/case/infix_containers/volume/test.py similarity index 100% rename from test/case/infix_containers/container_volume/test.py rename to test/case/infix_containers/volume/test.py diff --git a/test/case/infix_containers/container_volume/topology.dot b/test/case/infix_containers/volume/topology.dot similarity index 100% rename from test/case/infix_containers/container_volume/topology.dot rename to test/case/infix_containers/volume/topology.dot diff --git a/test/case/infix_containers/container_volume/topology.svg b/test/case/infix_containers/volume/topology.svg similarity index 100% rename from test/case/infix_containers/container_volume/topology.svg rename to test/case/infix_containers/volume/topology.svg From ce8dc0de9c58ff4093a34559452dfdca2cbddd75 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 13 Oct 2025 08:32:34 +0200 Subject: [PATCH 17/26] test: update firewall/basic topology and doc Signed-off-by: Joachim Wiberg --- test/case/infix_firewall/basic/test.adoc | 3 +- test/case/infix_firewall/basic/topology.dot | 8 +-- test/case/infix_firewall/basic/topology.svg | 58 +++++++++++++-------- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/test/case/infix_firewall/basic/test.adoc b/test/case/infix_firewall/basic/test.adoc index bf6aef8b..015e9a4c 100644 --- a/test/case/infix_firewall/basic/test.adoc +++ b/test/case/infix_firewall/basic/test.adoc @@ -11,7 +11,7 @@ image::basic.svg[align=center, scaledwidth=50%] - Single zone configuration, "public", with action=drop - Allowed services: SSH (port 22), DHCPv6-client, mySSH (custom, port 222) - All other ports (HTTP, HTTPS, Telnet, etc.) blocked -- Verifies unused interfaces automatically assigned to default zone +- Check that unused interfaces are automatically assigned to default zone ==== Topology @@ -26,6 +26,7 @@ image::topology.svg[Basic Firewall for End Devices topology, align=center, scale . Verify ICMPv6 is dropped . Verify SSH service is allowed . Verify custom mySSH service is allowed +. Verify HTTP service override (8080 allowed, 80 blocked) . Verify other ports are blocked diff --git a/test/case/infix_firewall/basic/topology.dot b/test/case/infix_firewall/basic/topology.dot index c75e3bbf..52174355 100644 --- a/test/case/infix_firewall/basic/topology.dot +++ b/test/case/infix_firewall/basic/topology.dot @@ -1,26 +1,26 @@ graph "1x3" { layout = "neato"; overlap = false; - esep = "+80"; + esep = "+30"; node [shape=record, fontname="DejaVu Sans Mono, Book"]; edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; host [ label="host | { mgmt | data }", - pos="1,1!", + pos="10,10.95!", requires="controller" ]; target [ label="{ mgmt | data | unused } | target", - pos="3,1!", + pos="30,10!", requires="infix", ]; dummy [ label="{ link } | dummy", - pos="5,1!", + pos="29.8,00!", requires="infix", ]; diff --git a/test/case/infix_firewall/basic/topology.svg b/test/case/infix_firewall/basic/topology.svg index 644d700d..22a6325f 100644 --- a/test/case/infix_firewall/basic/topology.svg +++ b/test/case/infix_firewall/basic/topology.svg @@ -1,45 +1,59 @@ - + - - + + 1x3 - + host - -host - -mgmt - -data + +host + +mgmt + +data target - -mgmt - -data - -unused - -target + +mgmt + +data + +unused + +target host:mgmt--target:mgmt - + host:data--target:data - -192.168.1.42/24 + +192.168.1.42/24 + + + +dummy + +link + +dummy + + + +target:unused--dummy:link + From 3b24fab324644ce5721dc1d9e9110b3fd84c169c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 20 Oct 2025 14:39:55 +0200 Subject: [PATCH 18/26] cli: fix 'copy FILE running-config' use-case When copying to the running datastore we cannot use sr_copy_config(), instead we must use sr_replace_config(). This fix covers both the case of 'copy startup-config running-config' and 'copy FILE running-config'. Fixes #1203 Signed-off-by: Joachim Wiberg --- src/bin/Makefile.am | 4 +-- src/bin/configure.ac | 5 +-- src/bin/copy.c | 83 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index 15ede0cf..1345414c 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -6,8 +6,8 @@ bin_PROGRAMS = copy erase files copy_SOURCES = copy.c util.c util.h copy_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE copy_CFLAGS = -W -Wall -Wextra -copy_CFLAGS += $(libite_CFLAGS) $(sysrepo_CFLAGS) -copy_LDADD = $(libite_LIBS) $(sysrepo_LIBS) +copy_CFLAGS += $(libite_CFLAGS) $(libyang_CFLAGS) $(sysrepo_CFLAGS) +copy_LDADD = $(libite_LIBS) $(libyang_LIBS) $(sysrepo_LIBS) erase_SOURCES = erase.c util.c util.h erase_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE diff --git a/src/bin/configure.ac b/src/bin/configure.ac index 10aaf0a8..8b5c12c8 100644 --- a/src/bin/configure.ac +++ b/src/bin/configure.ac @@ -15,6 +15,7 @@ AC_PROG_INSTALL PKG_PROG_PKG_CONFIG PKG_CHECK_MODULES([libite], [libite >= 2.5.0]) +PKG_CHECK_MODULES([libyang], [libyang >= 3.0.0]) PKG_CHECK_MODULES([sysrepo], [sysrepo >= 2.2.36]) # Misc variable replacements for below Summary @@ -55,8 +56,8 @@ cat <datastore, timeout * 1000); - if (rc) - emsg(sess, ERRMSG "unable to copy configuration, err %d: %s\n", - rc, sr_strerror(rc)); - else - set_owner(dstds->path, user); + + /* + * When copying TO running-config, use sr_replace_config() + * to trigger change callbacks. Otherwise use sr_copy_config() + * for direct datastore copy without callbacks. + */ + if (dstds->datastore == SR_DS_RUNNING && srcds->path) { + rc = replace_running(conn, sess, srcds->path, timeout); + if (!rc) + set_owner(dstds->path, user); + } else { + /* Direct copy for other datastores (no callbacks needed) */ + rc = sr_copy_config(sess, NULL, srcds->datastore, timeout * 1000); + if (rc) + emsg(sess, ERRMSG "unable to copy configuration, err %d: %s\n", rc, sr_strerror(rc)); + else + set_owner(dstds->path, user); + } } rc = sr_disconnect(conn); @@ -320,9 +365,29 @@ static int copy(const char *src, const char *dst, const char *remote_user) if (rc) { fprintf(stderr, ERRMSG "failed downloading %s", src); } else { - rc = systemf("sysrepocfg -d %s -I%s -f json", dstds->sysrepocfg, fn); - if (rc) - fprintf(stderr, ERRMSG "failed loading %s from %s", dst, src); + /* Use replace_config() for running-config (triggers callbacks), sysrepocfg for others */ + if (dstds->datastore == SR_DS_RUNNING) { + if (sr_connect(SR_CONN_DEFAULT, &conn)) { + fprintf(stderr, ERRMSG "connection to datastore failed\n"); + rc = 1; + } else { + sr_log_syslog("klishd", SR_LL_WRN); + if (sr_session_start(conn, SR_DS_RUNNING, &sess)) { + fprintf(stderr, ERRMSG "unable to open transaction to %s\n", dst); + rc = 1; + } else { + sr_nacm_set_user(sess, user); + rc = replace_running(conn, sess, fn, timeout); + if (!rc) + set_owner(dstds->path, user); + } + sr_disconnect(conn); + } + } else { + rc = systemf("sysrepocfg -d %s -I%s -f json", dstds->sysrepocfg, fn); + if (rc) + fprintf(stderr, ERRMSG "failed loading %s from %s", dst, src); + } } } else { if (strstr(src, "://") && strstr(dst, "://")) { From e6a04fbd952f6ad9311b42a2ccf0358ba8aeba24 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 20 Oct 2025 16:24:32 +0200 Subject: [PATCH 19/26] cli: add 'validate', or '-n', dry run to copy command This commit adds config file validation to the copy command, discussed in #373. Allowing users to test their config files before restoring a backup. The feature could also be used for the automatic rollback when downgrading to an earlier version of the OS. Fixes #373 Signed-off-by: Joachim Wiberg --- doc/cli/introduction.md | 10 +++++++- src/bin/copy.c | 35 +++++++++++++++++++++------- src/klish-plugin-infix/src/infix.c | 24 ++++++++----------- src/klish-plugin-infix/xml/infix.xml | 1 + 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/doc/cli/introduction.md b/doc/cli/introduction.md index 5ddd2347..a104f8a4 100644 --- a/doc/cli/introduction.md +++ b/doc/cli/introduction.md @@ -191,4 +191,12 @@ change that, e.g., breaks networking, it is trivial to revert back by: admin@host-12-34-56:/> copy startup-config running-config ``` -Or restarting the device. +Or restart the device, for example if the change to the configuration +caused you to lose contact with the system (it happens to the best of +us). The system will start up from the last "save gave". + +> **Tip:** when restoring a backup of a configuration, or having manually +> edited a config file, you can validate it using system's YANG models, +> it is *not* applied if validation is successful: +> +> `copy /media/backup/old.cfg running-config validate` diff --git a/src/bin/copy.c b/src/bin/copy.c index 8dc76414..db675c05 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -34,6 +34,8 @@ struct infix_ds infix_config[] = { static const char *prognm = "copy"; static int timeout; +static int dry_run; +static int quiet; /* @@ -180,11 +182,12 @@ static const char *infix_ds(const char *text, struct infix_ds **ds) /* * Load configuration from a file and replace running datastore. * This triggers all sysrepo change callbacks, unlike sr_copy_config(). + * In dry-run mode, only validates the configuration without applying. */ static int replace_running(sr_conn_ctx_t *conn, sr_session_ctx_t *sess, const char *file, int timeout) { - uint32_t flags = LYD_PARSE_NO_STATE | LYD_PARSE_ONLY; + uint32_t flags = LYD_PARSE_NO_STATE | LYD_PARSE_ONLY | LYD_PARSE_STORE_ONLY | LYD_PARSE_STRICT; struct lyd_node *data = NULL; const struct ly_ctx *ctx; LY_ERR err; @@ -199,18 +202,26 @@ static int replace_running(sr_conn_ctx_t *conn, sr_session_ctx_t *sess, return 1; } - /* Replace running config (triggers callbacks, takes ownership on success) */ - rc = sr_replace_config(sess, NULL, data, timeout * 1000); - if (rc) { - emsg(sess, ERRMSG "unable to replace configuration, err %d: %s\n", - rc, sr_strerror(rc)); + if (dry_run) { + if (!quiet) { + printf("Configuration validated, %s: OK\n", file); + fflush(stdout); + } lyd_free_all(data); + rc = 0; + } else { + /* Replace running config, assumes ownership of 'data' */ + rc = sr_replace_config(sess, NULL, data, timeout * 1000); + if (rc) { + emsg(sess, ERRMSG "unable to replace configuration, err %d: %s\n", + rc, sr_strerror(rc)); + lyd_free_all(data); + } } return rc; } - static int copy(const char *src, const char *dst, const char *remote_user) { struct infix_ds *srcds = NULL, *dstds = NULL; @@ -450,6 +461,8 @@ static int usage(int rc) "\n" "Options:\n" " -h This help text\n" + " -n Dry-run, validate configuration without applying\n" + " -q Quiet mode, suppress informational messages\n" " -u USER Username for remote commands, like scp\n" " -t SEEC Timeout for the operation, or default %d sec\n" " -v Show version\n", prognm, timeout); @@ -464,10 +477,16 @@ int main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "ht:u:v")) != EOF) { + while ((c = getopt(argc, argv, "hnqt:u:v")) != EOF) { switch(c) { case 'h': return usage(0); + case 'n': + dry_run = 1; + break; + case 'q': + quiet = 1; + break; case 't': timeout = atoi(optarg); break; diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 41c17df5..5ee0d0ed 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -28,7 +28,7 @@ const uint8_t kplugin_infix_major = 1; const uint8_t kplugin_infix_minor = 0; -static void cd_home(kcontext_t *ctx) +static const char *cd_home(kcontext_t *ctx) { const char *user = "root"; ksession_t *session; @@ -43,6 +43,8 @@ static void cd_home(kcontext_t *ctx) pw = getpwnam(user); chdir(pw->pw_dir); + + return user; } static int systemf(const char *fmt, ...) @@ -201,8 +203,8 @@ int infix_copy(kcontext_t *ctx) { kpargv_t *pargv = kcontext_pargv(ctx); const char *src, *dst; - const char *username; char user[256] = ""; + char validate[8] = ""; kparg_t *parg; src = kparg_value(kpargv_find(pargv, "src")); @@ -214,26 +216,20 @@ int infix_copy(kcontext_t *ctx) if (parg) snprintf(user, sizeof(user), "-u %s", kparg_value(parg)); - cd_home(ctx); - username = ksession_user(kcontext_session(ctx)); + parg = kpargv_find(pargv, "validate"); + if (parg) + strlcpy(validate, "-n", sizeof(validate)); - return systemf("sudo -u %s copy %s %s %s", username, user, src, dst); + /* Ensure we run the copy command as the logged-in user, not root (klishd) */ + return systemf("doas -u %s copy %s %s %s %s", cd_home(ctx), validate, user, src, dst); } int infix_shell(kcontext_t *ctx) { - const char *user = "root"; - ksession_t *session; + const char *user = cd_home(ctx); pid_t pid; int rc; - session = kcontext_session(ctx); - if (session) { - user = ksession_user(session); - if (!user) - user = "root"; - } - pid = fork(); if (pid == -1) return -1; diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index c40e249d..6e2137e3 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -188,6 +188,7 @@ + From 92e80b4239db604b1b3718dda1620be9e896c435 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 20 Oct 2025 16:36:02 +0200 Subject: [PATCH 20/26] cli: fix copy to missing startup-config file Fixes #981 Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index db675c05..77f7a2b3 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -286,10 +286,18 @@ static int copy(const char *src, const char *dst, const char *remote_user) } else { /* Direct copy for other datastores (no callbacks needed) */ rc = sr_copy_config(sess, NULL, srcds->datastore, timeout * 1000); - if (rc) + if (rc) { emsg(sess, ERRMSG "unable to copy configuration, err %d: %s\n", rc, sr_strerror(rc)); - else - set_owner(dstds->path, user); + } else { + /* Export datastore to backing file if it has one */ + if (dstds->path) { + rc = systemf("sysrepocfg -d %s -X%s -f json", dstds->sysrepocfg, dstds->path); + if (rc) + fprintf(stderr, ERRMSG "failed saving %s to %s\n", srcds->name, dstds->path); + else + set_owner(dstds->path, user); + } + } } } rc = sr_disconnect(conn); @@ -397,7 +405,15 @@ static int copy(const char *src, const char *dst, const char *remote_user) } else { rc = systemf("sysrepocfg -d %s -I%s -f json", dstds->sysrepocfg, fn); if (rc) - fprintf(stderr, ERRMSG "failed loading %s from %s", dst, src); + fprintf(stderr, ERRMSG "failed loading %s from %s\n", dst, src); + else if (dstds->path) { + rc = systemf("sysrepocfg -d %s -X%s -f json", dstds->sysrepocfg, dstds->path); + if (rc) + fprintf(stderr, ERRMSG "failed saving %s\n", dstds->path); + else + set_owner(dstds->path, user); + } + } } } else { From ad96965b16a95217d1df98d9bb33cf87cf6fa77c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 20 Oct 2025 20:09:39 +0200 Subject: [PATCH 21/26] cli: restrict copy and erase commands This is a follow-up to PR #717 where path traversal protection was discussed. A year later and it's clear that having a user-friendly copy tool in the shell is a good thing, but that we proably want to restrict what it can do when called from the CLI. A sanitize flag (-s) is added to control the behavior, when used in the shell without -s, both commands act like traditional UNIX tools and do assume . for relative paths, and allow ../, whereas when running from the CLI only /media/ is allowed and otherwise files are assumed to be in $HOME or /cfg Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 25 ++++++---- src/bin/erase.c | 34 +++++++------- src/bin/util.c | 74 +++++++++++++++++++----------- src/bin/util.h | 9 ++-- src/klish-plugin-infix/src/infix.c | 4 +- 5 files changed, 86 insertions(+), 60 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index 77f7a2b3..ad0c9ce7 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -36,6 +36,7 @@ static const char *prognm = "copy"; static int timeout; static int dry_run; static int quiet; +static int sanitize; /* @@ -204,7 +205,7 @@ static int replace_running(sr_conn_ctx_t *conn, sr_session_ctx_t *sess, if (dry_run) { if (!quiet) { - printf("Configuration validated, %s: OK\n", file); + printf("Configuration validated, %s OK\n", file); fflush(stdout); } lyd_free_all(data); @@ -334,10 +335,10 @@ static int copy(const char *src, const char *dst, const char *remote_user) if (dstds && dstds->path) fn = dstds->path; else - fn = cfg_adjust(dst, src, adjust, sizeof(adjust)); + fn = cfg_adjust(dst, src, adjust, sizeof(adjust), sanitize); if (!fn) { - fprintf(stderr, ERRMSG "invalid destination path.\n"); + fprintf(stderr, ERRMSG "file not found.\n"); rc = -1; goto err; } @@ -371,9 +372,9 @@ static int copy(const char *src, const char *dst, const char *remote_user) tmpfn = mktemp(temp_file); fn = tmpfn; } else { - fn = cfg_adjust(src, NULL, adjust, sizeof(adjust)); + fn = cfg_adjust(src, NULL, adjust, sizeof(adjust), sanitize); if (!fn) { - fprintf(stderr, ERRMSG "invalid source file location.\n"); + fprintf(stderr, ERRMSG "file not found.\n"); rc = 1; goto err; } @@ -423,9 +424,9 @@ static int copy(const char *src, const char *dst, const char *remote_user) } if (strstr(src, "://")) { - fn = cfg_adjust(dst, src, adjust, sizeof(adjust)); + fn = cfg_adjust(dst, src, adjust, sizeof(adjust), sanitize); if (!fn) { - fprintf(stderr, ERRMSG "invalid destination file location.\n"); + fprintf(stderr, ERRMSG "file not found.\n"); rc = 1; goto err; } @@ -439,9 +440,9 @@ static int copy(const char *src, const char *dst, const char *remote_user) rc = systemf("curl %s -Lo %s %s", remote_user, fn, src); } else if (strstr(dst, "://")) { - fn = cfg_adjust(src, NULL, adjust, sizeof(adjust)); + fn = cfg_adjust(src, NULL, adjust, sizeof(adjust), sanitize); if (!fn) { - fprintf(stderr, ERRMSG "invalid source file location.\n"); + fprintf(stderr, ERRMSG "file not found.\n"); rc = 1; goto err; } @@ -479,6 +480,7 @@ static int usage(int rc) " -h This help text\n" " -n Dry-run, validate configuration without applying\n" " -q Quiet mode, suppress informational messages\n" + " -s Sanitize paths for CLI use (restrict path traversal)\n" " -u USER Username for remote commands, like scp\n" " -t SEEC Timeout for the operation, or default %d sec\n" " -v Show version\n", prognm, timeout); @@ -493,7 +495,7 @@ int main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "hnqt:u:v")) != EOF) { + while ((c = getopt(argc, argv, "hnqst:u:v")) != EOF) { switch(c) { case 'h': return usage(0); @@ -503,6 +505,9 @@ int main(int argc, char *argv[]) case 'q': quiet = 1; break; + case 's': + sanitize = 1; + break; case 't': timeout = atoi(optarg); break; diff --git a/src/bin/erase.c b/src/bin/erase.c index 716c310b..acfe8c4a 100644 --- a/src/bin/erase.c +++ b/src/bin/erase.c @@ -10,31 +10,25 @@ #include "util.h" static const char *prognm = "erase"; - +static int sanitize; static int do_erase(const char *path) { - char *fn; + char buf[PATH_MAX]; + const char *fn; - if (access(path, F_OK)) { - size_t len = strlen(path) + 10; + fn = cfg_adjust(path, NULL, buf, sizeof(buf), sanitize); + if (!fn) { + fprintf(stderr, ERRMSG "file not found.\n"); + return 1; + } - fn = alloca(len); - if (!fn) { - fprintf(stderr, ERRMSG "failed allocating memory.\n"); - return -1; - } - - cfg_adjust(path, NULL, fn, len); - } else - fn = (char *)path; - - if (!yorn("Remove %s, are you sure", fn)) + if (!yorn("Remove %s, are you sure", path)) return 0; if (remove(fn)) { - fprintf(stderr, ERRMSG "failed removing %s: %s\n", fn, strerror(errno)); - return -1; + fprintf(stderr, ERRMSG "failed removing %s: %s\n", path, strerror(errno)); + return 11; } return 0; @@ -46,6 +40,7 @@ static int usage(int rc) "\n" "Options:\n" " -h This help text\n" + " -s Sanitize paths for CLI use (restrict path traversal)\n" " -v Show version\n", prognm); return rc; @@ -55,10 +50,13 @@ int main(int argc, char *argv[]) { int c; - while ((c = getopt(argc, argv, "hv")) != EOF) { + while ((c = getopt(argc, argv, "hsv")) != EOF) { switch(c) { case 'h': return usage(0); + case 's': + sanitize = 1; + break; case 'v': puts(PACKAGE_VERSION); return 0; diff --git a/src/bin/util.c b/src/bin/util.c index 0e739b1e..629ecfde 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -67,7 +68,7 @@ int has_ext(const char *fn, const char *ext) return 0; } -static const char *basenm(const char *fn) +const char *basenm(const char *fn) { const char *ptr; @@ -75,44 +76,65 @@ static const char *basenm(const char *fn) return ""; ptr = strrchr(fn, '/'); - if (!ptr) + if (ptr) + ptr++; + else ptr = fn; return ptr; } -char *cfg_adjust(const char *fn, const char *tmpl, char *buf, size_t len) +static int path_allowed(const char *path) { - if (strstr(fn, "../")) - return NULL; /* relative paths not allowed */ + const char *accepted[] = { + "/media/", + "/cfg/", + getenv("HOME"), + NULL + }; - if (fn[0] == '/') { - strlcpy(buf, fn, len); - return buf; /* allow absolute paths */ + for (int i = 0; accepted[i]; i++) { + if (!strncmp(path, accepted[i], strlen(accepted[i]))) + return 1; } - /* Files in /cfg must end in .cfg */ - if (!strncmp(fn, "/cfg/", 5)) { - strlcpy(buf, fn, len); - if (!has_ext(fn, ".cfg")) - strlcat(buf, ".cfg", len); + return 0; +} - return buf; +char *cfg_adjust(const char *fn, const char *tmpl, char *buf, size_t len, int sanitize) +{ + char tmp[256], resolved[PATH_MAX]; + + if (strlen(basenm(fn)) == 0) { + if (!tmpl) + return NULL; + + fn = basenm(tmpl); + /* Fall through */ } - /* Files ending with .cfg belong in /cfg */ - if (has_ext(fn, ".cfg")) { - snprintf(buf, len, "/cfg/%s", fn); - return buf; + if (sanitize) { + if (strstr(fn, "../")) + return NULL; + + if (fn[0] == '/') { + if (!path_allowed(fn)) + return NULL; + } else { + snprintf(tmp, sizeof(tmp), "/cfg/%s", fn); + if (!has_ext(tmp, ".cfg")) + strlcat(tmp, ".cfg", sizeof(tmp)); + fn = tmp; + } + + /* If file exists, resolve symlinks and verify still in whitelist */ + if (!access(fn, F_OK) && realpath(fn, resolved)) { + if (!path_allowed(resolved)) + return NULL; + fn = resolved; + } } - if (strlen(fn) > 0 && fn[0] == '.' && tmpl) { - if (fn[1] == '/' && fn[2] != 0) - strlcpy(buf, fn, len); - else - strlcpy(buf, basenm(tmpl), len); - } else - strlcpy(buf, fn, len); - + strlcpy(buf, fn, len); return buf; } diff --git a/src/bin/util.h b/src/bin/util.h index 6c5ded9d..2b34e25b 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -7,11 +7,12 @@ #define ERRMSG "Error: " #define INFMSG "Note: " -int yorn (const char *fmt, ...); +int yorn (const char *fmt, ...); -int files (const char *path, const char *stripext); +int files (const char *path, const char *stripext); -int has_ext (const char *fn, const char *ext); -char *cfg_adjust (const char *fn, const char *tmpl, char *buf, size_t len); +const char *basenm (const char *fn); +int has_ext (const char *fn, const char *ext); +char *cfg_adjust (const char *fn, const char *tmpl, char *buf, size_t len, int sanitize); #endif /* BIN_UTIL_H_ */ diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 5ee0d0ed..47c821b1 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -120,7 +120,7 @@ int infix_erase(kcontext_t *ctx) cd_home(ctx); - return systemf("erase %s", path); + return systemf("erase -s %s", path); } int infix_files(kcontext_t *ctx) @@ -221,7 +221,7 @@ int infix_copy(kcontext_t *ctx) strlcpy(validate, "-n", sizeof(validate)); /* Ensure we run the copy command as the logged-in user, not root (klishd) */ - return systemf("doas -u %s copy %s %s %s %s", cd_home(ctx), validate, user, src, dst); + return systemf("doas -u %s copy -s %s %s %s %s", cd_home(ctx), validate, user, src, dst); } int infix_shell(kcontext_t *ctx) From 3e03ece1d90cb232d1f071697bf7d87bbbf85b51 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 23 Oct 2025 13:02:19 +0200 Subject: [PATCH 22/26] cli: sanitize regular file to file copy The regular file-to-file copy, was missing calls to cfg_adjust(), this commit fixes that and adds some helpful comments for each use-case. Also, drop insecure mktemp() in favor of our own version which uses the basename of the remote source file. Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 48 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index ad0c9ce7..6f667d8d 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -226,7 +226,6 @@ static int replace_running(sr_conn_ctx_t *conn, sr_session_ctx_t *sess, static int copy(const char *src, const char *dst, const char *remote_user) { struct infix_ds *srcds = NULL, *dstds = NULL; - char temp_file[20] = "/tmp/copy.XXXXXX"; const char *tmpfn = NULL; sr_session_ctx_t *sess; const char *fn = NULL; @@ -253,9 +252,8 @@ static int copy(const char *src, const char *dst, const char *remote_user) user = getuser(); - /* 1. Regular ds copy */ + /* 1. Regular ds to ds copy */ if (srcds && dstds) { - /* Ensure the dst ds is writable */ if (!dstds->rw) { fprintf(stderr, ERRMSG "not possible to write to \"%s\", skipping.\n", dst); rc = 1; @@ -311,12 +309,14 @@ static int copy(const char *src, const char *dst, const char *remote_user) if (srcds) { /* 2. Export from a datastore somewhere else */ + if (strstr(dst, "://")) { if (srcds->path) fn = srcds->path; else { snprintf(adjust, sizeof(adjust), "/tmp/%s.cfg", srcds->name); fn = tmpfn = adjust; + remove(tmpfn); rc = systemf("sysrepocfg -d %s -X%s -f json", srcds->sysrepocfg, fn); } @@ -357,6 +357,8 @@ static int copy(const char *src, const char *dst, const char *remote_user) else set_owner(fn, user); } else if (dstds) { + /* 3. Import from somewhere to a datastore */ + if (!dstds->sysrepocfg) { fprintf(stderr, ERRMSG "not possible to import to this datastore.\n"); rc = 1; @@ -367,10 +369,16 @@ static int copy(const char *src, const char *dst, const char *remote_user) goto err; } - /* 3. Import from somewhere to a datastore */ if (strstr(src, "://")) { - tmpfn = mktemp(temp_file); - fn = tmpfn; + fn = basenm(src); + if (fn[0] == 0) { + fprintf(stderr, ERRMSG "missing filename in sorce URI.\n"); + rc = 1; + goto err; + } + snprintf(adjust, sizeof(adjust), "/tmp/%s", fn); + fn = tmpfn = adjust; + remove(tmpfn); } else { fn = cfg_adjust(src, NULL, adjust, sizeof(adjust), sanitize); if (!fn) { @@ -385,7 +393,6 @@ static int copy(const char *src, const char *dst, const char *remote_user) if (rc) { fprintf(stderr, ERRMSG "failed downloading %s", src); } else { - /* Use replace_config() for running-config (triggers callbacks), sysrepocfg for others */ if (dstds->datastore == SR_DS_RUNNING) { if (sr_connect(SR_CONN_DEFAULT, &conn)) { fprintf(stderr, ERRMSG "connection to datastore failed\n"); @@ -418,6 +425,8 @@ static int copy(const char *src, const char *dst, const char *remote_user) } } } else { + /* 4. regular copy file -> file, either may be remote */ + if (strstr(src, "://") && strstr(dst, "://")) { fprintf(stderr, ERRMSG "copy from remote to remote is not supported.\n"); goto err; @@ -448,17 +457,34 @@ static int copy(const char *src, const char *dst, const char *remote_user) } if (access(fn, F_OK)) - fprintf(stderr, ERRMSG "no such file %s, aborting.", fn); + fprintf(stderr, ERRMSG "no such file %s.", fn); else rc = systemf("curl %s -LT %s %s", remote_user, fn, dst); } else { - if (!access(dst, F_OK)) { - if (!yorn("Overwrite existing file %s", dst)) { + char bufa[256], bufb[256]; + const char *from, *to; + + from = cfg_adjust(src, NULL, bufa, sizeof(bufa), sanitize); + if (!from) { + fprintf(stderr, ERRMSG "no such file %s.", src); + rc = 1; + goto err; + } + + to = cfg_adjust(dst, src, bufb, sizeof(bufb), sanitize); + if (!to) { + fprintf(stderr, ERRMSG "no such file %s.", dst); + rc = 1; + goto err; + } + + if (!access(to, F_OK)) { + if (!yorn("Overwrite existing file %s", to)) { fprintf(stderr, "OK, aborting.\n"); return 0; } } - rc = systemf("cp %s %s", src, dst); + rc = systemf("cp %s %s", from, to); } } From 21256a8ac1da47a2ddd18a5cc1528b365fe56985 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 20 Oct 2025 20:40:32 +0200 Subject: [PATCH 23/26] bin: add bash completion for copy command Add bash completion for the common datastores, like we already do in the CLI, and update the usage text accordingly. Also, make sure to install to /usr/bin, not /bin since we've now merged the hierarchies since a while back. Signed-off-by: Joachim Wiberg --- package/bin/bin.mk | 2 +- src/bin/Makefile.am | 4 ++ src/bin/copy.bash | 93 +++++++++++++++++++++++++++++++++++++++++++++ src/bin/copy.c | 24 ++++++++---- 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 src/bin/copy.bash diff --git a/package/bin/bin.mk b/package/bin/bin.mk index 5ac94ac9..15d276a9 100644 --- a/package/bin/bin.mk +++ b/package/bin/bin.mk @@ -11,7 +11,7 @@ BIN_LICENSE = BSD-3-Clause BIN_LICENSE_FILES = LICENSE BIN_REDISTRIBUTE = NO BIN_DEPENDENCIES = sysrepo libite -BIN_CONF_OPTS = --prefix= --disable-silent-rules +BIN_CONF_OPTS = --disable-silent-rules BIN_AUTORECONF = YES define BIN_CONF_ENV diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index 1345414c..f33dd3e9 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -3,6 +3,10 @@ ACLOCAL_AMFLAGS = -I m4 bin_PROGRAMS = copy erase files +# Bash completion +bashcompdir = $(datadir)/bash-completion/completions +dist_bashcomp_DATA = copy.bash + copy_SOURCES = copy.c util.c util.h copy_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE copy_CFLAGS = -W -Wall -Wextra diff --git a/src/bin/copy.bash b/src/bin/copy.bash new file mode 100644 index 00000000..f46b54be --- /dev/null +++ b/src/bin/copy.bash @@ -0,0 +1,93 @@ +# bash completion for copy command +# SPDX-License-Identifier: ISC + +_copy_completion() +{ + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Options for the copy command + opts="-h -n -q -s -t -u -v" + + local datastores_dst="running-config startup-config" + local datastores_src="running-config startup-config factory-config" + + case "${prev}" in + -t) + # Timeout expects a number, no completion + return 0 + ;; + -u) + # Username, could complete with getent passwd but keep it simple + return 0 + ;; + esac + + # If current word starts with -, complete options + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 + fi + + # Determine position (source or destination) + # Count non-option arguments before current position + local arg_count=0 + local i + for ((i=1; i < COMP_CWORD; i++)); do + case "${COMP_WORDS[i]}" in + -h|-n|-q|-s|-v) + # Flag without argument + ;; + -t|-u) + # Flag with argument, skip next word + ((i++)) + ;; + -*) + # Unknown flag, might have argument + ;; + *) + # Non-option argument + ((arg_count++)) + ;; + esac + done + + # Helper function to add non-hidden files/dirs, filtering out dotfiles unless explicitly requested + _add_files() { + local IFS=$'\n' + local files + + # If user is explicitly typing a dotfile path, include dotfiles + if [[ ${cur} == .* || ${cur} == */.* ]]; then + files=( $(compgen -f -- "${cur}") ) + else + # Only show non-hidden files and directories + files=( $(compgen -f -- "${cur}" | grep -v '/\.[^/]*$' | grep -v '^\.[^/]') ) + fi + + COMPREPLY+=( "${files[@]}" ) + } + + case ${arg_count} in + 0) + # First argument (source): complete with files and all datastores including factory-config + COMPREPLY=( $(compgen -W "${datastores_src}" -- ${cur}) ) + _add_files + ;; + 1) + # Second argument (destination): complete with files and limited datastores (no factory-config) + COMPREPLY=( $(compgen -W "${datastores_dst}" -- ${cur}) ) + _add_files + ;; + *) + # No more arguments expected + return 0 + ;; + esac + + return 0 +} + +complete -F _copy_completion copy diff --git a/src/bin/copy.c b/src/bin/copy.c index 6f667d8d..bc3d14cd 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -503,13 +503,23 @@ static int usage(int rc) printf("Usage: %s [OPTIONS] SRC DST\n" "\n" "Options:\n" - " -h This help text\n" - " -n Dry-run, validate configuration without applying\n" - " -q Quiet mode, suppress informational messages\n" - " -s Sanitize paths for CLI use (restrict path traversal)\n" - " -u USER Username for remote commands, like scp\n" - " -t SEEC Timeout for the operation, or default %d sec\n" - " -v Show version\n", prognm, timeout); + " -h This help text\n" + " -n Dry-run, validate configuration without applying\n" + " -q Quiet mode, suppress informational messages\n" + " -s Sanitize paths for CLI use (restrict path traversal)\n" + " -t SEC Timeout for the operation, or default %d sec\n" + " -u USER Username for remote commands, like scp\n" + " -v Show version\n" + "\n" + "Files:\n" + " SRC JSON configuration file, or a datastore\n" + " DST A file or datastore, except factory-config\n" + "\n" + "Datastores:\n" + " running-config The running datastore, current active config\n" + " startup-config The non-volatile config used at startup\n" + " factory-config The device's factory default configuration\n" + "\n", prognm, timeout); return rc; } From ff175c02f73311e401115538a78f05150cd10cd8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 12 Oct 2025 23:10:51 +0200 Subject: [PATCH 24/26] doc: add section on container volume management Since there is no safe way (unique hash/id) to identify unused named volumes, we cannot automate removal of them. Since volume data, when compared to a container image, is quite small, it was decided that we document this instead. Signed-off-by: Joachim Wiberg --- doc/container.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/doc/container.md b/doc/container.md index 1f169185..765d8996 100644 --- a/doc/container.md +++ b/doc/container.md @@ -614,6 +614,50 @@ empty: then rsync". > volume between containers. All the tricks possible with volumes may > be added in a later release. +### Volume Management + +Volumes are persistent storage that survive container restarts and image +upgrades, making them ideal for application data. However, this also means +they **are not automatically removed** when a container is deleted from the +configuration. + +This design choice prevents accidental data loss, especially in scenarios +where: + + - A container is temporarily removed and re-added with the same name + - A container is replaced with a different configuration but same name + - System upgrades or configuration changes affect container definitions + +To clean up unused volumes and reclaim disk space, use the admin-exec +command: + + admin@example:/> container prune + Deleted Images + ... + Deleted Volumes + ntpd-varlib + system-data + + Total reclaimed space: 45.2MB + +The `container prune` command safely removes: + + - Unused container images + - Volumes not attached to any container (running or stopped) + - Other unused container resources + +> [!TIP] +> You can monitor container resource usage with the command: +> +> admin@example:/> show container usage +> +> This displays disk space used by images, containers, and volumes, +> helping you decide when to run the prune command. +> +> To see which volumes exist and which containers use them: +> +> admin@example:/> show container volumes + ### Content Mounts Content mounts are a special type of file mount where the file contents From ef73ec6ce8fbf8a7579bc2ee0e631eb36c82ac0d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 14 Oct 2025 10:50:24 +0200 Subject: [PATCH 25/26] doc: explain docker image tags and rewrite upgrade section Much of the content was made obsolete by recent changes, we now optimize the experience for users, avoiding recreate at boot unless checksums for configuration or base image have changed. This was also a good time to explain the difference between mutable and immutable tags, which sometimes is a cause for great confusion. Signed-off-by: Joachim Wiberg --- doc/container.md | 237 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 189 insertions(+), 48 deletions(-) diff --git a/doc/container.md b/doc/container.md index 765d8996..1d66166d 100644 --- a/doc/container.md +++ b/doc/container.md @@ -246,80 +246,221 @@ archive, which helps greatly with container upgrades (see below): admin@example:/> show log ... - Nov 20 07:24:56 infix container[5040]: Fetching ftp://192.168.122.1/curios-oci-amd64-v24.05.0.tar.gz - Nov 20 07:24:56 infix container[5040]: curios-oci-amd64-v24.05.0.tar.gz downloaded successfully. - Nov 20 07:24:56 infix container[5040]: curios-oci-amd64-v24.05.0.tar.gz checksum verified OK. - Nov 20 07:24:57 infix container[5040]: Cleaning up extracted curios-oci-amd64-v24.05.0 - Nov 20 07:24:57 infix container[5040]: podman create --name sys --conmon-pidfile=/run/container:sys.pid --read-only --replace --quiet --cgroup-parent=containers --restart=always --systemd=false --tz=local --hostname sys --log-driver k8s-file --log-opt path=/run/containers/sys.fifo --network=none curios-oci-amd64-v24.05.0 - Nov 20 07:24:57 infix container[3556]: b02e945c43c9bce2c4be88e31d6f63cfdb1a3c8bdd02179376eb059a49ae05e4 + Nov 20 07:24:56 example container[5040]: Fetching ftp://192.168.122.1/curios-oci-amd64-v24.05.0.tar.gz + Nov 20 07:24:56 example container[5040]: curios-oci-amd64-v24.05.0.tar.gz downloaded successfully. + Nov 20 07:24:56 example container[5040]: curios-oci-amd64-v24.05.0.tar.gz checksum verified OK. + Nov 20 07:24:57 example container[5040]: Cleaning up extracted curios-oci-amd64-v24.05.0 + Nov 20 07:24:57 example container[5040]: podman create --name sys --conmon-pidfile=/run/container:sys.pid --read-only --replace --quiet --cgroup-parent=containers --restart=always --systemd=false --tz=local --hostname sys --log-driver k8s-file --log-opt path=/run/containers/sys.fifo --network=none curios-oci-amd64-v24.05.0 + Nov 20 07:24:57 example container[3556]: b02e945c43c9bce2c4be88e31d6f63cfdb1a3c8bdd02179376eb059a49ae05e4 -Upgrading a Container Image +Understanding Image Tags +------------------------ + +Docker images use tags to identify different versions of the same image. +Understanding the difference between *mutable* and *immutable* tags is +important for managing container upgrades effectively. + +### Mutable Tags + +Tags like `:latest`, `:edge`, or `:stable` are *mutable* — they point to +different images over time as new versions are published to the registry. + +**Advantages:** + + - Convenient: upgrade without changing configuration + - Simple: use the CLI command `container upgrade NAME` to get the latest version, + there is even a convenient RPC for controlling the remotely + - Good for: development, testing, and systems that auto-update + +**Trade-offs:** + + - Less reproducible: different systems may run different versions + - Less predictable: upgrades happen when you pull, not when you plan + - Harder to rollback: previous version may no longer be available + +**Example mutable tags:** + +``` +docker://nginx:latest # Always points to newest release +docker://myapp:edge # Development/bleeding edge version +oci-archive:/var/tmp/app.tar # Local archive that may be replaced +``` + +### Immutable Tags + +Version-specific tags like `:v1.0.1`, `:24.11.0`, or digest references +like `@sha256:abc123...` are *immutable* — they always reference the +exact same image content. + +**Advantages:** + + - Reproducible: all systems run identical versions + - Predictable: upgrades only happen when you change configuration + - Auditable: clear history of what ran when + - Good for: production, compliance, and controlled deployments + +**Trade-offs:** + + - More explicit: must update configuration to upgrade + - Requires planning: need to know which version to use + +**Example immutable tags:** + +``` +docker://nginx:1.25.3 # Specific version number +docker://myapp:v2.1.0 # Semantic version tag +docker://nginx@sha256:abc123 # Cryptographic digest (most immutable) +``` + +> [!TIP] +> **Best practice for production:** Use specific version tags (`:v1.0.1`) +> rather than mutable tags (`:latest`). This ensures all your systems run +> identical software and upgrades happen only when you decide. + +Upgrading Container Images --------------------------- + ![Up-to-date Shield](img/shield-checkmark.svg){ align=right width="100" } The applications in your container are an active part of the system as a whole, so make it a routine to keep your container images up-to-date! -Containers are created at first setup and at every boot. If the image -exists in the file system it is reused -- i.e., an image pulled from a -remote registry is not fetched again. +### How Container Lifecycle Works -To upgrade a versioned image: - - update your `running-config` to use the new `image:tag` - - `leave` to activate the change, if you are in the CLI - - Podman pulls the new image in the background - - Your container is recreated with the new image - - The container is started +Infix intelligently manages container lifecycles to provide a smooth +experience while minimizing unnecessary work: -For "unversioned" images, e.g., images using a `:latest` or `:edge` tag, -use the following CLI command (`NAME` is the name of your container): +**At first setup:** When you configure a container for the first time, +Infix fetches the image (if needed) and creates the container instance. - admin@example:/> container upgrade NAME +**At boot time:** Infix checks if the container needs to be recreated by +comparing checksums for: -This stops the container, does `container pull IMAGE`, and recreates it -with the new image. Upgraded containers are automatically restarted. + - The image archive that the container was built from + - The container configuration script + +**When configuration changes:** If you modify any container settings +(network, volumes, environment, etc.), the container is automatically +recreated with the new configuration. + +**When explicitly upgraded:** Using the `container upgrade` command forces +a fresh pull of the image and recreates the container. + +This means that in most cases, **containers persist across reboots** and +are only recreated when actually necessary. Your container's state stored +in volumes is preserved across recreations. Since Infix containers use a +read-only root filesystem, any changes written outside of volumes or the +writable paths provided by Podman (`/dev`, `/dev/shm`, `/run`, `/tmp`, +`/var/tmp`) will be lost when the container is recreated. + +### Method 1: Upgrading Immutable Tags + +When using version-specific tags, you upgrade by explicitly changing the +image reference in your configuration: + +``` +admin@example:/> configure +admin@example:/config/> edit container web +admin@example:/config/container/web/> set image docker://nginx:1.25.3 +admin@example:/config/container/web/> leave +``` + +**What happens:** + + 1. Podman pulls the new image in the background (if not already present) + 2. Your container is automatically stopped + 3. The container is recreated with the new image + 4. The container is started with your existing volumes intact + +**Example:** Upgrading from one version to another: + +``` +admin@example:/> configure +admin@example:/config/> edit container system +admin@example:/config/container/system/> show image +image ghcr.io/kernelkit/curios:v24.11.0; +admin@example:/config/container/system/> set image ghcr.io/kernelkit/curios:v24.12.0 +admin@example:/config/container/system/> leave +admin@example:/> show log +... +Dec 13 14:32:15 example container[1523]: Pulling ghcr.io/kernelkit/curios:v24.12.0... +Dec 13 14:32:18 example container[1523]: Stopping old container instance... +Dec 13 14:32:19 example container[1523]: Creating new container with updated image... +Dec 13 14:32:20 example container[1523]: Container system started successfully +``` + +### Method 2: Upgrading Mutable Tags + +For images using mutable tags like `:latest` or `:edge`, use the +`container upgrade` command: + +``` +admin@example:/> container upgrade NAME +``` + +This command: + + 1. Stops the running container + 2. Pulls the latest version of the image from the registry + 3. Recreates the container with the new image + 4. Starts the container automatically **Example using registry:** - admin@example:/> container upgrade system - system - Trying to pull ghcr.io/kernelkit/curios:edge... - Getting image source signatures - Copying blob 07bfba95fe93 done - Copying config 0cb6059c0f done - Writing manifest to image destination - Storing signatures - 0cb6059c0f4111650ddbc7dbc4880c64ab8180d4bdbb7269c08034defc348f17 - system: not running. - 59618cc3c84bef341c1f5251a62be1592e459cc990f0b8864bc0f5be70e60719 +``` +admin@example:/> container upgrade system +system +Trying to pull ghcr.io/kernelkit/curios:edge... +Getting image source signatures +Copying blob 07bfba95fe93 done +Copying config 0cb6059c0f done +Writing manifest to image destination +Storing signatures +0cb6059c0f4111650ddbc7dbc4880c64ab8180d4bdbb7269c08034defc348f17 +system: not running. +59618cc3c84bef341c1f5251a62be1592e459cc990f0b8864bc0f5be70e60719 +``` -An OCI archive image can be upgraded in a similar manner, the first step -is of course to get the new archive onto the system (see above), and -then, provided the `oci-archive:/path/to/archive` format is used, call -the upgrade command as +**Example using local OCI archive:** - admin@example:/> container upgrade system - Upgrading container system with local archive: oci-archive:/var/tmp/curios-oci-amd64.tar.gz ... - 7ab4a07ee0c6039837419b7afda4da1527a70f0c60c0f0ac21cafee05ba24b52 +An OCI archive image can be upgraded in a similar manner. First, get the +new archive onto the system (see Container Images section above), then, +provided the `oci-archive:/path/to/archive` format is used in your +configuration, call the upgrade command: -OCI archives can also be fetched from ftp/http/https URL, in that case -the upgrade can be done the same way as a registry image (above). +``` +admin@example:/> container upgrade system +Upgrading container system with local archive: oci-archive:/var/tmp/curios-oci-amd64.tar.gz ... +7ab4a07ee0c6039837419b7afda4da1527a70f0c60c0f0ac21cafee05ba24b52 +``` + +OCI archives can also be fetched from ftp/http/https URLs. In that case, +the upgrade works the same way as a registry image — Infix downloads the +new archive and recreates the container. + +### Embedded Container Images > [!TIP] > Containers running from OCI images embedded in the operating system, -> e.g., `/lib/oci/mycontainer.tar.gz`, always run from the version in -> the operating system. To upgrade, install the new container image at -> build time, after system upgrade the container is also upgraded. The -> system unpacks and loads the OCI images into Podman every boot, which -> ensures the running container always has known starting state. +> e.g., `/lib/oci/mycontainer.tar.gz`, are automatically kept in sync +> with the Infix system image version. +> +> **How it works:** When you build a custom Infix image with embedded OCI +> archives, those containers will be upgraded whenever you upgrade the +> Infix operating system itself. At boot, Infix checks if the embedded +> image has changed and automatically recreates the container if needed. > > **Example:** default builds of Infix include a couple of OCI images > for reference, one is `/lib/oci/curios-nftables-v24.11.0.tar.gz`, but > there is also a symlink called `curios-nftables-latest.tar.gz` in the > same directory, which is what the Infix regression tests use in the -> image configuration of the container. This is what enables easy -> upgrades of the container along with the system itself. +> image configuration of the container. When the system is upgraded and +> the embedded image changes, the test containers are automatically +> recreated with the new version. +> +> This approach ensures your embedded containers always match your system +> version without any manual intervention. Capabilities From f081bef3bae58773089757d50853944b1a424310 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 14 Oct 2025 11:24:29 +0200 Subject: [PATCH 26/26] doc: update changelog with fixes and new features Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 8f389bdb..48e87457 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -13,12 +13,40 @@ All notable changes to the project are documented in this file. - Extend NETCONF and RESTCONF scripting documentation with operational data examples, discovery patterns, and common workflow examples, issue #1156 - Initial support for a zone-based firewall, based on `firewalld`, issue #448 +- Add `validate` option to CLI `copy` command. This can be used before doing a + restore of a backup, or when having edited configuration files manually. With + the validate flag (`-n` from the shell) the file is only loaded and validated + against the YANG models, it is *not* rolled in if validation is successful. + Example: `copy /media/backup/old.cfg running-config validate`, issue #373 - Automatically expand `/var` partition on SD card at first boot on RPi +- New `upgrade` RPC (action) for containers using images with mutable tags +- Optimize startup of preexisting containers by adding metadata to track all + OCI archives loaded into container store, and all container configurations + used to create container instances. Instances are now only recreated when + metadata from an existing instance does not match either the configuration + or the image — because of configuration changes or image upgrades +- Updated container documentation on volumes, image tags, and image upgrade ### Fixes +- Fix #981: copying any file, including `running-config`, to the persistent + back-end store for `startup-config`, does not take +- Fix #1146: Possible to set longer containers names than the system supports. + Root cause, a limit of 15 characters implicitly imposed by the service mgmt + daemon, Finit. The length has not been increased to 64 characters (min: 2) + and the YANG model now properly warns if the name is outside of these limits +- Fix #1147: Use container metadata to clean up lingering old container images + instead of using the too broad `podman image prune -af` command +- Fix #1148: Only retry container instance create on remote images +- Fix #1149: Increase `podman stop` timeout, from 10 to 30 seconds, needed with + bigger containers on heavily loaded systems - Fix #1194: CLI `text-editor` command does not do proper input sanitation - Fix #1197: RPi4 no longer boots after BPi-R3 merge, introduced in v25.09 +- Upgrade fixes for containers with mutable images, e.g., `:latest`. Infix now + always tries to fetch a new version of the OCI archive, for remote images, + regardless of the transport. After upgrade the old image is pruned +- Fix #1203: copying any file, including `startup-config`, to `running-config` + does not take [v25.09.0][] - 2025-09-30 -------------------------