fix(build): version-match the containerd pause image to kubeadm (C4) … #46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| # Cancel superseded runs on the same ref to save runner time. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| # Fast Go gates: format, vet, test (with coverage), build, lint. | |
| # The Go toolchain is read from go.mod so CI can never drift from the | |
| # module's `go` directive (CLAUDE.md no-drift rule, hardening item C3). | |
| gates: | |
| name: build / vet / test / lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: false | |
| - name: gofmt -s (check only) | |
| run: make fmt-check | |
| - name: go vet | |
| run: make vet | |
| - name: go test (with coverage) | |
| run: make test | |
| - name: go build | |
| run: make build | |
| - name: golangci-lint | |
| uses: golangci/golangci-lint-action@v9 | |
| with: | |
| # Pin to the version validated locally against the v2 config. | |
| version: v2.12.2 | |
| # Derive the image-build matrix from the single source of truth for the | |
| # supported Kubernetes window: kubeadm.SupportedMinors in version.go (ADR-3). | |
| # Rolling the window there automatically rolls this CI matrix. | |
| discover: | |
| name: resolve supported version window | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| minors: ${{ steps.minors.outputs.minors }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Extract SupportedMinors -> JSON matrix | |
| id: minors | |
| run: | | |
| set -euo pipefail | |
| # SupportedMinors is a static string-literal slice; pull each | |
| # "major.minor" token off its declaration line. | |
| minors="$(grep 'SupportedMinors *=' internal/kubeadm/version.go \ | |
| | grep -oE '[0-9]+\.[0-9]+' \ | |
| | jq -R . | jq -cs .)" | |
| if [ "$minors" = "[]" ]; then | |
| echo "could not parse SupportedMinors from version.go" >&2 | |
| exit 1 | |
| fi | |
| echo "Supported window: $minors" | |
| echo "minors=$minors" >> "$GITHUB_OUTPUT" | |
| # Product deliverable: the multi-stage Kairos image bundling the provider | |
| # plus kubeadm/kubelet/kubectl/containerd/runc/CNI (all checksum-verified | |
| # inside the Dockerfile). Built once per supported minor; each minor's patch | |
| # is resolved to the latest upstream release at build time so pins never go | |
| # stale as the window rolls. | |
| image: | |
| name: image build (k8s ${{ matrix.minor }}) | |
| needs: discover | |
| runs-on: ubuntu-latest | |
| # The Hadron (musl) image builds containerd AND kubelet static from source, | |
| # which adds several minutes per leg over the old binary-download path. | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| minor: ${{ fromJSON(needs.discover.outputs.minors) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Resolve Kubernetes patch + commit + crictl versions | |
| id: ver | |
| run: | | |
| set -euo pipefail | |
| k8s="$(curl -fsSL "https://dl.k8s.io/release/stable-${{ matrix.minor }}.txt")" | |
| case "$k8s" in | |
| v${{ matrix.minor }}.*) : ;; | |
| *) echo "unexpected patch ${k8s} for minor ${{ matrix.minor }}" >&2; exit 1 ;; | |
| esac | |
| # The Hadron (musl) image builds kubelet static from source, pinning the | |
| # clone to an immutable commit SHA. Resolve the SHA the resolved tag | |
| # points at and pass it to `make image`, or the from-source build's | |
| # commit assertion fails. Prefer the peeled tag (^{} -> the commit the | |
| # annotated tag wraps, i.e. clone HEAD); fall back to the bare ref. | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}^{}" | cut -f1)" | |
| if [ -z "$kcommit" ]; then | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}" | cut -f1)" | |
| fi | |
| case "$kcommit" in | |
| ?*) : ;; | |
| *) echo "could not resolve commit SHA for ${k8s}" >&2; exit 1 ;; | |
| esac | |
| # cri-tools publishes one release per minor: v<minor>.0. | |
| crictl="v${{ matrix.minor }}.0" | |
| echo "k8s=${k8s} kcommit=${kcommit} crictl=${crictl}" | |
| echo "k8s=${k8s}" >> "$GITHUB_OUTPUT" | |
| echo "kcommit=${kcommit}" >> "$GITHUB_OUTPUT" | |
| echo "crictl=${crictl}" >> "$GITHUB_OUTPUT" | |
| - name: Build Kairos image | |
| run: | | |
| make image \ | |
| VERSION=ci-${{ matrix.minor }} \ | |
| KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \ | |
| KUBERNETES_COMMIT=${{ steps.ver.outputs.kcommit }} \ | |
| CRICTL_VERSION=${{ steps.ver.outputs.crictl }} | |
| - name: Verify bundled binaries match the target minor | |
| run: | | |
| set -euo pipefail | |
| img="kairos-kubeadm:ci-${{ matrix.minor }}" | |
| kubeadm_v="$(docker run --rm --entrypoint /usr/bin/kubeadm "$img" version -o short)" | |
| crictl_v="$(docker run --rm --entrypoint /usr/bin/crictl "$img" --version)" | |
| echo "bundled kubeadm: ${kubeadm_v}" | |
| echo "bundled crictl: ${crictl_v}" | |
| case "$kubeadm_v" in | |
| v${{ matrix.minor }}.*) ;; | |
| *) echo "kubeadm minor mismatch: want v${{ matrix.minor }}.x, got ${kubeadm_v}" >&2; exit 1 ;; | |
| esac | |
| case "$crictl_v" in | |
| *v${{ matrix.minor }}.*) ;; | |
| *) echo "crictl minor mismatch: want v${{ matrix.minor }}.x, got ${crictl_v}" >&2; exit 1 ;; | |
| esac | |
| echo "OK: image bundles the k8s ${{ matrix.minor }} toolchain" | |
| - name: Verify containerd sandbox (pause) image is version-matched (C4) | |
| run: | | |
| set -euo pipefail | |
| img="kairos-kubeadm:ci-${{ matrix.minor }}" | |
| # The build pins containerd's sandbox_image to the pause kubeadm expects; | |
| # assert they match so a stale/hardcoded pause tag can't regress (C4). | |
| want="$(docker run --rm --entrypoint /usr/bin/kubeadm "$img" \ | |
| config images list --image-repository registry.k8s.io | grep -E '/pause:[0-9]')" | |
| got="$(docker run --rm --entrypoint sh "$img" -c \ | |
| "grep -oE 'registry.k8s.io/pause:[0-9.]+' /etc/containerd/config.toml")" | |
| echo "kubeadm pause: ${want}; containerd sandbox_image: ${got}" | |
| [ "$got" = "$want" ] || { echo "pause mismatch (C4): containerd ${got} != kubeadm ${want}" >&2; exit 1; } | |
| echo "OK: sandbox_image is version-matched to kubeadm" | |
| - name: Verify kubeadm exposes the upgrade-path surface (ADR-12) | |
| run: | | |
| set -euo pipefail | |
| img="kairos-kubeadm:ci-${{ matrix.minor }}" | |
| ka() { docker run --rm --entrypoint /usr/bin/kubeadm "$img" "$@"; } | |
| need() { # <help-output> <flag> <label> | |
| printf '%s\n' "$1" | grep -q -- "$2" \ | |
| || { echo "MISSING $2 in '$3' (bundled kubeadm ${{ matrix.minor }})" >&2; exit 1; } | |
| echo " ok: $3 supports $2" | |
| } | |
| # The provider drives only these kubeadm subcommands/flags for upgrades. | |
| # Asserting them against the SHIPPED binary per minor catches an upstream | |
| # removal/rename before it breaks an upgrade at runtime (ADR-12 U-N). | |
| # `kubeadm upgrade apply <ver> --yes --certificate-renewal=true` | |
| apply_help="$(ka upgrade apply --help)" | |
| need "$apply_help" --yes "upgrade apply" | |
| need "$apply_help" --certificate-renewal "upgrade apply" | |
| # `kubeadm upgrade node` (follower / worker convergence) must exist. | |
| ka upgrade node --help >/dev/null | |
| echo " ok: upgrade node subcommand exists" | |
| # `kubeadm init phase kubelet-start --config` (ADR-12-R1 API-free repair). | |
| ks_help="$(ka init phase kubelet-start --help)" | |
| need "$ks_help" --config "init phase kubelet-start" | |
| # `kubeadm init phase upload-certs --config` (cert-key flows via config, | |
| # never on argv -- the mint keystone, ADR-11 #3 / ADR-12 B3). | |
| uc_help="$(ka init phase upload-certs --help)" | |
| need "$uc_help" --config "init phase upload-certs" | |
| echo "OK: kubeadm ${{ matrix.minor }} exposes the upgrade-path subcommands/flags" | |
| # ADR-13 Tier-1 e2e suite: real kubeadm in a privileged systemd node container. | |
| # Runs all scenarios in test/e2e/ (init-converge, reset, init-clobber-refusal, | |
| # upgrade-skew-refusal, worker-join) against the real provider binary. | |
| # | |
| # SECURITY NOTE (privileged containers, ADR-13 E3 gate): the e2e node container | |
| # must run --privileged because kubeadm/kubelet/containerd/etcd require real | |
| # cgroup v2 control, mount namespaces, netns creation, and /sys/fs/cgroup write | |
| # access -- unavailable without privilege inside a container. This is the same | |
| # constraint kind (Kubernetes-in-Docker) has. The only image that runs privileged | |
| # is OUR OWN built kairos-kubeadm-e2e-node image, derived from the kairos-kubeadm | |
| # base we build in this same job from the checkout source. No third-party image | |
| # is ever pulled. Ephemeral per-run kubeadm bootstrap tokens (bounded TTL, never | |
| # persisted) are the only secrets; no long-lived credentials are embedded. Teardown | |
| # is guaranteed: t.Cleanup registers docker rm -fv per container, and the failure | |
| # step below sweeps leaked containers by label. | |
| # | |
| # Image strategy: option (a) -- rebuild kairos-kubeadm + e2e-node in this job | |
| # using the same stable-<minor>.txt patch + commit resolution as the image job. | |
| # This is self-contained (no artifact upload/download between jobs), | |
| # fault-isolated per-minor-leg, and avoids docker/actions-artifact size limits. | |
| # The from-source containerd+kubelet build dominates the build overhead; the | |
| # per-leg timeout below carries headroom for it. | |
| # | |
| # GitHub-hosted ubuntu-latest supports --privileged containers, /boot/config-*, | |
| # and /lib/modules. The harness mounts those read-only so kubeadm's | |
| # SystemVerification preflight can read the kernel config without relaxing any | |
| # preflight check. | |
| e2e: | |
| name: e2e (k8s ${{ matrix.minor }}) | |
| needs: discover | |
| # SECURITY (ADR-13 E3, security-architect R1): this job runs a --privileged | |
| # node container (real kubeadm/kubelet/containerd) on attacker-influenceable | |
| # PR code. The load-bearing controls are: (1) GitHub-HOSTED ephemeral runner | |
| # only -- NEVER a self-hosted runner; (2) NO secrets and an explicit | |
| # read-only token (below) -- NEVER add secrets or packages:write; (3) the | |
| # `pull_request` trigger (NOT pull_request_target), so fork PRs get no secrets. | |
| # These four conditions, not the container flags, are what contain a hostile PR. | |
| permissions: | |
| contents: read | |
| runs-on: ubuntu-latest | |
| # Adds the from-source containerd+kubelet build of the Hadron base image on | |
| # top of the e2e suite itself; raised from 50 to keep headroom per leg. | |
| timeout-minutes: 65 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| minor: ${{ fromJSON(needs.discover.outputs.minors) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Resolve Kubernetes patch + commit + crictl versions | |
| id: ver | |
| run: | | |
| set -euo pipefail | |
| k8s="$(curl -fsSL "https://dl.k8s.io/release/stable-${{ matrix.minor }}.txt")" | |
| case "$k8s" in | |
| v${{ matrix.minor }}.*) : ;; | |
| *) echo "unexpected patch ${k8s} for minor ${{ matrix.minor }}" >&2; exit 1 ;; | |
| esac | |
| # The Hadron base builds kubelet static from source and asserts the | |
| # cloned tag resolves to this commit SHA; resolve and forward it (see | |
| # the image job for the full rationale). | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}^{}" | cut -f1)" | |
| if [ -z "$kcommit" ]; then | |
| kcommit="$(git ls-remote https://github.com/kubernetes/kubernetes "refs/tags/${k8s}" | cut -f1)" | |
| fi | |
| case "$kcommit" in | |
| ?*) : ;; | |
| *) echo "could not resolve commit SHA for ${k8s}" >&2; exit 1 ;; | |
| esac | |
| crictl="v${{ matrix.minor }}.0" | |
| echo "k8s=${k8s} kcommit=${kcommit} crictl=${crictl}" | |
| echo "k8s=${k8s}" >> "$GITHUB_OUTPUT" | |
| echo "kcommit=${kcommit}" >> "$GITHUB_OUTPUT" | |
| echo "crictl=${crictl}" >> "$GITHUB_OUTPUT" | |
| - name: Build kairos-kubeadm base image | |
| run: | | |
| make image \ | |
| VERSION=ci-${{ matrix.minor }} \ | |
| KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \ | |
| KUBERNETES_COMMIT=${{ steps.ver.outputs.kcommit }} \ | |
| CRICTL_VERSION=${{ steps.ver.outputs.crictl }} \ | |
| IMAGE=kairos-kubeadm:${{ steps.ver.outputs.k8s }} | |
| - name: Build e2e node image | |
| run: | | |
| make e2e-node-image \ | |
| KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \ | |
| BASE_IMAGE=kairos-kubeadm:${{ steps.ver.outputs.k8s }} \ | |
| E2E_NODE_IMAGE=kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }} | |
| - name: Run e2e suite | |
| run: | | |
| E2E_NODE_IMAGE=kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }} \ | |
| E2E_KUBERNETES_VERSION=${{ steps.ver.outputs.k8s }} \ | |
| go test -tags e2e -count=1 -timeout 45m -v ./test/e2e/... | |
| - name: Prune e2e container volumes and images on failure | |
| if: failure() | |
| run: | | |
| docker ps -aq --filter 'label=provider-kubernetes-e2e=1' \ | |
| | xargs -r docker rm -fv || true | |
| docker image rm -f \ | |
| "kairos-kubeadm-e2e-node:${{ steps.ver.outputs.k8s }}" \ | |
| "kairos-kubeadm:${{ steps.ver.outputs.k8s }}" || true | |
| docker volume prune -f || true |