Files
Ekklesia/scripts/test-ci-sweep.sh
T
ekklesiaandClaude Opus 5.5 703d92436b test(ci): every CI stack is cleaned up, even when its job is killed
A migration-smoke stack (PostgreSQL and SeaweedFS) leaked on the shared
runner and ran for two days, until it was removed by hand. Every CI
script already tears its stack down in an EXIT/INT/TERM trap, but a
trap never runs when the runner kills the job container: a cancelled
job, or the runner's own time limit, since Gitea 1.24 ignores
timeout-minutes.

Static checks (tests/ci-scripts.test.ts):
- every stack script names its project through one helper: the reserved
  ekklesia-ci- prefix, run, attempt, job key, and a unique suffix;
- everything the deploy smoke creates outside Compose carries the CI
  label;
- every workflow job that starts stacks sweeps stale CI stacks first,
  and removes its own in a final if: always() step. Each matrix leg has
  its own key, so one cannot remove the other's running stack.

Behaviour against real Docker (scripts/test-ci-sweep.sh):
- --job removes this job's containers, network, volume, image and
  staging directory, and leaves other jobs' and non-CI stacks alone;
- --job disconnects, never removes, a job container on the stack's
  network, and refuses to run outside CI;
- --stale spares young stacks and removes old ones, never anything
  outside the prefix.

Fails today: there is no helper, no sweep, no label and no step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 02:01:01 +00:00

129 lines
5.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Proves scripts/ci-sweep.sh against real Docker resources: what it removes, and above all what it
# leaves alone. CI runs it before trusting the sweep with a shared runner's daemon.
#
# bash scripts/test-ci-sweep.sh
#
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' deploy/compose.yaml | head -1)"
[[ -n "$image" ]] || {
echo "test-ci-sweep: no pinned PostgreSQL image in deploy/compose.yaml." >&2
exit 1
}
docker image inspect "$image" >/dev/null 2>&1 || docker pull --quiet "$image" >/dev/null
nonce="$(openssl rand -hex 4)"
compose_label="com.docker.compose.project"
ci_label="org.riverofglorymn.ekklesia.ci.project"
failures=0
created=()
check() {
if eval "$1"; then
echo "ok $2"
else
echo "FAIL $2"
failures=$((failures + 1))
fi
}
# project_exists <project>: any container, network, volume, or image still carries its label.
project_exists() {
local project="$1"
[[ -n "$(
docker ps --all --quiet --filter "label=${compose_label}=${project}"
docker ps --all --quiet --filter "label=${ci_label}=${project}"
docker network ls --quiet --filter "label=${compose_label}=${project}"
docker volume ls --quiet --filter "label=${compose_label}=${project}"
docker image ls --quiet --filter "label=${ci_label}=${project}"
)" ]]
}
staging_exists() {
docker run --rm --entrypoint test -v /tmp:/host-tmp "$image" -d "/host-tmp/$1"
}
# make_stack <project>: what a CI script leaves behind when it is killed: a Compose-labeled network,
# volume, and running container; a CI-labeled container and image; and a staging directory on the
# Docker host.
make_stack() {
local project="$1"
created+=("$project")
docker network create --label "${compose_label}=${project}" "${project}_default" >/dev/null
docker volume create --label "${compose_label}=${project}" "${project}_data" >/dev/null
docker run --detach --name "${project}-db" --label "${compose_label}=${project}" \
--network "${project}_default" -v "${project}_data:/data" --entrypoint sleep "$image" 600 >/dev/null
docker run --detach --name "${project}-browser" --label "${ci_label}=${project}" \
--entrypoint sleep "$image" 600 >/dev/null
printf 'FROM %s\nLABEL %s=%s\n' "$image" "$ci_label" "$project" |
docker build --quiet --tag "${project}-image:test" - >/dev/null
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$image" -p "/host-tmp/${project}"
}
job_container="test-ci-sweep-job-${nonce}"
cleanup() {
local status=$?
trap - EXIT INT TERM
docker rm --force "$job_container" >/dev/null 2>&1 || true
for project in "${created[@]}"; do
docker ps --all --quiet --filter "label=${compose_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
docker ps --all --quiet --filter "label=${ci_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
docker network rm "${project}_default" >/dev/null 2>&1 || true
docker volume rm --force "${project}_data" >/dev/null 2>&1 || true
docker image rm --force "${project}-image:test" >/dev/null 2>&1 || true
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$image" -rf "/host-tmp/${project}" >/dev/null 2>&1 || true
done
exit "$status"
}
trap cleanup EXIT INT TERM
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
# Fake run identifiers: nothing here can match a real run's stacks.
export GITHUB_RUN_ID="sweeptest${nonce}"
export GITHUB_RUN_ATTEMPT="1"
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
foreign="ekklesia-sweep-control-${nonce}"
for project in "$mine" "$sibling" "$foreign"; do
make_stack "$project"
done
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
docker run --detach --name "$job_container" --entrypoint sleep "$image" 600 >/dev/null
docker network connect "${mine}_default" "$job_container"
echo "--- --job removes this job's stacks, at any age, and nothing else"
EKKLESIA_CI_JOB_KEY=alpha bash scripts/ci-sweep.sh --job
check '! project_exists "$mine"' "this job's containers, network, volume, and image are gone"
check '! staging_exists "$mine"' "this job's staging directory on the Docker host is gone"
check 'project_exists "$sibling"' "another job's stack in the same run is left alone"
check 'project_exists "$foreign"' "a stack outside the CI prefix is left alone"
check 'docker inspect "$job_container" >/dev/null 2>&1' \
"a job container connected to the stack's network is disconnected, never removed"
echo "--- --job refuses to run outside CI, where it could remove a developer's own stacks"
check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
"--job without GITHUB_RUN_ID fails"
echo "--- --stale removes only CI stacks older than its threshold"
bash scripts/ci-sweep.sh --stale
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
check '! staging_exists "$sibling"' "its staging directory too"
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
if ((failures > 0)); then
echo "test-ci-sweep: ${failures} check(s) failed." >&2
exit 1
fi
echo "CI sweep checks passed."