Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/published-image-canary.yml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`; `.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:<digest>' -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.
Expand Down
14 changes: 14 additions & 0 deletions tests/mesh/validate-canary.sh
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions tests/unit/test_mesh_role_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading