Skip to main content

max / makenotwork

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