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

@@ -8,7 +8,7 @@ host: change these files in git and deploy again.
|---|---| |---|---|
| `deploy.sh` | build + test → release → flip → `up -d` → health gate → rollback on failure | | `deploy.sh` | build + test → release → flip → `up -d` → health gate → rollback on failure |
| `deploy.sh --rollback` | restore the previous release in one command | | `deploy.sh --rollback` | restore the previous release in one command |
| `docker-compose.yml` | runtime container: `127.0.0.1:8096`, `mem_limit == memswap_limit == 120m`, capped logs | | `docker-compose.yml` | runtime container: published on `127.0.0.1:8096` **and** `192.168.20.13:8096` (never `0.0.0.0`), `mem_limit == memswap_limit == 120m`, capped logs |
| `Dockerfile` | runtime image only — alpine + the prebuilt release payload, no toolchain, no network | | `Dockerfile` | runtime image only — alpine + the prebuilt release payload, no toolchain, no network |
| `rehearse.sh` | repeatable proof that deploy, fail-fast, health-gate rollback and `--rollback` all work | | `rehearse.sh` | repeatable proof that deploy, fail-fast, health-gate rollback and `--rollback` all work |
@@ -67,7 +67,8 @@ the script works on a host where `go` is not on `PATH`.
It builds three throwaway services in a scratch directory, runs the real script It builds three throwaway services in a scratch directory, runs the real script
against the real release root and the real port, and asserts: against the real release root and the real port, and asserts:
1. a good release deploys, answers `/healthz`, publishes on `127.0.0.1` only, 1. a good release deploys, answers `/healthz` over loopback and the LAN
address, publishes on exactly those two addresses with no wildcard bind,
and has `memswap_limit == mem_limit`; and has `memswap_limit == mem_limit`;
2. a build failure exits non-zero, leaves `current` untouched and keeps serving; 2. a build failure exits non-zero, leaves `current` untouched and keeps serving;
3. a release whose `/healthz` is `500` is rolled back by the health gate, exits 3. a release whose `/healthz` is `500` is rolled back by the health gate, exits
@@ -85,9 +86,26 @@ host swap on a host that is already swapping. Measured RSS for a Go static
server is single-digit MiB; `deploy.sh` prints the real figure after every server is single-digit MiB; `deploy.sh` prints the real figure after every
deploy so drift shows up in the log. deploy so drift shows up in the log.
## Bind addresses — decided 2026-10-26
The container publishes on two explicit addresses and nothing else:
| Address | Why |
|---|---|
| `127.0.0.1:8096` | `deploy.sh`'s health gate and any check made on `.13` itself |
| `192.168.20.13:8096` | the LAN address Caddy on `.35` proxies to (`docs/phase-3-deploy.md` §3.3) |
A wildcard bind (`0.0.0.0`) is still forbidden: Caddy on `.35` stays the only
public listener. The phase document asked for loopback only, but a loopback-only
listener is unreachable from `.35`; Sam resolved the contradiction on AUD-133
with "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096". `APP_LAN_IP`
overrides the LAN address (default `192.168.20.13`); `deploy.sh --lan-ip ADDR`
sets it for one run. Both `deploy.sh` and `rehearse.sh` assert that the LAN
address answers, not just loopback.
## Caddy ## Caddy
`docs/phase-3-deploy.md` §3.3 holds the block for `.35`. It is additive, applied `docs/phase-3-deploy.md` §3.3 holds the block for `.35`. It is additive, applied
with `caddy reload` (never a restart), and **Sam applies it** — see with `caddy reload` (never a restart), and **Sam applies it**. The block proxies
`docs/phase-3-evidence.md` for the open bind-address question that decides to `192.168.20.13:8096`, which is why the container publishes on that address as
whether that block points at `192.168.20.13:8096` or at `.13`'s Caddy. well as loopback (see *Bind addresses* above).

View File

