Skip to content

Commit 8b418b5

Browse files
hyperpolymathclaude
andcommitted
fix(deploy): correct runbook ordering, rollback safety, and version gating
Adversarial review of this deploy unit found operator-facing defects: - Section 2 ran scp before the target directories existed, so a verbatim run failed on the first copy. Create /opt/echidna (root section) and ~/.config/containers/systemd (deploy user) first. - The auto-update rollback claim was not true as configured: an unhealthy-but-running container satisfies systemd, so nothing would have failed and nothing would have rolled back. Add HealthOnFailure=kill to echidna.container — an unhealthy container now fails the unit, which is what makes both Restart=always and auto-update's rollback fire — and describe the mechanism honestly. - The manual rollback procedure was silently undone by the next podman-auto-update.timer run, which re-pulls registry :latest. Stop the timer first, and document pinned mode as the durable exit. - The podman >= 4.4 floor was a guess that several keys in these units (HealthOnFailure= in particular) would not satisfy. Replace the guess with a measurement: the preflight now runs the quadlet generator in dry-run mode on the target host, which proves every key is understood regardless of version. - Clarify that the 1500m container cap sits deliberately below the 2 GB host requirement to leave headroom, and note that the plaintext isolation probe is asserting a closed port on purpose. Convert the indentation-based literal blocks to delimited source blocks so the runbook renders predictably. Verified: asciidoctor renders with no warnings; quadlet -dryrun generates all three units cleanly with --health-on-failure kill in the resulting ExecStart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b2b2a14 commit 8b418b5

3 files changed

Lines changed: 183 additions & 99 deletions

File tree

deno.lock

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deploy/hetzner/README.adoc

Lines changed: 170 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ Let's Encrypt), orchestrated with podman systemd quadlets.
1919

2020
* *Quadlets over podman-compose*: systemd-native restart-on-boot with no
2121
extra daemon, unit-level dependency ordering, and first-class
22-
`AutoUpdate=registry` integration with `podman-auto-update.timer`
23-
(which rolls back automatically when the updated container fails its
24-
healthcheck). Requires podman >= 4.4.
22+
`AutoUpdate=registry` integration with `podman-auto-update.timer`.
2523
* *Rootless*: the API executes user-supplied prover input; run both
2624
units as an unprivileged `deploy` user with linger.
2725
* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet
@@ -35,72 +33,112 @@ Let's Encrypt), orchestrated with podman systemd quadlets.
3533

3634
== 0. Preflight (read-only, do first)
3735

38-
ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
36+
[source,console]
37+
----
38+
ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
39+
----
3940

4041
Gates — stop and reassess if any fail:
4142

4243
* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something
4344
else owns them, this plan does not apply as-is.
44-
* >= 2 GB RAM free for the `:latest` image (Z3 + Lean). The `:full`
45-
image (Isabelle/Agda/Julia) needs >= 4 GB and is not deployed here.
46-
* podman >= 4.4 for quadlets. Debian 12 ships 4.3.1 — either install a
47-
newer podman or fall back to `podman generate systemd --new --files`
48-
(same containers, generated units instead of quadlets).
45+
* At least 2 GB RAM. The API container is capped at 1500 MB
46+
(`PodmanArgs=--memory=1500m`), deliberately below the host total so
47+
Caddy and the OS keep headroom; raise both together if you want more.
48+
The `:full` image (Isabelle/Agda/Julia) needs >= 4 GB and is not
49+
deployed here.
50+
* *Quadlet keys are all understood by the installed podman.* Do not
51+
trust a version number for this — measure it. Copy the three unit
52+
files to the server and run the generator in dry-run mode:
53+
+
54+
[source,console]
55+
----
56+
scp echidna.network echidna.container caddy.container <user>@<SERVER_IP>:/tmp/qcheck/
57+
ssh <user>@<SERVER_IP> 'QUADLET_UNIT_DIRS=/tmp/qcheck /usr/libexec/podman/quadlet -dryrun'
58+
----
59+
+
60+
It must print generated `[Service]` sections for all three units with
61+
no warnings. These units were authored and dry-run verified against podman
62+
5.4; podman 5.x is recommended and 4.x is likely to reject at least
63+
one key (`HealthOnFailure=` in particular). If the dry-run complains,
64+
use the Debian-12 fallback in the appendix instead.
4965
* Image pullable anonymously:
5066
+
51-
podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
67+
[source,console]
68+
----
69+
podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
70+
----
5271
+
5372
On 401: make the GHCR package public (GitHub → Packages → echidna →
5473
Package settings → visibility) or use a read-only PAT with
5574
`podman login ghcr.io` as the deploy user.
5675

5776
== 1. Server preparation (as root)
5877

