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]>
199 lines
11 KiB
Bash
Executable File
199 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
###############################################################################
|
|
# infra/agent/build-image.sh — build the pinned `agent-runner` image and prove it works.
|
|
#
|
|
# ./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 — 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 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 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
|
|
|
|
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 -------------------------------------------
|
|
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}}"
|
|
: "${CLAUDE_CODE_MIN_VERSION:?CLAUDE_CODE_MIN_VERSION not set in ${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 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="${TAG_OVERRIDE:-claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}}"
|
|
IMAGE_REF="${AGENT_IMAGE_REPO}:${IMAGE_TAG}"
|
|
|
|
log "Building ${IMAGE_REF}"
|
|
info "engine ${ENGINE} ($(${ENGINE} --version 2>/dev/null | head -1))"
|
|
info "pins from ${AGENT_ENV}"
|
|
info " base ${AGENT_BASE_IMAGE}"
|
|
info " node ${NODEJS_MAJOR}"
|
|
info " claude ${CLAUDE_CODE_VERSION} (floor ${CLAUDE_CODE_MIN_VERSION})"
|
|
info " model ${AGENT_MODEL}"
|
|
|
|
# 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}" \
|
|
--build-arg "NODEJS_MAJOR=${NODEJS_MAJOR}" \
|
|
--build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \
|
|
--build-arg "CLAUDE_CODE_MIN_VERSION=${CLAUDE_CODE_MIN_VERSION}" \
|
|
--build-arg "AGENT_MODEL=${AGENT_MODEL}" \
|
|
"${HERE}"
|
|
|
|
# --- verify: the point of the whole exercise ---------------------------------
|
|
# A build that merely succeeded is not evidence this image is usable. Prove the tools
|
|
# EXECUTE, as the NON-ROOT uid, with a READ-ONLY root filesystem and only the two volumes
|
|
# the admission policy admits — the exact shape a worker Job runs in. Anything that works
|
|
# here but not there is a 3am discovery.
|
|
if [ "${VERIFY}" -eq 1 ]; then
|
|
log "Verifying inside the image (uid 65532, read-only rootfs, workspace+tmp only)"
|
|
"${ENGINE}" run --rm \
|
|
--user 65532:65532 \
|
|
--read-only \
|
|
--tmpfs /workspace:rw,mode=1777 \
|
|
--tmpfs /tmp:rw,mode=1777 \
|
|
--network none \
|
|
--entrypoint "" \
|
|
"${IMAGE_REF}" \
|
|
/bin/bash -euo pipefail -c '
|
|
echo "--- id ---"; id
|
|
echo "--- HOME ---"; echo "HOME=${HOME} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
|
|
echo "--- baked versions ---"; cat /etc/agent-runner-versions
|
|
echo "--- the gate toolchain the worker needs (CLAUDE.md §3) ---"
|
|
for t in git python3 bash yamllint check-jsonschema kyverno promtool chainsaw flux sops age kubectl jq diff; do
|
|
if command -v "$t" >/dev/null 2>&1; then printf " ok %s\n" "$t";
|
|
else printf " MISSING %s\n" "$t"; missing=1; fi
|
|
done
|
|
[ -z "${missing:-}" ] || { echo "the gate cannot run in this image"; exit 1; }
|
|
echo "--- python can import the renderer dependency ---"
|
|
python3 -c "import yaml; print(\"PyYAML\", yaml.__version__)"
|
|
echo "--- claude ---"; claude --version
|
|
echo "--- the two entrypoints refuse arguments ---"
|
|
/usr/local/bin/pm-worker --anything 2>&1 | grep -q "takes no arguments" && echo " pm-worker: rejected"
|
|
/usr/local/bin/pm-dispatch --anything 2>&1 | grep -q "takes no arguments" && echo " pm-dispatch: rejected"
|
|
echo "--- the dispatcher selftest (branch vocabulary, result envelopes, Job template, persona) ---"
|
|
PM_SELFTEST=1 /usr/local/bin/pm-dispatch
|
|
echo "--- the entrypoints are root-owned and NOT writable by the runtime uid ---"
|
|
for f in /usr/local/bin/pm-worker /usr/local/bin/pm-dispatch /etc/pm-agent/worker-job-template.yaml; do
|
|
stat -c " %n %U:%G %a" "$f"
|
|
if [ -w "$f" ]; then echo "UNEXPECTED: uid $(id -u) can write $f"; exit 1; fi
|
|
done
|
|
echo " confirmed: not writable by uid $(id -u)"
|
|
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 "--- the two writable paths really are writable ---"
|
|
touch "${HOME}/.probe" /tmp/.probe && echo " confirmed: ${HOME} and /tmp are writable"
|
|
echo "--- uid is non-zero ---"
|
|
[ "$(id -u)" -ne 0 ] && echo " confirmed: uid $(id -u), not root"
|
|
echo "--- no Anthropic API key is baked into the image (it would outrank the subscription token) ---"
|
|
[ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] && echo " confirmed: neither is set"
|
|
'
|
|
# The worker's input guards, exercised in the image, with the interpreter and the script
|
|
# 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"
|
|
#
|
|
# 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 \
|
|
-e PM_WORKER_PATH=/usr/local/bin/pm-worker \
|
|
--entrypoint "" \
|
|
"${IMAGE_REF}" \
|
|
/bin/bash -s < "${HERE}/probe-validation.sh"
|
|
|
|
log "Image verified"
|
|
fi
|
|
|
|
# --- 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 "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."
|