@@ -28,6 +28,10 @@ DEFAULT_ROOT="/home/sam/deployment/resume"
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT="${DEPLOY_ROOT:-$DEFAULT_ROOT}" ROOT="${DEPLOY_ROOT:-$DEFAULT_ROOT}"
PORT="${APP_PORT:-$DEFAULT_PORT}" PORT="${APP_PORT:-$DEFAULT_PORT}"
# Caddy on .35 proxies to this host's LAN address (phase doc §3.3), so the
# container publishes on loopback *and* on that address. Sam's decision on
# AUD-133, 2026-10-26: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096".
LAN_IP="${APP_LAN_IP:-192.168.20.13}"
DO_ROLLBACK=0 DO_ROLLBACK=0
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-40}" HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-40}"
@@ -39,6 +43,7 @@ Usage: deploy.sh [options]
--source DIR build from DIR instead of the repo this script lives in --source DIR build from DIR instead of the repo this script lives in
--root DIR release root (default /home/sam/deployment/resume) --root DIR release root (default /home/sam/deployment/resume)
--port N app port (default 8096) --port N app port (default 8096)
--lan-ip ADDR LAN address the container also publishes on (default 192.168.20.13)
--health-timeout N seconds to wait for /healthz (default 40) --health-timeout N seconds to wait for /healthz (default 40)
-h, --help this text -h, --help this text
@@ -46,6 +51,7 @@ Environment:
GO path to the go toolchain (auto-detected, incl. /nix/store) GO path to the go toolchain (auto-detected, incl. /nix/store)
DEPLOY_ROOT same as --root DEPLOY_ROOT same as --root
APP_PORT same as --port APP_PORT same as --port
APP_LAN_IP same as --lan-ip
EOF EOF
} }
@@ -55,6 +61,7 @@ while [[ $# -gt 0 ]]; do
--source) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;; --source) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
--root) ROOT="$2"; shift 2 ;; --root) ROOT="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
--lan-ip) LAN_IP="$2"; shift 2 ;;
--health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;; --health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
@@ -110,6 +117,17 @@ check_health() {
fi fi
fi fi
log "health: $url -> 200" log "health: $url -> 200"
# The container also publishes on the LAN address because that is what Caddy
# on .35 proxies to. A loopback-only success would be a site nobody can reach.
local lan_url lan_code
lan_url="http://${LAN_IP}:${PORT}/healthz"
lan_code=$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "$lan_url" 2>/dev/null || true)
if [[ "$lan_code" != "200" ]]; then
log "health: $lan_url -> HTTP ${lan_code:-none} (LAN bind missing?)"
return 1
fi
log "health: $lan_url -> 200"
return 0 return 0
} }
@@ -218,7 +236,7 @@ do_deploy() {
echo "built_on=$(hostname)" echo "built_on=$(hostname)"
echo "binary_sha256=$(sha256sum "$release/bin/server" | cut -d' ' -f1)" echo "binary_sha256=$(sha256sum "$release/bin/server" | cut -d' ' -f1)"
} > "$release/manifest.txt" } > "$release/manifest.txt"
printf 'APP_VERSION=%s\nAPP_PORT=%s\n' "$ts" "$PORT" > "$release/.env" printf 'APP_VERSION=%s\nAPP_PORT=%s\nAPP_LAN_IP=%s\n' "$ts" "$PORT" "$LAN_IP" > "$release/.env"
# 3. Runtime image, tagged with this release. # 3. Runtime image, tagged with this release.
log "deploy: docker build resume/app:$ts" log "deploy: docker build resume/app:$ts"

View File

@@ -6,7 +6,14 @@
# #
# Limits that are not negotiable (AGENTS.md §4.3): # Limits that are not negotiable (AGENTS.md §4.3):
# mem_limit == memswap_limit == 120m -> the container cannot touch host swap # mem_limit == memswap_limit == 120m -> the container cannot touch host swap
# ports 127.0.0.1:$PORT:8096 -> Caddy on .35 is the only public listener # ports never 0.0.0.0 -> Caddy on .35 is the only public listener
#
# Published on two explicit addresses and nothing else (Sam, 2026-10-26, on
# AUD-133: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096"):
# 127.0.0.1 -> deploy.sh health gate and local checks on .13
# 192.168.20.13 -> the LAN address Caddy on .35 proxies to (phase doc §3.3)
# A wildcard bind is still forbidden; if APP_LAN_IP is unset the LAN address
# defaults to this host's own 192.168.20.13.
# #
# .13 has every default Docker address pool subnetted out (many projects), so a # .13 has every default Docker address pool subnetted out (many projects), so a
# single-container service uses the host bridge and publishes its port rather # single-container service uses the host bridge and publishes its port rather
@@ -22,6 +29,7 @@ services:
network_mode: bridge network_mode: bridge
ports: ports:
- "127.0.0.1:${APP_PORT:-8096}:8096" - "127.0.0.1:${APP_PORT:-8096}:8096"
- "${APP_LAN_IP:-192.168.20.13}:${APP_PORT:-8096}:8096"
environment: environment:
PORT: "8096" PORT: "8096"
APP_VERSION: "${APP_VERSION}" APP_VERSION: "${APP_VERSION}"

