From 80a25f74d7238bc0203a1033d4aae2f7dbb228f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 5 Nov 2026 11:53:24 +0000 Subject: [PATCH] feat: test published role images in mesh --- tests/mesh/role-image.sh | 16 ++++++++++++ tests/mesh/run.sh | 34 ++++++++++++++---------- tests/unit/test_mesh_role_image.py | 42 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 tests/mesh/role-image.sh create mode 100644 tests/unit/test_mesh_role_image.py diff --git a/tests/mesh/role-image.sh b/tests/mesh/role-image.sh new file mode 100644 index 00000000..c7bf2372 --- /dev/null +++ b/tests/mesh/role-image.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Select the image used by the ARC manifests. An explicitly supplied image is pulled by k3s. +# shellcheck disable=SC2034 # The caller reads both selected values. +grtest_select_role_image() { + if [ "${GRTEST_ROLE_IMAGE+x}" ]; then + if [ -z "$GRTEST_ROLE_IMAGE" ]; then + printf '%s\n' 'GRTEST_ROLE_IMAGE must not be empty' >&2 + return 1 + fi + role_controller_image="$GRTEST_ROLE_IMAGE" + role_image_published=1 + else + role_controller_image=grtest/fleet-role:test + role_image_published=0 + fi +} diff --git a/tests/mesh/run.sh b/tests/mesh/run.sh index d9b9c571..4ce92aea 100755 --- a/tests/mesh/run.sh +++ b/tests/mesh/run.sh @@ -3,7 +3,7 @@ # # With GRTEST_PIN_CONTEXT=1, the cluster scenarios run the role, and the in_cluster recovery playbook, with github_runner_cluster_docker_context pinned while the current Docker context they see points at nothing (tests/lib/docker_context.sh), so any docker call that ignored the pin fails the run; afterwards the current context must be unchanged. context_checks the role refuses a pinned context that does not exist, and a pinned context alongside DOCKER_HOST, before writing anything (not in the default set). # -# Usage: tests/mesh/run.sh ... (default: all three). Needs Docker with Compose, and Python 3 with ansible-core (ANSIBLE_PLAYBOOK overrides which ansible-playbook runs). Creates only Docker objects named grtest-*, and removes them again on exit unless GRTEST_KEEP=1. +# Usage: tests/mesh/run.sh ... (default: all three). Needs Docker with Compose, and Python 3 with ansible-core (ANSIBLE_PLAYBOOK overrides which ansible-playbook runs). Creates only Docker objects named grtest-*, and removes them again on exit unless GRTEST_KEEP=1. Set GRTEST_ROLE_IMAGE to a published image reference to have k3s pull it for role_controller and role_swap scenarios instead of building and importing the checkout's image; the registry must be reachable without a pull secret. set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" @@ -1190,7 +1190,9 @@ scenario_native_server() { # The slow link the role_controller scenario puts on a voter, in milliseconds each way on its mesh interface: above the unfit round trip limit, a tenth of the election timeout the inventory sets. role_controller_delay_ms=$((etcd_election_ms / 10 + 100)) -role_controller_image=grtest/fleet-role:test +# shellcheck source=tests/mesh/role-image.sh +. "${repo_root}/tests/mesh/role-image.sh" +grtest_select_role_image platform_namespace=github-runner-platform role_status() { kubectl_on "$api_node" --namespace "$platform_namespace" get configmap fleet-role-status -o 'jsonpath={.data.status\.json}' 2>/dev/null; } @@ -1217,20 +1219,24 @@ keep_role_image_loaded() { done } -# Runs the role controller's probe and controller in the cluster, the probes at $1 seconds (role_controller_sample_seconds by default): the image built from this checkout is loaded into every node (the native host's containerd too, when the scenario has one), the ARC role renders its manifests, and they are applied and rolled out. +# Runs the role controller's probe and controller in the cluster, the probes at $1 seconds (role_controller_sample_seconds by default): the ARC role renders its manifests for the selected image, which is built and loaded into every node by default or pulled by k3s when GRTEST_ROLE_IMAGE is set. deploy_role_controller() { local index image_file manifests sample_seconds=${1:-$role_controller_sample_seconds} - log "Building the role controller image from this checkout and loading it into every node" - docker build --quiet --file "${repo_root}/fleet-role/Dockerfile" --tag "$role_controller_image" "$repo_root" >/dev/null - image_file="${work}/fleet-role-image.tar" - docker save --output "$image_file" "$role_controller_image" - for index in $(seq 1 "$node_count"); do - docker exec -i "$(container "$index")" /bin/ctr --address /run/k3s/containerd/containerd.sock --namespace k8s.io images import - < "$image_file" >/dev/null - done - if [ -n "$native_role" ]; then native_exec k3s ctr --namespace k8s.io images import - < "$image_file" >/dev/null; fi - # A host that changes role starts a container whose containerd has no copy of the image (the server and agent services keep separate data volumes, and a real host pulls the image from its registry), so a loop loads it into each node container as one comes up, which keeps the new voter's probe running. - keep_role_image_loaded "$image_file" & - image_loader_pid=$! + if [ "$role_image_published" = 1 ]; then + log "Using published role controller image ${role_controller_image}; k3s pulls it on each node" + else + log "Building the role controller image from this checkout and loading it into every node" + docker build --quiet --file "${repo_root}/fleet-role/Dockerfile" --tag "$role_controller_image" "$repo_root" >/dev/null + image_file="${work}/fleet-role-image.tar" + docker save --output "$image_file" "$role_controller_image" + for index in $(seq 1 "$node_count"); do + docker exec -i "$(container "$index")" /bin/ctr --address /run/k3s/containerd/containerd.sock --namespace k8s.io images import - < "$image_file" >/dev/null + done + if [ -n "$native_role" ]; then native_exec k3s ctr --namespace k8s.io images import - < "$image_file" >/dev/null; fi + # A host that changes role starts a container whose containerd has no copy of the image (the server and agent services keep separate data volumes, and a real host pulls the image from its registry), so a loop loads it into each node container as one comes up, which keeps the new voter's probe running. + keep_role_image_loaded "$image_file" & + image_loader_pid=$! + fi log "Rendering the ARC role's manifests for the controller and the probe, and applying them" manifests="${work}/fleet-role.yaml" diff --git a/tests/unit/test_mesh_role_image.py b/tests/unit/test_mesh_role_image.py new file mode 100644 index 00000000..1361282c --- /dev/null +++ b/tests/unit/test_mesh_role_image.py @@ -0,0 +1,42 @@ +"""Image selection for the disposable mesh role-controller scenarios.""" + +import os +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SELECTOR = ROOT / "tests/mesh/role-image.sh" + + +class MeshRoleImageTest(unittest.TestCase): + def select(self, image: str | None) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("GRTEST_ROLE_IMAGE", None) + if image is not None: + env["GRTEST_ROLE_IMAGE"] = image + return subprocess.run( + ["bash", "-c", 'set -eu; . "$1"; grtest_select_role_image; printf "%s\\n%s\\n" "$role_controller_image" "$role_image_published"', "bash", str(SELECTOR)], + env=env, text=True, capture_output=True, check=False, + ) + + def test_default_builds_the_local_image(self) -> None: + result = self.select(None) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "grtest/fleet-role:test\n0\n") + + def test_explicit_tag_or_digest_is_passed_through(self) -> None: + for image in ("ghcr.io/example/fleet-role:1.2.3", "ghcr.io/example/fleet-role@sha256:" + "a" * 64): + with self.subTest(image=image): + result = self.select(image) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, f"{image}\n1\n") + + def test_empty_explicit_image_is_rejected(self) -> None: + result = self.select("") + self.assertNotEqual(result.returncode, 0) + self.assertIn("must not be empty", result.stderr) + + +if __name__ == "__main__": + unittest.main()