Skip to main content

max / makenotwork

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