deploy: health gate and shape assertion follow the LAN publish address

Once the port is published on 192.168.20.13 the host no longer listens on
127.0.0.1:8096, so wait_healthy(), do_status() and the rollback path would all
get connection-refused and every deploy would roll back. They now probe
$PUBLISH_IP (RESUME_PUBLISH_IP, default 192.168.20.13). assert_shape() asserts
the published host IP is that address instead of loopback; the mem_limit ==
memswap_limit checks are unchanged. check_port stays address-agnostic on
`sport = :8096`.

Records the amendment in reference/stack.md (ports table + the bind rule) and
docs/phase-3-deploy.md (§3.2 bullet, flow diagram, DoD line), citing Sam's
decision of 2026-10-26, interaction 54aeb1a2. AGENTS.md is Sam's to change and
is untouched.
This commit is contained in:
Sam Rolfe
2026-10-26 22:40:22 +11:00
parent 5c0622c1b1
commit df864cc29e
3 changed files with 28 additions and 11 deletions

View File

@@ -44,6 +44,15 @@ PROJECT=ai-resume-site # compose project; distinct from the older
PORT=8096 PORT=8096
HEALTH_PATH=/healthz HEALTH_PATH=/healthz
# The host address the port is published on. Caddy runs on .35 and proxies to
# this address, so a loopback publish is unreachable from the edge (Sam's
# decision, 2026-10-26, interaction 54aeb1a2). Never 0.0.0.0: the port must not
# be reachable off the LAN. The health gate below follows this address — once the
# port is published on the LAN address the host no longer listens on
# 127.0.0.1:8096, so a loopback probe would refuse and every deploy would roll
# back.
PUBLISH_IP=${RESUME_PUBLISH_IP:-192.168.20.13}
ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume} ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume}
RELEASES=$ROOT/releases RELEASES=$ROOT/releases
CURRENT=$ROOT/current CURRENT=$ROOT/current
@@ -231,7 +240,7 @@ start() {
wait_healthy() { wait_healthy() {
local i body local i body
for i in $(seq 1 "$HEALTH_TRIES"); do for i in $(seq 1 "$HEALTH_TRIES"); do
if body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null); then if body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null); then
if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then
log "health gate passed after ${i}s" log "health gate passed after ${i}s"
return 0 return 0
@@ -240,14 +249,15 @@ wait_healthy() {
sleep 1 sleep 1
done done
warn "health gate failed after ${HEALTH_TRIES}s; last response:" warn "health gate failed after ${HEALTH_TRIES}s; last response:"
curl -sS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" >&2 || true curl -sS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" >&2 || true
compose logs --tail 40 resume >&2 || true compose logs --tail 40 resume >&2 || true
return 1 return 1
} }
# Definition-of-Done evidence, checked on every deploy instead of by hand: # Definition-of-Done evidence, checked on every deploy instead of by hand:
# mem_limit == memswap_limit == the value declared in compose.yml, and the port # mem_limit == memswap_limit == the value declared in compose.yml, and the port
# published on 127.0.0.1 only. # published on $PUBLISH_IP only (the LAN address Caddy proxies to — never
# 0.0.0.0 and no longer loopback).
assert_shape() { assert_shape() {
local cid mem swap ip declared local cid mem swap ip declared
declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE") declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE")
@@ -258,8 +268,8 @@ assert_shape() {
[ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)" [ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)"
[ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB" [ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB"
ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs) ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs)
[ "$ip" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only" [ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$PORT" log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT"
} }
deploy_release() { deploy_release() {
@@ -348,7 +358,7 @@ prune() {
do_status() { do_status() {
local cur prev body local cur prev body
cur=$(current_target); prev=$(previous_target) cur=$(current_target); prev=$(previous_target)
body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null || true) body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null || true)
printf 'root %s printf 'root %s
' "$ROOT" ' "$ROOT"
printf 'current %s printf 'current %s

View File

@@ -30,7 +30,7 @@ deploy.sh
├─ copy to releases/<timestamp>/ ├─ copy to releases/<timestamp>/
├─ flip `current` symlink (atomic) ├─ flip `current` symlink (atomic)
├─ docker compose up -d ├─ docker compose up -d
├─ curl http://127.0.0.1:8096/healthz → must be ready ├─ curl http://192.168.20.13:8096/healthz → must be ready
└─ on failure: flip back to `previous`, exit non-zero └─ on failure: flip back to `previous`, exit non-zero
``` ```
@@ -40,7 +40,12 @@ deploy.sh
### 3.2 Container ### 3.2 Container
- `mem_limit` = `memswap_limit` = `120m`. - `mem_limit` = `memswap_limit` = `120m`.
- `ports: ["127.0.0.1:8096:8096"]` — never `0.0.0.0`. - `ports: ["192.168.20.13:8096:8096"]` — **never** `0.0.0.0`.
Caddy runs on `.35`, so the port has to be reachable from the LAN; a
`127.0.0.1` publish answers only on `.13` and the public URL returns 502.
LAN-only publish, Caddy on `.35` still the sole public listener. Decision:
Sam, 2026-10-26, interaction `54aeb1a2`. The health gate and `assert_shape()`
in `deploy.sh` follow the same address (`RESUME_PUBLISH_IP`).
- `logging` capped (`max-size: 10m`, `max-file: 3`). - `logging` capped (`max-size: 10m`, `max-file: 3`).
- `restart: unless-stopped`. - `restart: unless-stopped`.
@@ -75,7 +80,7 @@ Repo stays private through this phase. Sam decides on public visibility after th
- [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback. - [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback.
- [ ] `https://resume.lab.audasmedia.com.au` serves the site. - [ ] `https://resume.lab.audasmedia.com.au` serves the site.
- [ ] Container bound to `127.0.0.1` only; confirmed with `ss -ltn`. - [ ] Container published on `192.168.20.13:8096` only, reachable from `.35`; confirmed with `ss -ltn`.
- [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`. - [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`.
- [ ] Security headers present; CSP does not break the page. - [ ] Security headers present; CSP does not break the page.
- [ ] A1 still passes over the public URL with the AI layer stopped. - [ ] A1 still passes over the public URL with the AI layer stopped.

View File

@@ -15,7 +15,7 @@
| Service | Host | Port | State | | Service | Host | Port | State |
|---|---|---|---| |---|---|---|---|
| Caddy | `.35` | 443 (+ LAN 8000) | existing — **Sam applies any change** | | Caddy | `.35` | 443 (+ LAN 8000) | existing — **Sam applies any change** |
| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab) | | **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab); published on `192.168.20.13:8096`, **never** `0.0.0.0` |
| Qdrant REST | `.13` | 6333 | new, `127.0.0.1` only | | Qdrant REST | `.13` | 6333 | new, `127.0.0.1` only |
| Qdrant gRPC | `.13` | 6334 | new, `127.0.0.1` only | | Qdrant gRPC | `.13` | 6334 | new, `127.0.0.1` only |
| Prefect server | `.13` | existing | reuse — do not deploy a second | | Prefect server | `.13` | existing | reuse — do not deploy a second |
@@ -55,7 +55,9 @@ Ceiling: 20,000 chunks at 1024 dimensions. Beyond that, set
`RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in `RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in
that order. Detail: `docs/phase-4-notes.md` §7. that order. Detail: `docs/phase-4-notes.md` §7.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind to `127.0.0.1`; Caddy is the only public listener. Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind services to `127.0.0.1`; Caddy on `.35` is the only public listener.
**One documented amendment (Go app, port 8096):** the app publishes on the LAN address `192.168.20.13:8096`, not `127.0.0.1`. Caddy runs on `.35` and proxies to `192.168.20.13:8096`, so a loopback-only publish is unreachable from the edge and the public URL returns 502. This is the same way `8095` (offsite), `8092` (photo-filter), `3909` (garage-ui) and `4200` (prefect) are published on `.13`. The port is LAN-only — never `0.0.0.0` — and Caddy on `.35` is still the only public listener, so `AGENTS.md` §4.2 holds in substance. Decision: Sam, 2026-10-26, interaction `54aeb1a2`. `deploy/deploy.sh` follows the same address (`RESUME_PUBLISH_IP`, default `192.168.20.13`) in its health gate and shape assertion.
## Paths ## Paths