//! Build matrix + host topology, across two files. //! //! Three orthogonal axes: hosts (what can build natively), apps (what ships //! which targets and where its recipes live), and the implicit target axis //! tying them together. Adding a platform is config — a new recipe plus a host //! that declares the target — not code. //! //! The two files split by who owns the fact: //! //! - The daemon's `bento.toml` declares the build hosts, shared across every //! app, and points at each app's checkout. //! - Each app's own `bento.toml`, at the root of its repo, declares how that //! app builds: targets, cargo features, recipe directory, version path. //! //! Keeping the per-app half in the repo means it is versioned with the code it //! describes and reviewed in the same commit, rather than drifting in a config //! file on one machine that nothing else can see. use crate::domain::{AppId, Target}; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; /// The resolved build matrix: shared hosts, plus each app's own manifest read /// from its repo. /// /// Two files, split by who owns the fact. Hosts are shared infrastructure and /// stay in the daemon's `bento.toml`; how an app builds (its targets, features, /// recipes) is a property of that app and lives in `bento.toml` at the root of /// its repo, versioned with the code it describes. The daemon's file only says /// where each app's checkout is. #[derive(Debug, Clone)] pub struct Topology { pub hosts: Vec, /// Apps by name, each merged from its pointer and its in-repo manifest. pub app: HashMap, } /// The daemon-side `bento.toml`: hosts, and where each app's checkout lives. #[derive(Debug, Clone, Deserialize)] struct RawTopology { #[serde(default, rename = "host")] hosts: Vec, /// `[app.]` table, now only a pointer at the repo. #[serde(default)] app: HashMap, } /// An app's entry in the daemon's file: just where to find it. /// /// The app name stays here rather than in the repo because it is an API /// identity — it keys routes, runs, and collected artifacts. A repo should not /// be able to rename the thing the daemon has history for by editing a file. #[derive(Debug, Clone, Deserialize)] struct AppPointer { /// Default checkout path, used by every host that does not override it. repo: String, /// Per-host overrides of `repo`, keyed by host name. #[serde(default)] repo_by_host: HashMap, } /// What a repo produces, which decides how Bento releases it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Kind { /// A distributable application: built per target, artifacts collected. #[default] App, /// A crate published to a registry. Not platform-specific — it builds and /// uploads from one host — so it runs a single `publish.rhai` rather than a /// recipe per platform, and its `targets` name the host that does it. Library, /// A long-running binary that is run rather than distributed to users: no /// bundle, no signature for a human to check, no registry. Built per target /// like an app. /// /// The kind says what the thing IS, not who delivers it, and there are two /// ways it reaches the host that runs it: /// /// - `[[deploy]]` in the app's own manifest — Bento installs it and restarts /// the unit, so the release ends at `deploy`. /// - A `[handoff.]` in the daemon config — Bento ends at `collect` and /// hands the artifact to a Sando, which decides whether it advances a stage /// (wiki `sando-bento-boundary`). /// /// Exactly one, enforced by [`Topology::validate_delivery`]: neither is a /// build that archives into nothing, both is two systems installing one /// service with no rule for which wins. /// /// A separate kind rather than an app with an extra step, because the two /// differ in what `verify` means. An app's verify is Gatekeeper (is this /// bundle notarized); a service's is running the toolchain against the /// binary it just built — the same thing a library's crate preflight does. /// That is also why a handed-off service is not simply `kind = "app"`: an /// app's `verify` would demand a Gatekeeper grant from a Linux build host /// that can never honestly hold one. Service, } /// Where one target of a [`Kind::Service`] gets installed, from a `[[deploy]]` /// table in the app's own `bento.toml`. /// /// It lives in the repo's manifest rather than the daemon's file for the same /// reason `targets` does: which host runs the service is a fact about the /// service, and a change to it should arrive in the same commit as the change /// that needed it, not drift in a config on one machine. /// /// The binding is target -> host, so the recipe never names a machine: it calls /// `deploy()` and the target it is already running for decides where that goes. /// That is what keeps the aarch64 build from ever being installable on the /// x86_64 box, without the recipe having to be careful. #[derive(Debug, Clone, Deserialize)] pub struct DeployTarget { /// Which built target this entry installs. Must be one the app ships. pub target: Target, /// SSH destination of the host that runs the service: a tailnet alias /// (`astra`) or `user@host`. Reached over the same `SshExec` every other /// remote host uses, and `local` runs on the daemon's own box. pub host: String, /// Optional SSH port, when the service host is not on 22. #[serde(default)] pub port: Option, /// Absolute path of the unit's `ExecStart` binary on that host, e.g. /// `/usr/local/bin/pom`. The privileged installer is what actually writes /// here; this is the argument it is given. pub install_path: String, /// The systemd unit to restart once the binary is in place, e.g. /// `pom.service`. pub service: String, /// URL the recipe can assert answers 200 after the restart. Read by the /// recipe via `health_url()`; Bento does not poll it on the recipe's behalf, /// because what counts as healthy is the service's business. #[serde(default)] pub health_url: Option, } /// `bento.toml` at the root of an app's repo: everything about how that app /// builds. Versioned with the code, so a change to targets or features arrives /// in the same commit as the change that needed it. #[derive(Debug, Clone, Deserialize)] struct AppManifest { #[serde(default)] kind: Kind, #[serde(default = "default_branch")] branch: String, #[serde(default = "default_recipe_dir")] recipe_dir: String, #[serde(default)] version_path: Option, #[serde(default)] features: Vec, #[serde(default)] require_all_targets: bool, targets: Vec, /// Service install destinations, one per target. Empty for any other kind. #[serde(default, rename = "deploy")] deploy: Vec, #[serde(default = "default_tag_format")] tag_format: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HostTransport { /// SSH push (or local, when `ssh = "local"`). Every Linux/Windows step and /// every non-signing macOS step. #[default] Ssh, /// In-session `ops-agent` over HTTP (`agent_url`). Required for macOS /// sign/notarize/staple — codesign can only use the Developer ID key from /// the Aqua GUI session (design §7 "THE WALL"), which a plain SSH session /// cannot reach. Agent, } #[derive(Debug, Clone, Deserialize)] pub struct Host { pub name: String, /// Tailnet alias or `user@host`; `local` runs commands directly. pub ssh: String, /// Targets this host can build natively. A target only dispatches to a host /// that lists it — this is how no-cross-compile is enforced structurally. #[serde(default)] pub targets: Vec, /// How `bentod` reaches this host to run steps (see [`HostTransport`]). #[serde(default)] pub transport: HostTransport, /// Base URL of this host's `ops-agent`, e.g. `http://mbp:8765`. Required /// when `transport = "agent"`; ignored otherwise. #[serde(default)] pub agent_url: Option, /// Actuate capabilities this host's executor is granted (`build`, `sign`, /// …). Defaults to a plain build host so an existing `bento.toml` keeps /// loading; the mac host widens this to sign/notarize/staple. #[serde(default = "default_actuate")] pub actuate: Vec, /// Observe capabilities (read-only host inspection). #[serde(default = "default_observe")] pub observe: Vec, /// Absolute path ON THIS HOST that artifact pulls are confined to — the /// declared root the sync transport (`build_sync`) rsyncs collected files /// out of. Never tilde-expanded (it names a location on the remote host, not /// the daemon): write it out, e.g. `/home/max/Code/Apps`, /// `/Users/max/Code/Apps`, `C:/Users/me/Code/Apps`. /// /// Unset ⇒ this host's sync transport pulls NOTHING (`ops_exec` is /// fail-closed). Without it, `collect(host, '/Users/max/.tauri/passwords.env')` /// would rsync the notary credential into `dist_root` — "THE WALL" held on /// the agent plane but not the sync plane the agent hosts are collected over. /// /// Kept alongside [`Self::pull_roots`] rather than replaced by it: every /// topology in existence writes the singular, and a config that has to be /// edited in lockstep with a binary is a way to brick the daemon. #[serde(default)] pub pull_root: Option, /// Further artifact roots on this host, on the same terms as /// [`Self::pull_root`]. The two are unioned; declaring both is normal. /// /// Plural because a build host builds out of more than one tree. fw13 and /// astra hold `~/Code/Apps` and `~/Code/MNW`, and pom's first hand-off /// release failed collecting from the second (2026-08-09). Widening the /// singular to `~/Code` would have covered `~/Code/_private` and its signing /// keys, which is the thing the fence is for, so the list says the narrower /// true thing instead. #[serde(default)] pub pull_roots: Vec, } impl Host { /// Every artifact root declared for this host, singular and plural merged. /// Empty means this host pulls nothing, which is the fail-closed default. pub fn artifact_roots(&self) -> Vec { self.pull_root .iter() .cloned() .chain(self.pull_roots.iter().cloned()) .collect() } } /// Every build host can, by definition, build and package. Keeping these the /// defaults lets an existing `bento.toml` (which only declared name/ssh/targets) /// load unchanged through the executor refactor. fn default_actuate() -> Vec { vec!["build".into(), "package".into()] } /// A build host's read-only surface: its build logs, and the artifacts it /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`). /// Retrieving the artifact is the last step of every release, so defaulting it on /// keeps an existing `bento.toml` from dead-ending there after a full build. fn default_observe() -> Vec { vec!["build-log".into(), "artifact".into()] } /// An app as the runner sees it: its pointer merged with its in-repo manifest. #[derive(Debug, Clone)] pub struct AppConfig { /// Default checkout path: where this app is cloned on a host that does not /// override it. Read through [`AppConfig::repo_for`], never directly, on any /// path that names a build host. pub repo: String, /// Per-host overrides of [`AppConfig::repo`], keyed by host name. /// /// One path for every host is a unix assumption. The Windows checkout is at /// `C:/Users/me/Code/Apps/goingson`, not at `~/Code/Apps/goingson`, and until /// this existed the Windows recipes worked around it by hard-coding the path /// and never calling `repo()` — which meant they could not use /// `checkout_sha(h)` either, since that builds its git commands from the /// app's one path. `pin_release` had the same bug and ran /// `git -C ~/Code/Apps/goingson` on windows-x86. /// /// Declared rather than derived from the host's `pull_root`: deriving would /// need the checkout to sit under the pull root, which is false for the /// library crates (`~/Code/Libraries/...` against a `pull_root` of /// `~/Code/Apps`). pub repo_by_host: HashMap, /// What this repo produces (see [`Kind`]). pub kind: Kind, pub branch: String, /// Recipe directory relative to the repo (`dist/recipes`). pub recipe_dir: String, /// Where to read the release version, relative to the repo. Unset (the /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the /// Tauri-app path. A non-Tauri workspace app (audiofiles) sets this to the /// member crate that carries the version, e.g. /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a /// `Cargo.toml`. pub version_path: Option, /// Cargo features every release build of this app enables, exposed to /// recipes as `feature_flags()`. /// /// Declared here rather than written into each recipe so one app cannot /// ship a feature on one target and miss it on another: a per-recipe flag /// has to be repeated once per platform, and the one that gets missed /// fails silently, producing a binary that builds and runs with a feature /// quietly absent. pub features: Vec, /// Opt-in: `publish` refuses unless every declared target of this /// `(app, version)` has a successful latest run — the all-targets-green gate /// that stops a partial release (macOS published while windows is red or /// still building). Off by default so independent per-target publishing /// keeps working; a release that must ship as a set turns it on. pub require_all_targets: bool, /// Targets this app ships. pub targets: Vec, /// Service install destinations, one per target (see [`DeployTarget`]). /// Empty unless `kind = "service"`. pub deploy: Vec, /// How this app's release tag is spelled, with `{version}` substituted. /// Defaults to `v{version}`, which is right for a repo holding one product. /// /// It exists for the repos that hold several. MNW is one `.git` over the /// server, sando, multithreaded, pom and more, each versioned separately, so /// a bare `v0.4.1` there names no product in particular — and pom and /// multithreaded are both at 0.4.1, so it is ambiguous the day it is /// created rather than eventually. Those apps set `pom-v{version}` and the /// tag says which release it is. pub tag_format: String, } fn default_tag_format() -> String { "v{version}".into() } impl AppConfig { /// Where this app is checked out on `host`. /// /// The single reader of the path for anything that runs on a build host, so /// a new call site cannot quietly reintroduce the one-path-per-app /// assumption. Daemon-local reads (the version, the recipe directory, the /// crate preflight) are a different question and keep using `repo`. pub fn repo_for(&self, host: &str) -> &str { self.repo_by_host .get(host) .map_or(self.repo.as_str(), String::as_str) } /// The install destination for `target`, if this app declares one. pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> { self.deploy.iter().find(|d| d.target == target) } /// This app's release tag for `version`. pub fn tag_for(&self, version: &crate::domain::Version) -> String { self.tag_format.replace("{version}", &version.to_string()) } } fn default_branch() -> String { "main".into() } fn default_recipe_dir() -> String { "dist/recipes".into() } /// Where an app's manifest lives inside its repo. pub const APP_MANIFEST: &str = "bento.toml"; /// Check one app's `[[deploy]]` tables at load time. /// /// Every one of these fails a release later and more expensively if it is only /// caught when the recipe runs — a bad `install_path` is a root `install` to the /// wrong place, and a target with no entry is a build that silently deploys /// nothing. The privileged installer on each host re-checks its own arguments /// (it is the thing holding the sudo grant, so it cannot trust a caller); this /// is the earlier, friendlier half of the same rule. fn validate_deploy(name: &str, app: &AppConfig) -> Result<()> { if app.deploy.is_empty() { // A service with no `[[deploy]]` used to be rejected outright. It is // legal now, and means "somebody else installs this" — the Sando/Bento // boundary, where Bento builds and packages and Sando decides whether a // thing advances a stage. What is NOT legal is a service that neither // deploys nor hands off, which is still "a service that lands nowhere". // // That second half cannot be checked here: it depends on the daemon's // `[handoff]` tables, and this function only knows the app manifest. It // is a cross-document invariant, so it lives where both documents are in // hand — [`Topology::validate_delivery`], called at startup and by // `--check-config`. return Ok(()); } anyhow::ensure!( app.kind == Kind::Service, "app `{name}` declares [[deploy]] entries but is not `kind = \"service\"`; \ only a service is installed onto a host" ); let mut seen = Vec::new(); for d in &app.deploy { anyhow::ensure!( app.targets.contains(&d.target), "app `{name}`: [[deploy]] names target {} which the app does not ship", d.target ); anyhow::ensure!( !seen.contains(&d.target), "app `{name}`: two [[deploy]] entries for target {} — \ one target installs to one place", d.target ); seen.push(d.target); anyhow::ensure!( !d.host.trim().is_empty(), "app `{name}`: [[deploy]] for {} has an empty host", d.target ); // Absolute, and no `..` to walk out of wherever it appears to point. anyhow::ensure!( d.install_path.starts_with('/') && !Path::new(&d.install_path) .components() .any(|c| c == std::path::Component::ParentDir), "app `{name}`: install_path `{}` must be an absolute path with no `..`", d.install_path ); // A bare unit name. Anything with a slash or whitespace is either a // path or an attempt to smuggle a second argument into `systemctl`. anyhow::ensure!( d.service.ends_with(".service") && !d.service.contains('/') && !d.service.chars().any(char::is_whitespace), "app `{name}`: service `{}` must be a bare unit name ending in `.service`", d.service ); } // A service that ships a target it cannot install is a build with no ending. for t in &app.targets { anyhow::ensure!( seen.contains(t), "app `{name}`: target {t} has no [[deploy]] entry — \ every target a service ships must say where it lands" ); } Ok(()) } impl Topology { pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading topology at {}", path.display()))?; let raw: RawTopology = toml::from_str(&raw) .with_context(|| format!("parsing topology at {}", path.display()))?; Self::resolve(raw) } /// Merge each app pointer with the manifest in its repo. /// /// The manifest is read from the checkout on the daemon host, the same way /// the version is (`engine::version_from_repo`). An app whose repo is not /// checked out here cannot be released from here either, so failing at load /// with the path in hand beats failing mid-run. fn resolve(raw: RawTopology) -> Result { let mut app = HashMap::with_capacity(raw.app.len()); for (name, ptr) in raw.app { let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST); let text = std::fs::read_to_string(&manifest_path).with_context(|| { format!( "app `{name}`: reading {}. Per-app build config lives in the app's repo; \ create it there with `targets = [...]`", manifest_path.display() ) })?; let m: AppManifest = toml::from_str(&text) .with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?; app.insert( name, AppConfig { repo: ptr.repo, repo_by_host: ptr.repo_by_host, kind: m.kind, branch: m.branch, recipe_dir: m.recipe_dir, version_path: m.version_path, features: m.features, require_all_targets: m.require_all_targets, targets: m.targets, deploy: m.deploy, tag_format: m.tag_format, }, ); } let topo = Topology { hosts: raw.hosts, app, }; topo.validate()?; Ok(topo) } /// Parse a daemon-side topology from a string, resolving app manifests from /// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml` /// into a temp repo so they exercise the same path as production rather /// than a parallel one. #[cfg(test)] pub fn from_str_for_tests(s: &str) -> Result { Self::resolve(toml::from_str(s)?) } /// Every service reaches the box that runs it exactly one way. /// /// A service either installs itself (`[[deploy]]` in its own manifest) or is /// handed to a Sando that does (`[handoff.]` in the daemon config). /// Neither is the original "a service that lands nowhere has no release": /// it would build, archive, and stop, looking green while nothing shipped. /// /// Both is refused too, and that is the more useful half. Under the boundary /// (wiki `sando-bento-boundary`) deciding whether a thing advances a stage is /// Sando's job, so a service that also installs itself has two systems with /// an opinion about what is running and no rule for which wins. Better to /// fail at startup than to discover it when a promote and a recipe disagree. /// /// Cross-document, so it cannot live in `validate()`: the manifest is in the /// app's repo and the handoff is in the daemon's config, and `Topology` only /// parses the first. Called from `main` after both are loaded, which is also /// what `--check-config` runs. pub fn validate_delivery(&self, cfg: &crate::config::Config) -> Result<()> { for (name, app) in &self.app { if app.kind != Kind::Service { continue; } let hands_off = cfg.handoff.contains_key(name.as_str()); match (app.deploy.is_empty(), hands_off) { (true, false) => anyhow::bail!( "app `{name}` is a service but neither declares [[deploy]] entries nor has \ a [handoff.{name}] table in the daemon config — it would build and archive \ and never reach the host that runs it" ), (false, true) => anyhow::bail!( "app `{name}` is a service that both declares [[deploy]] entries and has a \ [handoff.{name}] table — it would be installed by Bento AND handed to Sando \ to install. Pick one: Bento deploys it, or Sando does" ), _ => {} } } Ok(()) } fn validate(&self) -> Result<()> { anyhow::ensure!( !self.hosts.is_empty(), "topology must declare at least one host" ); anyhow::ensure!( !self.app.is_empty(), "topology must declare at least one app" ); // Every target an app ships must have a host that can build it. for (name, app) in &self.app { for t in &app.targets { if self.host_for(*t).is_none() { anyhow::bail!("app `{name}` ships target {t} but no host declares it"); } } // The tag reaches a remote login shell inside `git checkout "..."`, // and it must actually vary per release. anyhow::ensure!( app.tag_format.contains("{version}"), "app `{name}`: tag_format `{}` must contain `{{version}}`, or every \ release would resolve to the same tag", app.tag_format ); anyhow::ensure!( !app.tag_format .chars() .any(|c| matches!(c, '"' | '`' | '$' | ';' | '&' | '|' | '\\' | ' ')), "app `{name}`: tag_format `{}` contains shell metacharacters", app.tag_format ); // A typo'd host name here is invisible: the lookup misses and every // host silently gets the default path, which is the exact bug the // override exists to fix. for host in app.repo_by_host.keys() { anyhow::ensure!( self.hosts.iter().any(|h| &h.name == host), "app `{name}`: repo_by_host names host `{host}`, which no [[host]] declares" ); } validate_deploy(name, app)?; } // Capability/transport coherence: a host that declares buildable targets // must be granted `build` (otherwise its own recipes would be denied at // dispatch), and an agent-transport host must say where its agent is. for h in &self.hosts { if !h.targets.is_empty() && !h.actuate.iter().any(|a| a == "build") { anyhow::bail!( "host `{}` declares buildable targets but is not granted the `build` capability", h.name ); } if h.transport == HostTransport::Agent && h.agent_url.is_none() { anyhow::bail!( "host `{}` uses transport = \"agent\" but sets no agent_url", h.name ); } } Ok(()) } /// The first host that declares `target` as buildable. pub fn host_for(&self, target: Target) -> Option<&Host> { self.hosts.iter().find(|h| h.targets.contains(&target)) } pub fn app(&self, app: &AppId) -> Option<&AppConfig> { self.app.get(app.as_str()) } } #[cfg(test)] mod tests { use super::*; const HOSTS: &str = r#" [[host]] name = "fw13" ssh = "local" targets = ["linux/x86_64"] [[host]] name = "mbp" ssh = "mbp" targets = ["macos/aarch64", "ios/universal"] "#; const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"] "#; /// Load a daemon-side topology whose single app's manifest is written into /// a temp repo, so tests go through the same two-file path as production. /// The tempdir is returned so it outlives the borrow. fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> { let dir = tempfile::tempdir().unwrap(); let repo = dir.path().join("goingson"); std::fs::create_dir_all(&repo).unwrap(); std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap(); let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display()); Topology::from_str_for_tests(&daemon).map(|t| (t, dir)) } /// As [`load_with`], but the app pointer carries extra lines (a /// `repo_by_host` table) beneath its `repo`. fn load_with_pointer( hosts: &str, manifest: &str, pointer_extra: &str, ) -> Result<(Topology, tempfile::TempDir)> { let dir = tempfile::tempdir().unwrap(); let repo = dir.path().join("goingson"); std::fs::create_dir_all(&repo).unwrap(); std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap(); let daemon = format!( "{hosts}\n[app.goingson]\nrepo = \"{}\"\n{pointer_extra}", repo.display() ); Topology::from_str_for_tests(&daemon).map(|t| (t, dir)) } fn load(hosts: &str) -> Result { load_with(hosts, MANIFEST).map(|(t, dir)| { std::mem::forget(dir); t }) } #[test] fn parses_and_resolves_hosts() { let t = load(HOSTS).unwrap(); assert_eq!(t.hosts.len(), 2); let target: Target = "macos/aarch64".parse().unwrap(); assert_eq!(t.host_for(target).unwrap().name, "mbp"); assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main"); } /// `features` is optional: every topology written before it existed must /// keep loading, and an app that declares none gets an empty list rather /// than a parse error. #[test] fn features_defaults_empty_and_parses_when_present() { let t = load(HOSTS).unwrap(); assert!(t.app(&"goingson".into()).unwrap().features.is_empty()); let (t, _dir) = load_with( HOSTS, "targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n", ) .unwrap(); assert_eq!( t.app(&"goingson".into()).unwrap().features, vec!["supernote".to_string(), "extra".to_string()] ); } /// No `repo_by_host` is the ordinary case and every host resolves to the one /// declared path — including a host that does not exist, since the resolver /// is a lookup with a default and not a validation. #[test] fn repo_for_defaults_to_the_single_path_for_every_host() { let t = load(HOSTS).unwrap(); let app = t.app(&"goingson".into()).unwrap(); assert!(app.repo_by_host.is_empty()); for host in ["fw13", "mbp", "nobody"] { assert_eq!(app.repo_for(host), app.repo); } } /// The Windows shape: one host's checkout is somewhere else entirely, and /// the override wins for that host and only that host. #[test] fn repo_by_host_overrides_one_host_only() { let (t, _dir) = load_with_pointer( HOSTS, MANIFEST, "[app.goingson.repo_by_host]\nmbp = \"/Users/max/Code/Apps/goingson\"\n", ) .unwrap(); let app = t.app(&"goingson".into()).unwrap(); assert_eq!(app.repo_for("mbp"), "/Users/max/Code/Apps/goingson"); assert_eq!(app.repo_for("fw13"), app.repo); } /// A misspelled host name would resolve to nothing and hand every host the /// default path, which looks exactly like a working config. #[test] fn repo_by_host_naming_an_unknown_host_is_rejected() { let err = load_with_pointer( HOSTS, MANIFEST, "[app.goingson.repo_by_host]\nwindows-x86 = \"C:/Users/me/Code/Apps/goingson\"\n", ) .unwrap_err(); assert!(format!("{err:#}").contains("windows-x86"), "{err:#}"); } #[test] fn rejects_target_without_a_host() { let only_linux = r#" [[host]] name = "fw13" ssh = "local" targets = ["linux/x86_64"] "#; assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err()); } #[test] fn capability_defaults_make_a_build_host() { let t = load(HOSTS).unwrap(); let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap(); assert_eq!(fw13.transport, HostTransport::Ssh); assert!(fw13.actuate.contains(&"build".to_string())); assert!(fw13.actuate.contains(&"package".to_string())); } #[test] fn agent_transport_parses_with_url_and_caps() { let (t, _dir) = load_with( r#" [[host]] name = "mbp" ssh = "mbp" targets = ["macos/aarch64"] transport = "agent" agent_url = "http://mbp:8765" actuate = ["build", "sign", "notarize", "staple"] "#, "targets = [\"macos/aarch64\"]\n", ) .unwrap(); let mbp = &t.hosts[0]; assert_eq!(mbp.transport, HostTransport::Agent); assert_eq!(mbp.agent_url.as_deref(), Some("http://mbp:8765")); assert!(mbp.actuate.contains(&"sign".to_string())); } #[test] fn agent_host_without_url_is_rejected() { let bad = r#" [[host]] name = "mbp" ssh = "mbp" targets = ["macos/aarch64"] transport = "agent" "#; assert!(load(bad).is_err()); } /// A repo holding one product tags `v0.4.1`; a repo holding several has to /// say which product a tag is for. MNW is one `.git` over the server, sando, /// multithreaded and pom, and pom and multithreaded are BOTH at 0.4.1 — so a /// bare `v0.4.1` there is ambiguous the day it is created, not eventually. #[test] fn tag_format_defaults_to_v_and_can_name_the_product() { let v = |s: &str| crate::domain::Version::parse(s).unwrap(); let t = load(HOSTS).unwrap(); let app = t.app(&"goingson".into()).unwrap(); assert_eq!(app.tag_format, "v{version}"); assert_eq!(app.tag_for(&v("0.4.1")), "v0.4.1"); let (t, _dir) = load_with( HOSTS, "targets = [\"linux/x86_64\"]\ntag_format = \"pom-v{version}\"\n", ) .unwrap(); assert_eq!( t.app(&"goingson".into()).unwrap().tag_for(&v("0.4.1")), "pom-v0.4.1" ); } /// The tag is interpolated into `git checkout "..."` on a remote host, and /// it has to actually vary per release. A format with no `{version}` would /// pin every release to one tag, which is worse than failing. #[test] fn tag_format_must_vary_and_stay_shell_safe() { let bad = |f: &str| { load_with( HOSTS, &format!("targets = [\"linux/x86_64\"]\ntag_format = \"{f}\"\n"), ) .is_err() }; assert!(bad("release"), "a constant tag pins every release together"); assert!(bad("v{version}; rm -rf /")); assert!(bad("v{version}$(id)")); assert!(bad("v{version} extra")); assert!(!bad("pom-v{version}")); assert!(!bad("release/{version}")); } /// A service's `[[deploy]]` entries resolve, and the target -> destination /// binding is what the runner reads. The recipe never names a host, so this /// mapping is the only thing deciding which box each binary lands on. #[test] fn service_deploy_entries_resolve_per_target() { let (t, _dir) = load_with( HOSTS, r#"kind = "service" targets = ["linux/x86_64", "macos/aarch64"] [[deploy]] target = "linux/x86_64" host = "root@prod" port = 2200 install_path = "/usr/local/bin/demo" service = "demo.service" health_url = "http://prod:9100/api/health" [[deploy]] target = "macos/aarch64" host = "mbp" install_path = "/usr/local/bin/demo" service = "demo.service" "#, ) .unwrap(); let app = t.app(&"goingson".into()).unwrap(); assert_eq!(app.kind, Kind::Service); let x86 = app.deploy_for("linux/x86_64".parse().unwrap()).unwrap(); assert_eq!(x86.host, "root@prod"); assert_eq!(x86.port, Some(2200)); assert_eq!( x86.health_url.as_deref(), Some("http://prod:9100/api/health") ); let mac = app.deploy_for("macos/aarch64".parse().unwrap()).unwrap(); assert_eq!(mac.host, "mbp"); assert_eq!(mac.port, None); } /// `[[deploy]]` entries still mean "a service installs itself", so they stay /// refused on anything that is not a service. /// /// The other direction moved. A service with no `[[deploy]]` used to be /// rejected here; it is now legal at the manifest level and means "somebody /// else installs this", with `validate_delivery` deciding whether that /// somebody exists. This function only sees the manifest, so it cannot know. #[test] fn deploy_entries_belong_only_to_a_service() { let deploy = "\n[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\ install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n"; assert!( load_with(HOSTS, &format!("targets = [\"linux/x86_64\"]\n{deploy}")).is_err(), "only a service installs onto a host" ); assert!( load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_ok(), "a service with no [[deploy]] parses; whether it hands off is validate_delivery's call" ); } /// A service reaches its host exactly one way. Neither route is a build that /// archives into nothing; both routes is two systems installing one service /// with no rule for which wins. #[test] fn a_service_must_deploy_itself_or_hand_off_but_not_both() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = crate::config::Config::for_tests(tmp.path()); let (handing_off, _keep) = load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").unwrap(); let name = handing_off.app.keys().next().unwrap().clone(); // Neither: refused, and the message says what would have happened. let err = handing_off.validate_delivery(&cfg).unwrap_err(); assert!( format!("{err:#}").contains("never reach the host"), "{err:#}" ); // Handed off: fine. This is pom under the boundary. cfg.handoff.insert( name.clone(), crate::config::Handoff { host: "local".into(), staging_root: "/srv/sando/staging".into(), url: "http://127.0.0.1:7766".into(), sando_app: None, token_env: None, }, ); handing_off.validate_delivery(&cfg).unwrap(); // Both: refused. Bento would install it and Sando would too. let (self_deploying, _keep2) = load_with( HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\ [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\ install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n", ) .unwrap(); let err = self_deploying.validate_delivery(&cfg).unwrap_err(); assert!(format!("{err:#}").contains("Pick one"), "{err:#}"); // And a non-service is never subject to any of it. let (plain, _keep3) = load_with(HOSTS, "targets = [\"linux/x86_64\"]\n").unwrap(); plain.validate_delivery(&cfg).unwrap(); } /// Every target a service ships must say where it lands. Without this a /// half-configured service builds both arches and silently installs one. #[test] fn service_target_without_a_deploy_entry_is_rejected() { let err = load_with( HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\", \"macos/aarch64\"]\n\ [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\ install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n", ) .unwrap_err(); assert!( format!("{err:#}").contains("macos/aarch64"), "must name the target with no destination: {err:#}" ); } /// The install path and unit name reach a root script on a production host. /// It re-checks them itself (it holds the sudo grant, so it cannot trust a /// caller), but a config that could only ever be refused should fail here, /// where the fix is one file away rather than mid-deploy. #[test] fn deploy_rejects_paths_and_units_the_installer_would_refuse() { let entry = |install: &str, service: &str| { format!( "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\ [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\ install_path = \"{install}\"\nservice = \"{service}\"\n" ) }; // Relative, and absolute-with-`..` — both are a root `install` somewhere // other than where the config appears to say. assert!(load_with(HOSTS, &entry("usr/local/bin/d", "d.service")).is_err()); assert!(load_with(HOSTS, &entry("/opt/../etc/systemd/system/x", "d.service")).is_err()); // A unit name that is really a path, or that smuggles a second argument // past `systemctl restart`. assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "/etc/x.service")).is_err()); assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service x")).is_err()); assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d")).is_err()); // The shape that should pass. assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service")).is_ok()); } /// Two entries for one target: the second silently wins in a `find`, so the /// binary lands somewhere the config's first answer says it does not. #[test] fn duplicate_deploy_entries_for_one_target_are_rejected() { let e = "[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\ install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n"; assert!( load_with( HOSTS, &format!("kind = \"service\"\ntargets = [\"linux/x86_64\"]\n{e}{e}") ) .is_err() ); } /// A deploy entry for a target the app does not build. It would never run, /// and it reads as coverage that does not exist. #[test] fn deploy_entry_for_an_unshipped_target_is_rejected() { assert!( load_with( HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\ [[deploy]]\ntarget = \"macos/aarch64\"\nhost = \"h\"\n\ install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n", ) .is_err() ); } #[test] fn build_host_without_build_capability_is_rejected() { let bad = r#" [[host]] name = "fw13" ssh = "local" targets = ["linux/x86_64"] actuate = ["package"] "#; assert!(load(bad).is_err()); } } #[cfg(test)] mod live_config_smoke { use super::*; /// The real `~/.config/bento/bento.toml` plus the real in-repo manifests /// must load. This is the config an actual release reads; a schema change /// that parses in fixtures but not on this machine is the failure mode /// worth catching. Skips when the file is absent (CI, another host). #[test] fn live_topology_loads_if_present() { let Some(home) = std::env::var_os("HOME") else { return; }; let path = Path::new(&home).join(".config/bento/bento.toml"); if !path.exists() { return; } let topo = Topology::load(&path).expect("live bento.toml must load"); topo.app(&"balanced_breakfast".into()) .expect("bb configured"); let af = topo .app(&"audiofiles".into()) .expect("audiofiles configured"); assert_eq!( af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml") ); // The library crates resolve as libraries, so they take publish.rhai // rather than a per-platform recipe. The whole makeover suite is here; // this names the ends of it plus one crate outside it, since the point // is the `kind` resolution and not a roll call of the registry. for name in ["makeover", "makeover-touch", "pter", "alloy_tui"] { let c = topo .app(&name.into()) .unwrap_or_else(|| panic!("{name} configured")); assert_eq!(c.kind, Kind::Library, "{name} must be a library"); } assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App); } }