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:
@@ -44,6 +44,15 @@ PROJECT=ai-resume-site # compose project; distinct from the older
|
||||
PORT=8096
|
||||
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}
|
||||
RELEASES=$ROOT/releases
|
||||
CURRENT=$ROOT/current
|
||||
@@ -231,7 +240,7 @@ start() {
|
||||
wait_healthy() {
|
||||
local i body
|
||||
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
|
||||
log "health gate passed after ${i}s"
|
||||
return 0
|
||||
@@ -240,14 +249,15 @@ wait_healthy() {
|
||||
sleep 1
|
||||
done
|
||||
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
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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
|
||||
# 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() {
|
||||
local cid mem swap ip declared
|
||||
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" = "$((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" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only"
|
||||
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$PORT"
|
||||
[ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only"
|
||||
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT"
|
||||
}
|
||||
|
||||
deploy_release() {
|
||||
@@ -348,7 +358,7 @@ prune() {
|
||||
do_status() {
|
||||
local cur prev body
|
||||
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
|
||||
' "$ROOT"
|
||||
printf 'current %s
|
||||
|
||||
@@ -30,7 +30,7 @@ deploy.sh
|
||||
├─ copy to releases/<timestamp>/
|
||||
├─ flip `current` symlink (atomic)
|
||||
├─ 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
|
||||
```
|
||||
|
||||
@@ -40,7 +40,12 @@ deploy.sh
|
||||
|
||||
### 3.2 Container
|
||||
- `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`).
|
||||
- `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.
|
||||
- [ ] `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`.
|
||||
- [ ] Security headers present; CSP does not break the page.
|
||||
- [ ] A1 still passes over the public URL with the AI layer stopped.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| Service | Host | Port | State |
|
||||
|---|---|---|---|
|
||||
| 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 gRPC | `.13` | 6334 | new, `127.0.0.1` only |
|
||||
| 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
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user