#!/usr/bin/env bash ############################################################################### # infra/agent/build-image.sh — build the pinned `agent-runner` image and prove it works. # # ./build-image.sh # build the verify target, then the image # ./build-image.sh --print-build-args # print KEY=VALUE build args (what CI consumes) # ./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 builds the SAME Containerfile on # rootless BuildKit (platform/buildkit/), taking its build args from `--print-build-args`, and # on a push to `main` pushes it to the Gitea registry and records the registry-returned digest # in the job summary. Renovate then opens the pull request that moves platform/pm-agent/ onto # that digest. This script is the LOCAL path (podman by default); its proof — the # Containerfile's `verify` stage — is the same one CI runs. # # 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`. # # NO `run` STEP. Verification used to `run` the image as uid 65532 with a read-only rootfs. # CI has no container runtime (rootless BuildKit only), so the assertions moved into the # Containerfile's `verify` stage and this script builds that target. `--print-build-args` # prints the pins as KEY=VALUE for the CI pipeline; AGENT_BASE_IMAGE is the caller's choice. ############################################################################### set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${HERE}/../.." && pwd)" ENGINE="${ENGINE:-podman}" VERIFY=1 PRINT_ARGS=0 for arg in "$@"; do case "$arg" in --no-verify) VERIFY=0 ;; --print-build-args) PRINT_ARGS=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; } # --- 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 build args, in ONE place; `--print-build-args` hands them to CI (which builds on rootless # BuildKit and supplies its own base reference). AGENT_BASE_IMAGE is deliberately NOT in this # list: the caller decides the base. BUILD_ARGS=( "NODEJS_MAJOR=${NODEJS_MAJOR}" "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" "CLAUDE_CODE_MIN_VERSION=${CLAUDE_CODE_MIN_VERSION}" "AGENT_MODEL=${AGENT_MODEL}" ) if [ "${PRINT_ARGS}" -eq 1 ]; then printf '%s\n' "${BUILD_ARGS[@]}"; exit 0; fi command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)" # --- 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}") ARG_FLAGS=(--build-arg "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}") for a in "${BUILD_ARGS[@]}"; do ARG_FLAGS+=(--build-arg "$a"); done # --- verify: the point of the whole exercise --------------------------------- # A build that merely succeeded is not evidence this image is usable. The proof is the # Containerfile's `verify` stage: as the NON-ROOT uid, with no network, the toolchain executes, # both entrypoints refuse arguments, the dispatcher selftest passes, the entrypoints are not # writable, nothing outside /workspace and /tmp is writable, and pm-worker's input guards # (probe-validation.sh) refuse every hostile input. It is a build target rather than a # `run` so the SAME proof runs here and in CI, where no container runtime exists — see the # stage's header for what a build step can and cannot reproduce of the pod's shape. if [ "${VERIFY}" -eq 1 ]; then log "Verifying (Containerfile target: verify)" "${ENGINE}" build "${ARG_FLAGS[@]}" --target verify --file "${HERE}/Containerfile" "${HERE}" log "Image verified" fi "${ENGINE}" build "${LABEL_ARGS[@]}" "${ARG_FLAGS[@]}" --target agent-runner \ --file "${HERE}/Containerfile" --tag "${IMAGE_REF}" "${HERE}" # --- 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."