Skip to main content

max / makenotwork

14.0 KB · 257 lines History Blame Raw
1 # Sando deploy units
2
3 systemd units and scripts that run on the Sando host (fw13) and on deploy
4 targets. Host-specific secrets, certs, and tailnet IPs are **not** here — they
5 live in the Syncthing private layer (`_private/infra/`, `_private/deploy`).
6
7 ## Files
8
9 | File | Where it runs | Purpose |
10 |------|---------------|---------|
11 | `sandod.service` | Sando host | The Sando daemon (`sandod`). |
12 | `bootstrap-sandod-host.sh` | Sando host | One-time host setup for the daemon. |
13 | `bootstrap-node.sh` | a deploy target | One-time node setup (release dirs, deploy user, service). |
14 | `sando-daemon.toml.example` | Sando host | Template for the daemon config (`sando.toml`). |
15 | `post-receive` | git remote | Push-to-deploy hook. |
16 | `sandod-backup-fetch.{service,timer}` | Sando host | Daily pull of every configured prod dump into `/srv/sando/backups/` (04:00 UTC). |
17 | `mnw-testnot-seed.{sh,service}` | Sando host | Reset testnot.work to the fabricated example catalog (`--seed-examples`). On-demand, not scheduled. Ends by running the smoke check below. |
18 | `mnw-testnot-smoke.sh` | anywhere with network | Fail loudly when the testnot demo goes thin: storefronts that render fewer items than discover advertises, covers that 404 behind a 200 page, empty feeds. Checks content, not status codes. |
19 | `sando-update@.service` + `sando-self-update.sh` | Sando host | Self-update: rebuild + restart `sandod` to a target sha. |
20 | `10-sando-update.rules` | Sando host | polkit grant letting the `sando` user start (only) `sando-update@*`. |
21 | `install-companion.sh` | a deploy target | Install a staged companion binary + restart its unit (companion services). |
22 | `mnw-companion.sudoers` | a deploy target | Scoped sudo grant for the deploy user to run `install-companion.sh`. |
23
24 ## Self-update (deploying the controller itself)
25
26 The deploy pipeline ships the *MNW server*, not `sandod`. To deploy a new
27 `sandod` (the controller's own code), `sandod` exposes `POST /self-update
28 {"sha":"<hex>"}` (bearer-gated like the other mutators). It cannot do the work
29 itself — it runs `User=sando`, `NoNewPrivileges`, `ProtectSystem=strict`, so it
30 can neither write `/usr/local/bin/sandod` nor restart its own service. It only
31 *triggers* the root oneshot `sando-update@<sha>.service`, which the `sando` user
32 is allowed to `start` (and nothing else) by `10-sando-update.rules`. That unit
33 runs `sando-self-update.sh`: it builds `sando/daemon` **as the sando user** in a
34 dedicated checkout (`/srv/sando/self-update`, never the operator's dev tree),
35 then — as root — installs the binary and `systemctl restart sandod`.
36
37 Three hardening properties gate that install (postmortem 2026-07-09 recovery):
38
39 - **Source = the local bare repo** `/srv/sando/mnw.git`, not a remote fetch. The
40 self-update sha was already deployed as a server release, so `/rebuild` has
41 force-updated the bare repo's `main` to include it — the sha and a trusted
42 provenance ref are both present locally. A remote fetch gave the `sando` user
43 no git creds and broke the moment git hosting was down.
44 - **Clean build** (`cargo clean` first) so no stale incremental object survives
45 across shas. A reused pre-`node_health` `Gate` enum object once produced a
46 binary that crash-looped on the current config.
47 - **`--check-config` self-test before the swap.** The freshly built binary must
48 load + parse the live daemon config + topology (`sandod --check-config`, run as
49 the sando user against `/etc/sando/sando-daemon.toml`). A binary that can't
50 understand the current config fails here and is never installed — `sandod`
51 keeps running on the old one instead of crash-looping on the new.
52
53 Trigger `/self-update` only when no MNW build/deploy is in flight — `sandod`
54 rejects it in that window anyway (the restart would SIGKILL an in-flight deploy),
55 but treat controller updates as a separate, deliberate op.
56
57 Verify a self-update landed by polling `/state`: the new `sandod_version` field
58 reports the running controller's package version (distinct from the tier
59 versions, which are the deployed product).
60
61 One-time install (as root), then every future controller deploy is one API call:
62
63 ```sh
64 sudo install -d /usr/local/lib/sando
65 sudo install -m 0755 sando-self-update.sh /usr/local/lib/sando/sando-self-update.sh
66 sudo install -m 0644 sando-update@.service /etc/systemd/system/
67 sudo install -m 0644 10-sando-update.rules /etc/polkit-1/rules.d/
68 sudo systemctl daemon-reload
69 ```
70
71 Bootstrap caveat: the *first* `sandod` carrying `/self-update` still has to be
72 installed by hand (build + `install` + `systemctl restart sandod`) — there is no
73 endpoint to call until it is running. Self-update is for every deploy after that.
74 Trigger + watch:
75
76 ```sh
77 SHA=$(git -C ~/Code/MNW rev-parse HEAD)
78 curl -sS -X POST "$BASE/self-update" -H 'Content-Type: application/json' \
79 -H "Authorization: Bearer $SANDO_API_TOKEN" -d "{\"sha\":\"$SHA\"}"
80 journalctl -u "sando-update@$SHA" -f
81 ```
82
83 ## Editing a node's env file
84
85 `/etc/mnw/makenotwork.env` must stay **mode 0640, owned root:<service user>**.
86 `bootstrap-node.sh` creates it that way and the deploy depends on it.
87
88 The trap, which cost a failed prod deploy on 2026-08-01: systemd reads
89 `EnvironmentFile=` as root before dropping to `User=`, so the running service
90 does not care about the mode. Sando's pre-swap config check is the only thing
91 that reads the file **as the deploy user**, because its whole job is to load the
92 config the way the service will before swapping the symlink. So a file rewritten
93 0600 leaves production serving happily while the next deploy is already broken,
94 and nothing says so until someone ships.
95
96 Rewriting the file through a temp file is what does it:
97
98 # WRONG: the temp file carries root's 077 umask, and `mv` carries it over
99 { cat /etc/mnw/makenotwork.env; echo "NEW_VAR=x"; } > /tmp/env && \
100 mv /tmp/env /etc/mnw/makenotwork.env
101
102 `mv` replaces the inode, so the new file keeps the temp's 0600 and loses any ACL
103 the old one carried. Append in place, or restore the mode explicitly afterwards:
104
105 # RIGHT: in-place, mode preserved
106 echo "NEW_VAR=x" >> /etc/mnw/makenotwork.env
107
108 # or, if the file must be rewritten
109 install -m 0640 -o root -g "$SERVICE_USER" /tmp/env /etc/mnw/makenotwork.env
110
111 The deploy now fails with a readability probe naming the user, the mode and the
112 owner rather than a bare `Permission denied` from inside a generated script, so
113 this is recoverable in one read — but it is still a failed deploy.
114
115 ## Rollback contract
116
117 A Sando rollback (canary rollback of a node, or an operator swapping the
118 `current` symlink back to an older release dir) restores **the binary and the
119 release contents only**. The database does not roll back:
120
121 - MNW migrates **forward** on boot (`makenotwork.service` runs pending migrations
122 when it starts).
123 - `sqlx::migrate::Migrator` has no down path, and Sando never invokes one.
124
125 So rolling, say, `0.10.15` back to `0.10.14` **after** `0.10.15` applied a
126 migration leaves the old binary running against a newer schema. That is a
127 one-way door for any release that carries a migration: forward is safe, back is
128 not. Sando has no restore-to-prod path either — `/backup/fetch` pulls the prod
129 dump solely as input to the `migration_dry_run` gate, not to restore a live node.
130
131 **Operating rule.** Promote a migration-bearing release with
132 `{"bears_migration": true}`. That forces a fresh `manual_confirm` on the
133 predecessor tier before the advance, even on a tier that configures no confirm,
134 so the one-way advance is a conscious step. `hotfix` does not suppress it (it
135 skips only `burn_in`). To actually undo a migration-bearing release you must
136 restore the database from a backup by hand first; a symlink rollback alone will
137 serve a mismatched schema.
138
139 **Does the backup actually restore?** The `migration_dry_run` gate answers this
140 on every build, once per `[[migration_check]]`: it resets that check's scratch
141 database, restores the latest dump of the database the check is for, and runs
142 the migrator against it. A failed restore fails the gate. Both prod databases
143 are covered — `makenotwork` and `multithreaded`, each from its own dump under
144 `/srv/sando/backups/`. A check that names a scratch database of its own gets it
145 created by the daemon on first run, so adding one owes no step here. What is
146 *not* automated is a full restore-to-serving drill (restore into a throwaway
147 target and confirm the app boots and serves
148 against it) — that is tracked as an infra task, not wired into the pipeline.
149
150 ## Companion services (deploying mnw-cli in lockstep)
151
152 `mnw-cli` (the public git-SSH server that proxies to `/api/internal/*`) shares
153 the server's internal-API contract but used to deploy from its own
154 `mnw-cli/deploy/deploy.sh`. It drifted two months out of lockstep and broke git
155 hosting the moment the server tightened that contract (0.10.14, postmortem #4).
156 Sando now builds and ships it in the **same promote** as the server:
157
158 - **Build**`[[companion]]` in the daemon config lists crates to compile from
159 the same worktree/sha as the server. Each is built after the server and staged
160 into the release bundle as `companions/<name>`. A companion that fails to build
161 fails the whole pipeline — that is the lockstep guarantee.
162 - **Deploy**`[[tier.node.companion]]` on a node says which companions it
163 installs, to what `install_path`, and which unit to restart. After the server
164 is swapped and back up (mnw-cli is `After=makenotwork.service`), the node
165 installs the staged binary and restarts the unit. testnot has no mnw-cli, so
166 only `prod-1` declares it.
167
168 Deploy runs `sudo /usr/local/lib/mnw/install-companion.sh <src> <dst> <service>`
169 over the node's executor. The wrapper keeps the deploy user's sudo grant to one
170 auditable script (it validates: src inside a release bundle, dst under `/opt`,
171 service a bare `*.service`) rather than a broad `install`/`systemctl` grant.
172
173 The grant names every deploy user Sando SSHes as — `makenotwork` for prod-1,
174 `deploy` for testnot-1 — so the file installs verbatim on any node and needs no
175 hand-edit at install time. sudoers accepts a user that is not present on the box,
176 so the other node's line is inert rather than an error. A node added later needs
177 its `ssh_target` user added to `mnw-companion.sudoers`.
178
179 One-time per node that hosts a companion (currently prod-1), as root:
180
181 ```sh
182 sudo install -d /usr/local/lib/mnw
183 sudo install -m 0755 install-companion.sh /usr/local/lib/mnw/install-companion.sh
184 sudo install -m 0440 mnw-companion.sudoers /etc/sudoers.d/mnw-companion
185 sudo visudo -cf /etc/sudoers.d/mnw-companion # validate before trusting it
186 ```
187
188 After that, every promote that reaches the node ships the server and its
189 companions together; no separate `mnw-cli` deploy step. The legacy
190 `mnw-cli/deploy/deploy.sh` is retired once the first lockstep prod promote lands.
191
192 ## testnot.work staging
193
194 testnot is gated app-side to Fan+/creator accounts
195 (`ACCESS_GATE=fan_plus_or_creator`). It exists so creators and Fan+ members can
196 preview upcoming features, and to back the pre-cutover migration dry-run.
197
198 **Source of truth: the seeded example catalog** (`mnw-testnot-seed.sh`). testnot
199 no longer mirrors production. Instead it runs a self-contained catalog of
200 fabricated `@example.test` creators and public-domain items, built by the app's
201 `--seed-examples` flow — so no prod-derived data lives on the staging box. The
202 script stops the app, resets the schema (recreating `public` owned by the app
203 role — PG15+ otherwise blocks the app role's boot migrations), then runs the
204 binary once with `--seed-examples`, which migrates the empty schema and runs the
205 guarded seed before exiting, and restarts the app. It is idempotent — re-run it
206 any time to reset testnot to the fixed catalog. There is no pause-flag; the
207 catalog is stable by construction.
208
209 Media (previews/downloads) attaches only when object storage is configured in
210 `/etc/mnw/makenotwork.env` (`S3_*`, plus `S3_PUBLIC_BUCKET` + `CDN_BASE_URL` for
211 covers). Until MinIO is stood up on testnot, items seed **hidden** (creators,
212 projects, blog posts, and follow counts still show).
213
214 Install on the Sando host:
215
216 ```sh
217 sudo install -m 0755 mnw-testnot-seed.sh /usr/local/bin/mnw-testnot-seed.sh
218 sudo install -m 0755 mnw-testnot-smoke.sh /usr/local/bin/mnw-testnot-smoke.sh
219 sudo install -m 0644 mnw-testnot-seed.service /etc/systemd/system/
220 sudo systemctl daemon-reload
221 ```
222
223 Install both. The seed script ends by `exec`ing the smoke check from its own
224 directory, so a seed installed without its sibling reseeds and then fails at the
225 last step.
226
227 Reset testnot any time: `sudo /usr/local/bin/mnw-testnot-seed.sh`
228 (or `sudo systemctl start mnw-testnot-seed.service`).
229
230 A reseed is not finished when the health check passes. Healthy is not the same
231 as worth showing: every page can return 200 while the catalog is empty, which is
232 how nine of eleven items stayed invisible for weeks. The smoke check is the part
233 that notices, and it runs automatically at the end of every seed. Run it alone
234 whenever you want the same answer: `./mnw-testnot-smoke.sh`. Set
235 `SKIP_CONTENT_SMOKE=1` on the seed to skip it while the catalog is deliberately
236 mid-change.
237
238 Its floors are sealed baselines in the same spirit as `tests/test_hygiene.rs`:
239 they freeze what is true today and fail on a new violation. Move one only in the
240 improving direction, and never loosen one to make a run pass.
241
242 The seed flow is the only way testnot gets its data. The prod-restore refresh it
243 replaced was deleted on 2026-07-22; testnot holds no prod-derived data and there
244 is no path that puts any there.
245
246 **Redeploy the binary** (no Sando integration yet — manual): build the release
247 on the Sando host, then over Tailscale SSH as root on the target, copy the
248 current release dir to a new one, stream the new `makenotwork` binary in, stream
249 a tar of `static/` + `docs/` (`docs/` = `server/site-docs/{public,examples}` +
250 `server/docs/business/assumptions.toml`, mirroring
251 `server/deploy/archive/deploy.sh.legacy`),
252 flip the `current` symlink, and `systemctl restart makenotwork.service` (which
253 boot-migrates). Old release dirs are kept for rollback.
254
255 Cert, the makenotwork Postgres password, the Caddyfile, and the operator
256 runbook for the root-level node setup are in `_private/infra/testnot/`.
257