59-
apt update && apt upgrade -y
60-
apt install -y podman unattended-upgrades
61-
dpkg-reconfigure -f noninteractive unattended-upgrades
78+
[source,console]
79+
----
80+
apt update && apt upgrade -y
81+
apt install -y podman unattended-upgrades ufw
82+
dpkg-reconfigure -f noninteractive unattended-upgrades
83+
84+
# Firewall: SSH + HTTP(S) only
85+
ufw default deny incoming
86+
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
87+
ufw enable
6288
63-
# Firewall: SSH + HTTP(S) only
64-
apt install -y ufw
65-
ufw default deny incoming
66-
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
67-
ufw enable
89+
# Unprivileged deploy user with lingering services
90+
useradd -m -s /bin/bash deploy
91+
loginctl enable-linger deploy
6892
69-
# Unprivileged deploy user with lingering services
70-
useradd -m -s /bin/bash deploy
71-
loginctl enable-linger deploy
93+
# Let rootless podman bind 80/443
94+
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
95+
sysctl --system
7296
73-
# Let rootless podman bind 80/443
74-
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
75-
sysctl --system
97+
# Destination for the Caddyfile and the Caddy build context
98+
mkdir -p /opt/echidna && chown deploy:deploy /opt/echidna
99+
----
76100

77-
== 2. Install the deploy unit (as deploy)
101+
== 2. Install the deploy unit
78102

79-
From your workstation, from this directory:
103+
Create the quadlet directory *before* copying anything into it — as
104+
the deploy user on the server:
80105

81-
scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
82-
scp echidna.network echidna.container caddy.container \
83-
deploy@<SERVER_IP>:~/.config/containers/systemd/
106+
[source,console]
107+
----
108+
mkdir -p ~/.config/containers/systemd
109+
----
84110

85-
(Create the target dirs first: `sudo mkdir -p /opt/echidna && sudo chown deploy /opt/echidna`
86-
and `mkdir -p ~/.config/containers/systemd` as deploy.)
111+
Then, from your workstation in this directory:
87112

88-
On the server, as deploy:
113+
[source,console]
114+
----
115+
scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
116+
scp echidna.network echidna.container caddy.container \
117+
deploy@<SERVER_IP>:~/.config/containers/systemd/
118+
----
89119

90-
cd /opt/echidna
91-
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
92-
podman pull ghcr.io/hyperpolymath/echidna:latest
93-
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
94-
# ^ record this digest in env.example / your notes for rollback
120+
Back on the server, as deploy:
95121

96-
systemctl --user daemon-reload
97-
systemctl --user list-unit-files | grep -E 'echidna|caddy'
122+
[source,console]
123+
----
124+
cd /opt/echidna
125+
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
126+
podman pull ghcr.io/hyperpolymath/echidna:latest
127+
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
128+
# ^ record this digest in env.example / your notes for rollback
129+
130+
systemctl --user daemon-reload
131+
systemctl --user list-unit-files | grep -E 'echidna|caddy'
132+
----
98133

99134
== 3. DNS (before starting Caddy)
100135

101136
Create in the Cloudflare zone for nesy-prover.dev:
102137

103-
A api <SERVER_IP> proxied=false (grey cloud), TTL 300
138+
[source]
139+
----
140+
A api <SERVER_IP> proxied=false (grey cloud), TTL 300
141+
----
104142

