Skip to main content

max / makenotwork

50.0 KB · 1182 lines History Blame Raw
1 //! Build matrix + host topology, across two files.
2 //!
3 //! Three orthogonal axes: hosts (what can build natively), apps (what ships
4 //! which targets and where its recipes live), and the implicit target axis
5 //! tying them together. Adding a platform is config — a new recipe plus a host
6 //! that declares the target — not code.
7 //!
8 //! The two files split by who owns the fact:
9 //!
10 //! - The daemon's `bento.toml` declares the build hosts, shared across every
11 //! app, and points at each app's checkout.
12 //! - Each app's own `bento.toml`, at the root of its repo, declares how that
13 //! app builds: targets, cargo features, recipe directory, version path.
14 //!
15 //! Keeping the per-app half in the repo means it is versioned with the code it
16 //! describes and reviewed in the same commit, rather than drifting in a config
17 //! file on one machine that nothing else can see.
18
19 use crate::domain::{AppId, Target};
20 use anyhow::{Context, Result};
21 use serde::Deserialize;
22 use std::collections::HashMap;
23 use std::path::{Path, PathBuf};
24
25 /// The resolved build matrix: shared hosts, plus each app's own manifest read
26 /// from its repo.
27 ///
28 /// Two files, split by who owns the fact. Hosts are shared infrastructure and
29 /// stay in the daemon's `bento.toml`; how an app builds (its targets, features,
30 /// recipes) is a property of that app and lives in `bento.toml` at the root of
31 /// its repo, versioned with the code it describes. The daemon's file only says
32 /// where each app's checkout is.
33 #[derive(Debug, Clone)]
34 pub struct Topology {
35 pub hosts: Vec<Host>,
36 /// Apps by name, each merged from its pointer and its in-repo manifest.
37 pub app: HashMap<String, AppConfig>,
38 }
39
40 /// The daemon-side `bento.toml`: hosts, and where each app's checkout lives.
41 #[derive(Debug, Clone, Deserialize)]
42 struct RawTopology {
43 #[serde(default, rename = "host")]
44 hosts: Vec<Host>,
45 /// `[app.<name>]` table, now only a pointer at the repo.
46 #[serde(default)]
47 app: HashMap<String, AppPointer>,
48 }
49
50 /// An app's entry in the daemon's file: just where to find it.
51 ///
52 /// The app name stays here rather than in the repo because it is an API
53 /// identity — it keys routes, runs, and collected artifacts. A repo should not
54 /// be able to rename the thing the daemon has history for by editing a file.
55 #[derive(Debug, Clone, Deserialize)]
56 struct AppPointer {
57 /// Default checkout path, used by every host that does not override it.
58 repo: String,
59 /// Per-host overrides of `repo`, keyed by host name.
60 #[serde(default)]
61 repo_by_host: HashMap<String, String>,
62 }
63
64 /// What a repo produces, which decides how Bento releases it.
65 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
66 #[serde(rename_all = "snake_case")]
67 pub enum Kind {
68 /// A distributable application: built per target, artifacts collected.
69 #[default]
70 App,
71 /// A crate published to a registry. Not platform-specific — it builds and
72 /// uploads from one host — so it runs a single `publish.rhai` rather than a
73 /// recipe per platform, and its `targets` name the host that does it.
74 Library,
75 /// A long-running binary that is run rather than distributed to users: no
76 /// bundle, no signature for a human to check, no registry. Built per target
77 /// like an app.
78 ///
79 /// The kind says what the thing IS, not who delivers it, and there are two
80 /// ways it reaches the host that runs it:
81 ///
82 /// - `[[deploy]]` in the app's own manifest — Bento installs it and restarts
83 /// the unit, so the release ends at `deploy`.
84 /// - A `[handoff.<app>]` in the daemon config — Bento ends at `collect` and
85 /// hands the artifact to a Sando, which decides whether it advances a stage
86 /// (wiki `sando-bento-boundary`).
87 ///
88 /// Exactly one, enforced by [`Topology::validate_delivery`]: neither is a
89 /// build that archives into nothing, both is two systems installing one
90 /// service with no rule for which wins.
91 ///
92 /// A separate kind rather than an app with an extra step, because the two
93 /// differ in what `verify` means. An app's verify is Gatekeeper (is this
94 /// bundle notarized); a service's is running the toolchain against the
95 /// binary it just built — the same thing a library's crate preflight does.
96 /// That is also why a handed-off service is not simply `kind = "app"`: an
97 /// app's `verify` would demand a Gatekeeper grant from a Linux build host
98 /// that can never honestly hold one.
99 Service,
100 }
101
102 /// Where one target of a [`Kind::Service`] gets installed, from a `[[deploy]]`
103 /// table in the app's own `bento.toml`.
104 ///
105 /// It lives in the repo's manifest rather than the daemon's file for the same
106 /// reason `targets` does: which host runs the service is a fact about the
107 /// service, and a change to it should arrive in the same commit as the change
108 /// that needed it, not drift in a config on one machine.
109 ///
110 /// The binding is target -> host, so the recipe never names a machine: it calls
111 /// `deploy()` and the target it is already running for decides where that goes.
112 /// That is what keeps the aarch64 build from ever being installable on the
113 /// x86_64 box, without the recipe having to be careful.
114 #[derive(Debug, Clone, Deserialize)]
115 pub struct DeployTarget {
116 /// Which built target this entry installs. Must be one the app ships.
117 pub target: Target,
118 /// SSH destination of the host that runs the service: a tailnet alias
119 /// (`astra`) or `user@host`. Reached over the same `SshExec` every other
120 /// remote host uses, and `local` runs on the daemon's own box.
121 pub host: String,
122 /// Optional SSH port, when the service host is not on 22.
123 #[serde(default)]
124 pub port: Option<u16>,
125 /// Absolute path of the unit's `ExecStart` binary on that host, e.g.
126 /// `/usr/local/bin/pom`. The privileged installer is what actually writes
127 /// here; this is the argument it is given.
128 pub install_path: String,
129 /// The systemd unit to restart once the binary is in place, e.g.
130 /// `pom.service`.
131 pub service: String,
132 /// URL the recipe can assert answers 200 after the restart. Read by the
133 /// recipe via `health_url()`; Bento does not poll it on the recipe's behalf,
134 /// because what counts as healthy is the service's business.
135 #[serde(default)]
136 pub health_url: Option<String>,
137 }
138
139 /// `bento.toml` at the root of an app's repo: everything about how that app
140 /// builds. Versioned with the code, so a change to targets or features arrives
141 /// in the same commit as the change that needed it.
142 #[derive(Debug, Clone, Deserialize)]
143 struct AppManifest {
144 #[serde(default)]
145 kind: Kind,
146 #[serde(default = "default_branch")]
147 branch: String,
148 #[serde(default = "default_recipe_dir")]
149 recipe_dir: String,
150 #[serde(default)]
151 version_path: Option<String>,
152 #[serde(default)]
153 features: Vec<String>,
154 #[serde(default)]
155 require_all_targets: bool,
156 targets: Vec<Target>,
157 /// Service install destinations, one per target. Empty for any other kind.
158 #[serde(default, rename = "deploy")]
159 deploy: Vec<DeployTarget>,
160 #[serde(default = "default_tag_format")]
161 tag_format: String,
162 }
163
164 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
165 #[serde(rename_all = "snake_case")]
166 pub enum HostTransport {
167 /// SSH push (or local, when `ssh = "local"`). Every Linux/Windows step and
168 /// every non-signing macOS step.
169 #[default]
170 Ssh,
171 /// In-session `ops-agent` over HTTP (`agent_url`). Required for macOS
172 /// sign/notarize/staple — codesign can only use the Developer ID key from
173 /// the Aqua GUI session (design §7 "THE WALL"), which a plain SSH session
174 /// cannot reach.
175 Agent,
176 }
177
178 #[derive(Debug, Clone, Deserialize)]
179 pub struct Host {
180 pub name: String,
181 /// Tailnet alias or `user@host`; `local` runs commands directly.
182 pub ssh: String,
183 /// Targets this host can build natively. A target only dispatches to a host
184 /// that lists it — this is how no-cross-compile is enforced structurally.
185 #[serde(default)]
186 pub targets: Vec<Target>,
187 /// What this machine IS, as `id/version` from `/etc/os-release`
188 /// (e.g. `pop/24.04`, `alloy/0.1`).
189 ///
190 /// Checked against the host in the release preflight barrier, beside the
191 /// commit check: a build host that was rebuilt into something else is
192 /// refused before any artifact is built, rather than surfacing later as a
193 /// binary the deploy node cannot resolve. astra is the standing example, its
194 /// toolchain held together by a `.bashrc` line that a rebuild loses.
195 ///
196 /// Optional, and silence is a skip rather than a refusal: the macOS and
197 /// Windows hosts have no `/etc/os-release` to state. See
198 /// [`ops_core::base_image`].
199 #[serde(default)]
200 pub base_image: Option<ops_core::base_image::BaseImage>,
201 /// The glibc version this host builds against, as `ldd --version` reports it
202 /// (e.g. `2.39`). Checked independently of [`Self::base_image`].
203 ///
204 /// This is the number that sets the floor of every Linux binary built here,
205 /// and the reason the fleet's bases are worth stating at all: fw13 at glibc
206 /// 2.39 builds what production at glibc 2.39 runs, with no margin and on two
207 /// distros with independent update cadences.
208 #[serde(default)]
209 pub libc: Option<String>,
210 /// How `bentod` reaches this host to run steps (see [`HostTransport`]).
211 #[serde(default)]
212 pub transport: HostTransport,
213 /// Base URL of this host's `ops-agent`, e.g. `http://mbp:8765`. Required
214 /// when `transport = "agent"`; ignored otherwise.
215 #[serde(default)]
216 pub agent_url: Option<String>,
217 /// Actuate capabilities this host's executor is granted (`build`, `sign`,
218 /// …). Defaults to a plain build host so an existing `bento.toml` keeps
219 /// loading; the mac host widens this to sign/notarize/staple.
220 #[serde(default = "default_actuate")]
221 pub actuate: Vec<String>,
222 /// Observe capabilities (read-only host inspection).
223 #[serde(default = "default_observe")]
224 pub observe: Vec<String>,
225 /// Absolute path ON THIS HOST that artifact pulls are confined to — the
226 /// declared root the sync transport (`build_sync`) rsyncs collected files
227 /// out of. Never tilde-expanded (it names a location on the remote host, not
228 /// the daemon): write it out, e.g. `/home/max/Code/Apps`,
229 /// `/Users/max/Code/Apps`, `C:/Users/me/Code/Apps`.
230 ///
231 /// Unset ⇒ this host's sync transport pulls NOTHING (`ops_exec` is
232 /// fail-closed). Without it, `collect(host, '/Users/max/.tauri/passwords.env')`
233 /// would rsync the notary credential into `dist_root` — "THE WALL" held on
234 /// the agent plane but not the sync plane the agent hosts are collected over.
235 ///
236 /// Kept alongside [`Self::pull_roots`] rather than replaced by it: every
237 /// topology in existence writes the singular, and a config that has to be
238 /// edited in lockstep with a binary is a way to brick the daemon.
239 #[serde(default)]
240 pub pull_root: Option<PathBuf>,
241 /// Further artifact roots on this host, on the same terms as
242 /// [`Self::pull_root`]. The two are unioned; declaring both is normal.
243 ///
244 /// Plural because a build host builds out of more than one tree. fw13 and
245 /// astra hold `~/Code/Apps` and `~/Code/MNW`, and pom's first hand-off
246 /// release failed collecting from the second (2026-08-09). Widening the
247 /// singular to `~/Code` would have covered `~/Code/_private` and its signing
248 /// keys, which is the thing the fence is for, so the list says the narrower
249 /// true thing instead.
250 #[serde(default)]
251 pub pull_roots: Vec<PathBuf>,
252 /// Absolute path ON THIS HOST holding the trees releases are built in:
253 /// `<worktree_root>/<repo>/<app>`, one git worktree per app, detached at the
254 /// release tag. `/home/max/Code/.bento`, `/Users/max/Code/.bento`,
255 /// `C:/Users/me/Code/.bento`. Required of any host that declares targets.
256 ///
257 /// A release used to build in the ordinary checkout, which meant pinning a
258 /// tree somebody else is working in to a tag for the length of the build —
259 /// so editing in Helix during a release meant editing tag content. It also
260 /// made every release end by putting that tree back, which is where the
261 /// detached-HEAD and dirty-lockfile failures came from (task `8b5366e2`).
262 /// Bento owning its own tree removes the shared state rather than tidying
263 /// up after it.
264 ///
265 /// Two things decide the path, and both are load-bearing:
266 ///
267 /// - **Under `~/Code`**, because `~/Code/.cargo/config.toml`'s `[patch]`
268 /// block redirects the in-house git dependencies to the working copies in
269 /// the tree. Cargo finds that file by walking up from the build directory,
270 /// so a tree outside `~/Code` silently resolves those dependencies from
271 /// their remotes instead and builds something else.
272 /// - **One directory**, not a sibling per repo: it is stated once here and
273 /// never edited as repos come and go, the repos themselves stay untouched,
274 /// and it is one path to exclude from tooling and one to delete to reclaim
275 /// the space.
276 ///
277 /// It is an artifact root too (see [`Self::artifact_roots`]) rather than a
278 /// second thing to declare beside `pull_roots`. The binary a release
279 /// collects is built here, so a worktree root that was not pullable would
280 /// fail every collect, and two settings that must agree are two settings
281 /// that can drift.
282 #[serde(default)]
283 pub worktree_root: Option<PathBuf>,
284 }
285
286 impl Host {
287 /// Every artifact root declared for this host: `pull_root`, `pull_roots` and
288 /// the worktree root, merged. Empty means this host pulls nothing, which is
289 /// the fail-closed default.
290 pub fn artifact_roots(&self) -> Vec<PathBuf> {
291 self.pull_root
292 .iter()
293 .cloned()
294 .chain(self.pull_roots.iter().cloned())
295 .chain(self.worktree_root.iter().cloned())
296 .collect()
297 }
298
299 /// Where this app's release is built on this host:
300 /// `<worktree_root>/<repo>/<app>`, `repo` being the checkout's directory
301 /// name. `None` for a host with no worktree root, which cannot build.
302 ///
303 /// Joined with `/` rather than [`PathBuf::join`] because the result is a
304 /// path on the REMOTE host, rendered into git commands there. The daemon is
305 /// Linux and one build host is Windows, where `PathBuf` would render `\` and
306 /// the remote shell would not thank us; git takes forward slashes on every
307 /// platform.
308 pub fn worktree_for(&self, repo_dir: &str, app: &AppId) -> Option<String> {
309 let root = self.worktree_root.as_ref()?.display().to_string();
310 Some(format!(
311 "{}/{repo_dir}/{app}",
312 root.trim_end_matches('/'),
313 app = app.as_str()
314 ))
315 }
316 }
317
318 /// Every build host can, by definition, build and package. Keeping these the
319 /// defaults lets an existing `bento.toml` (which only declared name/ssh/targets)
320 /// load unchanged through the executor refactor.
321 fn default_actuate() -> Vec<String> {
322 vec!["build".into(), "package".into()]
323 }
324 /// A build host's read-only surface: its build logs, and the artifacts it
325 /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`).
326 /// Retrieving the artifact is the last step of every release, so defaulting it on
327 /// keeps an existing `bento.toml` from dead-ending there after a full build.
328 fn default_observe() -> Vec<String> {
329 vec!["build-log".into(), "artifact".into()]
330 }
331
332 /// An app as the runner sees it: its pointer merged with its in-repo manifest.
333 #[derive(Debug, Clone)]
334 pub struct AppConfig {
335 /// Default checkout path: where this app is cloned on a host that does not
336 /// override it. Read through [`AppConfig::repo_for`], never directly, on any
337 /// path that names a build host.
338 pub repo: String,
339 /// Per-host overrides of [`AppConfig::repo`], keyed by host name.
340 ///
341 /// One path for every host is a unix assumption. The Windows checkout is at
342 /// `C:/Users/me/Code/Apps/goingson`, not at `~/Code/Apps/goingson`, and until
343 /// this existed the Windows recipes worked around it by hard-coding the path
344 /// and never calling `repo()` — which meant they could not use
345 /// `checkout_sha(h)` either, since that builds its git commands from the
346 /// app's one path. `pin_release` had the same bug and ran
347 /// `git -C ~/Code/Apps/goingson` on windows-x86.
348 ///
349 /// Declared rather than derived from the host's `pull_root`: deriving would
350 /// need the checkout to sit under the pull root, which is false for the
351 /// library crates (`~/Code/Libraries/...` against a `pull_root` of
352 /// `~/Code/Apps`).
353 pub repo_by_host: HashMap<String, String>,
354 /// What this repo produces (see [`Kind`]).
355 pub kind: Kind,
356 pub branch: String,
357 /// Recipe directory relative to the repo (`dist/recipes`).
358 pub recipe_dir: String,
359 /// Where to read the release version, relative to the repo. Unset (the
360 /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
361 /// Tauri-app path. A non-Tauri workspace app (audiofiles) sets this to the
362 /// member crate that carries the version, e.g.
363 /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
364 /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
365 /// `Cargo.toml`.
366 pub version_path: Option<String>,
367 /// Cargo features every release build of this app enables, exposed to
368 /// recipes as `feature_flags()`.
369 ///
370 /// Declared here rather than written into each recipe so one app cannot
371 /// ship a feature on one target and miss it on another: a per-recipe flag
372 /// has to be repeated once per platform, and the one that gets missed
373 /// fails silently, producing a binary that builds and runs with a feature
374 /// quietly absent.
375 pub features: Vec<String>,
376 /// Opt-in: `publish` refuses unless every declared target of this
377 /// `(app, version)` has a successful latest run — the all-targets-green gate
378 /// that stops a partial release (macOS published while windows is red or
379 /// still building). Off by default so independent per-target publishing
380 /// keeps working; a release that must ship as a set turns it on.
381 pub require_all_targets: bool,
382 /// Targets this app ships.
383 pub targets: Vec<Target>,
384 /// Service install destinations, one per target (see [`DeployTarget`]).
385 /// Empty unless `kind = "service"`.
386 pub deploy: Vec<DeployTarget>,
387 /// How this app's release tag is spelled, with `{version}` substituted.
388 /// Defaults to `v{version}`, which is right for a repo holding one product.
389 ///
390 /// It exists for the repos that hold several. MNW is one `.git` over the
391 /// server, sando, multithreaded, pom and more, each versioned separately, so
392 /// a bare `v0.4.1` there names no product in particular — and pom and
393 /// multithreaded are both at 0.4.1, so it is ambiguous the day it is
394 /// created rather than eventually. Those apps set `pom-v{version}` and the
395 /// tag says which release it is.
396 pub tag_format: String,
397 }
398
399 fn default_tag_format() -> String {
400 "v{version}".into()
401 }
402
403 impl AppConfig {
404 /// Where this app is checked out on `host`.
405 ///
406 /// The single reader of the path for anything that runs on a build host, so
407 /// a new call site cannot quietly reintroduce the one-path-per-app
408 /// assumption. Daemon-local reads (the version, the recipe directory, the
409 /// crate preflight) are a different question and keep using `repo`.
410 pub fn repo_for(&self, host: &str) -> &str {
411 self.repo_by_host
412 .get(host)
413 .map_or(self.repo.as_str(), String::as_str)
414 }
415
416 /// The install destination for `target`, if this app declares one.
417 pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> {
418 self.deploy.iter().find(|d| d.target == target)
419 }
420
421 /// This app's release tag for `version`.
422 pub fn tag_for(&self, version: &crate::domain::Version) -> String {
423 self.tag_format.replace("{version}", &version.to_string())
424 }
425 }
426
427 fn default_branch() -> String {
428 "main".into()
429 }
430 fn default_recipe_dir() -> String {
431 "dist/recipes".into()
432 }
433
434 /// Where an app's manifest lives inside its repo.
435 pub const APP_MANIFEST: &str = "bento.toml";
436
437 /// Check one app's `[[deploy]]` tables at load time.
438 ///
439 /// Every one of these fails a release later and more expensively if it is only
440 /// caught when the recipe runs — a bad `install_path` is a root `install` to the
441 /// wrong place, and a target with no entry is a build that silently deploys
442 /// nothing. The privileged installer on each host re-checks its own arguments
443 /// (it is the thing holding the sudo grant, so it cannot trust a caller); this
444 /// is the earlier, friendlier half of the same rule.
445 fn validate_deploy(name: &str, app: &AppConfig) -> Result<()> {
446 if app.deploy.is_empty() {
447 // A service with no `[[deploy]]` used to be rejected outright. It is
448 // legal now, and means "somebody else installs this" — the Sando/Bento
449 // boundary, where Bento builds and packages and Sando decides whether a
450 // thing advances a stage. What is NOT legal is a service that neither
451 // deploys nor hands off, which is still "a service that lands nowhere".
452 //
453 // That second half cannot be checked here: it depends on the daemon's
454 // `[handoff]` tables, and this function only knows the app manifest. It
455 // is a cross-document invariant, so it lives where both documents are in
456 // hand — [`Topology::validate_delivery`], called at startup and by
457 // `--check-config`.
458 return Ok(());
459 }
460 anyhow::ensure!(
461 app.kind == Kind::Service,
462 "app `{name}` declares [[deploy]] entries but is not `kind = \"service\"`; \
463 only a service is installed onto a host"
464 );
465 let mut seen = Vec::new();
466 for d in &app.deploy {
467 anyhow::ensure!(
468 app.targets.contains(&d.target),
469 "app `{name}`: [[deploy]] names target {} which the app does not ship",
470 d.target
471 );
472 anyhow::ensure!(
473 !seen.contains(&d.target),
474 "app `{name}`: two [[deploy]] entries for target {} — \
475 one target installs to one place",
476 d.target
477 );
478 seen.push(d.target);
479 anyhow::ensure!(
480 !d.host.trim().is_empty(),
481 "app `{name}`: [[deploy]] for {} has an empty host",
482 d.target
483 );
484 // Absolute, and no `..` to walk out of wherever it appears to point.
485 anyhow::ensure!(
486 d.install_path.starts_with('/')
487 && !Path::new(&d.install_path)
488 .components()
489 .any(|c| c == std::path::Component::ParentDir),
490 "app `{name}`: install_path `{}` must be an absolute path with no `..`",
491 d.install_path
492 );
493 // A bare unit name. Anything with a slash or whitespace is either a
494 // path or an attempt to smuggle a second argument into `systemctl`.
495 anyhow::ensure!(
496 d.service.ends_with(".service")
497 && !d.service.contains('/')
498 && !d.service.chars().any(char::is_whitespace),
499 "app `{name}`: service `{}` must be a bare unit name ending in `.service`",
500 d.service
501 );
502 }
503 // A service that ships a target it cannot install is a build with no ending.
504 for t in &app.targets {
505 anyhow::ensure!(
506 seen.contains(t),
507 "app `{name}`: target {t} has no [[deploy]] entry — \
508 every target a service ships must say where it lands"
509 );
510 }
511 Ok(())
512 }
513
514 impl Topology {
515 pub fn load(path: &Path) -> Result<Self> {
516 let raw = std::fs::read_to_string(path)
517 .with_context(|| format!("reading topology at {}", path.display()))?;
518 let raw: RawTopology = toml::from_str(&raw)
519 .with_context(|| format!("parsing topology at {}", path.display()))?;
520 Self::resolve(raw)
521 }
522
523 /// Merge each app pointer with the manifest in its repo.
524 ///
525 /// The manifest is read from the checkout on the daemon host, the same way
526 /// the version is (`engine::version_from_repo`). An app whose repo is not
527 /// checked out here cannot be released from here either, so failing at load
528 /// with the path in hand beats failing mid-run.
529 fn resolve(raw: RawTopology) -> Result<Self> {
530 let mut app = HashMap::with_capacity(raw.app.len());
531 for (name, ptr) in raw.app {
532 let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST);
533 let text = std::fs::read_to_string(&manifest_path).with_context(|| {
534 format!(
535 "app `{name}`: reading {}. Per-app build config lives in the app's repo; \
536 create it there with `targets = [...]`",
537 manifest_path.display()
538 )
539 })?;
540 let m: AppManifest = toml::from_str(&text)
541 .with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?;
542 app.insert(
543 name,
544 AppConfig {
545 repo: ptr.repo,
546 repo_by_host: ptr.repo_by_host,
547 kind: m.kind,
548 branch: m.branch,
549 recipe_dir: m.recipe_dir,
550 version_path: m.version_path,
551 features: m.features,
552 require_all_targets: m.require_all_targets,
553 targets: m.targets,
554 deploy: m.deploy,
555 tag_format: m.tag_format,
556 },
557 );
558 }
559 let topo = Topology {
560 hosts: raw.hosts,
561 app,
562 };
563 topo.validate()?;
564 Ok(topo)
565 }
566
567 /// Parse a daemon-side topology from a string, resolving app manifests from
568 /// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml`
569 /// into a temp repo so they exercise the same path as production rather
570 /// than a parallel one.
571 ///
572 /// One difference from production, and it is about noise rather than
573 /// coverage: a host that declares no `worktree_root` is given one here
574 /// instead of being refused. Most tests never build from git — pinning is
575 /// off, and their "repos" are plain directories — so making every fixture
576 /// carry a path would say nothing except that the field exists. The tests
577 /// that DO exercise the worktree set it themselves, to a temp directory, and
578 /// `validate` still refuses a real config that omits it.
579 #[cfg(test)]
580 pub fn from_str_for_tests(s: &str) -> Result<Self> {
581 let mut raw: RawTopology = toml::from_str(s)?;
582 for h in &mut raw.hosts {
583 if h.worktree_root.is_none() {
584 // Per process: a test that pins without setting this is a bug,
585 // and a shared path would make it show up as a stale directory
586 // from an earlier run rather than as itself.
587 h.worktree_root = Some(std::env::temp_dir().join(format!(
588 "bento-tests-never-build-here-{}",
589 std::process::id()
590 )));
591 }
592 }
593 Self::resolve(raw)
594 }
595
596 /// Every service reaches the box that runs it exactly one way.
597 ///
598 /// A service either installs itself (`[[deploy]]` in its own manifest) or is
599 /// handed to a Sando that does (`[handoff.<app>]` in the daemon config).
600 /// Neither is the original "a service that lands nowhere has no release":
601 /// it would build, archive, and stop, looking green while nothing shipped.
602 ///
603 /// Both is refused too, and that is the more useful half. Under the boundary
604 /// (wiki `sando-bento-boundary`) deciding whether a thing advances a stage is
605 /// Sando's job, so a service that also installs itself has two systems with
606 /// an opinion about what is running and no rule for which wins. Better to
607 /// fail at startup than to discover it when a promote and a recipe disagree.
608 ///
609 /// Cross-document, so it cannot live in `validate()`: the manifest is in the
610 /// app's repo and the handoff is in the daemon's config, and `Topology` only
611 /// parses the first. Called from `main` after both are loaded, which is also
612 /// what `--check-config` runs.
613 pub fn validate_delivery(&self, cfg: &crate::config::Config) -> Result<()> {
614 for (name, app) in &self.app {
615 if app.kind != Kind::Service {
616 continue;
617 }
618 let hands_off = cfg.handoff.contains_key(name.as_str());
619 match (app.deploy.is_empty(), hands_off) {
620 (true, false) => anyhow::bail!(
621 "app `{name}` is a service but neither declares [[deploy]] entries nor has \
622 a [handoff.{name}] table in the daemon config — it would build and archive \
623 and never reach the host that runs it"
624 ),
625 (false, true) => anyhow::bail!(
626 "app `{name}` is a service that both declares [[deploy]] entries and has a \
627 [handoff.{name}] table — it would be installed by Bento AND handed to Sando \
628 to install. Pick one: Bento deploys it, or Sando does"
629 ),
630 _ => {}
631 }
632 }
633 Ok(())
634 }
635
636 fn validate(&self) -> Result<()> {
637 anyhow::ensure!(
638 !self.hosts.is_empty(),
639 "topology must declare at least one host"
640 );
641 anyhow::ensure!(
642 !self.app.is_empty(),
643 "topology must declare at least one app"
644 );
645 // Every target an app ships must have a host that can build it.
646 for (name, app) in &self.app {
647 for t in &app.targets {
648 if self.host_for(*t).is_none() {
649 anyhow::bail!("app `{name}` ships target {t} but no host declares it");
650 }
651 }
652 // The tag reaches a remote login shell inside `git checkout "..."`,
653 // and it must actually vary per release.
654 anyhow::ensure!(
655 app.tag_format.contains("{version}"),
656 "app `{name}`: tag_format `{}` must contain `{{version}}`, or every \
657 release would resolve to the same tag",
658 app.tag_format
659 );
660 anyhow::ensure!(
661 !app.tag_format
662 .chars()
663 .any(|c| matches!(c, '"' | '`' | '$' | ';' | '&' | '|' | '\\' | ' ')),
664 "app `{name}`: tag_format `{}` contains shell metacharacters",
665 app.tag_format
666 );
667 // A typo'd host name here is invisible: the lookup misses and every
668 // host silently gets the default path, which is the exact bug the
669 // override exists to fix.
670 for host in app.repo_by_host.keys() {
671 anyhow::ensure!(
672 self.hosts.iter().any(|h| &h.name == host),
673 "app `{name}`: repo_by_host names host `{host}`, which no [[host]] declares"
674 );
675 }
676 validate_deploy(name, app)?;
677 }
678 // Capability/transport coherence: a host that declares buildable targets
679 // must be granted `build` (otherwise its own recipes would be denied at
680 // dispatch), and an agent-transport host must say where its agent is.
681 for h in &self.hosts {
682 if !h.targets.is_empty() && !h.actuate.iter().any(|a| a == "build") {
683 anyhow::bail!(
684 "host `{}` declares buildable targets but is not granted the `build` capability",
685 h.name
686 );
687 }
688 // A build host with no worktree root has nowhere to build: the
689 // release would have to fall back to the ordinary checkout, which is
690 // the shared mutable state the worktree exists to end. Refusing at
691 // load makes that a startup error naming the host, rather than a
692 // second code path nobody remembers is there.
693 if !h.targets.is_empty() && h.worktree_root.is_none() {
694 anyhow::bail!(
695 "host `{}` declares buildable targets but sets no worktree_root; \
696 releases build in `<worktree_root>/<repo>/<app>` and there is no \
697 fallback. Set it to the `.bento` directory under that host's code \
698 tree, e.g. worktree_root = \"/home/max/Code/.bento\"",
699 h.name
700 );
701 }
702 if h.transport == HostTransport::Agent && h.agent_url.is_none() {
703 anyhow::bail!(
704 "host `{}` uses transport = \"agent\" but sets no agent_url",
705 h.name
706 );
707 }
708 }
709 Ok(())
710 }
711
712 /// The first host that declares `target` as buildable.
713 pub fn host_for(&self, target: Target) -> Option<&Host> {
714 self.hosts.iter().find(|h| h.targets.contains(&target))
715 }
716
717 pub fn app(&self, app: &AppId) -> Option<&AppConfig> {
718 self.app.get(app.as_str())
719 }
720 }
721
722 #[cfg(test)]
723 mod tests {
724 use super::*;
725
726 const HOSTS: &str = r#"
727 [[host]]
728 name = "fw13"
729 ssh = "local"
730 targets = ["linux/x86_64"]
731
732 [[host]]
733 name = "mbp"
734 ssh = "mbp"
735 targets = ["macos/aarch64", "ios/universal"]
736 "#;
737
738 const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"]
739 "#;
740
741 /// Load a daemon-side topology whose single app's manifest is written into
742 /// a temp repo, so tests go through the same two-file path as production.
743 /// The tempdir is returned so it outlives the borrow.
744 fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> {
745 let dir = tempfile::tempdir().unwrap();
746 let repo = dir.path().join("goingson");
747 std::fs::create_dir_all(&repo).unwrap();
748 std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
749 let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display());
750 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
751 }
752
753 /// As [`load_with`], but the app pointer carries extra lines (a
754 /// `repo_by_host` table) beneath its `repo`.
755 fn load_with_pointer(
756 hosts: &str,
757 manifest: &str,
758 pointer_extra: &str,
759 ) -> Result<(Topology, tempfile::TempDir)> {
760 let dir = tempfile::tempdir().unwrap();
761 let repo = dir.path().join("goingson");
762 std::fs::create_dir_all(&repo).unwrap();
763 std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
764 let daemon = format!(
765 "{hosts}\n[app.goingson]\nrepo = \"{}\"\n{pointer_extra}",
766 repo.display()
767 );
768 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
769 }
770
771 fn load(hosts: &str) -> Result<Topology> {
772 load_with(hosts, MANIFEST).map(|(t, dir)| {
773 std::mem::forget(dir);
774 t
775 })
776 }
777
778 #[test]
779 fn parses_and_resolves_hosts() {
780 let t = load(HOSTS).unwrap();
781 assert_eq!(t.hosts.len(), 2);
782 let target: Target = "macos/aarch64".parse().unwrap();
783 assert_eq!(t.host_for(target).unwrap().name, "mbp");
784 assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
785 }
786
787 /// `features` is optional: every topology written before it existed must
788 /// keep loading, and an app that declares none gets an empty list rather
789 /// than a parse error.
790 #[test]
791 fn features_defaults_empty_and_parses_when_present() {
792 let t = load(HOSTS).unwrap();
793 assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
794
795 let (t, _dir) = load_with(
796 HOSTS,
797 "targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n",
798 )
799 .unwrap();
800 assert_eq!(
801 t.app(&"goingson".into()).unwrap().features,
802 vec!["supernote".to_string(), "extra".to_string()]
803 );
804 }
805
806 /// No `repo_by_host` is the ordinary case and every host resolves to the one
807 /// declared path — including a host that does not exist, since the resolver
808 /// is a lookup with a default and not a validation.
809 #[test]
810 fn repo_for_defaults_to_the_single_path_for_every_host() {
811 let t = load(HOSTS).unwrap();
812 let app = t.app(&"goingson".into()).unwrap();
813 assert!(app.repo_by_host.is_empty());
814 for host in ["fw13", "mbp", "nobody"] {
815 assert_eq!(app.repo_for(host), app.repo);
816 }
817 }
818
819 /// The Windows shape: one host's checkout is somewhere else entirely, and
820 /// the override wins for that host and only that host.
821 #[test]
822 fn repo_by_host_overrides_one_host_only() {
823 let (t, _dir) = load_with_pointer(
824 HOSTS,
825 MANIFEST,
826 "[app.goingson.repo_by_host]\nmbp = \"/Users/max/Code/Apps/goingson\"\n",
827 )
828 .unwrap();
829 let app = t.app(&"goingson".into()).unwrap();
830 assert_eq!(app.repo_for("mbp"), "/Users/max/Code/Apps/goingson");
831 assert_eq!(app.repo_for("fw13"), app.repo);
832 }
833
834 /// A misspelled host name would resolve to nothing and hand every host the
835 /// default path, which looks exactly like a working config.
836 #[test]
837 fn repo_by_host_naming_an_unknown_host_is_rejected() {
838 let err = load_with_pointer(
839 HOSTS,
840 MANIFEST,
841 "[app.goingson.repo_by_host]\nwindows-x86 = \"C:/Users/me/Code/Apps/goingson\"\n",
842 )
843 .unwrap_err();
844 assert!(format!("{err:#}").contains("windows-x86"), "{err:#}");
845 }
846
847 #[test]
848 fn rejects_target_without_a_host() {
849 let only_linux = r#"
850 [[host]]
851 name = "fw13"
852 ssh = "local"
853 targets = ["linux/x86_64"]
854 "#;
855 assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err());
856 }
857
858 #[test]
859 fn capability_defaults_make_a_build_host() {
860 let t = load(HOSTS).unwrap();
861 let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap();
862 assert_eq!(fw13.transport, HostTransport::Ssh);
863 assert!(fw13.actuate.contains(&"build".to_string()));
864 assert!(fw13.actuate.contains(&"package".to_string()));
865 }
866
867 #[test]
868 fn agent_transport_parses_with_url_and_caps() {
869 let (t, _dir) = load_with(
870 r#"
871 [[host]]
872 name = "mbp"
873 ssh = "mbp"
874 targets = ["macos/aarch64"]
875 transport = "agent"
876 agent_url = "http://mbp:8765"
877 actuate = ["build", "sign", "notarize", "staple"]
878 "#,
879 "targets = [\"macos/aarch64\"]\n",
880 )
881 .unwrap();
882 let mbp = &t.hosts[0];
883 assert_eq!(mbp.transport, HostTransport::Agent);
884 assert_eq!(mbp.agent_url.as_deref(), Some("http://mbp:8765"));
885 assert!(mbp.actuate.contains(&"sign".to_string()));
886 }
887
888 #[test]
889 fn agent_host_without_url_is_rejected() {
890 let bad = r#"
891 [[host]]
892 name = "mbp"
893 ssh = "mbp"
894 targets = ["macos/aarch64"]
895 transport = "agent"
896
897 "#;
898 assert!(load(bad).is_err());
899 }
900
901 /// A repo holding one product tags `v0.4.1`; a repo holding several has to
902 /// say which product a tag is for. MNW is one `.git` over the server, sando,
903 /// multithreaded and pom, and pom and multithreaded are BOTH at 0.4.1 — so a
904 /// bare `v0.4.1` there is ambiguous the day it is created, not eventually.
905 #[test]
906 fn tag_format_defaults_to_v_and_can_name_the_product() {
907 let v = |s: &str| crate::domain::Version::parse(s).unwrap();
908
909 let t = load(HOSTS).unwrap();
910 let app = t.app(&"goingson".into()).unwrap();
911 assert_eq!(app.tag_format, "v{version}");
912 assert_eq!(app.tag_for(&v("0.4.1")), "v0.4.1");
913
914 let (t, _dir) = load_with(
915 HOSTS,
916 "targets = [\"linux/x86_64\"]\ntag_format = \"pom-v{version}\"\n",
917 )
918 .unwrap();
919 assert_eq!(
920 t.app(&"goingson".into()).unwrap().tag_for(&v("0.4.1")),
921 "pom-v0.4.1"
922 );
923 }
924
925 /// The tag is interpolated into `git checkout "..."` on a remote host, and
926 /// it has to actually vary per release. A format with no `{version}` would
927 /// pin every release to one tag, which is worse than failing.
928 #[test]
929 fn tag_format_must_vary_and_stay_shell_safe() {
930 let bad = |f: &str| {
931 load_with(
932 HOSTS,
933 &format!("targets = [\"linux/x86_64\"]\ntag_format = \"{f}\"\n"),
934 )
935 .is_err()
936 };
937 assert!(bad("release"), "a constant tag pins every release together");
938 assert!(bad("v{version}; rm -rf /"));
939 assert!(bad("v{version}$(id)"));
940 assert!(bad("v{version} extra"));
941 assert!(!bad("pom-v{version}"));
942 assert!(!bad("release/{version}"));
943 }
944
945 /// A service's `[[deploy]]` entries resolve, and the target -> destination
946 /// binding is what the runner reads. The recipe never names a host, so this
947 /// mapping is the only thing deciding which box each binary lands on.
948 #[test]
949 fn service_deploy_entries_resolve_per_target() {
950 let (t, _dir) = load_with(
951 HOSTS,
952 r#"kind = "service"
953 targets = ["linux/x86_64", "macos/aarch64"]
954
955 [[deploy]]
956 target = "linux/x86_64"
957 host = "root@prod"
958 port = 2200
959 install_path = "/usr/local/bin/demo"
960 service = "demo.service"
961 health_url = "http://prod:9100/api/health"
962
963 [[deploy]]
964 target = "macos/aarch64"
965 host = "mbp"
966 install_path = "/usr/local/bin/demo"
967 service = "demo.service"
968 "#,
969 )
970 .unwrap();
971 let app = t.app(&"goingson".into()).unwrap();
972 assert_eq!(app.kind, Kind::Service);
973 let x86 = app.deploy_for("linux/x86_64".parse().unwrap()).unwrap();
974 assert_eq!(x86.host, "root@prod");
975 assert_eq!(x86.port, Some(2200));
976 assert_eq!(
977 x86.health_url.as_deref(),
978 Some("http://prod:9100/api/health")
979 );
980 let mac = app.deploy_for("macos/aarch64".parse().unwrap()).unwrap();
981 assert_eq!(mac.host, "mbp");
982 assert_eq!(mac.port, None);
983 }
984
985 /// `[[deploy]]` entries still mean "a service installs itself", so they stay
986 /// refused on anything that is not a service.
987 ///
988 /// The other direction moved. A service with no `[[deploy]]` used to be
989 /// rejected here; it is now legal at the manifest level and means "somebody
990 /// else installs this", with `validate_delivery` deciding whether that
991 /// somebody exists. This function only sees the manifest, so it cannot know.
992 #[test]
993 fn deploy_entries_belong_only_to_a_service() {
994 let deploy = "\n[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
995 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
996 assert!(
997 load_with(HOSTS, &format!("targets = [\"linux/x86_64\"]\n{deploy}")).is_err(),
998 "only a service installs onto a host"
999 );
1000 assert!(
1001 load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_ok(),
1002 "a service with no [[deploy]] parses; whether it hands off is validate_delivery's call"
1003 );
1004 }
1005
1006 /// A service reaches its host exactly one way. Neither route is a build that
1007 /// archives into nothing; both routes is two systems installing one service
1008 /// with no rule for which wins.
1009 #[test]
1010 fn a_service_must_deploy_itself_or_hand_off_but_not_both() {
1011 let tmp = tempfile::tempdir().unwrap();
1012 let mut cfg = crate::config::Config::for_tests(tmp.path());
1013
1014 let (handing_off, _keep) =
1015 load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").unwrap();
1016 let name = handing_off.app.keys().next().unwrap().clone();
1017
1018 // Neither: refused, and the message says what would have happened.
1019 let err = handing_off.validate_delivery(&cfg).unwrap_err();
1020 assert!(
1021 format!("{err:#}").contains("never reach the host"),
1022 "{err:#}"
1023 );
1024
1025 // Handed off: fine. This is pom under the boundary.
1026 cfg.handoff.insert(
1027 name.clone(),
1028 crate::config::Handoff {
1029 host: "local".into(),
1030 staging_root: "/srv/sando/staging".into(),
1031 url: "http://127.0.0.1:7766".into(),
1032 sando_app: None,
1033 token_env: None,
1034 },
1035 );
1036 handing_off.validate_delivery(&cfg).unwrap();
1037
1038 // Both: refused. Bento would install it and Sando would too.
1039 let (self_deploying, _keep2) = load_with(
1040 HOSTS,
1041 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1042 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1043 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1044 )
1045 .unwrap();
1046 let err = self_deploying.validate_delivery(&cfg).unwrap_err();
1047 assert!(format!("{err:#}").contains("Pick one"), "{err:#}");
1048
1049 // And a non-service is never subject to any of it.
1050 let (plain, _keep3) = load_with(HOSTS, "targets = [\"linux/x86_64\"]\n").unwrap();
1051 plain.validate_delivery(&cfg).unwrap();
1052 }
1053
1054 /// Every target a service ships must say where it lands. Without this a
1055 /// half-configured service builds both arches and silently installs one.
1056 #[test]
1057 fn service_target_without_a_deploy_entry_is_rejected() {
1058 let err = load_with(
1059 HOSTS,
1060 "kind = \"service\"\ntargets = [\"linux/x86_64\", \"macos/aarch64\"]\n\
1061 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1062 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1063 )
1064 .unwrap_err();
1065 assert!(
1066 format!("{err:#}").contains("macos/aarch64"),
1067 "must name the target with no destination: {err:#}"
1068 );
1069 }
1070
1071 /// The install path and unit name reach a root script on a production host.
1072 /// It re-checks them itself (it holds the sudo grant, so it cannot trust a
1073 /// caller), but a config that could only ever be refused should fail here,
1074 /// where the fix is one file away rather than mid-deploy.
1075 #[test]
1076 fn deploy_rejects_paths_and_units_the_installer_would_refuse() {
1077 let entry = |install: &str, service: &str| {
1078 format!(
1079 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1080 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1081 install_path = \"{install}\"\nservice = \"{service}\"\n"
1082 )
1083 };
1084 // Relative, and absolute-with-`..` — both are a root `install` somewhere
1085 // other than where the config appears to say.
1086 assert!(load_with(HOSTS, &entry("usr/local/bin/d", "d.service")).is_err());
1087 assert!(load_with(HOSTS, &entry("/opt/../etc/systemd/system/x", "d.service")).is_err());
1088 // A unit name that is really a path, or that smuggles a second argument
1089 // past `systemctl restart`.
1090 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "/etc/x.service")).is_err());
1091 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service x")).is_err());
1092 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d")).is_err());
1093 // The shape that should pass.
1094 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service")).is_ok());
1095 }
1096
1097 /// Two entries for one target: the second silently wins in a `find`, so the
1098 /// binary lands somewhere the config's first answer says it does not.
1099 #[test]
1100 fn duplicate_deploy_entries_for_one_target_are_rejected() {
1101 let e = "[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1102 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
1103 assert!(
1104 load_with(
1105 HOSTS,
1106 &format!("kind = \"service\"\ntargets = [\"linux/x86_64\"]\n{e}{e}")
1107 )
1108 .is_err()
1109 );
1110 }
1111
1112 /// A deploy entry for a target the app does not build. It would never run,
1113 /// and it reads as coverage that does not exist.
1114 #[test]
1115 fn deploy_entry_for_an_unshipped_target_is_rejected() {
1116 assert!(
1117 load_with(
1118 HOSTS,
1119 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1120 [[deploy]]\ntarget = \"macos/aarch64\"\nhost = \"h\"\n\
1121 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1122 )
1123 .is_err()
1124 );
1125 }
1126
1127 #[test]
1128 fn build_host_without_build_capability_is_rejected() {
1129 let bad = r#"
1130 [[host]]
1131 name = "fw13"
1132 ssh = "local"
1133 targets = ["linux/x86_64"]
1134 actuate = ["package"]
1135
1136 "#;
1137 assert!(load(bad).is_err());
1138 }
1139 }
1140
1141 #[cfg(test)]
1142 mod live_config_smoke {
1143 use super::*;
1144
1145 /// The real `~/.config/bento/bento.toml` plus the real in-repo manifests
1146 /// must load. This is the config an actual release reads; a schema change
1147 /// that parses in fixtures but not on this machine is the failure mode
1148 /// worth catching. Skips when the file is absent (CI, another host).
1149 #[test]
1150 fn live_topology_loads_if_present() {
1151 let Some(home) = std::env::var_os("HOME") else {
1152 return;
1153 };
1154 let path = Path::new(&home).join(".config/bento/bento.toml");
1155 if !path.exists() {
1156 return;
1157 }
1158 let topo = Topology::load(&path).expect("live bento.toml must load");
1159 topo.app(&"balanced_breakfast".into())
1160 .expect("bb configured");
1161 let af = topo
1162 .app(&"audiofiles".into())
1163 .expect("audiofiles configured");
1164 assert_eq!(
1165 af.version_path.as_deref(),
1166 Some("crates/audiofiles-app/Cargo.toml")
1167 );
1168
1169 // The library crates resolve as libraries, so they take publish.rhai
1170 // rather than a per-platform recipe. The whole makeover suite is here;
1171 // this names the ends of it plus one crate outside it, since the point
1172 // is the `kind` resolution and not a roll call of the registry.
1173 for name in ["makeover", "makeover-touch", "pter", "alloy_tui"] {
1174 let c = topo
1175 .app(&name.into())
1176 .unwrap_or_else(|| panic!("{name} configured"));
1177 assert_eq!(c.kind, Kind::Library, "{name} must be a library");
1178 }
1179 assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App);
1180 }
1181 }
1182