Phase 3: dual bind per Sam's decision (DoD 3 amended, DoD 1/4/8 re-evidenced)

Sam answered the bind question on AUD-133 (2026-10-26):
"A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096".

- docker-compose: publish 127.0.0.1 and ${APP_LAN_IP:-192.168.20.13}, never 0.0.0.0
- deploy.sh: carry APP_LAN_IP into the release .env, --lan-ip flag, health gate
  now requires /healthz 200 on loopback AND the LAN address
- rehearse.sh: assert both binds, no wildcard, LAN reachability; KEEP_REHEARSAL
  now really keeps the container/image; N3 waits up to 20s instead of 3s
- docs/phase-3-evidence.md: decision recorded, new measured output

DoD: 1, 3 (as amended by Sam), 4, 8 met with recorded output. 2, 5, 6 deferred
on the Phase 2 binary; 7 is Sam's Caddy apply. rehearse.sh 24/24, exit 0.
This commit is contained in:
System agent
2026-10-26 17:44:22 +11:00
parent 8b3209fab5
commit e6240c1e8e
5 changed files with 147 additions and 56 deletions

View File

@@ -4,7 +4,7 @@
# Builds three throwaway Go services in a scratch directory and runs the real
# deploy mechanism against the real release root and the real port:
#
# 1. good -> deploys, health-gated, serving
# 1. good -> deploys, health-gated, serving on loopback and the LAN address
# 2. broken -> does not compile: deploy must fail fast, current must not move
# 3. unhealthy -> builds and runs, but /healthz is 500: the health gate must
# roll back to the previous release and exit non-zero
@@ -24,6 +24,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY="$HERE/deploy.sh"
ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}"
PORT="${APP_PORT:-8096}"
LAN_IP="${APP_LAN_IP:-192.168.20.13}"
SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}"
KEEP="${KEEP_REHEARSAL:-0}"
@@ -33,19 +34,19 @@ no() { printf ' FAIL %s\n' "$*"; fail=$((fail + 1)); }
hdr() { printf '\n== %s\n' "$*"; }
cleanup() {
if [[ "$KEEP" == "1" ]]; then
printf '\nkept for inspection: container resume-app, images resume/app:*, release root %s, scratch %s\n' "$ROOT" "$SCRATCH"
return 0
fi
if docker ps -a --format '{{.Names}}' | grep -qx 'resume-app'; then
docker rm -f resume-app >/dev/null 2>&1 || true
fi
docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \
&& docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
if [[ "$KEEP" == "1" ]]; then
printf '\nkept: release root %s, scratch %s, images resume/app:*\n' "$ROOT" "$SCRATCH"
else
rm -rf "$ROOT"
[[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
printf '\ncleaned: release root and rehearsal sources removed\n'
fi
rm -rf "$ROOT"
[[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
printf '\ncleaned: container, images, release root and rehearsal sources removed\n'
}
trap cleanup EXIT
@@ -111,6 +112,8 @@ PY
}
health() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" || true; }
health_lan() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$LAN_IP:$PORT/healthz" || true; }
listeners() { ss -ltn 2>/dev/null | awk -v p=":${PORT}\$" '$4 ~ p {print $4}' | sort -u; }
serving_version() { curl -s -m 3 "http://127.0.0.1:$PORT/healthz" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p'; }
current_target() { [[ -L "$ROOT/current" ]] && readlink -f "$ROOT/current" || echo none; }
@@ -136,7 +139,14 @@ else
fi
[[ "$(health)" == "200" ]] && ok "/healthz 200 on 127.0.0.1:$PORT" || no "/healthz not 200"
v1=$(serving_version); [[ -n "$v1" ]] && ok "serving release $v1" || no "no version reported"
ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1 only" || no "unexpected bind"
ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1:$PORT (loopback, used by the deploy.sh health gate)" || no "no loopback bind"
ss -ltn | grep -q "$LAN_IP:$PORT" && ok "container publishes on $LAN_IP:$PORT (the address Caddy on .35 proxies to)" || no "no LAN bind on $LAN_IP:$PORT"
if listeners | grep -qE '^(0\.0\.0\.0|\*|\[::\]|::)'; then
no "wildcard bind on $PORT: $(listeners | tr '\n' ' ')"
else
ok "no wildcard bind on $PORT — only: $(listeners | tr '\n' ' ')"
fi
[[ "$(health_lan)" == "200" ]] && ok "/healthz 200 over the LAN address http://$LAN_IP:$PORT" || no "LAN address not answering"
mem=$(docker inspect -f '{{.HostConfig.Memory}}' resume-app); swp=$(docker inspect -f '{{.HostConfig.MemorySwap}}' resume-app)
[[ "$mem" == "$swp" && "$mem" != "0" ]] && ok "memswap_limit == mem_limit ($mem bytes)" || no "mem limits differ ($mem vs $swp)"
@@ -175,8 +185,12 @@ rel=$(readlink -f "$ROOT/current")
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" down >/dev/null 2>&1
[[ "$(health)" != "200" ]] && ok "service down after compose down" || no "still serving after compose down"
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" up -d >/dev/null 2>&1
sleep 3
[[ "$(health)" == "200" ]] && ok "service restored after compose up" || no "service did not come back"
restored=0
for _ in $(seq 1 20); do
if [[ "$(health)" == "200" ]]; then restored=1; break; fi
sleep 1
done
[[ $restored == 1 ]] && ok "service restored after compose up" || no "service did not come back within 20s"
# --------------------------------------------------------------- verdict
hdr "verdict"