//! Build matrix + host topology (`bento.toml`). //! //! Three orthogonal axes, all declared here: hosts (what can build natively), //! apps (what ships which targets and where its recipes live), and the //! implicit target axis that ties them together. Adding a platform is config — //! a new recipe plus a host that declares the target — not code. use crate::domain::{AppId, Target}; use anyhow::{Context, Result}; use ops_core::remote::RemoteHost; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; #[derive(Debug, Clone, Deserialize)] pub struct Topology { #[serde(default, rename = "host")] pub hosts: Vec, /// `[app.]` table. #[serde(default)] pub app: HashMap, } #[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, } #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { /// Checkout path on each build host (apps are cloned on every host). pub repo: String, #[serde(default = "default_branch")] pub branch: String, /// Recipe directory relative to the repo (`dist/recipes`). #[serde(default = "default_recipe_dir")] pub recipe_dir: String, /// Targets this app ships. pub targets: Vec, } fn default_branch() -> String { "main".into() } fn default_recipe_dir() -> String { "dist/recipes".into() } 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 topo: Topology = toml::from_str(&raw)?; topo.validate()?; Ok(topo) } 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"); } } } 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)) } /// Map of host name -> `RemoteHost`, for the recipe `sh(host, cmd)` API. pub fn remote_hosts(&self) -> HashMap { self.hosts.iter().map(|h| (h.name.clone(), RemoteHost::new(h.ssh.clone()))).collect() } pub fn app(&self, app: &AppId) -> Option<&AppConfig> { self.app.get(app.as_str()) } } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = r#" [[host]] name = "fw13" ssh = "local" targets = ["linux/x86_64"] [[host]] name = "mbp" ssh = "mbp" targets = ["macos/aarch64", "ios/universal"] [app.goingson] repo = "~/Code/Apps/goingson" targets = ["macos/aarch64", "linux/x86_64"] "#; fn load(s: &str) -> Result { let t: Topology = toml::from_str(s)?; t.validate()?; Ok(t) } #[test] fn parses_and_resolves_hosts() { let t = load(SAMPLE).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"); } #[test] fn rejects_target_without_a_host() { let bad = r#" [[host]] name = "fw13" ssh = "local" targets = ["linux/x86_64"] [app.goingson] repo = "x" targets = ["windows/x86_64"] "#; assert!(load(bad).is_err()); } #[test] fn remote_hosts_marks_local() { let t = load(SAMPLE).unwrap(); let hosts = t.remote_hosts(); assert!(hosts["fw13"].is_local()); assert!(!hosts["mbp"].is_local()); } }