Skip to main content

max / makenotwork

4.2 KB · 151 lines History Blame Raw
1 //! Build matrix + host topology (`bento.toml`).
2 //!
3 //! Three orthogonal axes, all declared here: hosts (what can build natively),
4 //! apps (what ships which targets and where its recipes live), and the
5 //! implicit target axis that ties them together. Adding a platform is config —
6 //! a new recipe plus a host that declares the target — not code.
7
8 use crate::domain::{AppId, Target};
9 use anyhow::{Context, Result};
10 use ops_core::remote::RemoteHost;
11 use serde::Deserialize;
12 use std::collections::HashMap;
13 use std::path::Path;
14
15 #[derive(Debug, Clone, Deserialize)]
16 pub struct Topology {
17 #[serde(default, rename = "host")]
18 pub hosts: Vec<Host>,
19 /// `[app.<name>]` table.
20 #[serde(default)]
21 pub app: HashMap<String, AppConfig>,
22 }
23
24 #[derive(Debug, Clone, Deserialize)]
25 pub struct Host {
26 pub name: String,
27 /// Tailnet alias or `user@host`; `local` runs commands directly.
28 pub ssh: String,
29 /// Targets this host can build natively. A target only dispatches to a host
30 /// that lists it — this is how no-cross-compile is enforced structurally.
31 #[serde(default)]
32 pub targets: Vec<Target>,
33 }
34
35 #[derive(Debug, Clone, Deserialize)]
36 pub struct AppConfig {
37 /// Checkout path on each build host (apps are cloned on every host).
38 pub repo: String,
39 #[serde(default = "default_branch")]
40 pub branch: String,
41 /// Recipe directory relative to the repo (`dist/recipes`).
42 #[serde(default = "default_recipe_dir")]
43 pub recipe_dir: String,
44 /// Targets this app ships.
45 pub targets: Vec<Target>,
46 }
47
48 fn default_branch() -> String {
49 "main".into()
50 }
51 fn default_recipe_dir() -> String {
52 "dist/recipes".into()
53 }
54
55 impl Topology {
56 pub fn load(path: &Path) -> Result<Self> {
57 let raw = std::fs::read_to_string(path)
58 .with_context(|| format!("reading topology at {}", path.display()))?;
59 let topo: Topology = toml::from_str(&raw)?;
60 topo.validate()?;
61 Ok(topo)
62 }
63
64 fn validate(&self) -> Result<()> {
65 anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host");
66 anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app");
67 // Every target an app ships must have a host that can build it.
68 for (name, app) in &self.app {
69 for t in &app.targets {
70 if self.host_for(*t).is_none() {
71 anyhow::bail!("app `{name}` ships target {t} but no host declares it");
72 }
73 }
74 }
75 Ok(())
76 }
77
78 /// The first host that declares `target` as buildable.
79 pub fn host_for(&self, target: Target) -> Option<&Host> {
80 self.hosts.iter().find(|h| h.targets.contains(&target))
81 }
82
83 /// Map of host name -> `RemoteHost`, for the recipe `sh(host, cmd)` API.
84 pub fn remote_hosts(&self) -> HashMap<String, RemoteHost> {
85 self.hosts.iter().map(|h| (h.name.clone(), RemoteHost::new(h.ssh.clone()))).collect()
86 }
87
88 pub fn app(&self, app: &AppId) -> Option<&AppConfig> {
89 self.app.get(app.as_str())
90 }
91 }
92
93 #[cfg(test)]
94 mod tests {
95 use super::*;
96
97 const SAMPLE: &str = r#"
98 [[host]]
99 name = "fw13"
100 ssh = "local"
101 targets = ["linux/x86_64"]
102
103 [[host]]
104 name = "mbp"
105 ssh = "mbp"
106 targets = ["macos/aarch64", "ios/universal"]
107
108 [app.goingson]
109 repo = "~/Code/Apps/goingson"
110 targets = ["macos/aarch64", "linux/x86_64"]
111 "#;
112
113 fn load(s: &str) -> Result<Topology> {
114 let t: Topology = toml::from_str(s)?;
115 t.validate()?;
116 Ok(t)
117 }
118
119 #[test]
120 fn parses_and_resolves_hosts() {
121 let t = load(SAMPLE).unwrap();
122 assert_eq!(t.hosts.len(), 2);
123 let target: Target = "macos/aarch64".parse().unwrap();
124 assert_eq!(t.host_for(target).unwrap().name, "mbp");
125 assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
126 }
127
128 #[test]
129 fn rejects_target_without_a_host() {
130 let bad = r#"
131 [[host]]
132 name = "fw13"
133 ssh = "local"
134 targets = ["linux/x86_64"]
135
136 [app.goingson]
137 repo = "x"
138 targets = ["windows/x86_64"]
139 "#;
140 assert!(load(bad).is_err());
141 }
142
143 #[test]
144 fn remote_hosts_marks_local() {
145 let t = load(SAMPLE).unwrap();
146 let hosts = t.remote_hosts();
147 assert!(hosts["fw13"].is_local());
148 assert!(!hosts["mbp"].is_local());
149 }
150 }
151