diff --git a/board/common/rootfs/etc/finit.d/available/container@.conf b/board/common/rootfs/etc/finit.d/available/container@.conf index 7a494c600..4e6b813cc 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 fe68c85a8..9579b2510 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 -- "$*" @@ -75,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 @@ -118,6 +128,7 @@ load_archive() { uri=$1 tag=$2 + tmp=$3 img=$(basename "$uri") # Supported transports for load and create @@ -134,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 @@ -205,7 +217,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..." @@ -230,6 +242,23 @@ load_archive() err 1 "failed tagging image as $tag" fi + # 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") + sha_file="$DOWNLOADS/${archive_basename}.sha256" + + 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 + # Clean up after ourselves if [ -n "$extracted" ]; then log "Cleaning up extracted $dir" @@ -261,10 +290,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 @@ -295,9 +330,25 @@ create() args="$args --network=none" fi + # Add optimization labels for meta-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) + fi + 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 + 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" @@ -331,11 +382,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() @@ -375,6 +505,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 +518,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") - - # Check if setup is still running ... - if kill -0 "$pid" 2>/dev/null; then - kill "$pid" - wait "$pid" 2>/dev/null - fi + 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 + + rm -f "$pidfile" + return 0 + fi - rm -f "$pidfile" - return 0 + 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 @@ -430,7 +570,7 @@ netrestart() done } -cleanup() +atexit() { pidfile=$(pidfn "$name") @@ -450,7 +590,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 @@ -460,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 @@ -490,7 +630,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] @@ -638,7 +778,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 @@ -646,6 +786,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 "$@" @@ -739,7 +882,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 @@ -775,28 +925,131 @@ 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 + + # 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}") echo $$ > "$pidfile" - 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 - - # 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 + # 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 + + # 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) - log "setup: 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 @@ -888,32 +1141,89 @@ case $cmd in podman stats -i 2 ;; upgrade) - # Start script used to initially create container - script=/run/containers/${1}.sh + if [ -z "$name" ]; then + name="$1" + fi + script=/run/containers/${name}.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; + # Verify container exists + if ! podman container exists "$name"; then + echo "No such container: $name" + exit 1 fi - # 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 ..." + # 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 $1" + echo ">> Failed recreating container $name" exit 1 fi - echo ">> Done." + + # 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 @@ -936,8 +1246,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/doc/TODO.org b/doc/TODO.org index 7e7840ce4..9dc98abc9 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -1,3 +1,9 @@ +* TODO Recontain + +- Document the upgrade command better, this also involves adding support for a ':latest' tag to + the curiOS project. Essentially there is a lot that's incorrect and needs cutting down or a + huge rewrite. + * TODO Add support for firewall - [X] All "implicit" policies in zones are now policies: intra- and inter-zone policies - [X] Add locked rules for implicit drop/reject policy as last rule in policy (ANY, ANY) diff --git a/doc/container.md b/doc/container.md index 1f1691851..765d8996a 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 diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 49fb59fe7..719d62288 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 22736992f..655d487be 100644 --- a/src/confd/src/infix-containers.c +++ b/src/confd/src/infix-containers.c @@ -21,6 +21,31 @@ #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. + * 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 @@ -37,16 +62,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; - - /* - * 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; - } + int offset; snprintf(script, sizeof(script), "%s.sh", name); fp = fopenf("w", "%s/%s", _PATH_CONT, script); @@ -68,9 +84,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)); @@ -256,9 +289,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; } @@ -270,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; } @@ -419,6 +477,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 9226bc243..07746d0eb 100644 --- a/src/confd/yang/confd/infix-containers.yang +++ b/src/confd/yang/confd/infix-containers.yang @@ -22,6 +22,13 @@ module infix-containers { prefix infix-sys; } + revision 2025-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"; + } + revision 2025-06-25 { description "Add file mode option to content mounts, allows creating scripts."; reference "internal"; @@ -62,6 +69,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 +143,7 @@ module infix-containers { leaf name { description "Name of the container"; - type string; + type ident; } leaf id { @@ -344,7 +359,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 +451,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 { @@ -474,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-06-25.yang b/src/confd/yang/confd/infix-containers@2025-10-12.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-10-12.yang diff --git a/src/confd/yang/containers.inc b/src/confd/yang/containers.inc index 4b2e3f30c..11dfc5bc5 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-10-12.yang" ) diff --git a/src/klish-plugin-infix/xml/containers.xml b/src/klish-plugin-infix/xml/containers.xml index 23becbae7..4d697c8a6 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 diff --git a/test/.env b/test/.env index 962afdf37..b6968427f 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 726d2c1a1..7fe48f874 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 53ec29154..41a0a7585 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/container_volume/test.py b/test/case/infix_containers/container_volume/test.py index 95be8fe07..896b8ea85 100755 --- a/test/case/infix_containers/container_volume/test.py +++ b/test/case/infix_containers/container_volume/test.py @@ -57,7 +57,7 @@ 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}" diff --git a/test/case/infix_containers/upgrade/download.sh b/test/case/infix_containers/upgrade/download.sh new file mode 100755 index 000000000..7bd28b1a5 --- /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.py b/test/case/infix_containers/upgrade/test.py new file mode 100755 index 000000000..7a071db79 --- /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 000000000..ebb673d5f --- /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/docker/Dockerfile b/test/docker/Dockerfile index 81a07e081..78aa3a8be 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 83b09004a..c28b084c6 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`)