Skip to main content

max / makenotwork

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