# Bento deploy units `bentod` runs as a systemd **user** service on fw13 (the x86_64 build gate). Unlike `sandod` (a hardened system service with a dedicated `sando` user), bentod is a user service under the operator: building the apps needs the operator's SSH keys (to the tailnet build hosts and the mbp ops-agent), the app checkouts under `~/Code/Apps`, and the `_private` layer for signing secrets (`secrets_root`). A dedicated system user can't reach those without copying keys and bind-mounting home, so a user service is the right model, and it lets the operator redeploy bentod with `systemctl --user restart` (no sudo). ## Files | File | Where it goes | Purpose | |------|---------------|---------| | `bentod.service` | `~/.config/systemd/user/` | The user service unit. | | `bento-daemon.toml.example` | `~/.config/bento/bento-daemon.toml` | Daemon-local config (paths + listen). Absolute paths only; no shell expansion. | | `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. | | (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. | ## Stand-up (no root except enable-linger) ```sh # 1. Build + install the binary cd ~/Code/MNW/bento/daemon && cargo build --release install -D -m 0755 target/release/bentod ~/.local/bin/bentod # 2. Config + state dirs mkdir -p ~/.config/bento ~/.local/state/bento/logs ~/Dist install -m 0644 ~/Code/MNW/bento/deploy/bento-daemon.toml.example ~/.config/bento/bento-daemon.toml install -m 0644 ~/Code/MNW/bento/deploy/bento.toml.example ~/.config/bento/bento.toml # (edit the two configs: absolute home paths, real host/app rows) # 3. Service mkdir -p ~/.config/systemd/user install -m 0644 ~/Code/MNW/bento/deploy/bentod.service ~/.config/systemd/user/ loginctl enable-linger "$USER" systemctl --user daemon-reload systemctl --user enable --now bentod # 4. Verify (address must match `listen` in bento-daemon.toml, see Auth below) curl -s http://127.0.0.1:8765/state | python3 -m json.tool ``` ## Where artifacts end up Each target's `collect` lands locally at `////` and is then deposited at `////` on the archive host named by the `[archive]` table. Same layout both sides, so the two are one tree at two addresses. astra holds the archive: always on, on the tailnet, and already the aarch64 build host and git mirror, so it is the one box every other build host can reach. That is what gives "where is the AppImage for goingson 1.4.0" a single answer covering the macOS and Windows targets too, rather than one per machine that built them. The archive host needs the directory to exist and to be writable by the SSH user bentod reaches it as; everything below it is created per release. ```sh ssh astra 'sudo install -d -o "$USER" -g "$USER" -m 0755 /var/lib/bento/artifacts' ``` Retention prunes `dist_root` and `logs_root` on the daemon box and never touches the archive: the archived copy is the one meant to outlive them. A failed deposit fails that target's `collect` step, before sign and publish. Leave the `[archive]` table out to skip archiving entirely. ## Handing an artifact to Sando Bento builds and packages; Sando decides whether a thing advances a stage. For a product Sando deploys, a `[handoff.]` table names where its bytes go after the recipe finishes: ```toml [handoff.pom] host = "sando@fw13" staging_root = "/srv/sando/staging" url = "http://100.103.89.95:7766" sando_app = "pom" token_env = "BENTO_SANDO_TOKEN" ``` `token_env` names the environment variable holding sandod's bearer token; it never holds the token itself and never points at a file. Set the variable in bentod's `EnvironmentFile` (`~/.config/bento/bento.env`, already there for `BENTO_API_TOKEN`) with the value of `SANDO_API_TOKEN` from `/etc/sando/sando.env` on the Sando host. Those two have to match, and the daemon says so by name when the variable is unset. A signing key and a bearer token are not the same kind of secret: a key is a file because a tool opens it by path, while a token is one opaque string held for the life of the process, and a token on disk in plaintext inside `_private` is a token in git. The older `token_file` key is refused rather than ignored, so a config still carrying it fails at startup instead of sending no header and collecting a 401. Per target, the collect directory is rsynced to `/--/` and sandod is asked to take it in. Sando proves the bundle against its record before anything else happens, so a transfer that dropped or corrupted a file is refused with the file named. Two things about the transfer are load-bearing. The artifact record does **not** travel with the bytes: it names the digest of the bundle, the digest covers every file in the bundle, so a record copied in among the artifacts would change the digest it names. It goes in the request body. And the staging directory is mirrored with `--delete` rather than merged, so a retry after a partial transfer holds exactly this attempt: a leftover file is an extra file, and an extra file is a manifest mismatch that would get an honest bundle refused. `host` is usually the same machine bentod runs on, and still goes through ssh: sandod runs as `sando`, bentod as a user unit, and `sando@fw13` lands the bytes owned by the process that has to rename them without a group or an ACL on the staging directory. That means bentod's user needs an SSH key in `sando`'s `authorized_keys`, and the staging directory has to exist: ```sh ssh sando@fw13 'install -d -m 0755 /srv/sando/staging' ``` A failed handoff fails that target run at a `handoff` step, which is a step no recipe runs: the daemon performs it after the recipe, and the failure needs a column of its own rather than contradicting a green `collect`. Leave the table out for every app Sando does not deploy, which is most of them. ## Auth On a loopback bind, build triggers are reachable only from the daemon's own host, so no token is required. To operate bentod over the tailnet, bind the tailnet IP in `bento-daemon.toml` and set `BENTO_API_TOKEN` via an `EnvironmentFile` in the unit. bentod refuses to start on a non-loopback bind without it (same posture as Sando's `SANDO_API_TOKEN`). **fw13 runs the second form**, not the loopback default the example config ships. `listen` is the tailnet address and `/build` and `/retry` require the bearer token, so a `127.0.0.1` curl gets no answer at all rather than an auth error. Read the address out of the config and the token out of its own file rather than passing either on a command line: ```sh set -a; . ~/.config/bento/bento.env; set +a curl -s -X POST http://$(grep -oP '(?<=^listen = ")[^"]+' ~/.config/bento/bento-daemon.toml)/build \ -H "authorization: Bearer $BENTO_API_TOKEN" -H 'content-type: application/json' \ -d '{"app":"makeover-webview","version":"0.5.1"}' ``` `/state`, `/status.json` and the log endpoints are read-only and unauthed, so watching a run needs the address but not the token. ## Recipes A real `/build` reads `//.rhai` from each app's checkout. One per platform per app: | App | macOS | iOS | Linux | Windows | |-----|-------|-----|-------|---------| | goingson | yes | yes | yes | yes (unsigned, unsupported) | | audiofiles | yes | — (egui, no iOS) | yes | yes (unsigned, unsupported) | | balanced_breakfast | pending¹ | pending¹ | yes | yes (unsigned, unsupported) | ¹ BB has no Developer ID signing / notarization infra or iOS project yet, so its macOS/iOS recipes are deferred until that lands. BB ships in a later wave. A library crate (`kind = "library"` in its own `bento.toml`) has no per-platform artifact, so the runner reads a single `publish.rhai` instead of the table above. Every library configured in the topology carries one. That recipe asserts HEAD is exactly a tag before it uploads, so a release needs `git tag -a v` first. A `linux.rhai` serves both arches: `build_host()` resolves to the native host the topology assigns the target (`linux/x86_64` → fw13, `linux/aarch64` → astra), so there is no cross-compilation and no hard-coded host name. macOS/iOS recipes run on the mbp `agent`-transport host where the Developer ID key is usable. ### The prebuild gate Every recipe runs a `prebuild` step between checkout and build: ``` cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` `sh_ok` aborts the recipe on a non-zero exit, and a failed step lands in the step ledger, so a red gate bars publish as well as build. It runs on the target's own build host rather than once centrally: a break confined to one platform (cfg-gated code, a Windows path) is then caught on the machine that would have shipped it. The cost is that the suite runs once per target. Features come from `feature_flags()`, so the gate compiles the same configuration the release build does. ### Recipe host-function vocabulary Beyond `step`/`sh`/`sh_ok`/`log`/`collect`/`publish`/`secret`/`env` and the macOS helpers (`codesign`/`notarize`/`staple`/`verify_gatekeeper`/`keychain_*`), recipes can read their own context: | Function | Returns | |----------|---------| | `version()` | the version being built (e.g. `"0.4.2"`) | | `build_host()` | the host this target builds on (e.g. `"fw13"`) | | `repo()` | the app's checkout path on this target's build host (`~`-prefixed on unix; `cd` into it, commands don't auto-cd) | | `target()` / `platform()` / `arch()` | `"linux/x86_64"` / `"linux"` / `"x86_64"` | Non-Tauri apps (audiofiles) set `version_path` in the topology to the crate `Cargo.toml` carrying the version, since they have no `tauri.conf.json`.