| 1 |
|
- |
//! Build matrix + host topology (`bento.toml`).
|
|
1 |
+ |
//! Build matrix + host topology, across two files.
|
| 2 |
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.
|
|
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.
|
| 7 |
18 |
|
|
| 8 |
19 |
|
use crate::domain::{AppId, Target};
|
| 9 |
20 |
|
use anyhow::{Context, Result};
|
| 11 |
22 |
|
use std::collections::HashMap;
|
| 12 |
23 |
|
use std::path::Path;
|
| 13 |
24 |
|
|
| 14 |
|
- |
#[derive(Debug, Clone, Deserialize)]
|
|
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)]
|
| 15 |
34 |
|
pub struct Topology {
|
| 16 |
|
- |
#[serde(default, rename = "host")]
|
| 17 |
35 |
|
pub hosts: Vec<Host>,
|
| 18 |
|
- |
/// `[app.<name>]` table.
|
| 19 |
|
- |
#[serde(default)]
|
|
36 |
+ |
/// Apps by name, each merged from its pointer and its in-repo manifest.
|
| 20 |
37 |
|
pub app: HashMap<String, AppConfig>,
|
| 21 |
38 |
|
}
|
| 22 |
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 |
+ |
/// Checkout path on each build host (apps are cloned on every host).
|
|
58 |
+ |
repo: String,
|
|
59 |
+ |
}
|
|
60 |
+ |
|
|
61 |
+ |
/// `bento.toml` at the root of an app's repo: everything about how that app
|
|
62 |
+ |
/// builds. Versioned with the code, so a change to targets or features arrives
|
|
63 |
+ |
/// in the same commit as the change that needed it.
|
|
64 |
+ |
#[derive(Debug, Clone, Deserialize)]
|
|
65 |
+ |
struct AppManifest {
|
|
66 |
+ |
#[serde(default = "default_branch")]
|
|
67 |
+ |
branch: String,
|
|
68 |
+ |
#[serde(default = "default_recipe_dir")]
|
|
69 |
+ |
recipe_dir: String,
|
|
70 |
+ |
#[serde(default)]
|
|
71 |
+ |
version_path: Option<String>,
|
|
72 |
+ |
#[serde(default)]
|
|
73 |
+ |
features: Vec<String>,
|
|
74 |
+ |
targets: Vec<Target>,
|
|
75 |
+ |
}
|
|
76 |
+ |
|
| 23 |
77 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
| 24 |
78 |
|
#[serde(rename_all = "snake_case")]
|
| 25 |
79 |
|
pub enum HostTransport {
|
| 74 |
128 |
|
vec!["build-log".into(), "artifact".into()]
|
| 75 |
129 |
|
}
|
| 76 |
130 |
|
|
| 77 |
|
- |
#[derive(Debug, Clone, Deserialize)]
|
|
131 |
+ |
/// An app as the runner sees it: its pointer merged with its in-repo manifest.
|
|
132 |
+ |
#[derive(Debug, Clone)]
|
| 78 |
133 |
|
pub struct AppConfig {
|
| 79 |
134 |
|
/// Checkout path on each build host (apps are cloned on every host).
|
| 80 |
135 |
|
pub repo: String,
|
| 81 |
|
- |
#[serde(default = "default_branch")]
|
| 82 |
136 |
|
pub branch: String,
|
| 83 |
137 |
|
/// Recipe directory relative to the repo (`dist/recipes`).
|
| 84 |
|
- |
#[serde(default = "default_recipe_dir")]
|
| 85 |
138 |
|
pub recipe_dir: String,
|
| 86 |
139 |
|
/// Where to read the release version, relative to the repo. Unset (the
|
| 87 |
140 |
|
/// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
|
| 90 |
143 |
|
/// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
|
| 91 |
144 |
|
/// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
|
| 92 |
145 |
|
/// `Cargo.toml`.
|
| 93 |
|
- |
#[serde(default)]
|
| 94 |
146 |
|
pub version_path: Option<String>,
|
| 95 |
147 |
|
/// Cargo features every release build of this app enables, exposed to
|
| 96 |
148 |
|
/// recipes as `feature_flags()`.
|
| 100 |
152 |
|
/// has to be repeated once per platform, and the one that gets missed
|
| 101 |
153 |
|
/// fails silently, producing a binary that builds and runs with a feature
|
| 102 |
154 |
|
/// quietly absent.
|
| 103 |
|
- |
#[serde(default)]
|
| 104 |
155 |
|
pub features: Vec<String>,
|
| 105 |
156 |
|
/// Targets this app ships.
|
| 106 |
157 |
|
pub targets: Vec<Target>,
|
| 113 |
164 |
|
"dist/recipes".into()
|
| 114 |
165 |
|
}
|
| 115 |
166 |
|
|
|
167 |
+ |
/// Where an app's manifest lives inside its repo.
|
|
168 |
+ |
pub const APP_MANIFEST: &str = "bento.toml";
|
|
169 |
+ |
|
| 116 |
170 |
|
impl Topology {
|
| 117 |
171 |
|
pub fn load(path: &Path) -> Result<Self> {
|
| 118 |
172 |
|
let raw = std::fs::read_to_string(path)
|
| 119 |
173 |
|
.with_context(|| format!("reading topology at {}", path.display()))?;
|
| 120 |
|
- |
let topo: Topology = toml::from_str(&raw)?;
|
|
174 |
+ |
let raw: RawTopology = toml::from_str(&raw)
|
|
175 |
+ |
.with_context(|| format!("parsing topology at {}", path.display()))?;
|
|
176 |
+ |
Self::resolve(raw)
|
|
177 |
+ |
}
|
|
178 |
+ |
|
|
179 |
+ |
/// Merge each app pointer with the manifest in its repo.
|
|
180 |
+ |
///
|
|
181 |
+ |
/// The manifest is read from the checkout on the daemon host, the same way
|
|
182 |
+ |
/// the version is (`engine::version_from_repo`). An app whose repo is not
|
|
183 |
+ |
/// checked out here cannot be released from here either, so failing at load
|
|
184 |
+ |
/// with the path in hand beats failing mid-run.
|
|
185 |
+ |
fn resolve(raw: RawTopology) -> Result<Self> {
|
|
186 |
+ |
let mut app = HashMap::with_capacity(raw.app.len());
|
|
187 |
+ |
for (name, ptr) in raw.app {
|
|
188 |
+ |
let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST);
|
|
189 |
+ |
let text = std::fs::read_to_string(&manifest_path).with_context(|| {
|
|
190 |
+ |
format!(
|
|
191 |
+ |
"app `{name}`: reading {}. Per-app build config lives in the app's repo; \
|
|
192 |
+ |
create it there with `targets = [...]`",
|
|
193 |
+ |
manifest_path.display()
|
|
194 |
+ |
)
|
|
195 |
+ |
})?;
|
|
196 |
+ |
let m: AppManifest = toml::from_str(&text)
|
|
197 |
+ |
.with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?;
|
|
198 |
+ |
app.insert(
|
|
199 |
+ |
name,
|
|
200 |
+ |
AppConfig {
|
|
201 |
+ |
repo: ptr.repo,
|
|
202 |
+ |
branch: m.branch,
|
|
203 |
+ |
recipe_dir: m.recipe_dir,
|
|
204 |
+ |
version_path: m.version_path,
|
|
205 |
+ |
features: m.features,
|
|
206 |
+ |
targets: m.targets,
|
|
207 |
+ |
},
|
|
208 |
+ |
);
|
|
209 |
+ |
}
|
|
210 |
+ |
let topo = Topology { hosts: raw.hosts, app };
|
| 121 |
211 |
|
topo.validate()?;
|
| 122 |
212 |
|
Ok(topo)
|
| 123 |
213 |
|
}
|
| 124 |
214 |
|
|
|
215 |
+ |
/// Parse a daemon-side topology from a string, resolving app manifests from
|
|
216 |
+ |
/// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml`
|
|
217 |
+ |
/// into a temp repo so they exercise the same path as production rather
|
|
218 |
+ |
/// than a parallel one.
|
|
219 |
+ |
#[cfg(test)]
|
|
220 |
+ |
pub fn from_str_for_tests(s: &str) -> Result<Self> {
|
|
221 |
+ |
Self::resolve(toml::from_str(s)?)
|
|
222 |
+ |
}
|
|
223 |
+ |
|
| 125 |
224 |
|
fn validate(&self) -> Result<()> {
|
| 126 |
225 |
|
anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host");
|
| 127 |
226 |
|
anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app");
|
| 164 |
263 |
|
mod tests {
|
| 165 |
264 |
|
use super::*;
|
| 166 |
265 |
|
|
| 167 |
|
- |
const SAMPLE: &str = r#"
|
|
266 |
+ |
const HOSTS: &str = r#"
|
| 168 |
267 |
|
[[host]]
|
| 169 |
268 |
|
name = "fw13"
|
| 170 |
269 |
|
ssh = "local"
|
| 174 |
273 |
|
name = "mbp"
|
| 175 |
274 |
|
ssh = "mbp"
|
| 176 |
275 |
|
targets = ["macos/aarch64", "ios/universal"]
|
|
276 |
+ |
"#;
|
| 177 |
277 |
|
|
| 178 |
|
- |
[app.goingson]
|
| 179 |
|
- |
repo = "~/Code/Apps/goingson"
|
| 180 |
|
- |
targets = ["macos/aarch64", "linux/x86_64"]
|
|
278 |
+ |
const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"]
|
| 181 |
279 |
|
"#;
|
| 182 |
280 |
|
|
| 183 |
|
- |
fn load(s: &str) -> Result<Topology> {
|
| 184 |
|
- |
let t: Topology = toml::from_str(s)?;
|
| 185 |
|
- |
t.validate()?;
|
| 186 |
|
- |
Ok(t)
|
|
281 |
+ |
/// Load a daemon-side topology whose single app's manifest is written into
|
|
282 |
+ |
/// a temp repo, so tests go through the same two-file path as production.
|
|
283 |
+ |
/// The tempdir is returned so it outlives the borrow.
|
|
284 |
+ |
fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> {
|
|
285 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
286 |
+ |
let repo = dir.path().join("goingson");
|
|
287 |
+ |
std::fs::create_dir_all(&repo).unwrap();
|
|
288 |
+ |
std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
|
|
289 |
+ |
let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display());
|
|
290 |
+ |
Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
|
|
291 |
+ |
}
|
|
292 |
+ |
|
|
293 |
+ |
fn load(hosts: &str) -> Result<Topology> {
|
|
294 |
+ |
load_with(hosts, MANIFEST).map(|(t, dir)| {
|
|
295 |
+ |
std::mem::forget(dir);
|
|
296 |
+ |
t
|
|
297 |
+ |
})
|
| 187 |
298 |
|
}
|
| 188 |
299 |
|
|
| 189 |
300 |
|
#[test]
|
| 190 |
301 |
|
fn parses_and_resolves_hosts() {
|
| 191 |
|
- |
let t = load(SAMPLE).unwrap();
|
|
302 |
+ |
let t = load(HOSTS).unwrap();
|
| 192 |
303 |
|
assert_eq!(t.hosts.len(), 2);
|
| 193 |
304 |
|
let target: Target = "macos/aarch64".parse().unwrap();
|
| 194 |
305 |
|
assert_eq!(t.host_for(target).unwrap().name, "mbp");
|
| 200 |
311 |
|
/// than a parse error.
|
| 201 |
312 |
|
#[test]
|
| 202 |
313 |
|
fn features_defaults_empty_and_parses_when_present() {
|
| 203 |
|
- |
let t = load(SAMPLE).unwrap();
|
|
314 |
+ |
let t = load(HOSTS).unwrap();
|
| 204 |
315 |
|
assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
|
| 205 |
316 |
|
|
| 206 |
|
- |
let with = SAMPLE.replace(
|
| 207 |
|
- |
"[app.goingson]\nrepo = \"~/Code/Apps/goingson\"",
|
| 208 |
|
- |
"[app.goingson]\nrepo = \"~/Code/Apps/goingson\"\nfeatures = [\"supernote\", \"extra\"]",
|
| 209 |
|
- |
);
|
| 210 |
|
- |
let t = load(&with).unwrap();
|
|
317 |
+ |
let (t, _dir) = load_with(
|
|
318 |
+ |
HOSTS,
|
|
319 |
+ |
"targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n",
|
|
320 |
+ |
)
|
|
321 |
+ |
.unwrap();
|
| 211 |
322 |
|
assert_eq!(
|
| 212 |
323 |
|
t.app(&"goingson".into()).unwrap().features,
|
| 213 |
324 |
|
vec!["supernote".to_string(), "extra".to_string()]
|
| 216 |
327 |
|
|
| 217 |
328 |
|
#[test]
|
| 218 |
329 |
|
fn rejects_target_without_a_host() {
|
| 219 |
|
- |
let bad = r#"
|
|
330 |
+ |
let only_linux = r#"
|
| 220 |
331 |
|
[[host]]
|
| 221 |
332 |
|
name = "fw13"
|
| 222 |
333 |
|
ssh = "local"
|
| 223 |
334 |
|
targets = ["linux/x86_64"]
|
| 224 |
|
- |
|
| 225 |
|
- |
[app.goingson]
|
| 226 |
|
- |
repo = "x"
|
| 227 |
|
- |
targets = ["windows/x86_64"]
|
| 228 |
335 |
|
"#;
|
| 229 |
|
- |
assert!(load(bad).is_err());
|
|
336 |
+ |
assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err());
|
| 230 |
337 |
|
}
|
| 231 |
338 |
|
|
| 232 |
339 |
|
#[test]
|
| 233 |
340 |
|
fn capability_defaults_make_a_build_host() {
|
| 234 |
|
- |
let t = load(SAMPLE).unwrap();
|
|
341 |
+ |
let t = load(HOSTS).unwrap();
|
| 235 |
342 |
|
let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap();
|
| 236 |
343 |
|
assert_eq!(fw13.transport, HostTransport::Ssh);
|
| 237 |
344 |
|
assert!(fw13.actuate.contains(&"build".to_string()));
|
| 240 |
347 |
|
|
| 241 |
348 |
|
#[test]
|
| 242 |
349 |
|
fn agent_transport_parses_with_url_and_caps() {
|
| 243 |
|
- |
let t = load(
|
|
350 |
+ |
let (t, _dir) = load_with(
|
| 244 |
351 |
|
r#"
|
| 245 |
352 |
|
[[host]]
|
| 246 |
353 |
|
name = "mbp"
|
| 249 |
356 |
|
transport = "agent"
|
| 250 |
357 |
|
agent_url = "http://mbp:8765"
|
| 251 |
358 |
|
actuate = ["build", "sign", "notarize", "staple"]
|
| 252 |
|
- |
|
| 253 |
|
- |
[app.goingson]
|
| 254 |
|
- |
repo = "x"
|
| 255 |
|
- |
targets = ["macos/aarch64"]
|
| 256 |
359 |
|
"#,
|
|
360 |
+ |
"targets = [\"macos/aarch64\"]\n",
|
| 257 |
361 |
|
)
|
| 258 |
362 |
|
.unwrap();
|
| 259 |
363 |
|
let mbp = &t.hosts[0];
|
| 271 |
375 |
|
targets = ["macos/aarch64"]
|
| 272 |
376 |
|
transport = "agent"
|
| 273 |
377 |
|
|
| 274 |
|
- |
[app.goingson]
|
| 275 |
|
- |
repo = "x"
|
| 276 |
|
- |
targets = ["macos/aarch64"]
|
| 277 |
378 |
|
"#;
|
| 278 |
379 |
|
assert!(load(bad).is_err());
|
| 279 |
380 |
|
}
|
| 287 |
388 |
|
targets = ["linux/x86_64"]
|
| 288 |
389 |
|
actuate = ["package"]
|
| 289 |
390 |
|
|
| 290 |
|
- |
[app.goingson]
|
| 291 |
|
- |
repo = "x"
|
| 292 |
|
- |
targets = ["linux/x86_64"]
|
| 293 |
391 |
|
"#;
|
| 294 |
392 |
|
assert!(load(bad).is_err());
|
| 295 |
393 |
|
}
|
| 296 |
394 |
|
}
|
|
395 |
+ |
|
|
396 |
+ |
#[cfg(test)]
|
|
397 |
+ |
mod live_config_smoke {
|
|
398 |
+ |
use super::*;
|
|
399 |
+ |
|
|
400 |
+ |
/// The real `~/.config/bento/bento.toml` plus the real in-repo manifests
|
|
401 |
+ |
/// must load. This is the config an actual release reads; a schema change
|
|
402 |
+ |
/// that parses in fixtures but not on this machine is the failure mode
|
|
403 |
+ |
/// worth catching. Skips when the file is absent (CI, another host).
|
|
404 |
+ |
#[test]
|
|
405 |
+ |
fn live_topology_loads_if_present() {
|
|
406 |
+ |
let Some(home) = std::env::var_os("HOME") else { return };
|
|
407 |
+ |
let path = Path::new(&home).join(".config/bento/bento.toml");
|
|
408 |
+ |
if !path.exists() {
|
|
409 |
+ |
return;
|
|
410 |
+ |
}
|
|
411 |
+ |
let topo = Topology::load(&path).expect("live bento.toml must load");
|
|
412 |
+ |
let bb = topo.app(&"balanced_breakfast".into()).expect("bb configured");
|
|
413 |
+ |
assert!(
|
|
414 |
+ |
bb.features.contains(&"supernote".to_string()),
|
|
415 |
+ |
"bb must ship the supernote feature, got {:?}",
|
|
416 |
+ |
bb.features
|
|
417 |
+ |
);
|
|
418 |
+ |
let af = topo.app(&"audiofiles".into()).expect("audiofiles configured");
|
|
419 |
+ |
assert_eq!(af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml"));
|
|
420 |
+ |
}
|
|
421 |
+ |
}
|