fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (pull_request) Successful in 41s
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (pull_request) Successful in 41s
PR #51's migration-smoke failed 0.7 s after the build: the suite pinned PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from .env.example, so concurrent runs on one runner (Gitea ignores workflow concurrency) contended for one host port. Compose's own error was discarded, so the failure said only that the services "did not become ready". Isolation, from an audit of every script CI runs: - test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0. - test-migrations-integration.sh and smoke-infrastructure.sh: project names gain a random suffix; run id plus PID alone is not unique when job containers share low PIDs. - smoke-deploy.sh: the edge /24 is chosen from those no network on the daemon uses, and the first `up` chooses again when a concurrent run wins a race for the same subnet (Docker's address-pool overlap), up to five times. - CI image-build: the image tag includes the run id and attempt, so a pull request and a push of the same commit cannot remove each other's image. - Already isolated: every other published port (ephemeral), the TLS suite's container and network (random), smoke staging directories and image tags (random). Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up` command's output, `docker compose ps`, and the last 40 log lines of each service, all redacted (supplied literals, credential-looking NAME=value pairs, URL user information, Authorization headers) with terminal control sequences stripped. All three scripts use it at every readiness check; the infrastructure smoke's unredacted full-log dump is replaced by it. tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the subnet choice, the CI image tag, the diagnostics, and the redaction itself (run through bash). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c737ebb472
commit
278e66049f
+57
-11
@@ -22,6 +22,9 @@ set -Eeuo pipefail
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
for command in docker openssl awk; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "smoke-deploy: $command is required." >&2
|
||||
@@ -45,15 +48,34 @@ built_image=""
|
||||
hostname="ekklesia.smoke.test"
|
||||
storage_hostname="storage.smoke.test"
|
||||
|
||||
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
|
||||
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
octet=$((RANDOM % 200 + 20))
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
||||
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
||||
# subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come
|
||||
# from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
choose_edge_network() {
|
||||
local in_use candidate attempt
|
||||
in_use="$(
|
||||
docker network ls --quiet |
|
||||
xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null |
|
||||
sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p'
|
||||
)" || in_use=""
|
||||
for attempt in $(seq 1 100); do
|
||||
candidate=$((RANDOM % 200 + 20))
|
||||
if ! grep -qx "$candidate" <<<"$in_use"; then
|
||||
octet="$candidate"
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2
|
||||
exit 1
|
||||
}
|
||||
choose_edge_network
|
||||
|
||||
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||
@@ -154,6 +176,8 @@ postgres_password="$(secret POSTGRES_PASSWORD)"
|
||||
s3_access_key="$(secret S3_ACCESS_KEY)"
|
||||
s3_secret_key="$(secret S3_SECRET_KEY)"
|
||||
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
|
||||
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret")
|
||||
|
||||
mkdir -p "$work/env"
|
||||
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
|
||||
@@ -207,8 +231,28 @@ EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||
EOF
|
||||
|
||||
step "Starting PostgreSQL and object storage"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 ||
|
||||
# This `up` creates the edge network. If another run claimed the same subnet since it was chosen,
|
||||
# Docker refuses with an address-pool overlap: choose another and start again, a bounded number of
|
||||
# times. Any other failure is reported with the project's containers and logs.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if up_output="$(
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||
)"; then
|
||||
break
|
||||
fi
|
||||
if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then
|
||||
echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another"
|
||||
"${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true
|
||||
choose_edge_network
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet"
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range"
|
||||
set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address"
|
||||
set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address"
|
||||
continue
|
||||
fi
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "PostgreSQL and object storage did not become healthy."
|
||||
done
|
||||
|
||||
step "Planning, then explicitly applying, migrations against this run's fresh database"
|
||||
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
|
||||
@@ -216,8 +260,10 @@ step "Planning, then explicitly applying, migrations against this run's fresh da
|
||||
fail "migrate (apply) failed."
|
||||
|
||||
step "Starting the API and the proxy"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 ||
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "the API and proxy did not become healthy."
|
||||
fi
|
||||
|
||||
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
|
||||
# for their direct connection to object-storage.
|
||||
|
||||
Reference in New Issue
Block a user