diff --git a/.github/workflows/published-image-canary.yml b/.github/workflows/published-image-canary.yml new file mode 100644 index 00000000..f4440a0c --- /dev/null +++ b/.github/workflows/published-image-canary.yml @@ -0,0 +1,76 @@ +name: Published image canary + +on: + workflow_dispatch: + inputs: + image: + description: Public fleet-role image pinned by sha256 digest + required: true + type: string + scenario: + description: Mesh role-controller scenario + required: true + type: choice + options: + - role_controller + - role_controller_swap + - role_swap + - role_swap_paused + - role_swap_blocked_peer + - role_swap_refused + +permissions: + contents: read + +jobs: + canary: + name: Published image mesh (${{ inputs.scenario }}) + runs-on: ubuntu-latest + timeout-minutes: 105 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Validate inputs + env: + IMAGE: ${{ inputs.image }} + SCENARIO: ${{ inputs.scenario }} + run: bash tests/mesh/validate-canary.sh "$IMAGE" "$SCENARIO" + + - name: Install Docker Compose + env: + COMPOSE_VERSION: v5.5.1 + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + mkdir -p "${HOME}/.docker/cli-plugins" + base="https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-${arch}" + curl -fsSL -o "${RUNNER_TEMP}/docker-compose" "$base" + echo "$(curl -fsSL "${base}.sha256" | cut -d' ' -f1) ${RUNNER_TEMP}/docker-compose" | sha256sum -c - + install -m 0755 "${RUNNER_TEMP}/docker-compose" "${HOME}/.docker/cli-plugins/docker-compose" + docker compose version + + - name: Install Ansible + run: | + set -euo pipefail + if ! python3 -m venv "${RUNNER_TEMP}/venv" 2>/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq python3-venv + python3 -m venv "${RUNNER_TEMP}/venv" + fi + "${RUNNER_TEMP}/venv/bin/pip" install --quiet --prefer-binary ansible-core + "${RUNNER_TEMP}/venv/bin/ansible-galaxy" collection install --no-cache -r ansible/requirements.yml -p "${RUNNER_TEMP}/collections" + echo "${RUNNER_TEMP}/venv/bin" >> "$GITHUB_PATH" + + - name: Run published image in the mesh + env: + ANSIBLE_COLLECTIONS_PATH: ${{ runner.temp }}/collections + GRTEST_WORK: ${{ runner.temp }}/grtest-mesh + GRTEST_ROLE_IMAGE: ${{ inputs.image }} + SCENARIO: ${{ inputs.scenario }} + run: tests/mesh/run.sh "$SCENARIO" diff --git a/README.md b/README.md index 4a552707..1af30c4a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Each Docker-in-Docker runner pod starts with an empty Docker daemon, so every jo - **Confirm no disk accumulates across repeated runner jobs:** the Docker host's disk usage (inside the VM, on a host that runs Docker in one) must stay flat across repeated `test-arc-runner.yml` runs. - **Test capabilities:** `tests/capabilities/run.sh` builds the reference Node.js tool image and libpq system-library image, renders a profile with capabilities through the ARC role and the scale-set chart, and runs a pod from the chart's runner template in a throwaway kind cluster to check that the tool reaches the tool cache and `PATH` and the library reaches the sysroot and its search paths; `.github/workflows/capabilities.yml` runs it on pull requests. `tests/system_libraries/run.sh [openmpi|highs|libpq|glpk|libmariadb]...` proves each reference system-library image works relocated: it copies the payload to `/opt/sysroot` as the role's init container does, applies the role's job-started hook, and runs the payload's programs in the stock `actions-runner` image and a program compiled against it in the reference build runner image (`tool-images/build`, the stock image with `build-essential` and `pkg-config`), including a two-process `mpirun` for Open MPI; `.github/workflows/system-libraries.yml` runs it on amd64 and arm64 on pull requests. The images under `tool-images/` build with `docker buildx build --platform linux/arm64,linux/amd64 tool-images/`; `.github/workflows/publish-tool-images.yml` publishes them (see Releases), and the release workflow does not. - **Test the build runner image's ids:** `tests/runner_build/run.sh` builds `tool-images/build` with its default runner ids and with `RUNNER_UID` and `RUNNER_GID` set to another, and checks the runner user in each; `.github/workflows/runner-build.yml` runs it on amd64 and arm64 for changes to the image. +- **Canary a published fleet-role image:** `gh workflow run published-image-canary.yml --ref main -f image='ghcr.io/exadev/github-runner-fleet-role@sha256:' -f scenario=role_controller` runs the chosen role-controller scenario in the disposable mesh on a GitHub-hosted Linux amd64 runner. The image must be publicly pullable and digest-pinned. The scenario choices are `role_controller`, `role_controller_swap`, `role_swap`, `role_swap_paused`, `role_swap_blocked_peer` and `role_swap_refused`; each tests only its own behaviour and the image selected for that runner's architecture, not other platform images. - **Test the registry cache:** `tests/registry_cache/run.sh` installs the cache as the ARC role renders it in a throwaway kind cluster, with Docker Hub, ghcr.io and a private registry behind basic auth, runs a pod from the chart's runner template as the role renders a Docker-in-Docker profile with the cache on, and checks in the cache's access logs that `docker pull` and a `docker-container` buildx builder pulled through it from all three without logging in, then scales the cache to nothing and checks that pulls and builds still work directly; `.github/workflows/registry-cache.yml` runs it on pull requests. - **Lint:** `.github/workflows/ci.yml` runs Shellcheck, actionlint, yamllint, hadolint, an Ansible syntax check, ansible-lint (at its `production` profile), and a collection build check on every push and pull request, plus commitlint (on every commit in a pull request, and on the pull request's own title) on pull requests specifically - all gated by one `required-checks` job so a repository ruleset only ever has to require that one check regardless of how many lint jobs exist. hadolint's warning-level pinning advice is visible but non-blocking (`--failure-threshold error`): this repo deliberately floats several images/packages on `latest` rather than pinning. yamllint's config lives in `.yamllint`, relaxing the three default rules that conflict with this project's own conventions (long single-line comments, no leading `---`, GitHub Actions' `on:` key). - **Commit messages:** `.husky/commit-msg` runs commitlint locally (after `npm ci`), the same check CI's commitlint job applies, so a message semantic-release would mishandle is rejected before it is pushed. The machine-wide hooks dispatcher hands off to it; without one, `git config core.hooksPath .husky` enables it. diff --git a/tests/mesh/validate-canary.sh b/tests/mesh/validate-canary.sh new file mode 100644 index 00000000..6fdc6e60 --- /dev/null +++ b/tests/mesh/validate-canary.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Validate the published-image canary inputs before starting the mesh. +set -euo pipefail + +image="${1:?image reference required}" +scenario="${2:?scenario required}" +if [[ ! "$image" =~ ^[a-z0-9][a-z0-9._:/-]*@sha256:[a-f0-9]{64}$ ]]; then + printf '%s\n' 'Image must be a lowercase registry reference pinned by sha256 digest' >&2 + exit 1 +fi +case "$scenario" in + role_controller | role_controller_swap | role_swap | role_swap_paused | role_swap_blocked_peer | role_swap_refused) ;; + *) printf 'Unsupported published-image scenario: %s\n' "$scenario" >&2; exit 1 ;; +esac diff --git a/tests/unit/test_mesh_role_image.py b/tests/unit/test_mesh_role_image.py index 1361282c..8b865a0b 100644 --- a/tests/unit/test_mesh_role_image.py +++ b/tests/unit/test_mesh_role_image.py @@ -38,5 +38,22 @@ def test_empty_explicit_image_is_rejected(self) -> None: self.assertIn("must not be empty", result.stderr) +class CanaryInputTest(unittest.TestCase): + def validate(self, image: str, scenario: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["bash", str(ROOT / "tests/mesh/validate-canary.sh"), image, scenario], text=True, capture_output=True, check=False) + + def test_accepts_only_image_exercising_scenarios(self) -> None: + image = "ghcr.io/example/fleet-role@sha256:" + "a" * 64 + for scenario in ("role_controller", "role_controller_swap", "role_swap", "role_swap_paused", "role_swap_blocked_peer", "role_swap_refused"): + with self.subTest(scenario=scenario): + self.assertEqual(self.validate(image, scenario).returncode, 0) + self.assertNotEqual(self.validate(image, "hosted").returncode, 0) + + def test_rejects_mutable_and_malformed_references(self) -> None: + for image in ("", "ghcr.io/example/fleet-role:latest", "ghcr.io/example/fleet-role@sha256:abc", "ghcr.io/example/fleet-role@sha256:" + "g" * 64): + with self.subTest(image=image): + self.assertNotEqual(self.validate(image, "role_controller").returncode, 0) + + if __name__ == "__main__": unittest.main()