feat(images): build, verify and publish the three images in CI; Renovate carries the digests
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]>
This commit is contained in:
1 parent
1301af9a4c
commit
0da34ddbc6
24 files changed
+1280
-210
No files matched your search
+69
-71
@@ -1,26 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
###############################################################################
|
||||
# infra/agent/build-image.sh — build the pinned `agent-runner` image and print its digest.
|
||||
# infra/agent/build-image.sh — build the pinned `agent-runner` image and prove it works.
|
||||
#
|
||||
# ./build-image.sh # build + verify + print the digest and push command
|
||||
# ./build-image.sh # build + verify + print the image id
|
||||
# ./build-image.sh --no-verify # build only (skip the in-image proof)
|
||||
# ENGINE=docker ./build-image.sh # build with docker instead of podman
|
||||
# AGENT_BASE_IMAGE=home-k3s-dev:local ./build-image.sh
|
||||
# # build on a LOCAL toolbox instead of the pinned one
|
||||
# # (`infra/devcontainer/dev.sh build` makes that tag)
|
||||
# IMAGE_TAG=foo ./build-image.sh # override the local tag (CI uses this)
|
||||
#
|
||||
# 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 and both entrypoints work INSIDE the
|
||||
# image as the non-root uid with the read-only root filesystem the policy forces, and prints
|
||||
# the exact push line for a human.
|
||||
# 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 platform/pm-agent/ onto that digest. So this script's job is to build, and to prove
|
||||
# the toolchain and both entrypoints work INSIDE the image as the non-root uid with the
|
||||
# read-only root filesystem the admission policy forces — the same proof locally and in CI.
|
||||
#
|
||||
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the four pins this image
|
||||
# adds (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds
|
||||
# the platform toolchain the base already carries. Nothing in this file invents a version.
|
||||
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the pins this image adds
|
||||
# (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds the
|
||||
# platform toolchain the base already carries. 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, but that is
|
||||
# a property of the push rather than a guarantee, so the push command below uses
|
||||
# `--digestfile`: the digest the REGISTRY returns is the authoritative one, and that is the
|
||||
# one that goes into platform/pm-agent/configmap.yaml.
|
||||
# THE BASE. AGENT_BASE_IMAGE in versions.env is the PUBLISHED toolbox, pinned by digest and
|
||||
# kept current by Renovate. An AGENT_BASE_IMAGE in the environment overrides it — CI passes
|
||||
# the toolbox it built in the same run, and a developer can pass `home-k3s-dev:local`.
|
||||
#
|
||||
# ENGINE PORTABILITY. CI runs this with ENGINE=docker against a Docker-in-Docker daemon, so
|
||||
# nothing below may be podman-only (`image exists`, `--digestfile`, `{{.Digest}}`), and
|
||||
# nothing may BIND-MOUNT a file from this checkout into a container: under DinD the daemon
|
||||
# resolves bind sources on ITS filesystem, where this checkout does not exist, and silently
|
||||
# mounts an empty directory instead. Files go in over stdin.
|
||||
###############################################################################
|
||||
set -euo pipefail
|
||||
|
||||
@@ -46,8 +56,11 @@ command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGI
|
||||
# --- pins: sourced, never invented -------------------------------------------
|
||||
AGENT_ENV="${HERE}/versions.env"
|
||||
[ -f "${AGENT_ENV}" ] || die "missing ${AGENT_ENV} — the pins live there, not here"
|
||||
BASE_OVERRIDE="${AGENT_BASE_IMAGE:-}"
|
||||
TAG_OVERRIDE="${IMAGE_TAG:-}"
|
||||
# shellcheck disable=SC1090
|
||||
source "${AGENT_ENV}"
|
||||
[ -z "${BASE_OVERRIDE}" ] || AGENT_BASE_IMAGE="${BASE_OVERRIDE}"
|
||||
: "${AGENT_BASE_IMAGE:?AGENT_BASE_IMAGE not set in ${AGENT_ENV}}"
|
||||
: "${NODEJS_MAJOR:?NODEJS_MAJOR not set in ${AGENT_ENV}}"
|
||||
: "${CLAUDE_CODE_VERSION:?CLAUDE_CODE_VERSION not set in ${AGENT_ENV}}"
|
||||
@@ -55,16 +68,28 @@ source "${AGENT_ENV}"
|
||||
: "${AGENT_MODEL:?AGENT_MODEL not set in ${AGENT_ENV}}"
|
||||
: "${AGENT_IMAGE_REPO:?AGENT_IMAGE_REPO not set in ${AGENT_ENV}}"
|
||||
|
||||
# --- the base must already exist ---------------------------------------------
|
||||
# Deliberately NOT built on demand. The toolbox build is its own reviewed artefact with its
|
||||
# own pins, and silently building it here would mean this script could change the toolchain
|
||||
# a worker gates with as a side effect of rebuilding the agent image.
|
||||
if ! "${ENGINE}" image exists "${AGENT_BASE_IMAGE}" 2>/dev/null; then
|
||||
die "base image '${AGENT_BASE_IMAGE}' is not present. Build the pinned toolbox first:
|
||||
${REPO_ROOT}/infra/devcontainer/dev.sh build"
|
||||
# --- the base must exist, locally or in the registry ---------------------------
|
||||
# Deliberately NOT built on demand. The toolbox is its own reviewed artefact with its own
|
||||
# pins, and silently building it here would mean this script could change the toolchain a
|
||||
# worker gates with as a side effect of rebuilding the agent image.
|
||||
case "${AGENT_BASE_IMAGE}" in
|
||||
*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
|
||||
die "AGENT_BASE_IMAGE is still the all-zero placeholder in ${AGENT_ENV}: the toolbox has
|
||||
not been published yet. Either let CI publish it (.gitea/workflows/images.yaml) and
|
||||
merge Renovate's digest bump, or build on a local toolbox:
|
||||
${REPO_ROOT}/infra/devcontainer/dev.sh build
|
||||
AGENT_BASE_IMAGE=home-k3s-dev:local $0" ;;
|
||||
esac
|
||||
if ! "${ENGINE}" image inspect "${AGENT_BASE_IMAGE}" >/dev/null 2>&1; then
|
||||
info "base ${AGENT_BASE_IMAGE} is not local; pulling it"
|
||||
"${ENGINE}" pull "${AGENT_BASE_IMAGE}" >/dev/null \
|
||||
|| die "base image '${AGENT_BASE_IMAGE}' is neither local nor pullable (registry login?).
|
||||
To build on a local toolbox instead:
|
||||
${REPO_ROOT}/infra/devcontainer/dev.sh build
|
||||
AGENT_BASE_IMAGE=home-k3s-dev:local $0"
|
||||
fi
|
||||
|
||||
IMAGE_TAG="claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}"
|
||||
IMAGE_TAG="${TAG_OVERRIDE:-claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}}"
|
||||
IMAGE_REF="${AGENT_IMAGE_REPO}:${IMAGE_TAG}"
|
||||
|
||||
log "Building ${IMAGE_REF}"
|
||||
@@ -75,7 +100,11 @@ info " node ${NODEJS_MAJOR}"
|
||||
info " claude ${CLAUDE_CODE_VERSION} (floor ${CLAUDE_CODE_MIN_VERSION})"
|
||||
info " model ${AGENT_MODEL}"
|
||||
|
||||
"${ENGINE}" build \
|
||||
# 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 "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}" \
|
||||
@@ -138,63 +167,32 @@ if [ "${VERIFY}" -eq 1 ]; then
|
||||
# that will actually run in the cluster. The probe is mounted read-only rather than baked
|
||||
# in: it is a build-time check, not something a worker Job has any business carrying.
|
||||
log "Probing pm-worker's input validation inside the image"
|
||||
"${ENGINE}" run --rm \
|
||||
#
|
||||
# The probe goes in over STDIN, not as a bind mount: under Docker-in-Docker (CI) a bind
|
||||
# source is resolved on the daemon's filesystem, where this file does not exist, and the
|
||||
# container would get an empty directory. PM_WORKER_PATH points it at the baked worker,
|
||||
# since the probe's own default (a sibling of the probe file) has no meaning on stdin.
|
||||
"${ENGINE}" run --rm -i \
|
||||
--user 65532:65532 \
|
||||
--read-only \
|
||||
--tmpfs /workspace:rw,mode=1777 \
|
||||
--tmpfs /tmp:rw,mode=1777 \
|
||||
--network none \
|
||||
--volume "${HERE}/probe-validation.sh:/probe-validation.sh:ro,Z" \
|
||||
-e PM_WORKER_PATH=/usr/local/bin/pm-worker \
|
||||
--entrypoint "" \
|
||||
"${IMAGE_REF}" \
|
||||
/bin/bash /probe-validation.sh
|
||||
/bin/bash -s < "${HERE}/probe-validation.sh"
|
||||
|
||||
log "Image verified"
|
||||
fi
|
||||
|
||||
# --- digest ------------------------------------------------------------------
|
||||
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
|
||||
# --- result ------------------------------------------------------------------
|
||||
# No digest is printed: a manifest digest does not exist until a registry has the manifest,
|
||||
# and the only digest anything may pin is the one the REGISTRY returns. CI captures that at
|
||||
# push time and prints it in the job summary (.gitea/workflows/scripts/images.sh).
|
||||
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
|
||||
|
||||
log "Built ${IMAGE_REF}"
|
||||
info "local manifest digest ${DIGEST}"
|
||||
info "size $(( SIZE / 1024 / 1024 )) MiB"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
================================================================================
|
||||
NOT PUSHED. Publishing is an operator action — this script holds no registry
|
||||
credential and will never acquire one.
|
||||
|
||||
OPERATOR: run these, then put the digest from the digestfile into
|
||||
platform/pm-agent/configmap.yaml (PM_WORKER_IMAGE) via a pull request:
|
||||
|
||||
${ENGINE} login gitea.home.keithjackson.dev
|
||||
|
||||
${ENGINE} push --digestfile /tmp/agent-runner.digest ${IMAGE_REF}
|
||||
|
||||
cat /tmp/agent-runner.digest
|
||||
# -> sha256:.... THIS is the authoritative digest, not the local one above.
|
||||
|
||||
# platform/pm-agent/configmap.yaml
|
||||
PM_WORKER_IMAGE: "${AGENT_IMAGE_REPO}@\$(cat /tmp/agent-runner.digest)"
|
||||
|
||||
WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/pm-agent-job-shape.yaml is a
|
||||
fail-closed Enforce policy. It DENIES any worker Job whose image is not
|
||||
${AGENT_IMAGE_REPO}@sha256:<digest> — a tag reference is denied,
|
||||
and so is any other repository path. The dispatcher also refuses to start if
|
||||
PM_WORKER_IMAGE carries no digest, so the failure names its own cause.
|
||||
|
||||
THE SAME IMAGE RUNS THE DISPATCHER. platform/pm-agent/deployment.yaml pins it too, so a
|
||||
new digest is a two-line change in one pull request: the Deployment's image and
|
||||
PM_WORKER_IMAGE. They must be the same digest — the dispatcher and its workers share the
|
||||
committed Job template and the env-name contract, and a mismatched pair would disagree
|
||||
about it.
|
||||
|
||||
TIGHTEN THE BOUNDARY AFTERWARDS: the policy admits the wildcard
|
||||
\`${AGENT_IMAGE_REPO}@sha256:*\`, so anyone who can push to that registry path can publish
|
||||
a different digest. Replacing the wildcard with the LITERAL digest closes that gap; it is a
|
||||
one-line change, reviewed in a pull request, which is exactly where a change to the
|
||||
security boundary belongs.
|
||||
================================================================================
|
||||
EOF
|
||||
info "image id $("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
|
||||
info "size $(( SIZE / 1024 / 1024 )) MiB"
|
||||
info "Not pushed. CI publishes this image from main (.gitea/workflows/images.yaml); see"
|
||||
info "platform/pm-agent/README.md for how a new digest reaches the cluster."
|
||||
@@ -22,7 +22,9 @@
|
||||
###############################################################################
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# `:-$0` because build-image.sh feeds this script over STDIN (`bash -s`), where BASH_SOURCE is
|
||||
# empty and `set -u` would abort on it; PM_WORKER_PATH is always set in that case.
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
WORKER="${PM_WORKER_PATH:-${HERE}/pm-worker}"
|
||||
[ -r "$WORKER" ] || { echo "cannot read ${WORKER}" >&2; exit 2; }
|
||||
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
# truth and, the first time the two drifted, the worker's "green" would stop meaning the
|
||||
# gate's green. So this file pins ONLY what the toolbox does not carry.
|
||||
|
||||
# The toolbox image the runner is layered on. Built by `infra/devcontainer/dev.sh build`
|
||||
# (tag `home-k3s-dev:local`); infra/agent/build-image.sh asserts it exists rather than
|
||||
# silently building a different base.
|
||||
AGENT_BASE_IMAGE=home-k3s-dev:local
|
||||
# The toolbox image the runner is layered on: the PUBLISHED toolbox, by digest. CI builds and
|
||||
# publishes it (.gitea/workflows/images.yaml) and Renovate moves this line onto each new digest
|
||||
# of its `main` tag via the marker below, in a pull request — nobody edits it by hand.
|
||||
#
|
||||
# ALL-ZERO UNTIL THE FIRST PUBLISH. The first run of the image pipeline on main sees this
|
||||
# placeholder, builds and publishes the toolbox first, and builds agent-runner FROM that fresh
|
||||
# digest. For a LOCAL build on a local toolbox, override it:
|
||||
# infra/devcontainer/dev.sh build && AGENT_BASE_IMAGE=home-k3s-dev:local infra/agent/build-image.sh
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
|
||||
AGENT_BASE_IMAGE=gitea.home.keithjackson.dev/home-network/toolbox@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# Node.js — the runtime for the Claude Code CLI, installed from the Fedora base's module
|
||||
# stream. A major version, not a patch: the CLI supports the active LTS line and the
|
||||
|
||||
@@ -80,6 +80,12 @@ spec:
|
||||
# Replaced by the dispatcher with the digest-pinned image. A tag here would be
|
||||
# DENIED by admission; the dispatcher refuses a non-digest PM_WORKER_IMAGE at
|
||||
# startup so the error names the real cause.
|
||||
#
|
||||
# DELIBERATELY ALL-ZERO, AND DELIBERATELY NOT TRACKED BY RENOVATE. This file is
|
||||
# baked INTO agent-runner, so it cannot carry agent-runner's own digest: bumping it
|
||||
# would change the image's inputs, CI would publish a new digest, Renovate would bump
|
||||
# it again — forever. pm-dispatch overwrites this field from PM_WORKER_IMAGE
|
||||
# (platform/pm-agent/configmap.yaml, which IS tracked) on every Job it creates.
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
imagePullPolicy: IfNotPresent
|
||||
# The pinned entrypoint, with NO args. Args on a pinned command are
|
||||
|
||||
Reference in new issue
Block a user