Operator rule 2026-10-29: image creation, hosting and lifecycle belong to CI/CD, never to a workstation build/push. Nothing had ever been pushed to the Gitea registry. - .gitea/workflows/images.yaml + scripts/images.sh: PRs build+verify (no credential); main builds, verifies and pushes toolbox, agent-runner (FROM toolbox) and conformance-runner as sha-<commit> + main, reading the digest back from the registry. Only changed images build. - Each image's own build-image.sh does the build and in-image verification, now engine-neutral (DinD: no podman-only calls, probe over stdin not a bind mount). New toolbox build-image.sh. - Digest references carry `# renovate-digest:` markers; renovate.json5 gains a custom.regex manager for them. The worker Job template is deliberately untracked (it would loop). - renovate.yaml pins renovate-version 44.167.0: the action's default (39.264.0) rejects the current config's managerFilePatterns, so every run would have died at CONFIG_VALIDATION. - Push credential: REGISTRY_PUSH_TOKEN secret; the Actions token has no package scope in Gitea 1.24.3 (container.go Authenticate). Retention: infra/gitea/registry-retention.sh. Co-Authored-By: Claude Opus 5.5 <[email protected]>
204 lines
10 KiB
Bash
Executable File
204 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
###############################################################################
|
|
# conformance/incluster/build-image.sh — build the pinned `conformance-runner`
|
|
# image and prove it works. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
|
|
#
|
|
# ./build-image.sh # build + verify + print the image id
|
|
# ./build-image.sh --no-verify # build only (skip the in-image tool proof)
|
|
# ./build-image.sh --verify-clone # ALSO really clone this repo with conformance-clone.
|
|
# # Opt-in because it needs the Gitea host reachable; the
|
|
# # default verify is offline so an air-gapped build is
|
|
# # still fully checked rather than half-checked.
|
|
# ENGINE=docker ./build-image.sh # build with docker instead of podman
|
|
# IMAGE_TAG=foo ./build-image.sh # override the local tag (CI uses this)
|
|
#
|
|
# THIS SCRIPT NEVER PUSHES — and nobody pushes this image from a workstation either.
|
|
# Publishing is done by CI: .gitea/workflows/images.yaml calls this script, and on a push to
|
|
# `main` it pushes what this script built AND verified to the Gitea registry, records the
|
|
# registry-returned digest in the job summary, and Renovate then opens the pull request that
|
|
# moves conformance/versions.env onto that digest. So this script's job is to build and to
|
|
# prove the toolchain works INSIDE the image as the non-root uid the policy demands — the same
|
|
# proof locally and in CI. It runs under ENGINE=docker in CI (Docker-in-Docker), so nothing
|
|
# below may be podman-only.
|
|
#
|
|
# 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.
|
|
#
|
|
# NO DIGEST IS PRINTED. A manifest digest does not exist until a registry holds the manifest,
|
|
# and the only digest anything may pin is the one the REGISTRY returns. CI captures that at
|
|
# push time (.gitea/workflows/scripts/images.sh) and Renovate carries it into
|
|
# conformance/versions.env.
|
|
###############################################################################
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
|
|
ENGINE="${ENGINE:-podman}"
|
|
TAG_OVERRIDE="${IMAGE_TAG:-}"
|
|
|
|
VERIFY=1
|
|
VERIFY_CLONE=0
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--no-verify) VERIFY=0 ;;
|
|
--verify-clone) VERIFY_CLONE=1 ;;
|
|
-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 a local handle only. CI re-tags what it publishes (git SHA + `main`), and
|
|
# admission ignores tags entirely: only the digest is ever referenced from a Job.
|
|
IMAGE_TAG="${TAG_OVERRIDE:-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}"
|
|
|
|
# Provenance: CI passes the commit it is building, so a pulled image names its source tree.
|
|
LABEL_ARGS=()
|
|
[ -z "${BUILD_REVISION:-}" ] || LABEL_ARGS+=(--label "org.opencontainers.image.revision=${BUILD_REVISION}")
|
|
|
|
"${ENGINE}" build "${LABEL_ARGS[@]}" \
|
|
--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 "--- conformance-clone: fixed path, root-owned, NOT writable by the runner ---"
|
|
stat -c "%n %U:%G %a" /usr/local/bin/conformance-clone
|
|
if [ -w /usr/local/bin/conformance-clone ]; then
|
|
echo "UNEXPECTED: uid $(id -u) can write the one executable the policy will trust"; exit 1
|
|
fi
|
|
echo "confirmed: not writable by uid $(id -u)"
|
|
# Each of these MUST fail. Assert the non-zero exit as well as the message: a check
|
|
# that only greps stderr would pass a build where the guard had stopped rejecting.
|
|
# (`|| true` inside the capture keeps `set -e`/`pipefail` from aborting on the
|
|
# expected failure — without it the verify dies at the first negative case.)
|
|
expect_fail() {
|
|
local label="$1"; shift
|
|
local out rc=0
|
|
out="$(env "$@" 2>&1)" || rc=$?
|
|
echo "--- ${label} ---"
|
|
printf "%s\n" "${out}" | head -2
|
|
if [ "${rc}" -eq 0 ]; then
|
|
echo "UNEXPECTED: that should have failed, but it exited 0"; exit 1
|
|
fi
|
|
echo "(rejected, exit ${rc})"
|
|
}
|
|
expect_fail "conformance-clone rejects arguments (that is where an injection would live)" \
|
|
conformance-clone --layer l2
|
|
expect_fail "conformance-clone rejects a missing SHA" \
|
|
conformance-clone
|
|
expect_fail "conformance-clone rejects a branch name instead of a SHA (no silent run of the tip)" \
|
|
CONFORMANCE_GIT_SHA=main conformance-clone
|
|
expect_fail "conformance-clone rejects an UNRENDERED placeholder" \
|
|
CONFORMANCE_GIT_SHA=@@CONFORMANCE_GIT_SHA@@ conformance-clone
|
|
expect_fail "conformance-clone rejects a HOME off the workspace volume" \
|
|
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
|
|
HOME=/etc conformance-clone
|
|
expect_fail "conformance-clone refuses a REDIRECTED repository URL (the env-injection hole)" \
|
|
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
|
|
CONFORMANCE_REPO_URL=https://evil.invalid/x.git conformance-clone
|
|
expect_fail "conformance-clone refuses a clone target off the workspace volume" \
|
|
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
|
|
CONFORMANCE_SRC=/etc/src conformance-clone
|
|
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
|
|
|
|
# --- verify the clone helper against the REAL repository (opt-in: needs network) ----
|
|
# The negative cases above prove conformance-clone refuses bad input. This proves the
|
|
# positive case: it lands the EXACT commit asked for and can prove it did. Without this, the
|
|
# rev-parse assertion is only an unexercised line of shell.
|
|
if [ "${VERIFY_CLONE}" -eq 1 ]; then
|
|
log "Verifying conformance-clone against the real repository"
|
|
SHA="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
|
|
info "requesting commit ${SHA}"
|
|
"${ENGINE}" run --rm \
|
|
--user 65532:65532 \
|
|
--read-only \
|
|
--tmpfs /workspace:rw,mode=1777 \
|
|
--network host \
|
|
--entrypoint "" \
|
|
-e "CONFORMANCE_GIT_SHA=${SHA}" \
|
|
"${IMAGE_REF}" \
|
|
/bin/bash -euo pipefail -c '
|
|
conformance-clone
|
|
echo "--- assert rev-parse HEAD == the requested SHA ---"
|
|
got="$(git -C /workspace/src rev-parse HEAD)"
|
|
echo "requested: ${CONFORMANCE_GIT_SHA}"
|
|
echo "HEAD: ${got}"
|
|
[ "${got}" = "${CONFORMANCE_GIT_SHA}" ] || { echo "MISMATCH"; exit 1; }
|
|
echo "confirmed: the working tree is at the requested commit"
|
|
test -x /workspace/src/conformance/run.sh && echo "confirmed: run.sh present and executable"
|
|
'
|
|
log "conformance-clone verified against the real repository"
|
|
fi
|
|
|
|
# --- result ------------------------------------------------------------------
|
|
IMAGE_ID="$("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
|
|
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
|
|
|
|
log "Built ${IMAGE_REF}"
|
|
info "image id ${IMAGE_ID}"
|
|
info "size $(( SIZE / 1024 / 1024 )) MiB"
|
|
info "Not pushed. CI publishes this image from main (.gitea/workflows/images.yaml) and"
|
|
info "Renovate moves conformance/versions.env onto the registry-returned digest."
|