Operator decision on PR #81: no CI job may reach a privileged daemon. The act runner is untouched. - platform/buildkit (+ clusters/base/buildkit.yaml): moby/buildkit v0.34.1-rootless pinned by digest, uid 1000, not privileged; caps drop ALL + SETUID/SETGID, seccomp/AppArmor Unconfined, read-only rootfs (each measured under podman). cert-manager CA + server/client certs, ClusterIP :1234, default-deny with ingress only from the act-runner pod (DinD masquerades jobs to it). No Kyverno exception: every applied policy matches app-namespace=true only. - Each Containerfile names its shipped stage and ends in a `verify` stage holding every former `docker run` assertion (uid 65532, RUN --network=none, writable-path inventory standing in for the read-only rootfs). The old `&& echo` checks that could never fail are real assertions now. - images.sh drives buildkitd with a pinned buildx (sha256-checked) via bake; PRs build verify targets with agent-runner FROM the in-run toolbox (target: context); main verifies, then pushes. Fails loudly when the builder secrets are absent or buildkitd is unreachable. - infra/gitea/buildkit-client-secret.sh copies the client cert into Actions secrets. Co-Authored-By: Claude Opus 5.5 <[email protected]>
147 lines
8.0 KiB
Bash
Executable File
147 lines
8.0 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 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."
|