| 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 |
|
| 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 |
|