Files
pi-devbox/scripts/ci-reclaim-docker.sh
T
joakimp 779efbf4fe
Lint / hadolint (push) Successful in 12s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 17s
Lint / actionlint (push) Successful in 26s
ci: pin the smoke image with a running container during the test
The label only protects against reclaims that honour it. The runner
hosts also run jobs we do not control: runner-2 has a second act_runner
registered with gitea.egl.lan on the same Docker daemon, and
opencode-devbox's workflows run raw `docker system prune -af` in 7
places. No prune removes an image a running container uses, so the
smoke step now starts a holder (`ci-reclaim-docker.sh hold`: --rm,
--network none, sleep 3600) right after the preflight, and the
always() cleanup step runs `release` (holder + image). Unprotected now:
only the seconds between the build step's load and the holder starting.

Verified on an isolated docker:29-dind: an unlabelled held image survives
raw system prune and image prune -af (control without holder: deleted);
hold fails on a missing image; holder is AutoRemove + network none;
release removes holder and image; reclaim behaviour unchanged.
2026-10-23 12:46:39 +02:00

142 lines
7.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# ci-reclaim-docker.sh — free disk on the CI runner's Docker daemon WITHOUT
# deleting a smoke image that another job is still testing.
#
# Used by every docker-publish.yml job that reclaims disk (build-base, smoke,
# smoke-studio, build-variant, build-variant-studio). Not shipped in the image.
#
# bash scripts/ci-reclaim-docker.sh # reclaim (default)
# bash scripts/ci-reclaim-docker.sh assert-protected IMG # smoke preflight
# bash scripts/ci-reclaim-docker.sh hold IMG # pin IMG while testing
# bash scripts/ci-reclaim-docker.sh release IMG # drop the pin + IMG
# bash scripts/ci-reclaim-docker.sh lint WORKFLOW... # lint.yml guard
#
# WHY THIS EXISTS. Everything that runs on one runner host shares its Docker
# daemon: concurrent jobs of a runner with capacity > 1 (runner-a1 had
# smoke-studio and build-variant going together in run 740), and the jobs of
# other act_runner containers on the same VM (runner-2 has one registered with
# gitea.jordbo.se and one with gitea.egl.lan). Each pi-devbox job used to start
# with `docker system prune -af --volumes`. That removes every image no
# container is using at that instant, and a smoke image is
# exactly that for most of its life: smoke-test.sh runs one `docker run --rm`
# per assertion, so between assertions nothing holds the image. v1.12.2 run 740
# (2026-10-22): smoke passed, build-variant started and pruned, and its log
# shows `untagged: pi-devbox:smoke-studio` at 22:58:49.11Z, 30 ms after
# smoke-studio had imported that image (22:58:49.08Z). Every smoke-studio
# assertion then failed with "Unable to find image 'pi-devbox:smoke-studio'
# locally", build-variant-studio was skipped, and the release went out without
# its -studio tags. Job ORDER cannot close this: smoke and smoke-studio prune
# each other too whenever their start times drift apart, and other runs share
# the daemon. Two defences, because we only control our own jobs:
# - the smoke images carry a label, and this script never prunes an image
# that has it (covers pi-devbox's own jobs, before and during the test);
# - `hold` starts a container from the image for the whole smoke step. No
# prune removes an image a running container uses, so this also covers
# jobs that never call this script (opencode-devbox's raw prunes,
# anything gitea.egl.lan runs). What is left is the few seconds between
# the build step loading the image and `hold` starting.
#
# WHAT IT DOES. The same reclaim as `docker system prune -af --volumes` +
# `docker builder prune -af`, done per object type so the image step can take
# a filter:
# 1. stopped containers (running ones are never touched)
# 2. unused images WITHOUT the label (the old `system prune -a` image step)
# 3. unused images WITH the label, older than SMOKE_IMAGE_MAX_AGE
# — orphans of a smoke job that died before its own cleanup step
# 4. unused networks, anonymous volumes (what `system prune --volumes` did)
# 5. the build cache
# Each step is `|| true`, as before: failing to reclaim disk must not fail the
# job. A filter the daemon rejected would show up as an error line in the log.
#
# `assert-protected IMG` is the smoke jobs' preflight: it fails unless IMG is in
# the daemon AND carries the label. Without it, a build that silently lost the
# label would look fine until the next time a sibling job's prune landed
# mid-test; and a missing image gets one clear error line instead of every
# assertion failing with "Unable to find image ... locally".
#
# `lint WORKFLOW...` fails if any workflow runs `docker system prune` or
# `docker image prune` itself. One such line in any job reopens the race, so
# lint.yml runs this on every push rather than relying on review.
#
# Environment:
# SMOKE_IMAGE_LABEL label key that protects an image (default below; the
# workflow sets it once in its top-level env)
# SMOKE_IMAGE_MAX_AGE `until=` value for the orphan sweep (default 6h; a
# smoke job takes ~10 min). Compared against the image's
# Created time, which is the build time: nothing here
# sets SOURCE_DATE_EPOCH.
set -u
SMOKE_IMAGE_LABEL="${SMOKE_IMAGE_LABEL:-se.jordbo.pi-devbox.ci-smoke-image}"
SMOKE_IMAGE_MAX_AGE="${SMOKE_IMAGE_MAX_AGE:-6h}"
step() { printf '+ %s\n' "$*"; "$@" || true; }
reclaim() {
step docker container prune -f
step docker image prune -af --filter "label!=${SMOKE_IMAGE_LABEL}"
step docker image prune -af --filter "label=${SMOKE_IMAGE_LABEL}" \
--filter "until=${SMOKE_IMAGE_MAX_AGE}"
step docker network prune -f
step docker volume prune -f
step docker builder prune -af
}
assert_protected() {
local img="${1:?usage: $0 assert-protected <image>}" val
if ! val=$(docker image inspect \
--format "{{ index .Config.Labels \"${SMOKE_IMAGE_LABEL}\" }}" "$img" 2>&1); then
echo "::error::${img} is not in the runner's Docker daemon (${val}). Was it removed by a concurrent prune that does not honour ${SMOKE_IMAGE_LABEL}?"
return 1
fi
if [ "$val" != "true" ]; then
echo "::error::${img} lacks the label ${SMOKE_IMAGE_LABEL}=true (got '${val}'), so a sibling job's reclaim could delete it mid-test. Check the build step's labels: input."
return 1
fi
echo "OK: ${img} is present and carries ${SMOKE_IMAGE_LABEL}=true"
}
holder_name() { printf 'ci-hold-%s' "$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')"; }
# The holder exits on its own after an hour (--rm), so a job killed before
# `release` does not pin its image forever; the age sweep then removes it.
hold() {
local img="${1:?usage: $0 hold <image>}" name
name=$(holder_name "$img")
docker rm -f "$name" >/dev/null 2>&1 || true
if ! docker run -d --rm --name "$name" --network none --entrypoint "" \
"$img" sleep 3600 >/dev/null; then
echo "::error::could not start holder ${name} for ${img}; is the image still in the daemon?"
return 1
fi
echo "OK: ${img} pinned by running container ${name} (exits by itself after 1 h)"
}
release() {
local img="${1:?usage: $0 release <image>}"
docker rm -f "$(holder_name "$img")" >/dev/null 2>&1 || true
docker image rm "$img" >/dev/null 2>&1 || true
echo "released ${img}"
}
lint_workflows() {
[ "$#" -gt 0 ] || { echo "usage: $0 lint <workflow.yml>..." >&2; return 2; }
local hits
hits=$(grep -nE '^[^#]*docker[[:space:]]+(system|image)[[:space:]]+prune' "$@" || true)
if [ -n "$hits" ]; then
echo "::error::raw docker prune in a workflow. It deletes smoke images other jobs are testing on the shared runner daemon; call 'bash scripts/ci-reclaim-docker.sh' instead."
printf '%s\n' "$hits"
return 1
fi
echo "OK: no raw 'docker system|image prune' in $# workflow file(s)"
}
case "${1:-reclaim}" in
reclaim) reclaim ;;
assert-protected) shift; assert_protected "$@" ;;
hold) shift; hold "$@" ;;
release) shift; release "$@" ;;
lint) shift; lint_workflows "$@" ;;
*) echo "usage: $0 [reclaim | assert-protected <image> | hold <image> | release <image> | lint <workflow.yml>...]" >&2; exit 2 ;;
esac