| 1 |
# Bento deploy units |
| 2 |
|
| 3 |
`bentod` runs as a systemd **user** service on fw13 (the x86_64 build gate). |
| 4 |
Unlike `sandod` (a hardened system service with a dedicated `sando` user), |
| 5 |
bentod is a user service under the operator: building the apps needs the |
| 6 |
operator's SSH keys (to the tailnet build hosts and the mbp ops-agent), the app |
| 7 |
checkouts under `~/Code/Apps`, and the `_private` layer for signing secrets |
| 8 |
(`secrets_root`). A dedicated system user can't reach those without copying keys |
| 9 |
and bind-mounting home — so a user service is the right model, and it lets the |
| 10 |
operator redeploy bentod with `systemctl --user restart` (no sudo). |
| 11 |
|
| 12 |
## Files |
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
| `bentod.service` | `~/.config/systemd/user/` | The user service unit. | |
| 17 |
| `bento-daemon.toml.example` | `~/.config/bento/bento-daemon.toml` | Daemon-local config (paths + listen). Absolute paths only — no shell expansion. | |
| 18 |
| `bento.toml.example` | `~/.config/bento/bento.toml` | Build topology (hosts + apps). `repo` paths are tilde-expanded; `repo_by_host` overrides the path for one host. | |
| 19 |
| (none, hand-written 0600) | `~/.config/bento/bento.env` | `BENTO_API_TOKEN=...` for a tailnet bind. Read by the unit's `EnvironmentFile=-`, so a loopback install can leave it absent. No example file ships: it holds a secret. | |
| 20 |
|
| 21 |
## Stand-up (no root except enable-linger) |
| 22 |
|
| 23 |
```sh |
| 24 |
# 1. Build + install the binary |
| 25 |
cd ~/Code/MNW/bento/daemon && cargo build --release |
| 26 |
install -D -m 0755 target/release/bentod ~/.local/bin/bentod |
| 27 |
|
| 28 |
# 2. Config + state dirs |
| 29 |
mkdir -p ~/.config/bento ~/.local/state/bento/logs ~/Dist |
| 30 |
install -m 0644 ~/Code/MNW/bento/deploy/bento-daemon.toml.example ~/.config/bento/bento-daemon.toml |
| 31 |
install -m 0644 ~/Code/MNW/bento/deploy/bento.toml.example ~/.config/bento/bento.toml |
| 32 |
# (edit the two configs: absolute home paths, real host/app rows) |
| 33 |
|
| 34 |
# 3. Service |
| 35 |
mkdir -p ~/.config/systemd/user |
| 36 |
install -m 0644 ~/Code/MNW/bento/deploy/bentod.service ~/.config/systemd/user/ |
| 37 |
loginctl enable-linger "$USER" |
| 38 |
systemctl --user daemon-reload |
| 39 |
systemctl --user enable --now bentod |
| 40 |
|
| 41 |
# 4. Verify (address must match `listen` in bento-daemon.toml, see Auth below) |
| 42 |
curl -s http://127.0.0.1:8765/state | python3 -m json.tool |
| 43 |
``` |
| 44 |
|
| 45 |
## Where artifacts end up |
| 46 |
|
| 47 |
Each target's `collect` lands locally at `<dist_root>/<app>/<version>/<target>/` |
| 48 |
and is then deposited at `<root>/<app>/<version>/<target>/` on the archive host |
| 49 |
named by the `[archive]` table. Same layout both sides, so the two are one tree |
| 50 |
at two addresses. |
| 51 |
|
| 52 |
astra holds the archive: always on, on the tailnet, and already the aarch64 build |
| 53 |
host and git mirror, so it is the one box every other build host can reach. That |
| 54 |
is what gives "where is the AppImage for goingson 1.4.0" a single answer covering |
| 55 |
the macOS and Windows targets too, rather than one per machine that built them. |
| 56 |
|
| 57 |
The archive host needs the directory to exist and to be writable by the SSH user |
| 58 |
bentod reaches it as; everything below it is created per release. |
| 59 |
|
| 60 |
```sh |
| 61 |
ssh astra 'sudo install -d -o "$USER" -g "$USER" -m 0755 /var/lib/bento/artifacts' |
| 62 |
``` |
| 63 |
|
| 64 |
Retention prunes `dist_root` and `logs_root` on the daemon box and never touches |
| 65 |
the archive: the archived copy is the one meant to outlive them. A failed deposit |
| 66 |
fails that target's `collect` step, before sign and publish. Leave the `[archive]` |
| 67 |
table out to skip archiving entirely. |
| 68 |
|
| 69 |
## Handing an artifact to Sando |
| 70 |
|
| 71 |
Bento builds and packages; Sando decides whether a thing advances a stage. For a |
| 72 |
product Sando deploys, a `[handoff.<app>]` table names where its bytes go after |
| 73 |
the recipe finishes: |
| 74 |
|
| 75 |
```toml |
| 76 |
[handoff.pom] |
| 77 |
host = "sando@fw13" |
| 78 |
staging_root = "/srv/sando/staging" |
| 79 |
url = "http://100.103.89.95:7766" |
| 80 |
sando_app = "pom" |
| 81 |
token_env = "BENTO_SANDO_TOKEN" |
| 82 |
``` |
| 83 |
|
| 84 |
`token_env` names the environment variable holding sandod's bearer token; it |
| 85 |
never holds the token itself and never points at a file. Set the variable in |
| 86 |
bentod's `EnvironmentFile` (`~/.config/bento/bento.env`, already there for |
| 87 |
`BENTO_API_TOKEN`) with the value of `SANDO_API_TOKEN` from |
| 88 |
`/etc/sando/sando.env` on the Sando host. Those two have to match, and the |
| 89 |
daemon says so by name when the variable is unset. |
| 90 |
|
| 91 |
This field used to be `token_file`, a path under `secrets_root`. That put a |
| 92 |
bearer token on disk in plaintext inside `_private`, which is under git now, and |
| 93 |
a signing key and a bearer token are not the same kind of secret: a key is a |
| 94 |
file because a tool opens it by path, while a token is one opaque string held |
| 95 |
for the life of the process. The old key is refused rather than ignored, so a |
| 96 |
config that was never migrated fails at startup instead of sending no header and |
| 97 |
collecting a 401. |
| 98 |
|
| 99 |
Per target, the collect directory is rsynced to |
| 100 |
`<staging_root>/<app>-<version>-<target>/` and sandod is asked to take it in. |
| 101 |
Sando proves the bundle against its record before anything else happens, so a |
| 102 |
transfer that dropped or corrupted a file is refused with the file named. |
| 103 |
|
| 104 |
Two things about the transfer are load-bearing. The artifact record does **not** |
| 105 |
travel with the bytes: it names the digest of the bundle, the digest covers every |
| 106 |
file in the bundle, so a record copied in among the artifacts would change the |
| 107 |
digest it names. It goes in the request body. And the staging directory is |
| 108 |
mirrored with `--delete` rather than merged, so a retry after a partial transfer |
| 109 |
holds exactly this attempt — a leftover file is an extra file, and an extra file |
| 110 |
is a manifest mismatch that would get an honest bundle refused. |
| 111 |
|
| 112 |
`host` is usually the same machine bentod runs on, and still goes through ssh: |
| 113 |
sandod runs as `sando`, bentod as a user unit, and `sando@fw13` lands the bytes |
| 114 |
owned by the process that has to rename them without a group or an ACL on the |
| 115 |
staging directory. That means bentod's user needs an SSH key in `sando`'s |
| 116 |
`authorized_keys`, and the staging directory has to exist: |
| 117 |
|
| 118 |
```sh |
| 119 |
ssh sando@fw13 'install -d -m 0755 /srv/sando/staging' |
| 120 |
``` |
| 121 |
|
| 122 |
A failed handoff fails that target run at a `handoff` step, which is a step no |
| 123 |
recipe runs — the daemon performs it after the recipe, and the failure needs a |
| 124 |
column of its own rather than contradicting a green `collect`. Leave the table |
| 125 |
out for every app Sando does not deploy, which is most of them. |
| 126 |
|
| 127 |
## Auth (CF2) |
| 128 |
|
| 129 |
On a loopback bind, build triggers are reachable only from the daemon's own |
| 130 |
host, so no token is required. To operate bentod over the tailnet, bind the |
| 131 |
tailnet IP in `bento-daemon.toml` and set `BENTO_API_TOKEN` via an |
| 132 |
`EnvironmentFile` in the unit. bentod refuses to start on a non-loopback bind |
| 133 |
without it (same posture as Sando's `SANDO_API_TOKEN`). |
| 134 |
|
| 135 |
**fw13 runs the second form**, not the loopback default the example config |
| 136 |
ships. `listen` is the tailnet address and `/build` and `/retry` require the |
| 137 |
bearer token, so a `127.0.0.1` curl gets no answer at all rather than an auth |
| 138 |
error. Read the address out of the config and the token out of its own file |
| 139 |
rather than passing either on a command line: |
| 140 |
|
| 141 |
```sh |
| 142 |
set -a; . ~/.config/bento/bento.env; set +a |
| 143 |
curl -s -X POST http://$(grep -oP '(?<=^listen = ")[^"]+' ~/.config/bento/bento-daemon.toml)/build \ |
| 144 |
-H "authorization: Bearer $BENTO_API_TOKEN" -H 'content-type: application/json' \ |
| 145 |
-d '{"app":"makeover-webview","version":"0.5.1"}' |
| 146 |
``` |
| 147 |
|
| 148 |
`/state`, `/status.json` and the log endpoints are read-only and unauthed, so |
| 149 |
watching a run needs the address but not the token. |
| 150 |
|
| 151 |
## Recipes |
| 152 |
|
| 153 |
A real `/build` reads `<app>/<recipe_dir>/<platform>.rhai` from each app's |
| 154 |
checkout. The recipes now exist, one per platform per app: |
| 155 |
|
| 156 |
|
| 157 |
|
| 158 |
| goingson | yes | yes | yes | yes (unsigned, unsupported) | |
| 159 |
| audiofiles | yes | — (egui, no iOS) | yes | yes (unsigned, unsupported) | |
| 160 |
| balanced_breakfast | pending¹ | pending¹ | yes | yes (unsigned, unsupported) | |
| 161 |
|
| 162 |
¹ BB has no Developer ID signing / notarization infra or iOS project yet, so its |
| 163 |
macOS/iOS recipes are deferred until that lands. BB ships in a later wave. |
| 164 |
|
| 165 |
A library crate (`kind = "library"` in its own `bento.toml`) has no per-platform |
| 166 |
artifact, so the runner reads a single `publish.rhai` instead of the table above. |
| 167 |
Every library configured in the topology now carries one: `makeover`, |
| 168 |
`makeover-geometry`, `makeover-build`, `makeover-webview`, `makeover-layout`, |
| 169 |
`pter`, `everycycle`, `supernote-push`, `alloy_tui`. That recipe asserts HEAD is |
| 170 |
exactly a tag before it uploads, so a release wants `git tag -a v<version>` |
| 171 |
first; a crate published by hand before it was wired up has no such tag, and its |
| 172 |
next release is the one that gets one. |
| 173 |
|
| 174 |
A `linux.rhai` serves both arches — `build_host()` resolves to the native host |
| 175 |
the topology assigns the target (`linux/x86_64` → fw13, `linux/aarch64` → astra), |
| 176 |
so there is no cross-compilation and no hard-coded host name. macOS/iOS recipes |
| 177 |
run on the mbp `agent`-transport host where the Developer ID key is usable. |
| 178 |
|
| 179 |
### The prebuild gate |
| 180 |
|
| 181 |
Every recipe runs a `prebuild` step between checkout and build: |
| 182 |
|
| 183 |
``` |
| 184 |
cargo clippy --workspace --all-targets <features> -- -D warnings |
| 185 |
cargo test --workspace <features> |
| 186 |
``` |
| 187 |
|
| 188 |
`sh_ok` aborts the recipe on a non-zero exit, and a failed step lands in the |
| 189 |
step ledger, so a red gate bars publish as well as build. It runs on the target's |
| 190 |
own build host rather than once centrally: a break confined to one platform |
| 191 |
(cfg-gated code, a Windows path) is then caught on the machine that would have |
| 192 |
shipped it. The cost is that the suite runs once per target. |
| 193 |
|
| 194 |
Features come from `feature_flags()`, so the gate compiles the same |
| 195 |
configuration the release build does. |
| 196 |
|
| 197 |
### Recipe host-function vocabulary |
| 198 |
|
| 199 |
Beyond `step`/`sh`/`sh_ok`/`log`/`collect`/`publish`/`secret`/`env` and the |
| 200 |
macOS helpers (`codesign`/`notarize`/`staple`/`verify_gatekeeper`/`keychain_*`), |
| 201 |
recipes can read their own context: |
| 202 |
|
| 203 |
|
| 204 |
|
| 205 |
| `version()` | the version being built (e.g. `"0.4.2"`) | |
| 206 |
| `build_host()` | the host this target builds on (e.g. `"fw13"`) | |
| 207 |
| `repo()` | the app's checkout path on this target's build host (`~`-prefixed on unix; `cd` into it, commands don't auto-cd) | |
| 208 |
| `target()` / `platform()` / `arch()` | `"linux/x86_64"` / `"linux"` / `"x86_64"` | |
| 209 |
|
| 210 |
Non-Tauri apps (audiofiles) set `version_path` in the topology to the crate |
| 211 |
`Cargo.toml` carrying the version, since they have no `tauri.conf.json`. |
| 212 |
|