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`)