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

@@ -1,12 +1,13 @@
# 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
real port on `.13`, and leaves nothing running. The site itself is not deployed
because Phase 2 has not produced a binary yet, and one decision is needed from
Sam before the container can be reachable by Caddy on `.35`.
real port on `.13`, and leaves nothing running. Sam answered the bind question on
2026-10-26 (**dual bind**, §5), and that answer is now in the committed compose
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 |
|---|---|---|---|
| 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) |
| 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 |
| 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 |
@@ -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
`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
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
PASS deploy.sh exit 0
PASS /healthz 200 on 127.0.0.1:8096
PASS serving release 20261026T062957Z
PASS container publishes on 127.0.0.1 only
PASS serving release 20261026T064248Z
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)
== 2. build failure fails fast, current does not move
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
== 3. unhealthy release is rolled back by the health gate
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 log records the health-gate failure
== 4. deploy.sh --rollback restores the previous release in one command
PASS second good release deployed
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 serving 20261026T062957Z again (was 20261026T063021Z)
PASS serving 20261026T064248Z again (was 20261026T064307Z)
== 5. N3: docker compose down && up restores the service
PASS service down after compose down
PASS service restored after compose up
== 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:
```
@@ -101,22 +115,31 @@ Re-run it any time with `./deploy/rehearse.sh` (exit 0 = every assertion held).
```
$ 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
name=/resume-app
mem_limit=125829120 bytes # 120 MiB
memswap_limit=125829120 bytes # 120 MiB — equal, so no host swap
port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"}]}
$ docker inspect resume-app
mem_limit=125829120 memswap_limit=125829120
port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"},{"HostIp":"192.168.20.13","HostPort":"8096"}]}
restart=unless-stopped
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):
```
rss=1.5MiB / 120MiB pct=1.25%
rss=6.9MiB / 120MiB pct=5.78% # just after start, before settling
$ docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' resume-app
rss=1.73MiB / 120MiB pct=1.44%
```
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
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`
@@ -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.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
@@ -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
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
port. That is the working pattern, and it is what §3.3's block assumes.
webrestrict) is proxied by `.35` straight to its LAN port. §3.3's block assumes
that pattern.
| Option | What changes | Consequence |
|---|---|---|
| **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 |
**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:
The committed `docker-compose.yml` currently follows the phase document (option
**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`.
| Change | Where |
|---|---|
| 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 |
| A1 over the public URL (DoD 6) | this issue, after 2 and 5 | — |
| Caddy block applied and other hostnames confirmed (DoD 7) | **Sam** | §4 |
| Bind-address decision (§5) | **Sam** | A or B above |