From 8619c60e6ecef9d98571f6d0766ec117a38683a9 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Tue, 29 Sep 2026 19:08:19 -0500 Subject: [PATCH] feat(conformance): build the runner image the in-cluster Job could never have run The committed L2/L3 Job pins `ghcr.io/kyverno/chainsaw:v0.2.12` with `command: ["./run.sh"]`. Those two lines cannot both be true. That image carries the chainsaw binary and essentially nothing else -- no bash, no python3, no kubectl, no git -- so the Job would have failed at exec of `./run.sh`, before a single test ran. Nothing in the tree said so. This adds the image that makes the Job runnable (plan docs/plans/l2-l3-conformance-ci-wiring.md section 3 item 1): * conformance/incluster/Containerfile -- bash + python3 + chainsaw + kubectl + git-core + tar/gzip/coreutils (the base64-tar results channel of item 3). Two-stage: a full Fedora fetches the release binaries and verifies kubectl's published sha256; the runtime layer is fedora-minimal and carries no downloader. Versions are --build-arg-injected from infra/devcontainer/versions.env (CHAINSAW_VERSION, KUBECTL_VERSION, FEDORA_VERSION) -- the single source of truth -- so the chainsaw and kubectl in the runner are the same pins the toolbox gate runs with. No new pin is invented here, and the ARG defaults are EMPTY so a build that forgets one fails loudly instead of baking a stale version. * conformance/incluster/build-image.sh -- builds it, PROVES the toolchain by executing it inside the image, and prints the digest. It never pushes: the registry credential is not an agent's to hold and publishing the artifact a fail-closed policy then trusts is an operator action. It prints the exact `podman push --digestfile ...` line instead. * conformance/versions.env -- CONFORMANCE_RUNNER_IMAGE, with the digest deliberately EMPTY and a loud block saying the operator fills it in after the push. Empty is a visible failure in whatever renders the Job; a guessed digest would be an ImagePullBackOff dressed up as a cluster fault. WHAT THE ADMISSION BOUNDARY FORCED, AND WHY THE IMAGE OBEYS IT platform/kyverno/admission/conformance-trigger-job-shape.yaml is Enforce + failurePolicy: Fail and it is the R-SEC-02 boundary. It is NOT modified here. Two of its clauses are load-bearing on the image itself: * `runAsNonRoot: true` with no explicit runAsUser means the kubelet rejects an image whose USER is a name it cannot resolve to a uid. So USER is NUMERIC (65532), not a friendly name. * The pod is readOnlyRootFilesystem and the policy admits only the `workspace` and `results` volumes -- there is no writable /tmp and one cannot be added without editing the boundary. So HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME and TMPDIR all point into /workspace, and git's safe.directory config is written to /etc/gitconfig at BUILD time because a runtime --global write would fail. VERIFIED BY RUNNING IT, not by a successful build As uid 65532 with --read-only: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4 (client), chainsaw 0.2.12; / confirmed read-only, /workspace writable, uid confirmed non-zero. Then the real thing -- `./run.sh --lint`, `--coverage-only` and `--layer l2 --no-cluster` all rc 0 inside the image against this worktree, emitting coverage.md/.json and report.junit.xml. KNOWN LIMIT, FLAGGED NOT PAPERED OVER run.sh runs the selected offline L0/L1 checks before it touches the cluster for ANY selection that is not offline-only, and those checks shell out to flux, promtool, kyverno and kustomize, which this image does not carry. Baking the whole devcontainer toolchain into the runner would roughly double its size to re-run work ci/l0.sh and ci/l1.sh already did offline in the same CI run. So the Job must select the cluster-gated layers only (`--layer l2` / `--layer l3`); `--layer all` and a bare `--requirements ` would drag the L0 checks in and fail on a missing tool. `--layer l2` is verified above to resolve the offline selection to nothing. Choosing the Job's args belongs to T-6.1b; the constraint is documented in the Containerfile header and the incluster README rather than left for someone to discover from a pod log. chainsaw-job.yaml's `image:` is left stale ON PURPOSE -- there is no digest to put there yet, and the trigger renders that template per run (plan item 4). The README now says so instead of letting the placeholder read as a pin. Co-Authored-By: Claude Opus 5 (1M context) --- conformance/incluster/Containerfile | 168 +++++++++++++++++++++++++++ conformance/incluster/README.md | 17 ++- conformance/incluster/build-image.sh | 156 +++++++++++++++++++++++++ conformance/versions.env | 47 ++++++++ 4 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 conformance/incluster/Containerfile create mode 100755 conformance/incluster/build-image.sh diff --git a/conformance/incluster/Containerfile b/conformance/incluster/Containerfile new file mode 100644 index 0000000..adae986 --- /dev/null +++ b/conformance/incluster/Containerfile @@ -0,0 +1,168 @@ +# ============================================================================= +# conformance-runner — the image the in-cluster L2/L3 Chainsaw Job actually runs. +# Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1. +# +# WHY THIS EXISTS. chainsaw-job.yaml pins `ghcr.io/kyverno/chainsaw:v0.2.12` and +# `command: ["./run.sh"]`. That image carries the chainsaw binary and almost nothing else — +# no bash, no python3, no kubectl, no git. So the committed Job could never have run: it +# would have failed at exec of `./run.sh`. This image carries exactly what +# `conformance/run.sh` needs: +# +# bash run.sh and every tests/**/check.sh are bash (arrays, mapfile, BASH_SOURCE) +# python3 lib/{run_l1_offline,gen_coverage,merge_junit,emit_skipped_junit,status}.py +# (stdlib only — conformance/versions.env PYTHON_MIN=3.9, so no pip layer) +# chainsaw the L1/L2/L3 runner itself (run_chainsaw / lint_tests) +# kubectl conf_cluster_reachable()'s `kubectl cluster-info` preflight + probe scripts +# git the init-container's `clone --depth 1` + `checkout ` (§3 item 2) and +# run.sh's `rev-parse --short HEAD` for the run id +# tar/gzip/coreutils the `=== ARTIFACTS-TGZ-BASE64 ===` results channel (§3 item 3) +# +# VERSIONS COME FROM infra/devcontainer/versions.env — NOT from new pins invented here. +# That file is the single source of truth for CHAINSAW_VERSION / KUBECTL_VERSION / +# FEDORA_VERSION, and build-image.sh sources it and passes them in as --build-arg. The +# ARG defaults below are deliberately EMPTY so a build that forgets to pass them fails +# loudly (see the assertion in the first RUN) instead of silently baking a stale version. +# +# KNOWN LIMIT — READ BEFORE CHOOSING THE JOB'S `args`. +# `run.sh` runs the selected OFFLINE L0/L1 checks (run_offline_checks) before it ever +# touches the cluster, for ANY selection that is not offline-only. Those checks shell out +# to flux / promtool / kyverno / kustomize, which this image deliberately does NOT carry — +# baking the whole devcontainer toolchain into the runner would roughly double its size to +# re-run work that ci/l0.sh + ci/l1.sh already did offline in the same CI run. So the Job +# must select the cluster-gated layers only, e.g. `args: ["--layer", "l2"]` / +# `["--layer", "l3"]`, where offline_selected_dirs() resolves to nothing and +# run_offline_checks() returns 0. `--layer all` and a bare `--requirements ` would drag +# the L0 checks in and fail on a missing tool. Flagged to the plan owner; choosing the Job's +# args is T-6.1b's call, not this image's. +# +# THE ADMISSION CONTRACT THIS IMAGE MUST SATISFY. +# platform/kyverno/admission/conformance-trigger-job-shape.yaml is a fail-closed Enforce +# ClusterPolicy (R-SEC-02) and it is the security boundary. It requires the Job's container +# to be `gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*` — a DIGEST, +# never a tag — with `runAsNonRoot: true` and `allowPrivilegeEscalation: false`. Two +# consequences are baked in here: +# +# 1. `USER` is NUMERIC. With `runAsNonRoot: true` and no explicit `runAsUser`, the kubelet +# REJECTS an image whose USER is a name it cannot resolve to a uid +# ("container has runAsNonRoot and image has non-numeric user"). So: USER 65532:65532. +# 2. The pod is `readOnlyRootFilesystem: true` and the policy admits only the `workspace` +# and `results` volumes — there is no writable /tmp and one cannot be added without +# changing the boundary. So HOME and every cache path point INTO /workspace (the +# emptyDir), not at the read-only root. That is why HOME=/workspace below. +# ============================================================================= + +# --- builder: fetch the pinned release binaries ------------------------------ +# A full Fedora for the download stage (it has curl + tar); none of it ships in the runtime +# image, so the runner does not carry a downloader it has no business having at runtime. +ARG FEDORA_VERSION=44 +FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION} AS builder + +ARG CHAINSAW_VERSION= +ARG KUBECTL_VERSION= +ARG TARGETARCH=amd64 + +# Fail the BUILD — not the Job, three weeks later — if a pin was not passed in. +RUN set -eux; \ + : "${CHAINSAW_VERSION:?pass --build-arg CHAINSAW_VERSION from infra/devcontainer/versions.env}"; \ + : "${KUBECTL_VERSION:?pass --build-arg KUBECTL_VERSION from infra/devcontainer/versions.env}" + +# Same URLs and the same arch handling as infra/devcontainer/Containerfile, so the chainsaw +# and kubectl in this image are the same builds the toolbox gate runs with. kubectl's +# published sha256 is verified; chainsaw publishes no per-asset checksum file at a stable +# URL, so its integrity rests on the tag + TLS (and, after the operator pushes, on this +# image's own digest). +RUN set -eux; \ + mkdir -p /out; \ + curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" \ + -o /out/kubectl; \ + curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl.sha256" \ + -o /tmp/kubectl.sha256; \ + echo "$(cat /tmp/kubectl.sha256) /out/kubectl" | sha256sum -c -; \ + curl -fsSL "https://github.com/kyverno/chainsaw/releases/download/v${CHAINSAW_VERSION}/chainsaw_linux_${TARGETARCH}.tar.gz" \ + | tar -xz -C /out chainsaw; \ + chmod 0755 /out/kubectl /out/chainsaw; \ + /out/kubectl version --client=true --output=yaml >/dev/null; \ + /out/chainsaw version >/dev/null + +# --- runtime ----------------------------------------------------------------- +ARG FEDORA_VERSION=44 +FROM registry.fedoraproject.org/fedora-minimal:${FEDORA_VERSION} + +ARG CHAINSAW_VERSION= +ARG KUBECTL_VERSION= +ARG FEDORA_VERSION=44 +# Nonroot uid/gid. 65532 is the distroless `nonroot` convention; the value is arbitrary but +# it MUST be numeric and non-zero — see "THE ADMISSION CONTRACT" above. +ARG RUNNER_UID=65532 +ARG RUNNER_GID=65532 + +LABEL org.opencontainers.image.title="conformance-runner" \ + org.opencontainers.image.description="bash + python3 + chainsaw + kubectl + git for the in-cluster L2/L3 conformance Job (conformance/run.sh)" \ + org.opencontainers.image.source="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl" \ + org.opencontainers.image.documentation="conformance/incluster/README.md" \ + homelab.keithjackson.dev/requirements="R-TEST-02,R-SEC-02" + +# bash: run.sh + every check.sh. python3: the stdlib report generators. +# git-core: the init-container clone (git-core, not git — drops the perl/svn tail). +# coreutils/findutils/grep/sed/gawk: find, sort, base64, xargs, sed -nE, awk in lib/*.sh. +# tar+gzip: the base64-tar results channel. diffutils: `diff` in the L1 golden compare. +# ca-certificates: TLS for the init clone and for kubectl's in-cluster API calls. +RUN set -eux; \ + microdnf -y install --setopt=install_weak_deps=0 --nodocs \ + bash python3 git-core coreutils findutils grep sed gawk tar gzip diffutils \ + ca-certificates; \ + microdnf clean all; \ + rm -rf /var/cache/* /var/log/dnf* + +COPY --from=builder /out/kubectl /out/chainsaw /usr/local/bin/ + +# The nonroot account is written directly rather than pulling in shadow-utils to run useradd +# once. HOME is /workspace because at runtime that emptyDir is the only writable path. +RUN set -eux; \ + echo "runner:x:${RUNNER_UID}:${RUNNER_GID}:conformance runner:/workspace:/bin/bash" >> /etc/passwd; \ + echo "runner:x:${RUNNER_GID}:" >> /etc/group; \ + install -d -m 0775 -o "${RUNNER_UID}" -g "${RUNNER_GID}" /workspace + +# SYSTEM-level config, because HOME is an emptyDir that starts empty on every run and the +# root filesystem is read-only — a runtime `--global` write would fail. +# safe.directory=*: the init-container clones into the shared emptyDir and the main container +# then runs `rev-parse` over it; any uid mismatch between the two would otherwise abort with +# "detected dubious ownership". The repo is fetched fresh per run into an emptyDir, so there +# is no pre-existing hostile checkout this relaxes a check against. +RUN set -eux; \ + printf '%s\n' \ + '[safe]' \ + ' directory = *' \ + '[advice]' \ + ' detachedHead = false' \ + > /etc/gitconfig; \ + chmod 0644 /etc/gitconfig + +# Provenance: what is actually in this layer, readable from a running pod. +RUN set -eux; \ + { echo "fedora=${FEDORA_VERSION}"; \ + echo "chainsaw=${CHAINSAW_VERSION}"; \ + echo "kubectl=${KUBECTL_VERSION}"; \ + echo "bash=$(bash --version | head -1)"; \ + echo "python3=$(python3 --version)"; \ + echo "git=$(git --version)"; \ + } > /etc/conformance-runner-versions; \ + chmod 0444 /etc/conformance-runner-versions + +# Every writable path points into the `workspace` emptyDir — the root filesystem is +# read-only and the admission policy forbids adding a third volume for scratch. +ENV HOME=/workspace \ + XDG_CACHE_HOME=/workspace/.cache \ + XDG_CONFIG_HOME=/workspace/.config \ + TMPDIR=/workspace/.tmp \ + PYTHONDONTWRITEBYTECODE=1 \ + CONF_ROOT=/workspace/conformance + +USER ${RUNNER_UID}:${RUNNER_GID} +WORKDIR /workspace + +# No ENTRYPOINT on purpose. The admission policy pins `command: ["./run.sh"]` with +# `workingDir: /workspace/conformance`; an ENTRYPOINT here would either be ignored or +# prepend something the policy cannot see. `bash` is the interactive fallback for a human +# debugging the image locally. +CMD ["/bin/bash"] diff --git a/conformance/incluster/README.md b/conformance/incluster/README.md index 77e3021..a46cb07 100644 --- a/conformance/incluster/README.md +++ b/conformance/incluster/README.md @@ -27,7 +27,22 @@ credentials — the Job's own scoped ServiceAccount does (R-SEC-02). audit item. - `chainsaw-job.yaml` — the Job skeleton. Single entrypoint `./run.sh`; CI sets `args` to `--requirements ` (merge gate) or `--layer all --strict-coverage` (phase - exit). Image pinned to `versions.env` `CHAINSAW_IMAGE`. + exit). **Its `image:` is STALE**: it still names `ghcr.io/kyverno/chainsaw:v0.2.12`, which + (a) cannot run `./run.sh` at all and (b) is DENIED at admission. The trigger renders this + template per run with `CONFORMANCE_RUNNER_IMAGE` injected (plan §3 item 4, T-6.1b); until + that lands, treat the literal `image:` here as a placeholder, not a pin. +- `Containerfile` — the **`conformance-runner`** image: `bash` + `python3` + `chainsaw` + + `kubectl` + `git`, non-root (numeric uid 65532) and working under a read-only root + filesystem. This is what the Job actually needs; the stock chainsaw image carries none of + it. Versions are `--build-arg`-injected from `infra/devcontainer/versions.env` — the single + source of truth — so the chainsaw/kubectl in the runner are the same pins the toolbox gate + uses. Read its header before changing the Job's `args`: `run.sh` runs the selected offline + L0/L1 checks for any non-offline-only selection, and those need flux/promtool/kyverno/ + kustomize, which this image deliberately omits. Use `--layer l2` / `--layer l3`. +- `build-image.sh` — builds the above, **verifies the toolchain by running it** inside the + image as uid 65532 with `--read-only`, and prints the digest. It **never pushes**: it + prints the exact `podman push --digestfile …` command for the operator instead. See + `conformance/versions.env` `CONFORMANCE_RUNNER_IMAGE` for the post-push pin. ## Flow 1. CI (Gitea Actions, delivered in T-6.1b) creates a Job from `chainsaw-job.yaml` (via a diff --git a/conformance/incluster/build-image.sh b/conformance/incluster/build-image.sh new file mode 100755 index 0000000..c41817a --- /dev/null +++ b/conformance/incluster/build-image.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +############################################################################### +# conformance/incluster/build-image.sh — build the pinned `conformance-runner` +# image and print its digest. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1. +# +# ./build-image.sh # build + verify + print the digest and push command +# ./build-image.sh --no-verify # build only (skip the in-image tool proof) +# ENGINE=docker ./build-image.sh # build with docker instead of podman +# +# THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is +# not an agent's to hold, and the pushed digest is what a fail-closed admission policy +# then trusts. So this builds locally, proves the toolchain works INSIDE the image as the +# non-root uid the policy demands, and prints the exact `podman push` line for a human. +# +# VERSIONS ARE NOT DEFINED HERE. They are read from infra/devcontainer/versions.env, the +# single source of truth (CHAINSAW_VERSION, KUBECTL_VERSION, FEDORA_VERSION) — the same +# pins the toolbox gate runs with. Nothing in this file invents a version. +# +# THE DIGEST CAVEAT — read it before pasting a digest into anything. +# The digest printed here is the LOCAL manifest digest. It is normally identical to the +# registry digest (podman pushes the manifest it already has rather than recompressing), +# but that is a property of the push, not a guarantee. So the push command below uses +# `--digestfile`: the digest the registry actually returns is the authoritative one, and +# that is the one that goes into conformance/versions.env. +############################################################################### +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${HERE}/../.." && pwd)" +ENGINE="${ENGINE:-podman}" + +VERIFY=1 +for arg in "$@"; do + case "$arg" in + --no-verify) VERIFY=0 ;; + -h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + +log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)" + +# --- pins: sourced, never invented ------------------------------------------- +TOOLCHAIN_ENV="${REPO_ROOT}/infra/devcontainer/versions.env" +[ -f "${TOOLCHAIN_ENV}" ] || die "missing ${TOOLCHAIN_ENV} — the pins live there, not here" +# shellcheck disable=SC1090 +source "${TOOLCHAIN_ENV}" +: "${CHAINSAW_VERSION:?CHAINSAW_VERSION not set in ${TOOLCHAIN_ENV}}" +: "${KUBECTL_VERSION:?KUBECTL_VERSION not set in ${TOOLCHAIN_ENV}}" +: "${FEDORA_VERSION:?FEDORA_VERSION not set in ${TOOLCHAIN_ENV}}" + +# --- image name: fixed by the admission policy ------------------------------- +# platform/kyverno/admission/conformance-trigger-job-shape.yaml admits ONLY +# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*. The repository path +# is therefore not a preference; a different path is a DENIED Job. Do not "improve" it here. +IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner" +# The tag is build convenience only (a human-readable handle for the push). Admission +# ignores it: only the digest is ever referenced from a Job. +IMAGE_TAG="chainsaw${CHAINSAW_VERSION}-kubectl${KUBECTL_VERSION}-fc${FEDORA_VERSION}" +IMAGE_REF="${IMAGE_REPO}:${IMAGE_TAG}" + +log "Building ${IMAGE_REF}" +info "engine ${ENGINE} ($(${ENGINE} --version 2>/dev/null | head -1))" +info "pins from ${TOOLCHAIN_ENV}" +info " CHAINSAW ${CHAINSAW_VERSION}" +info " KUBECTL ${KUBECTL_VERSION}" +info " FEDORA ${FEDORA_VERSION}" + +"${ENGINE}" build \ + --file "${HERE}/Containerfile" \ + --tag "${IMAGE_REF}" \ + --build-arg "CHAINSAW_VERSION=${CHAINSAW_VERSION}" \ + --build-arg "KUBECTL_VERSION=${KUBECTL_VERSION}" \ + --build-arg "FEDORA_VERSION=${FEDORA_VERSION}" \ + "${HERE}" + +# --- verify: the point of the whole exercise --------------------------------- +# The stock chainsaw image "had" chainsaw and still could not run ./run.sh. So prove the +# tools EXECUTE, and prove they execute as the NON-ROOT uid with a READ-ONLY root +# filesystem — the shape the admission policy forces. A pass here is the only evidence +# this image is usable; a build that merely succeeded is not. +if [ "${VERIFY}" -eq 1 ]; then + log "Verifying the toolchain inside the image (non-root, read-only rootfs)" + "${ENGINE}" run --rm \ + --user 65532:65532 \ + --read-only \ + --tmpfs /workspace:rw,mode=1777 \ + --entrypoint "" \ + "${IMAGE_REF}" \ + /bin/bash -euo pipefail -c ' + echo "--- id ---"; id + echo "--- HOME ---"; echo "HOME=${HOME}" + echo "--- bash ---"; bash --version | head -1 + echo "--- python3 ---"; python3 --version + echo "--- git ---"; git --version + echo "--- kubectl ---"; kubectl version --client + echo "--- chainsaw ---"; chainsaw version + echo "--- baked versions ---"; cat /etc/conformance-runner-versions + echo "--- rootfs is read-only (expected: a failure) ---" + if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: rootfs is writable"; exit 1; fi + echo "confirmed: / is read-only" + echo "--- HOME is writable (the workspace emptyDir) ---" + touch "${HOME}/.probe" && echo "confirmed: ${HOME} is writable" + echo "--- uid is non-zero ---" + [ "$(id -u)" -ne 0 ] && echo "confirmed: running as uid $(id -u), not root" + ' + log "Toolchain verified" +fi + +# --- digest ------------------------------------------------------------------ +DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")" +IMAGE_ID="$("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")" +SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")" + +log "Built ${IMAGE_REF}" +info "local manifest digest ${DIGEST}" +info "image id ${IMAGE_ID}" +info "size $(( SIZE / 1024 / 1024 )) MiB" + +cat < sha256:.... THIS is the authoritative digest, not the local one above. + + # conformance/versions.env + CONFORMANCE_RUNNER_IMAGE="${IMAGE_REPO}@\$(cat /tmp/conformance-runner.digest)" + +WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/conformance-trigger-job-shape.yaml +is a fail-closed Enforce policy. It DENIES any conformance-trigger Job whose image is not +${IMAGE_REPO}@sha256:. A tag reference is denied, +and so is any other repository path. + +TIGHTEN THE BOUNDARY AFTERWARDS: that policy's header asks for the wildcard +\`${IMAGE_REPO}@sha256:*\` to be replaced with the +LITERAL digest once the image exists, closing the "anyone who can push to this path" gap. +That is a separate, human-reviewed PR against the security boundary. +================================================================================ +EOF diff --git a/conformance/versions.env b/conformance/versions.env index 269b927..3a4ee47 100644 --- a/conformance/versions.env +++ b/conformance/versions.env @@ -6,6 +6,10 @@ # Chainsaw — L1 rendering / L2 behavioral / L3 fault-injection runner (ADR-0011). CHAINSAW_VERSION="v0.2.12" +# The UPSTREAM chainsaw image. It is NOT what the in-cluster Job runs — see +# CONFORMANCE_RUNNER_IMAGE below. It carries the chainsaw binary and essentially nothing +# else (no bash, no python3, no kubectl, no git), so `command: ["./run.sh"]` on this image +# fails at exec. Kept only as the provenance of the chainsaw binary we rebuild from. CHAINSAW_IMAGE="ghcr.io/kyverno/chainsaw:v0.2.12" # kubectl — used by run.sh for cluster reachability preflight and by probe scripts. @@ -18,3 +22,46 @@ KYVERNO_VERSION="v1.19.0" # Python 3 — coverage generator + JUnit emitter use stdlib only (no pip deps), so the # only constraint is a modern interpreter. PYTHON_MIN="3.9" + +# --- In-cluster conformance runner image ------------------------------------- +# Built from conformance/incluster/Containerfile by conformance/incluster/build-image.sh. +# This — not CHAINSAW_IMAGE — is what the L2/L3 Job runs (bash + python3 + chainsaw + +# kubectl + git). Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1. +# +# THE REPOSITORY PATH IS NOT A PREFERENCE. platform/kyverno/admission/ +# conformance-trigger-job-shape.yaml is a fail-closed Enforce ClusterPolicy (R-SEC-02) that +# ADMITS a conformance-trigger Job only when its image matches +# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:* +# Any other path, and any TAG reference, is DENIED at admission. Do not "tidy" these values. +CONFORMANCE_RUNNER_IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner" +# Build handle only. Admission never sees the tag; a Job must reference the digest. +CONFORMANCE_RUNNER_IMAGE_TAG="chainsaw0.2.12-kubectl1.36.4-fc44" +# +# ############################################################################ +# # DIGEST DELIBERATELY EMPTY — THE IMAGE HAS NOT BEEN PUBLISHED YET. # +# ############################################################################ +# The image has been BUILT and its toolchain verified locally (non-root uid 65532, +# read-only rootfs: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4, chainsaw 0.2.12, +# and `./run.sh --layer l2 --no-cluster` completing with rc 0). It has NOT been pushed: +# pushing needs a registry credential, and publishing the artifact a fail-closed security +# boundary then trusts is an OPERATOR action, not an agent's. +# +# OPERATOR, to complete the pin: +# 1. ./conformance/incluster/build-image.sh # rebuild, verify, print the commands +# 2. podman login gitea.home.keithjackson.dev +# 3. podman push --digestfile /tmp/conformance-runner.digest \ +# "${CONFORMANCE_RUNNER_IMAGE_REPO}:${CONFORMANCE_RUNNER_IMAGE_TAG}" +# 4. Set CONFORMANCE_RUNNER_IMAGE_DIGEST below to `cat /tmp/conformance-runner.digest` +# — the digest the REGISTRY returned, not the local manifest digest the build printed. +# 5. Land steps 4 in a PR (this file is GitOps input, not a live edit). +# +# UNTIL THEN: this is empty on purpose, NOT a placeholder to be guessed at. An empty value +# makes any consumer that renders the Job fail visibly; a fake or stale digest would instead +# produce an ImagePullBackOff that looks like a cluster fault. Do not invent one. +CONFORMANCE_RUNNER_IMAGE_DIGEST="" +CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_DIGEST:+${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}}" +# +# AFTERWARDS, TIGHTEN THE BOUNDARY: the header of conformance-trigger-job-shape.yaml asks for +# its `@sha256:*` wildcard to be replaced with the LITERAL digest once the image exists — +# that closes the residual "anyone who can push to this path" gap. Separate PR, human +# reviewed, because it edits the security boundary itself.