Skip to main content

max / makenotwork

49.5 KB · 1177 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 both `~/Code/Apps` and `~/Code/MNW`. Widening a single root to
246 /// `~/Code` would cover `~/Code/_private` and its signing keys, which is the
247 /// thing the fence is for, so the list says the narrower true thing
248 /// instead.
249 #[serde(default)]
250 pub pull_roots: Vec<PathBuf>,
251 /// Absolute path ON THIS HOST holding the trees releases are built in:
252 /// `<worktree_root>/<repo>/<app>`, one git worktree per app, detached at the
253 /// release tag. `/home/max/Code/.bento`, `/Users/max/Code/.bento`,
254 /// `C:/Users/me/Code/.bento`. Required of any host that declares targets.
255 ///
256 /// Bento owns this tree so a release never pins a checkout somebody else is
257 /// working in to a tag for the length of the build, and never has to put that
258 /// tree back afterwards.
259 ///
260 /// Two things decide the path, and both are load-bearing:
261 ///
262 /// - **Under `~/Code`**, because `~/Code/.cargo/config.toml`'s `[patch]`
263 /// block redirects the in-house git dependencies to the working copies in
264 /// the tree. Cargo finds that file by walking up from the build directory,
265 /// so a tree outside `~/Code` silently resolves those dependencies from
266 /// their remotes instead and builds something else.
267 /// - **One directory**, not a sibling per repo: it is stated once here and
268 /// never edited as repos come and go, the repos themselves stay untouched,
269 /// and it is one path to exclude from tooling and one to delete to reclaim
270 /// the space.
271 ///
272 /// It is an artifact root too (see [`Self::artifact_roots`]) rather than a
273 /// second thing to declare beside `pull_roots`. The binary a release
274 /// collects is built here, so a worktree root that was not pullable would
275 /// fail every collect, and two settings that must agree are two settings
276 /// that can drift.
277 #[serde(default)]
278 pub worktree_root: Option<PathBuf>,
279 }
280
281 impl Host {
282 /// Every artifact root declared for this host: `pull_root`, `pull_roots` and
283 /// the worktree root, merged. Empty means this host pulls nothing, which is
284 /// the fail-closed default.
285 pub fn artifact_roots(&self) -> Vec<PathBuf> {
286 self.pull_root
287 .iter()
288 .cloned()
289 .chain(self.pull_roots.iter().cloned())
290 .chain(self.worktree_root.iter().cloned())
291 .collect()
292 }
293
294 /// Where this app's release is built on this host:
295 /// `<worktree_root>/<repo>/<app>`, `repo` being the checkout's directory
296 /// name. `None` for a host with no worktree root, which cannot build.
297 ///
298 /// Joined with `/` rather than [`PathBuf::join`] because the result is a
299 /// path on the REMOTE host, rendered into git commands there. The daemon is
300 /// Linux and one build host is Windows, where `PathBuf` would render `\` and
301 /// the remote shell would not thank us; git takes forward slashes on every
302 /// platform.
303 pub fn worktree_for(&self, repo_dir: &str, app: &AppId) -> Option<String> {
304 let root = self.worktree_root.as_ref()?.display().to_string();
305 Some(format!(
306 "{}/{repo_dir}/{app}",
307 root.trim_end_matches('/'),
308 app = app.as_str()
309 ))
310 }
311 }
312
313 /// Every build host can, by definition, build and package. Keeping these the
314 /// defaults lets an existing `bento.toml` (which only declared name/ssh/targets)
315 /// load unchanged through the executor refactor.
316 fn default_actuate() -> Vec<String> {
317 vec!["build".into(), "package".into()]
318 }
319 /// A build host's read-only surface: its build logs, and the artifacts it
320 /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`).
321 /// Retrieving the artifact is the last step of every release, so defaulting it on
322 /// keeps an existing `bento.toml` from dead-ending there after a full build.
323 fn default_observe() -> Vec<String> {
324 vec!["build-log".into(), "artifact".into()]
325 }
326
327 /// An app as the runner sees it: its pointer merged with its in-repo manifest.
328 #[derive(Debug, Clone)]
329 pub struct AppConfig {
330 /// Default checkout path: where this app is cloned on a host that does not
331 /// override it. Read through [`AppConfig::repo_for`], never directly, on any
332 /// path that names a build host.
333 pub repo: String,
334 /// Per-host overrides of [`AppConfig::repo`], keyed by host name.
335 ///
336 /// One path for every host is a unix assumption. The Windows checkout is at
337 /// `C:/Users/me/Code/Apps/goingson`, not at `~/Code/Apps/goingson`, and until
338 /// this existed the Windows recipes worked around it by hard-coding the path
339 /// and never calling `repo()` — which meant they could not use
340 /// `checkout_sha(h)` either, since that builds its git commands from the
341 /// app's one path. `pin_release` had the same bug and ran
342 /// `git -C ~/Code/Apps/goingson` on windows-x86.
343 ///
344 /// Declared rather than derived from the host's `pull_root`: deriving would
345 /// need the checkout to sit under the pull root, which is false for the
346 /// library crates (`~/Code/Libraries/...` against a `pull_root` of
347 /// `~/Code/Apps`).
348 pub repo_by_host: HashMap<String, String>,
349 /// What this repo produces (see [`Kind`]).
350 pub kind: Kind,
351 pub branch: String,
352 /// Recipe directory relative to the repo (`dist/recipes`).
353 pub recipe_dir: String,
354 /// Where to read the release version, relative to the repo. Unset (the
355 /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
356 /// Tauri-app path. A non-Tauri workspace app (audiofiles) sets this to the
357 /// member crate that carries the version, e.g.
358 /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
359 /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
360 /// `Cargo.toml`.
361 pub version_path: Option<String>,
362 /// Cargo features every release build of this app enables, exposed to
363 /// recipes as `feature_flags()`.
364 ///
365 /// Declared here rather than written into each recipe so one app cannot
366 /// ship a feature on one target and miss it on another: a per-recipe flag
367 /// has to be repeated once per platform, and the one that gets missed
368 /// fails silently, producing a binary that builds and runs with a feature
369 /// quietly absent.
370 pub features: Vec<String>,
371 /// Opt-in: `publish` refuses unless every declared target of this
372 /// `(app, version)` has a successful latest run — the all-targets-green gate
373 /// that stops a partial release (macOS published while windows is red or
374 /// still building). Off by default so independent per-target publishing
375 /// keeps working; a release that must ship as a set turns it on.
376 pub require_all_targets: bool,
377 /// Targets this app ships.
378 pub targets: Vec<Target>,
379 /// Service install destinations, one per target (see [`DeployTarget`]).
380 /// Empty unless `kind = "service"`.
381 pub deploy: Vec<DeployTarget>,
382 /// How this app's release tag is spelled, with `{version}` substituted.
383 /// Defaults to `v{version}`, which is right for a repo holding one product.
384 ///
385 /// It exists for the repos that hold several. MNW is one `.git` over the
386 /// server, sando, multithreaded, pom and more, each versioned separately, so
387 /// a bare `v0.4.1` there names no product in particular — and pom and
388 /// multithreaded are both at 0.4.1, so it is ambiguous the day it is
389 /// created rather than eventually. Those apps set `pom-v{version}` and the
390 /// tag says which release it is.
391 pub tag_format: String,
392 }
393
394 fn default_tag_format() -> String {
395 "v{version}".into()
396 }
397
398 impl AppConfig {
399 /// Where this app is checked out on `host`.
400 ///
401 /// The single reader of the path for anything that runs on a build host, so
402 /// a new call site cannot quietly reintroduce the one-path-per-app
403 /// assumption. Daemon-local reads (the version, the recipe directory, the
404 /// crate preflight) are a different question and keep using `repo`.
405 pub fn repo_for(&self, host: &str) -> &str {
406 self.repo_by_host
407 .get(host)
408 .map_or(self.repo.as_str(), String::as_str)
409 }
410
411 /// The install destination for `target`, if this app declares one.
412 pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> {
413 self.deploy.iter().find(|d| d.target == target)
414 }
415
416 /// This app's release tag for `version`.
417 pub fn tag_for(&self, version: &crate::domain::Version) -> String {
418 self.tag_format.replace("{version}", &version.to_string())
419 }
420 }
421
422 fn default_branch() -> String {
423 "main".into()
424 }
425 fn default_recipe_dir() -> String {
426 "dist/recipes".into()
427 }
428
429 /// Where an app's manifest lives inside its repo.
430 pub const APP_MANIFEST: &str = "bento.toml";
431
432 /// Check one app's `[[deploy]]` tables at load time.
433 ///
434 /// Every one of these fails a release later and more expensively if it is only
435 /// caught when the recipe runs — a bad `install_path` is a root `install` to the
436 /// wrong place, and a target with no entry is a build that silently deploys
437 /// nothing. The privileged installer on each host re-checks its own arguments
438 /// (it is the thing holding the sudo grant, so it cannot trust a caller); this
439 /// is the earlier, friendlier half of the same rule.
440 fn validate_deploy(name: &str, app: &AppConfig) -> Result<()> {
441 if app.deploy.is_empty() {
442 // A service with no `[[deploy]]` used to be rejected outright. It is
443 // legal now, and means "somebody else installs this" — the Sando/Bento
444 // boundary, where Bento builds and packages and Sando decides whether a
445 // thing advances a stage. What is NOT legal is a service that neither
446 // deploys nor hands off, which is still "a service that lands nowhere".
447 //
448 // That second half cannot be checked here: it depends on the daemon's
449 // `[handoff]` tables, and this function only knows the app manifest. It
450 // is a cross-document invariant, so it lives where both documents are in
451 // hand — [`Topology::validate_delivery`], called at startup and by
452 // `--check-config`.
453 return Ok(());
454 }
455 anyhow::ensure!(
456 app.kind == Kind::Service,
457 "app `{name}` declares [[deploy]] entries but is not `kind = \"service\"`; \
458 only a service is installed onto a host"
459 );
460 let mut seen = Vec::new();
461 for d in &app.deploy {
462 anyhow::ensure!(
463 app.targets.contains(&d.target),
464 "app `{name}`: [[deploy]] names target {} which the app does not ship",
465 d.target
466 );
467 anyhow::ensure!(
468 !seen.contains(&d.target),
469 "app `{name}`: two [[deploy]] entries for target {} — \
470 one target installs to one place",
471 d.target
472 );
473 seen.push(d.target);
474 anyhow::ensure!(
475 !d.host.trim().is_empty(),
476 "app `{name}`: [[deploy]] for {} has an empty host",
477 d.target
478 );
479 // Absolute, and no `..` to walk out of wherever it appears to point.
480 anyhow::ensure!(
481 d.install_path.starts_with('/')
482 && !Path::new(&d.install_path)
483 .components()
484 .any(|c| c == std::path::Component::ParentDir),
485 "app `{name}`: install_path `{}` must be an absolute path with no `..`",
486 d.install_path
487 );
488 // A bare unit name. Anything with a slash or whitespace is either a
489 // path or an attempt to smuggle a second argument into `systemctl`.
490 anyhow::ensure!(
491 d.service.ends_with(".service")
492 && !d.service.contains('/')
493 && !d.service.chars().any(char::is_whitespace),
494 "app `{name}`: service `{}` must be a bare unit name ending in `.service`",
495 d.service
496 );
497 }
498 // A service that ships a target it cannot install is a build with no ending.
499 for t in &app.targets {
500 anyhow::ensure!(
501 seen.contains(t),
502 "app `{name}`: target {t} has no [[deploy]] entry — \
503 every target a service ships must say where it lands"
504 );
505 }
506 Ok(())
507 }
508
509 impl Topology {
510 pub fn load(path: &Path) -> Result<Self> {
511 let raw = std::fs::read_to_string(path)
512 .with_context(|| format!("reading topology at {}", path.display()))?;
513 let raw: RawTopology = toml::from_str(&raw)
514 .with_context(|| format!("parsing topology at {}", path.display()))?;
515 Self::resolve(raw)
516 }
517
518 /// Merge each app pointer with the manifest in its repo.
519 ///
520 /// The manifest is read from the checkout on the daemon host, the same way
521 /// the version is (`engine::version_from_repo`). An app whose repo is not
522 /// checked out here cannot be released from here either, so failing at load
523 /// with the path in hand beats failing mid-run.
524 fn resolve(raw: RawTopology) -> Result<Self> {
525 let mut app = HashMap::with_capacity(raw.app.len());
526 for (name, ptr) in raw.app {
527 let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST);
528 let text = std::fs::read_to_string(&manifest_path).with_context(|| {
529 format!(
530 "app `{name}`: reading {}. Per-app build config lives in the app's repo; \
531 create it there with `targets = [...]`",
532 manifest_path.display()
533 )
534 })?;
535 let m: AppManifest = toml::from_str(&text)
536 .with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?;
537 app.insert(
538 name,
539 AppConfig {
540 repo: ptr.repo,
541 repo_by_host: ptr.repo_by_host,
542 kind: m.kind,
543 branch: m.branch,
544 recipe_dir: m.recipe_dir,
545 version_path: m.version_path,
546 features: m.features,
547 require_all_targets: m.require_all_targets,
548 targets: m.targets,
549 deploy: m.deploy,
550 tag_format: m.tag_format,
551 },
552 );
553 }
554 let topo = Topology {
555 hosts: raw.hosts,
556 app,
557 };
558 topo.validate()?;
559 Ok(topo)
560 }
561
562 /// Parse a daemon-side topology from a string, resolving app manifests from
563 /// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml`
564 /// into a temp repo so they exercise the same path as production rather
565 /// than a parallel one.
566 ///
567 /// One difference from production, and it is about noise rather than
568 /// coverage: a host that declares no `worktree_root` is given one here
569 /// instead of being refused. Most tests never build from git — pinning is
570 /// off, and their "repos" are plain directories — so making every fixture
571 /// carry a path would say nothing except that the field exists. The tests
572 /// that DO exercise the worktree set it themselves, to a temp directory, and
573 /// `validate` still refuses a real config that omits it.
574 #[cfg(test)]
575 pub fn from_str_for_tests(s: &str) -> Result<Self> {
576 let mut raw: RawTopology = toml::from_str(s)?;
577 for h in &mut raw.hosts {
578 if h.worktree_root.is_none() {
579 // Per process: a test that pins without setting this is a bug,
580 // and a shared path would make it show up as a stale directory
581 // from an earlier run rather than as itself.
582 h.worktree_root = Some(std::env::temp_dir().join(format!(
583 "bento-tests-never-build-here-{}",
584 std::process::id()
585 )));
586 }
587 }
588 Self::resolve(raw)
589 }
590
591 /// Every service reaches the box that runs it exactly one way.
592 ///
593 /// A service either installs itself (`[[deploy]]` in its own manifest) or is
594 /// handed to a Sando that does (`[handoff.<app>]` in the daemon config).
595 /// Neither is refused: such a service would build, archive, and stop,
596 /// looking green while nothing shipped.
597 ///
598 /// Both is refused too, and that is the more useful half. Under the boundary
599 /// (wiki `sando-bento-boundary`) deciding whether a thing advances a stage is
600 /// Sando's job, so a service that also installs itself has two systems with
601 /// an opinion about what is running and no rule for which wins. Better to
602 /// fail at startup than to discover it when a promote and a recipe disagree.
603 ///
604 /// Cross-document, so it cannot live in `validate()`: the manifest is in the
605 /// app's repo and the handoff is in the daemon's config, and `Topology` only
606 /// parses the first. Called from `main` after both are loaded, which is also
607 /// what `--check-config` runs.
608 pub fn validate_delivery(&self, cfg: &crate::config::Config) -> Result<()> {
609 for (name, app) in &self.app {
610 if app.kind != Kind::Service {
611 continue;
612 }
613 let hands_off = cfg.handoff.contains_key(name.as_str());
614 match (app.deploy.is_empty(), hands_off) {
615 (true, false) => anyhow::bail!(
616 "app `{name}` is a service but neither declares [[deploy]] entries nor has \
617 a [handoff.{name}] table in the daemon config — it would build and archive \
618 and never reach the host that runs it"
619 ),
620 (false, true) => anyhow::bail!(
621 "app `{name}` is a service that both declares [[deploy]] entries and has a \
622 [handoff.{name}] table — it would be installed by Bento AND handed to Sando \
623 to install. Pick one: Bento deploys it, or Sando does"
624 ),
625 _ => {}
626 }
627 }
628 Ok(())
629 }
630
631 fn validate(&self) -> Result<()> {
632 anyhow::ensure!(
633 !self.hosts.is_empty(),
634 "topology must declare at least one host"
635 );
636 anyhow::ensure!(
637 !self.app.is_empty(),
638 "topology must declare at least one app"
639 );
640 // Every target an app ships must have a host that can build it.
641 for (name, app) in &self.app {
642 for t in &app.targets {
643 if self.host_for(*t).is_none() {
644 anyhow::bail!("app `{name}` ships target {t} but no host declares it");
645 }
646 }
647 // The tag reaches a remote login shell inside `git checkout "..."`,
648 // and it must actually vary per release.
649 anyhow::ensure!(
650 app.tag_format.contains("{version}"),
651 "app `{name}`: tag_format `{}` must contain `{{version}}`, or every \
652 release would resolve to the same tag",
653 app.tag_format
654 );
655 anyhow::ensure!(
656 !app.tag_format
657 .chars()
658 .any(|c| matches!(c, '"' | '`' | '$' | ';' | '&' | '|' | '\\' | ' ')),
659 "app `{name}`: tag_format `{}` contains shell metacharacters",
660 app.tag_format
661 );
662 // A typo'd host name here is invisible: the lookup misses and every
663 // host silently gets the default path, which is the exact bug the
664 // override exists to fix.
665 for host in app.repo_by_host.keys() {
666 anyhow::ensure!(
667 self.hosts.iter().any(|h| &h.name == host),
668 "app `{name}`: repo_by_host names host `{host}`, which no [[host]] declares"
669 );
670 }
671 validate_deploy(name, app)?;
672 }
673 // Capability/transport coherence: a host that declares buildable targets
674 // must be granted `build` (otherwise its own recipes would be denied at
675 // dispatch), and an agent-transport host must say where its agent is.
676 for h in &self.hosts {
677 if !h.targets.is_empty() && !h.actuate.iter().any(|a| a == "build") {
678 anyhow::bail!(
679 "host `{}` declares buildable targets but is not granted the `build` capability",
680 h.name
681 );
682 }
683 // A build host with no worktree root has nowhere to build: the
684 // release would have to fall back to the ordinary checkout, which is
685 // the shared mutable state the worktree exists to end. Refusing at
686 // load makes that a startup error naming the host, rather than a
687 // second code path nobody remembers is there.
688 if !h.targets.is_empty() && h.worktree_root.is_none() {
689 anyhow::bail!(
690 "host `{}` declares buildable targets but sets no worktree_root; \
691 releases build in `<worktree_root>/<repo>/<app>` and there is no \
692 fallback. Set it to the `.bento` directory under that host's code \
693 tree, e.g. worktree_root = \"/home/max/Code/.bento\"",
694 h.name
695 );
696 }
697 if h.transport == HostTransport::Agent && h.agent_url.is_none() {
698 anyhow::bail!(
699 "host `{}` uses transport = \"agent\" but sets no agent_url",
700 h.name
701 );
702 }
703 }
704 Ok(())
705 }
706
707 /// The first host that declares `target` as buildable.
708 pub fn host_for(&self, target: Target) -> Option<&Host> {
709 self.hosts.iter().find(|h| h.targets.contains(&target))
710 }
711
712 pub fn app(&self, app: &AppId) -> Option<&AppConfig> {
713 self.app.get(app.as_str())
714 }
715 }
716
717 #[cfg(test)]
718 mod tests {
719 use super::*;
720
721 const HOSTS: &str = r#"
722 [[host]]
723 name = "fw13"
724 ssh = "local"
725 targets = ["linux/x86_64"]
726
727 [[host]]
728 name = "mbp"
729 ssh = "mbp"
730 targets = ["macos/aarch64", "ios/universal"]
731 "#;
732
733 const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"]
734 "#;
735
736 /// Load a daemon-side topology whose single app's manifest is written into
737 /// a temp repo, so tests go through the same two-file path as production.
738 /// The tempdir is returned so it outlives the borrow.
739 fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> {
740 let dir = tempfile::tempdir().unwrap();
741 let repo = dir.path().join("goingson");
742 std::fs::create_dir_all(&repo).unwrap();
743 std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
744 let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display());
745 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
746 }
747
748 /// As [`load_with`], but the app pointer carries extra lines (a
749 /// `repo_by_host` table) beneath its `repo`.
750 fn load_with_pointer(
751 hosts: &str,
752 manifest: &str,
753 pointer_extra: &str,
754 ) -> Result<(Topology, tempfile::TempDir)> {
755 let dir = tempfile::tempdir().unwrap();
756 let repo = dir.path().join("goingson");
757 std::fs::create_dir_all(&repo).unwrap();
758 std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
759 let daemon = format!(
760 "{hosts}\n[app.goingson]\nrepo = \"{}\"\n{pointer_extra}",
761 repo.display()
762 );
763 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
764 }
765
766 fn load(hosts: &str) -> Result<Topology> {
767 load_with(hosts, MANIFEST).map(|(t, dir)| {
768 std::mem::forget(dir);
769 t
770 })
771 }
772
773 #[test]
774 fn parses_and_resolves_hosts() {
775 let t = load(HOSTS).unwrap();
776 assert_eq!(t.hosts.len(), 2);
777 let target: Target = "macos/aarch64".parse().unwrap();
778 assert_eq!(t.host_for(target).unwrap().name, "mbp");
779 assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
780 }
781
782 /// `features` is optional: every topology written before it existed must
783 /// keep loading, and an app that declares none gets an empty list rather
784 /// than a parse error.
785 #[test]
786 fn features_defaults_empty_and_parses_when_present() {
787 let t = load(HOSTS).unwrap();
788 assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
789
790 let (t, _dir) = load_with(
791 HOSTS,
792 "targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n",
793 )
794 .unwrap();
795 assert_eq!(
796 t.app(&"goingson".into()).unwrap().features,
797 vec!["supernote".to_string(), "extra".to_string()]
798 );
799 }
800
801 /// No `repo_by_host` is the ordinary case and every host resolves to the one
802 /// declared path — including a host that does not exist, since the resolver
803 /// is a lookup with a default and not a validation.
804 #[test]
805 fn repo_for_defaults_to_the_single_path_for_every_host() {
806 let t = load(HOSTS).unwrap();
807 let app = t.app(&"goingson".into()).unwrap();
808 assert!(app.repo_by_host.is_empty());
809 for host in ["fw13", "mbp", "nobody"] {
810 assert_eq!(app.repo_for(host), app.repo);
811 }
812 }
813
814 /// The Windows shape: one host's checkout is somewhere else entirely, and
815 /// the override wins for that host and only that host.
816 #[test]
817 fn repo_by_host_overrides_one_host_only() {
818 let (t, _dir) = load_with_pointer(
819 HOSTS,
820 MANIFEST,
821 "[app.goingson.repo_by_host]\nmbp = \"/Users/max/Code/Apps/goingson\"\n",
822 )
823 .unwrap();
824 let app = t.app(&"goingson".into()).unwrap();
825 assert_eq!(app.repo_for("mbp"), "/Users/max/Code/Apps/goingson");
826 assert_eq!(app.repo_for("fw13"), app.repo);
827 }
828
829 /// A misspelled host name would resolve to nothing and hand every host the
830 /// default path, which looks exactly like a working config.
831 #[test]
832 fn repo_by_host_naming_an_unknown_host_is_rejected() {
833 let err = load_with_pointer(
834 HOSTS,
835 MANIFEST,
836 "[app.goingson.repo_by_host]\nwindows-x86 = \"C:/Users/me/Code/Apps/goingson\"\n",
837 )
838 .unwrap_err();
839 assert!(format!("{err:#}").contains("windows-x86"), "{err:#}");
840 }
841
842 #[test]
843 fn rejects_target_without_a_host() {
844 let only_linux = r#"
845 [[host]]
846 name = "fw13"
847 ssh = "local"
848 targets = ["linux/x86_64"]
849 "#;
850 assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err());
851 }
852
853 #[test]
854 fn capability_defaults_make_a_build_host() {
855 let t = load(HOSTS).unwrap();
856 let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap();
857 assert_eq!(fw13.transport, HostTransport::Ssh);
858 assert!(fw13.actuate.contains(&"build".to_string()));
859 assert!(fw13.actuate.contains(&"package".to_string()));
860 }
861
862 #[test]
863 fn agent_transport_parses_with_url_and_caps() {
864 let (t, _dir) = load_with(
865 r#"
866 [[host]]
867 name = "mbp"
868 ssh = "mbp"
869 targets = ["macos/aarch64"]
870 transport = "agent"
871 agent_url = "http://mbp:8765"
872 actuate = ["build", "sign", "notarize", "staple"]
873 "#,
874 "targets = [\"macos/aarch64\"]\n",
875 )
876 .unwrap();
877 let mbp = &t.hosts[0];
878 assert_eq!(mbp.transport, HostTransport::Agent);
879 assert_eq!(mbp.agent_url.as_deref(), Some("http://mbp:8765"));
880 assert!(mbp.actuate.contains(&"sign".to_string()));
881 }
882
883 #[test]
884 fn agent_host_without_url_is_rejected() {
885 let bad = r#"
886 [[host]]
887 name = "mbp"
888 ssh = "mbp"
889 targets = ["macos/aarch64"]
890 transport = "agent"
891
892 "#;
893 assert!(load(bad).is_err());
894 }
895
896 /// A repo holding one product tags `v0.4.1`; a repo holding several has to
897 /// say which product a tag is for. MNW is one `.git` over the server, sando,
898 /// multithreaded and pom, and pom and multithreaded are BOTH at 0.4.1 — so a
899 /// bare `v0.4.1` there is ambiguous the day it is created, not eventually.
900 #[test]
901 fn tag_format_defaults_to_v_and_can_name_the_product() {
902 let v = |s: &str| crate::domain::Version::parse(s).unwrap();
903
904 let t = load(HOSTS).unwrap();
905 let app = t.app(&"goingson".into()).unwrap();
906 assert_eq!(app.tag_format, "v{version}");
907 assert_eq!(app.tag_for(&v("0.4.1")), "v0.4.1");
908
909 let (t, _dir) = load_with(
910 HOSTS,
911 "targets = [\"linux/x86_64\"]\ntag_format = \"pom-v{version}\"\n",
912 )
913 .unwrap();
914 assert_eq!(
915 t.app(&"goingson".into()).unwrap().tag_for(&v("0.4.1")),
916 "pom-v0.4.1"
917 );
918 }
919
920 /// The tag is interpolated into `git checkout "..."` on a remote host, and
921 /// it has to actually vary per release. A format with no `{version}` would
922 /// pin every release to one tag, which is worse than failing.
923 #[test]
924 fn tag_format_must_vary_and_stay_shell_safe() {
925 let bad = |f: &str| {
926 load_with(
927 HOSTS,
928 &format!("targets = [\"linux/x86_64\"]\ntag_format = \"{f}\"\n"),
929 )
930 .is_err()
931 };
932 assert!(bad("release"), "a constant tag pins every release together");
933 assert!(bad("v{version}; rm -rf /"));
934 assert!(bad("v{version}$(id)"));
935 assert!(bad("v{version} extra"));
936 assert!(!bad("pom-v{version}"));
937 assert!(!bad("release/{version}"));
938 }
939
940 /// A service's `[[deploy]]` entries resolve, and the target -> destination
941 /// binding is what the runner reads. The recipe never names a host, so this
942 /// mapping is the only thing deciding which box each binary lands on.
943 #[test]
944 fn service_deploy_entries_resolve_per_target() {
945 let (t, _dir) = load_with(
946 HOSTS,
947 r#"kind = "service"
948 targets = ["linux/x86_64", "macos/aarch64"]
949
950 [[deploy]]
951 target = "linux/x86_64"
952 host = "root@prod"
953 port = 2200
954 install_path = "/usr/local/bin/demo"
955 service = "demo.service"
956 health_url = "http://prod:9100/api/health"
957
958 [[deploy]]
959 target = "macos/aarch64"
960 host = "mbp"
961 install_path = "/usr/local/bin/demo"
962 service = "demo.service"
963 "#,
964 )
965 .unwrap();
966 let app = t.app(&"goingson".into()).unwrap();
967 assert_eq!(app.kind, Kind::Service);
968 let x86 = app.deploy_for("linux/x86_64".parse().unwrap()).unwrap();
969 assert_eq!(x86.host, "root@prod");
970 assert_eq!(x86.port, Some(2200));
971 assert_eq!(
972 x86.health_url.as_deref(),
973 Some("http://prod:9100/api/health")
974 );
975 let mac = app.deploy_for("macos/aarch64".parse().unwrap()).unwrap();
976 assert_eq!(mac.host, "mbp");
977 assert_eq!(mac.port, None);
978 }
979
980 /// `[[deploy]]` entries still mean "a service installs itself", so they stay
981 /// refused on anything that is not a service.
982 ///
983 /// A service with no `[[deploy]]` is legal at the manifest level and means
984 /// "somebody else installs this", with `validate_delivery` deciding whether
985 /// that somebody exists. This function only sees the manifest, so it cannot
986 /// know.
987 #[test]
988 fn deploy_entries_belong_only_to_a_service() {
989 let deploy = "\n[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
990 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
991 assert!(
992 load_with(HOSTS, &format!("targets = [\"linux/x86_64\"]\n{deploy}")).is_err(),
993 "only a service installs onto a host"
994 );
995 assert!(
996 load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_ok(),
997 "a service with no [[deploy]] parses; whether it hands off is validate_delivery's call"
998 );
999 }
1000
1001 /// A service reaches its host exactly one way. Neither route is a build that
1002 /// archives into nothing; both routes is two systems installing one service
1003 /// with no rule for which wins.
1004 #[test]
1005 fn a_service_must_deploy_itself_or_hand_off_but_not_both() {
1006 let tmp = tempfile::tempdir().unwrap();
1007 let mut cfg = crate::config::Config::for_tests(tmp.path());
1008
1009 let (handing_off, _keep) =
1010 load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").unwrap();
1011 let name = handing_off.app.keys().next().unwrap().clone();
1012
1013 // Neither: refused, and the message says what would have happened.
1014 let err = handing_off.validate_delivery(&cfg).unwrap_err();
1015 assert!(
1016 format!("{err:#}").contains("never reach the host"),
1017 "{err:#}"
1018 );
1019
1020 // Handed off: fine. This is pom under the boundary.
1021 cfg.handoff.insert(
1022 name.clone(),
1023 crate::config::Handoff {
1024 host: "local".into(),
1025 staging_root: "/srv/sando/staging".into(),
1026 url: "http://127.0.0.1:7766".into(),
1027 sando_app: None,
1028 token_env: None,
1029 },
1030 );
1031 handing_off.validate_delivery(&cfg).unwrap();
1032
1033 // Both: refused. Bento would install it and Sando would too.
1034 let (self_deploying, _keep2) = load_with(
1035 HOSTS,
1036 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1037 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1038 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1039 )
1040 .unwrap();
1041 let err = self_deploying.validate_delivery(&cfg).unwrap_err();
1042 assert!(format!("{err:#}").contains("Pick one"), "{err:#}");
1043
1044 // And a non-service is never subject to any of it.
1045 let (plain, _keep3) = load_with(HOSTS, "targets = [\"linux/x86_64\"]\n").unwrap();
1046 plain.validate_delivery(&cfg).unwrap();
1047 }
1048
1049 /// Every target a service ships must say where it lands. Without this a
1050 /// half-configured service builds both arches and silently installs one.
1051 #[test]
1052 fn service_target_without_a_deploy_entry_is_rejected() {
1053 let err = load_with(
1054 HOSTS,
1055 "kind = \"service\"\ntargets = [\"linux/x86_64\", \"macos/aarch64\"]\n\
1056 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1057 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1058 )
1059 .unwrap_err();
1060 assert!(
1061 format!("{err:#}").contains("macos/aarch64"),
1062 "must name the target with no destination: {err:#}"
1063 );
1064 }
1065
1066 /// The install path and unit name reach a root script on a production host.
1067 /// It re-checks them itself (it holds the sudo grant, so it cannot trust a
1068 /// caller), but a config that could only ever be refused should fail here,
1069 /// where the fix is one file away rather than mid-deploy.
1070 #[test]
1071 fn deploy_rejects_paths_and_units_the_installer_would_refuse() {
1072 let entry = |install: &str, service: &str| {
1073 format!(
1074 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1075 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1076 install_path = \"{install}\"\nservice = \"{service}\"\n"
1077 )
1078 };
1079 // Relative, and absolute-with-`..` — both are a root `install` somewhere
1080 // other than where the config appears to say.
1081 assert!(load_with(HOSTS, &entry("usr/local/bin/d", "d.service")).is_err());
1082 assert!(load_with(HOSTS, &entry("/opt/../etc/systemd/system/x", "d.service")).is_err());
1083 // A unit name that is really a path, or that smuggles a second argument
1084 // past `systemctl restart`.
1085 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "/etc/x.service")).is_err());
1086 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service x")).is_err());
1087 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d")).is_err());
1088 // The shape that should pass.
1089 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service")).is_ok());
1090 }
1091
1092 /// Two entries for one target: the second silently wins in a `find`, so the
1093 /// binary lands somewhere the config's first answer says it does not.
1094 #[test]
1095 fn duplicate_deploy_entries_for_one_target_are_rejected() {
1096 let e = "[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
1097 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
1098 assert!(
1099 load_with(
1100 HOSTS,
1101 &format!("kind = \"service\"\ntargets = [\"linux/x86_64\"]\n{e}{e}")
1102 )
1103 .is_err()
1104 );
1105 }
1106
1107 /// A deploy entry for a target the app does not build. It would never run,
1108 /// and it reads as coverage that does not exist.
1109 #[test]
1110 fn deploy_entry_for_an_unshipped_target_is_rejected() {
1111 assert!(
1112 load_with(
1113 HOSTS,
1114 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
1115 [[deploy]]\ntarget = \"macos/aarch64\"\nhost = \"h\"\n\
1116 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
1117 )
1118 .is_err()
1119 );
1120 }
1121
1122 #[test]
1123 fn build_host_without_build_capability_is_rejected() {
1124 let bad = r#"
1125 [[host]]
1126 name = "fw13"
1127 ssh = "local"
1128 targets = ["linux/x86_64"]
1129 actuate = ["package"]
1130
1131 "#;
1132 assert!(load(bad).is_err());
1133 }
1134 }
1135
1136 #[cfg(test)]
1137 mod live_config_smoke {
1138 use super::*;
1139
1140 /// The real `~/.config/bento/bento.toml` plus the real in-repo manifests
1141 /// must load. This is the config an actual release reads; a schema change
1142 /// that parses in fixtures but not on this machine is the failure mode
1143 /// worth catching. Skips when the file is absent (CI, another host).
1144 #[test]
1145 fn live_topology_loads_if_present() {
1146 let Some(home) = std::env::var_os("HOME") else {
1147 return;
1148 };
1149 let path = Path::new(&home).join(".config/bento/bento.toml");
1150 if !path.exists() {
1151 return;
1152 }
1153 let topo = Topology::load(&path).expect("live bento.toml must load");
1154 topo.app(&"balanced_breakfast".into())
1155 .expect("bb configured");
1156 let af = topo
1157 .app(&"audiofiles".into())
1158 .expect("audiofiles configured");
1159 assert_eq!(
1160 af.version_path.as_deref(),
1161 Some("crates/audiofiles-app/Cargo.toml")
1162 );
1163
1164 // The library crates resolve as libraries, so they take publish.rhai
1165 // rather than a per-platform recipe. The whole makeover suite is here;
1166 // this names the ends of it plus one crate outside it, since the point
1167 // is the `kind` resolution and not a roll call of the registry.
1168 for name in ["makeover", "makeover-touch", "pter", "alloy_tui"] {
1169 let c = topo
1170 .app(&name.into())
1171 .unwrap_or_else(|| panic!("{name} configured"));
1172 assert_eq!(c.kind, Kind::Library, "{name} must be a library");
1173 }
1174 assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App);
1175 }
1176 }
1177