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