fix(ops): stop a compose replica from blocking the blue/green release
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m57s
CI / tests-unit (push) Successful in 2m3s
CI / tests-ui (push) Successful in 2m49s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m48s

The deploy failed after the build, the migrations and the browser gate:
"Port 3002 is already in use". The holder was `epicnext-cms`, a compose
replica of release 6bffc537 that the daily scripts/docker-update.sh cron
had recreated at 03:30 with restart=unless-stopped. nginx serves the green
slot on 3003, so that replica was squatting the blue slot the next
candidate needed, and live traffic never noticed.

It got there because the updater's CI-ownership guard only tested
epicnext-cms-app. After a cutover to the green slot that container is
stopped, renamed and deleted, so the guard stopped firing while the host
stayed CI-managed.

- scripts/docker-update.sh: refuse a compose deployment on a CI host by
  checking both slot containers and the nginx upstream, which is the only
  thing that still marks the host as blue/green while a slot is idle.
- scripts/ci-deploy.sh: retire a compose replica of this checkout from
  the candidate port before starting the candidate, so a stray replica
  can never block a release again. Never a slot container, never the port
  nginx serves; anything else still fails loudly in assert_port_free.
- Tests cover both directions: a squatting replica is removed and the
  release lands, a replica on the live port is left alone.
This commit is contained in:
openhands committed 2026-10-05 21:13:49 +02:00
1 parent 11ad6d4376
commit 5b2eb91c5c
7 files changed
+186 -9

No files matched your search

+35 -2
View File
@@ -37,6 +37,17 @@ curl() {
if [ "$SCENARIO" = health-failure ] && candidate_is_new; then return 1; fi
echo '{"database":true}'
}
# Een compose-replica uit precies deze checkout (`docker compose up`, of de
# dagelijkse docker-update.sh) kan een blauwe/groene slot bezetten en de release
# blokkeren. De scenario's hieronder leveren zo'n replica op:
# port-taken-compose draait op 3002, terwijl live op 3003 draait → mag weg;
# compose-live draait op 3003, de poort waar nginx naar wijst → blijft.
compose_replica_port() {
case "$SCENARIO" in
port-taken-compose) printf '3002\n' ;;
compose-live) printf '3003\n' ;;
esac
}
sleep() { :; }
# De poortconflict-check (assert_port_free in scripts/ci-deploy.sh) leest de
# echte luisterende sockets. Zonder deze stub zou de simulatie de containers van
@@ -45,7 +56,7 @@ sleep() { :; }
# virtueel heeft toegewezen. Standaard is geen enkele poort bezet; het scenario
# 'port-taken' reserveert expliciet de kandidaatpoort.
ss() {
local requested="" arg
local requested="" arg replica_port
for arg in "$@"; do
case "$arg" in
sport=*) requested="${arg#sport=}" ;;
@@ -58,13 +69,35 @@ ss() {
printf 'LISTEN 0 511 0.0.0.0:%s 0.0.0.0:*\n' "$requested"
return 0
fi
# De replica houdt zijn poort bezet zolang hij bestaat: zo ziet de simulatie
# dat de kandidaat op 3002 pas kan starten nadat retire_compose_replicas() hem
# heeft verwijderd.
replica_port="$(compose_replica_port)"
if [ -n "$replica_port" ] && [ "$requested" = "$replica_port" ] && [ -f "$TEST_DIR/epicnext-cms" ]; then
printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n'
printf 'LISTEN 0 511 0.0.0.0:%s 0.0.0.0:*\n' "$replica_port"
return 0
fi
printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n'
}
docker() {
echo "docker $*" >> "$TEST_DIR/calls"
local name="${@: -1}"
case "$1" in
ps)
[ -z "$(compose_replica_port)" ] || echo compose123 ;;
inspect)
# De replica wordt in de simulatie bij zijn container-id opgezocht, net als
# in het echte script.
if [ "$name" = compose123 ]; then
case "${3:-}" in
'{{.Name}}') echo /epicnext-cms ;;
'{{.Image}}') echo sha256:compose ;;
'{{index .Config.Labels "com.docker.compose.project.config_files"}}') echo "$TEST_DIR/production/docker-compose.yml" ;;
'{{range .Config.Env}}{{println .}}{{end}}') printf 'PORT=%s\nHOSTNAME=0.0.0.0\n' "$(compose_replica_port)" ;;
esac
return 0
fi
[ -f "$TEST_DIR/$name" ] || return 1
if [ "${3:-}" = '{{.State.Running}}' ]; then echo true
elif [ "${3:-}" = '{{.Image}}' ]; then echo "sha256:$(cat "$TEST_DIR/$name")"
@@ -92,7 +125,7 @@ docker() {
*) return 0 ;;
esac
}
export -f git flock pnpm curl sleep ss docker nginx candidate_is_new
export -f git flock pnpm curl sleep ss docker nginx candidate_is_new compose_replica_port
node() {
echo "node $*" >> "$TEST_DIR/calls"
+1
View File
@@ -16,6 +16,7 @@ docker() {
case "$1 ${2:-}" in
'inspect --format')
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi
if [ "${@: -1}" = epicnext-cms-green ]; then [ "$SCENARIO" = ci-green ] && echo true; return 0; fi
if [ "${@: -1}" = previous123 ] || [ -f "$TEST_DIR/restored" ]; then echo sha256:old; return 0; fi
if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;;
'image inspect')