105143
Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01
106144
challenge reaches the server directly. (Optional later hardening:
@@ -111,71 +149,106 @@ server IP before the first Caddy start — ACME then succeeds first try.
111149

112150
== 4. Bring-up and verification
113151

114-
systemctl --user start echidna caddy
115-
podman ps # both Up, echidna (healthy)
116-
journalctl --user -u caddy -f # watch certificate issuance
152+
[source,console]
153+
----
154+
systemctl --user start echidna caddy
155+
podman ps # both Up, echidna (healthy)
156+
journalctl --user -u caddy -f # watch certificate issuance
157+
----
117158

118159
End-to-end, from anywhere:
119160

120-
curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
121-
curl -s https://api.nesy-prover.dev/api/provers | head
122-
curl -s -X POST https://api.nesy-prover.dev/api/verify \
123-
-H 'content-type: application/json' \
124-
-d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
161+
[source,console]
162+
----
163+
curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
164+
curl -s https://api.nesy-prover.dev/api/provers | head
165+
curl -s -X POST https://api.nesy-prover.dev/api/verify \
166+
-H 'content-type: application/json' \
167+
-d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
168+
----
125169

126170
Controls:
127171

128-
# Rate limit: burst 40 requests, expect HTTP 429 after ~30
129-
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
130-
https://api.nesy-prover.dev/api/health; done; echo
131-
# Headers: HSTS present, Server header absent
132-
curl -sI https://api.nesy-prover.dev/api/health
133-
# Isolation: direct API port must be unreachable from outside
134-
curl -m 5 http://<SERVER_IP>:8081/api/health || echo "unreachable (correct)"
172+
[source,console]
173+
----
174+
# Rate limit: burst 40 requests, expect HTTP 429 after ~30
175+
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
176+
https://api.nesy-prover.dev/api/health; done; echo
177+
178+
# Headers: HSTS present, Server header absent
179+
curl -sI https://api.nesy-prover.dev/api/health
180+
181+
# Isolation: the API port must NOT be reachable directly. This probe is
182+
# deliberately plaintext against the raw port — it is asserting that
183+
# nothing answers there, and must fail.
184+
curl -m 5 http://<SERVER_IP>:8081/api/health && echo "REACHABLE - FIX THIS" || echo "unreachable (correct)"
185+
----
135186

136-
Reboot survival (validates linger + [Install] + Restart in one shot):
187+
Reboot survival (validates linger + `[Install]` + Restart in one shot):
137188

138-
sudo reboot
139-
# after it returns:
140-
podman ps && curl -s https://api.nesy-prover.dev/api/health
189+
[source,console]
190+
----
191+
sudo reboot
192+
# after it returns:
193+
podman ps && curl -s https://api.nesy-prover.dev/api/health
194+
----
141195

142196
== 5. Updates and rollback
143197

144-
Enable unattended rollout of new releases (ghcr-publish.yml pushes
145-
`:latest` on every GitHub release):
146-
147-
systemctl --user enable --now podman-auto-update.timer
148-
podman auto-update --dry-run # see what would change
149-
podman auto-update # roll out immediately
150-
151-
`podman-auto-update` restarts the unit on a new image and rolls back
152-
automatically if the fresh container fails its healthcheck.
153-
154-
Manual rollback to a recorded digest:
155-
156-
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
157-
podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
158-
systemctl --user restart echidna
159-
160-
Pinned mode (opt out of auto-update): set `Image=` in
161-
`echidna.container` to a `:vX.Y.Z` tag or digest, remove
162-
`AutoUpdate=registry`, and bump via PR.
163-
164-
== Appendix: Debian 12 fallback (podman 4.3, no quadlets)
165-
166-
podman network create echidna-net
167-
podman create --name echidna --network echidna-net \
168-
--memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
169-
--security-opt no-new-privileges \
170-
--health-cmd '/app/bin/echidna --version' --health-interval 30s \
171-
ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
172-
podman create --name caddy --network echidna-net \
173-
-p 80:80 -p 443:443 \
174-
-v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
175-
-v caddy-data:/data -v caddy-config:/config \
176-
localhost/caddy-rl:2
177-
podman generate systemd --new --files --name echidna
178-
podman generate systemd --new --files --name caddy
179-
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
180-
systemctl --user daemon-reload
181-
systemctl --user enable --now container-echidna container-caddy
198+
`ghcr-publish.yml` pushes a new `:latest` on every GitHub release.
199+
Enable unattended rollout:
200+
201+
[source,console]
202+
----
203+
systemctl --user enable --now podman-auto-update.timer
204+
podman auto-update --dry-run # see what would change
205+
podman auto-update # roll out immediately
206+
----
207+
208+
How the safety net actually works: `podman auto-update` pulls the new
209+
image and restarts the unit. `HealthOnFailure=kill` in
210+
`echidna.container` means a container that goes unhealthy is killed,
211+
which fails the systemd unit — and auto-update rolls the unit back to
212+
the image it was running before when the restarted unit does not come
213+
up healthy. Without that key an unhealthy-but-running container would
214+
satisfy systemd and no rollback would occur, so do not remove it.
215+
216+
Manual rollback to a recorded digest. *Stop the timer first* —
217+
otherwise its next run pulls registry `:latest` again and silently
218+
undoes the rollback:
219+
220+
[source,console]
221+
----
222+
systemctl --user disable --now podman-auto-update.timer
223+
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
224+
podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
225+
systemctl --user restart echidna
226+
----
227+
228+
Re-enable the timer only once a fixed `:latest` has been published,
229+
or switch to pinned mode permanently: set `Image=` in
230+
`echidna.container` to a `:vX.Y.Z` tag or digest, drop
231+
`AutoUpdate=registry`, and bump it via PR.
232+
233+
== Appendix: fallback for podman without quadlet support
234+
235+
[source,console]
236+
----
237+
podman network create echidna-net
238+
podman create --name echidna --network echidna-net \
239+
--memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
240+
--security-opt no-new-privileges \
241+
--health-cmd '/app/bin/echidna --version' --health-interval 30s \
242+
--health-on-failure kill \
243+
ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
244+
podman create --name caddy --network echidna-net \
245+
-p 80:80 -p 443:443 \
246+
-v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
247+
-v caddy-data:/data -v caddy-config:/config \
248+
localhost/caddy-rl:2
249+
podman generate systemd --new --files --name echidna
250+
podman generate systemd --new --files --name caddy
251+
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
252+
systemctl --user daemon-reload
253+
systemctl --user enable --now container-echidna container-caddy
254+
----

deploy/hetzner/echidna.container

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Exec=server --port 8081 --host 0.0.0.0 --cors
2525
Environment=RUST_LOG=info
2626
HealthCmd=/app/bin/echidna --version
2727
HealthInterval=30s
28+
# kill (not stop) is what integrates with systemd: an unhealthy
29+
# container fails the unit, which is what makes Restart=always and
30+
# podman-auto-update's rollback actually trigger.
31+
HealthOnFailure=kill
2832
Tmpfs=/tmp:rw,size=256m
2933
NoNewPrivileges=true
3034
PodmanArgs=--memory=1500m --pids-limit=512

0 commit comments

Comments
 (0)