Skip to main content

max / makenotwork

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