View File

@@ -4,7 +4,7 @@
# Builds three throwaway Go services in a scratch directory and runs the real # Builds three throwaway Go services in a scratch directory and runs the real
# deploy mechanism against the real release root and the real port: # 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 # 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 # 3. unhealthy -> builds and runs, but /healthz is 500: the health gate must
# roll back to the previous release and exit non-zero # 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" DEPLOY="$HERE/deploy.sh"
ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}" ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}"
PORT="${APP_PORT:-8096}" PORT="${APP_PORT:-8096}"
LAN_IP="${APP_LAN_IP:-192.168.20.13}"
SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}" SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}"
KEEP="${KEEP_REHEARSAL:-0}" KEEP="${KEEP_REHEARSAL:-0}"
@@ -33,19 +34,19 @@ no() { printf ' FAIL %s\n' "$*"; fail=$((fail + 1)); }
hdr() { printf '\n== %s\n' "$*"; } hdr() { printf '\n== %s\n' "$*"; }
cleanup() { 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 if docker ps -a --format '{{.Names}}' | grep -qx 'resume-app'; then
docker rm -f resume-app >/dev/null 2>&1 || true docker rm -f resume-app >/dev/null 2>&1 || true
fi fi
docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \ docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \
&& docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \ && docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \
| xargs -r docker rmi -f >/dev/null 2>&1 || true | xargs -r docker rmi -f >/dev/null 2>&1 || true
if [[ "$KEEP" == "1" ]]; then rm -rf "$ROOT"
printf '\nkept: release root %s, scratch %s, images resume/app:*\n' "$ROOT" "$SCRATCH" [[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
else printf '\ncleaned: container, images, release root and rehearsal sources removed\n'
rm -rf "$ROOT"
[[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
printf '\ncleaned: release root and rehearsal sources removed\n'
fi
} }
trap cleanup EXIT 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() { 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'; } 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; } current_target() { [[ -L "$ROOT/current" ]] && readlink -f "$ROOT/current" || echo none; }
@@ -136,7 +139,14 @@ else
fi fi
[[ "$(health)" == "200" ]] && ok "/healthz 200 on 127.0.0.1:$PORT" || no "/healthz not 200" [[ "$(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" 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=$(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)" [[ "$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 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" [[ "$(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 docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" up -d >/dev/null 2>&1
sleep 3 restored=0
[[ "$(health)" == "200" ]] && ok "service restored after compose up" || no "service did not come back" 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 # --------------------------------------------------------------- verdict
hdr "verdict" hdr "verdict"

View File

@@ -1,12 +1,13 @@
# Phase 3 evidence — public deployment # Phase 3 evidence — public deployment
**Status: mechanism complete and rehearsed; public URL deferred.** **Status: mechanism complete and rehearsed; bind decided by Sam; public URL deferred.**
The deploy machinery exists, is rehearsed against the real release root and the The deploy machinery exists, is rehearsed against the real release root and the
real port on `.13`, and leaves nothing running. The site itself is not deployed real port on `.13`, and leaves nothing running. Sam answered the bind question on
because Phase 2 has not produced a binary yet, and one decision is needed from 2026-10-26 (**dual bind**, §5), and that answer is now in the committed compose
Sam before the container can be reachable by Caddy on `.35`. file and proven reachable from `.35`. The site itself is still not deployed
because Phase 2 has not produced a binary yet.
Recorded 2026-10-26 on `.13` (`nixos-desktop`), run `630e2ed9`. Recorded 2026-10-26 on `.13` (`nixos-desktop`), runs `630e2ed9` and `a61a5b22`.
--- ---
@@ -14,9 +15,9 @@ Recorded 2026-10-26 on `.13` (`nixos-desktop`), run `630e2ed9`.
| # | Item | Verdict | Evidence | | # | Item | Verdict | Evidence |
|---|---|---|---| |---|---|---|---|
| 1 | `deploy.sh` and `deploy.sh --rollback` work; a deliberate bad build proves rollback | **met** | §1, `deploy/rehearse.sh` 21/21 | | 1 | `deploy.sh` and `deploy.sh --rollback` work; a deliberate bad build proves rollback | **met** | §1, `deploy/rehearse.sh` 24/24 |
| 2 | `https://resume.lab.audasmedia.com.au` serves the site | **deferred** | Phase 2 has not produced a binary; Caddy block not yet applied (§4, §5) | | 2 | `https://resume.lab.audasmedia.com.au` serves the site | **deferred** | Phase 2 has not produced a binary; Caddy block not yet applied (§4, §5) |
| 3 | Container bound to `127.0.0.1` only, `ss -ltn` | **met for the container**; conflicts with reachability from `.35` — see §5 | §2 | | 3 | Container bound to `127.0.0.1` only, `ss -ltn` | **met as amended by Sam**: loopback **and** `192.168.20.13`, no wildcard — see §5 | §2 |
| 4 | `memswap_limit == mem_limit`, `docker inspect` | **met** | §2 | | 4 | `memswap_limit == mem_limit`, `docker inspect` | **met** | §2 |
| 5 | Security headers present, CSP does not break the page | **deferred** | headers belong to the `.35` Caddy block (§4) | | 5 | Security headers present, CSP does not break the page | **deferred** | headers belong to the `.35` Caddy block (§4) |
| 6 | A1 passes over the public URL with the AI layer stopped | **deferred** | needs a live URL | | 6 | A1 passes over the public URL with the AI layer stopped | **deferred** | needs a live URL |
@@ -31,46 +32,59 @@ Nothing in the deferred column is asserted. Each depends on the Phase 2 binary
## 1. What was rehearsed, and the result ## 1. What was rehearsed, and the result
`deploy/rehearse.sh` builds throwaway services and drives the real script. Full `deploy/rehearse.sh` builds throwaway services and drives the real script. Full
transcript: transcript of the run after the dual-bind change (24 assertions):
``` ```
== 0. preconditions == 0. preconditions
PASS port 8096 free PASS port 8096 free
PASS three throwaway sources built in /tmp/.../resume-rehearsal-snpEjy PASS three throwaway sources built in /tmp/.../rehearsal4
== 1. good release deploys and health-gates == 1. good release deploys and health-gates
PASS deploy.sh exit 0 PASS deploy.sh exit 0
PASS /healthz 200 on 127.0.0.1:8096 PASS /healthz 200 on 127.0.0.1:8096
PASS serving release 20261026T062957Z PASS serving release 20261026T064248Z
PASS container publishes on 127.0.0.1 only PASS container publishes on 127.0.0.1:8096 (loopback, used by the deploy.sh health gate)
PASS container publishes on 192.168.20.13:8096 (the address Caddy on .35 proxies to)
PASS no wildcard bind on 8096 — only: 127.0.0.1:8096 192.168.20.13:8096
PASS /healthz 200 over the LAN address http://192.168.20.13:8096
PASS memswap_limit == mem_limit (125829120 bytes) PASS memswap_limit == mem_limit (125829120 bytes)
== 2. build failure fails fast, current does not move == 2. build failure fails fast, current does not move
PASS deploy.sh exit 1 (non-zero, as required) PASS deploy.sh exit 1 (non-zero, as required)
PASS current unchanged (/home/sam/deployment/resume/releases/20261026T062957Z) PASS current unchanged (/home/sam/deployment/resume/releases/20261026T064248Z)
PASS previous release still serving PASS previous release still serving
== 3. unhealthy release is rolled back by the health gate == 3. unhealthy release is rolled back by the health gate
PASS deploy.sh exit 1 (non-zero, as required) PASS deploy.sh exit 1 (non-zero, as required)
PASS current restored to /home/sam/deployment/resume/releases/20261026T062957Z PASS current restored to /home/sam/deployment/resume/releases/20261026T064248Z
PASS rolled-back release serving again PASS rolled-back release serving again
PASS log records the health-gate failure PASS log records the health-gate failure
== 4. deploy.sh --rollback restores the previous release in one command == 4. deploy.sh --rollback restores the previous release in one command
PASS second good release deployed PASS second good release deployed
PASS rollback exit 0 PASS rollback exit 0
PASS current flipped back to /home/sam/deployment/resume/releases/20261026T062957Z PASS current flipped back to /home/sam/deployment/resume/releases/20261026T064248Z
PASS rolled-back release healthy PASS rolled-back release healthy
PASS serving 20261026T062957Z again (was 20261026T063021Z) PASS serving 20261026T064248Z again (was 20261026T064307Z)
== 5. N3: docker compose down && up restores the service == 5. N3: docker compose down && up restores the service
PASS service down after compose down PASS service down after compose down
PASS service restored after compose up PASS service restored after compose up
== verdict == verdict
passed 21, failed 0 passed 24, failed 0
``` ```
Reachability from the Caddy host itself (`.35`), run during that rehearsal:
```
$ ssh sam@192.168.20.35 'curl -sS -m3 -w "%{http_code}" http://192.168.20.13:8096/healthz'
200
```
That is the exact hop §3.3's `reverse_proxy` will take, proven before the Caddy
block is applied.
The health-gate rollback in the deploy log, verbatim: The health-gate rollback in the deploy log, verbatim:
``` ```
@@ -101,22 +115,31 @@ Re-run it any time with `./deploy/rehearse.sh` (exit 0 = every assertion held).
``` ```
$ ss -ltn | grep 8096 $ ss -ltn | grep 8096
LISTEN 0 4096 127.0.0.1:8096 0.0.0.0:* LISTEN 0 4096 127.0.0.1:8096 0.0.0.0:*
LISTEN 0 4096 192.168.20.13:8096 0.0.0.0:*
$ docker inspect -f '...' resume-app $ docker inspect resume-app
name=/resume-app mem_limit=125829120 memswap_limit=125829120
mem_limit=125829120 bytes # 120 MiB port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"},{"HostIp":"192.168.20.13","HostPort":"8096"}]}
memswap_limit=125829120 bytes # 120 MiB — equal, so no host swap
port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"}]}
restart=unless-stopped restart=unless-stopped
log_config={"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}} log_config={"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}}
image=resume/app:20261026T064248Z
$ curl -sS http://127.0.0.1:8096/healthz
{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...}
$ curl -sS http://192.168.20.13:8096/healthz
{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...}
``` ```
Two explicit addresses, no wildcard listener: `0.0.0.0` never appears, so
nothing but Caddy on `.35` can be reached from outside the LAN. The reasoning is
in §5.
Measured memory (idle Go static server, placeholder build): Measured memory (idle Go static server, placeholder build):
``` ```
rss=1.5MiB / 120MiB pct=1.25% $ docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' resume-app
rss=6.9MiB / 120MiB pct=5.78% # just after start, before settling rss=1.73MiB / 120MiB pct=1.44%
``` ```
Host pressure at the time of the rehearsal — this is why the cap is 120m: Host pressure at the time of the rehearsal — this is why the cap is 120m:
@@ -124,7 +147,7 @@ Host pressure at the time of the rehearsal — this is why the cap is 120m:
``` ```
$ free -m $ free -m
total used free available total used free available
Mem: 15918 14255 227 1663 Mem: 15918 13943 223 1975
``` ```
No residue was left behind: after the rehearsal there is no `resume-app` No residue was left behind: after the rehearsal there is no `resume-app`
@@ -190,14 +213,15 @@ breaks it, the CSP is wrong — do not weaken it to `unsafe-inline` for scripts.
--- ---
## 5. Open question for Sam — bind address vs Caddy reachability ## 5. Bind address — decided by Sam, 2026-10-26 (dual bind)
The phase document says two things that cannot both be true today: The phase document said two things that could not both be true:
- §3.2 and DoD item 3: the container publishes on **`127.0.0.1:8096` only**. - §3.2 and DoD item 3: the container publishes on **`127.0.0.1:8096` only**.
- §3.3: Caddy **on `.35`** proxies to **`192.168.20.13:8096`**. - §3.3: Caddy **on `.35`** proxies to **`192.168.20.13:8096`**.
A loopback-only listener on `.13` is not reachable from `.35`. Measured: A loopback-only listener on `.13` is not reachable from `.35`. Measured at the
time of the question:
``` ```
$ curl -sS -m3 http://192.168.20.13:8096/healthz $ curl -sS -m3 http://192.168.20.13:8096/healthz
@@ -206,17 +230,27 @@ curl: (7) Failed to connect to 192.168.20.13:8096
`.13`'s own Caddy (`*:8000`, `/etc/static/caddy/caddy_config`) only serves eight `.13`'s own Caddy (`*:8000`, `/etc/static/caddy/caddy_config`) only serves eight
legacy hostnames; every service added since (hub, compass, email-agent, legacy hostnames; every service added since (hub, compass, email-agent,
webrestrict) publishes on `0.0.0.0` and is proxied by `.35` directly to its LAN webrestrict) is proxied by `.35` straight to its LAN port. §3.3's block assumes
port. That is the working pattern, and it is what §3.3's block assumes. that pattern.
| Option | What changes | Consequence | **Answer (AUD-133, 2026-10-26):** "A - dual bind: 127.0.0.1:8096 and
|---|---|---| 192.168.20.13:8096". That is now the committed behaviour:
| **A (recommended, matches every current service)** | publish `127.0.0.1:8096` **and** `192.168.20.13:8096` | `deploy.sh` keeps its loopback health gate; `.35` reaches the app; §3.3 applied verbatim; no NixOS change. Exposure is the `.13` LAN, same as hub/compass today. DoD item 3 / N1 can then only claim "not on `0.0.0.0`" — needs your written change |
| **B (strict loopback)** | add `http://resume.lab.audasmedia.com.au:8000 { reverse_proxy localhost:8096 }` to `.13`'s Caddyfile, and `.35`'s block becomes `reverse_proxy 192.168.20.13:8000` | Satisfies N1 literally. But `/etc/static/caddy/caddy_config` is NixOS-generated — it needs a NixOS config change and `nixos-rebuild`, which this phase forbids and only you can do |
The committed `docker-compose.yml` currently follows the phase document (option | Change | Where |
**A**'s loopback half only: `127.0.0.1:8096`). Choosing A or B is a one-line |---|---|
change in `deploy/docker-compose.yml` plus, for B, the NixOS change on `.13`. | both addresses in `ports:` | `deploy/docker-compose.yml` (`${APP_LAN_IP:-192.168.20.13}`) |
| LAN address carried into the release `.env`; `--lan-ip ADDR` / `APP_LAN_IP` override | `deploy/deploy.sh` |
| health gate now requires `/healthz` 200 on **loopback and** the LAN address | `deploy/deploy.sh` `check_health()` |
| assertions for both binds, no wildcard, and LAN reachability | `deploy/rehearse.sh` |
Consequence for DoD item 3, stated plainly: the container is **not** bound to
`127.0.0.1` only. It is bound to `127.0.0.1` and `192.168.20.13`, never
`0.0.0.0`, so it is reachable on the `.13` LAN exactly like hub/compass today and
not from anywhere else. That is a written change to §3.2 / N1 made by Sam, not a
judgement call by the agent; §2 and §4 of this document carry the output.
No NixOS change and no `.13` Caddy change is needed under this option. §3.3's
block is applied verbatim.
--- ---
@@ -228,4 +262,3 @@ change in `deploy/docker-compose.yml` plus, for B, the NixOS change on `.13`.
| Header + CSP proof (DoD 5) | this issue, after the Caddy block is applied | Sam applies §4 | | Header + CSP proof (DoD 5) | this issue, after the Caddy block is applied | Sam applies §4 |
| A1 over the public URL (DoD 6) | this issue, after 2 and 5 | — | | A1 over the public URL (DoD 6) | this issue, after 2 and 5 | — |
| Caddy block applied and other hostnames confirmed (DoD 7) | **Sam** | §4 | | Caddy block applied and other hostnames confirmed (DoD 7) | **Sam** | §4 |
| Bind-address decision (§5) | **Sam** | A or B above |