Skip to main content

max / makenotwork

32.8 KB · 838 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 /// Checkout path on each build host (apps are cloned on every host).
58 repo: String,
59 }
60
61 /// What a repo produces, which decides how Bento releases it.
62 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
63 #[serde(rename_all = "snake_case")]
64 pub enum Kind {
65 /// A distributable application: built per target, artifacts collected.
66 #[default]
67 App,
68 /// A crate published to a registry. Not platform-specific — it builds and
69 /// uploads from one host — so it runs a single `publish.rhai` rather than a
70 /// recipe per platform, and its `targets` name the host that does it.
71 Library,
72 /// A long-running binary Bento ships onto the hosts that run it, rather than
73 /// distributing to users. Built per target like an app, but it ends at
74 /// `deploy` instead of `collect`: no bundle, no signature for a human to
75 /// check, no registry. Where each target lands is `[[deploy]]` in the app's own manifest.
76 ///
77 /// A separate kind rather than an app with an extra step, because the two
78 /// differ in what `verify` means. An app's verify is Gatekeeper (is this
79 /// bundle notarized); a service's is running the toolchain against the
80 /// binary it just built — the same thing a library's crate preflight does.
81 Service,
82 }
83
84 /// Where one target of a [`Kind::Service`] gets installed, from a `[[deploy]]`
85 /// table in the app's own `bento.toml`.
86 ///
87 /// It lives in the repo's manifest rather than the daemon's file for the same
88 /// reason `targets` does: which host runs the service is a fact about the
89 /// service, and a change to it should arrive in the same commit as the change
90 /// that needed it, not drift in a config on one machine.
91 ///
92 /// The binding is target -> host, so the recipe never names a machine: it calls
93 /// `deploy()` and the target it is already running for decides where that goes.
94 /// That is what keeps the aarch64 build from ever being installable on the
95 /// x86_64 box, without the recipe having to be careful.
96 #[derive(Debug, Clone, Deserialize)]
97 pub struct DeployTarget {
98 /// Which built target this entry installs. Must be one the app ships.
99 pub target: Target,
100 /// SSH destination of the host that runs the service: a tailnet alias
101 /// (`astra`) or `user@host`. Reached over the same `SshExec` every other
102 /// remote host uses, and `local` runs on the daemon's own box.
103 pub host: String,
104 /// Optional SSH port, when the service host is not on 22.
105 #[serde(default)]
106 pub port: Option<u16>,
107 /// Absolute path of the unit's `ExecStart` binary on that host, e.g.
108 /// `/usr/local/bin/pom`. The privileged installer is what actually writes
109 /// here; this is the argument it is given.
110 pub install_path: String,
111 /// The systemd unit to restart once the binary is in place, e.g.
112 /// `pom.service`.
113 pub service: String,
114 /// URL the recipe can assert answers 200 after the restart. Read by the
115 /// recipe via `health_url()`; Bento does not poll it on the recipe's behalf,
116 /// because what counts as healthy is the service's business.
117 #[serde(default)]
118 pub health_url: Option<String>,
119 }
120
121 /// `bento.toml` at the root of an app's repo: everything about how that app
122 /// builds. Versioned with the code, so a change to targets or features arrives
123 /// in the same commit as the change that needed it.
124 #[derive(Debug, Clone, Deserialize)]
125 struct AppManifest {
126 #[serde(default)]
127 kind: Kind,
128 #[serde(default = "default_branch")]
129 branch: String,
130 #[serde(default = "default_recipe_dir")]
131 recipe_dir: String,
132 #[serde(default)]
133 version_path: Option<String>,
134 #[serde(default)]
135 features: Vec<String>,
136 #[serde(default)]
137 require_all_targets: bool,
138 targets: Vec<Target>,
139 /// Service install destinations, one per target. Empty for any other kind.
140 #[serde(default, rename = "deploy")]
141 deploy: Vec<DeployTarget>,
142 #[serde(default = "default_tag_format")]
143 tag_format: String,
144 }
145
146 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
147 #[serde(rename_all = "snake_case")]
148 pub enum HostTransport {
149 /// SSH push (or local, when `ssh = "local"`). Every Linux/Windows step and
150 /// every non-signing macOS step.
151 #[default]
152 Ssh,
153 /// In-session `ops-agent` over HTTP (`agent_url`). Required for macOS
154 /// sign/notarize/staple — codesign can only use the Developer ID key from
155 /// the Aqua GUI session (design §7 "THE WALL"), which a plain SSH session
156 /// cannot reach.
157 Agent,
158 }
159
160 #[derive(Debug, Clone, Deserialize)]
161 pub struct Host {
162 pub name: String,
163 /// Tailnet alias or `user@host`; `local` runs commands directly.
164 pub ssh: String,
165 /// Targets this host can build natively. A target only dispatches to a host
166 /// that lists it — this is how no-cross-compile is enforced structurally.
167 #[serde(default)]
168 pub targets: Vec<Target>,
169 /// How `bentod` reaches this host to run steps (see [`HostTransport`]).
170 #[serde(default)]
171 pub transport: HostTransport,
172 /// Base URL of this host's `ops-agent`, e.g. `http://mbp:8765`. Required
173 /// when `transport = "agent"`; ignored otherwise.
174 #[serde(default)]
175 pub agent_url: Option<String>,
176 /// Actuate capabilities this host's executor is granted (`build`, `sign`,
177 /// …). Defaults to a plain build host so an existing `bento.toml` keeps
178 /// loading; the mac host widens this to sign/notarize/staple.
179 #[serde(default = "default_actuate")]
180 pub actuate: Vec<String>,
181 /// Observe capabilities (read-only host inspection).
182 #[serde(default = "default_observe")]
183 pub observe: Vec<String>,
184 /// Absolute path ON THIS HOST that artifact pulls are confined to — the
185 /// declared root the sync transport (`build_sync`) rsyncs collected files
186 /// out of. Never tilde-expanded (it names a location on the remote host, not
187 /// the daemon): write it out, e.g. `/home/max/Code/Apps`,
188 /// `/Users/max/Code/Apps`, `C:/Users/me/Code/Apps`.
189 ///
190 /// Unset ⇒ this host's sync transport pulls NOTHING (`ops_exec` is
191 /// fail-closed). Without it, `collect(host, '/Users/max/.tauri/passwords.env')`
192 /// would rsync the notary credential into `dist_root` — "THE WALL" held on
193 /// the agent plane but not the sync plane the agent hosts are collected over.
194 #[serde(default)]
195 pub pull_root: Option<PathBuf>,
196 }
197
198 /// Every build host can, by definition, build and package. Keeping these the
199 /// defaults lets an existing `bento.toml` (which only declared name/ssh/targets)
200 /// load unchanged through the executor refactor.
201 fn default_actuate() -> Vec<String> {
202 vec!["build".into(), "package".into()]
203 }
204 /// A build host's read-only surface: its build logs, and the artifacts it
205 /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`).
206 /// Retrieving the artifact is the last step of every release, so defaulting it on
207 /// keeps an existing `bento.toml` from dead-ending there after a full build.
208 fn default_observe() -> Vec<String> {
209 vec!["build-log".into(), "artifact".into()]
210 }
211
212 /// An app as the runner sees it: its pointer merged with its in-repo manifest.
213 #[derive(Debug, Clone)]
214 pub struct AppConfig {
215 /// Checkout path on each build host (apps are cloned on every host).
216 pub repo: String,
217 /// What this repo produces (see [`Kind`]).
218 pub kind: Kind,
219 pub branch: String,
220 /// Recipe directory relative to the repo (`dist/recipes`).
221 pub recipe_dir: String,
222 /// Where to read the release version, relative to the repo. Unset (the
223 /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
224 /// Tauri-app path. A non-Tauri workspace app (audiofiles) sets this to the
225 /// member crate that carries the version, e.g.
226 /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
227 /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
228 /// `Cargo.toml`.
229 pub version_path: Option<String>,
230 /// Cargo features every release build of this app enables, exposed to
231 /// recipes as `feature_flags()`.
232 ///
233 /// Declared here rather than written into each recipe so one app cannot
234 /// ship a feature on one target and miss it on another: a per-recipe flag
235 /// has to be repeated once per platform, and the one that gets missed
236 /// fails silently, producing a binary that builds and runs with a feature
237 /// quietly absent.
238 pub features: Vec<String>,
239 /// Opt-in: `publish` refuses unless every declared target of this
240 /// `(app, version)` has a successful latest run — the all-targets-green gate
241 /// that stops a partial release (macOS published while windows is red or
242 /// still building). Off by default so independent per-target publishing
243 /// keeps working; a release that must ship as a set turns it on.
244 pub require_all_targets: bool,
245 /// Targets this app ships.
246 pub targets: Vec<Target>,
247 /// Service install destinations, one per target (see [`DeployTarget`]).
248 /// Empty unless `kind = "service"`.
249 pub deploy: Vec<DeployTarget>,
250 /// How this app's release tag is spelled, with `{version}` substituted.
251 /// Defaults to `v{version}`, which is right for a repo holding one product.
252 ///
253 /// It exists for the repos that hold several. MNW is one `.git` over the
254 /// server, sando, multithreaded, pom and more, each versioned separately, so
255 /// a bare `v0.4.1` there names no product in particular — and pom and
256 /// multithreaded are both at 0.4.1, so it is ambiguous the day it is
257 /// created rather than eventually. Those apps set `pom-v{version}` and the
258 /// tag says which release it is.
259 pub tag_format: String,
260 }
261
262 fn default_tag_format() -> String {
263 "v{version}".into()
264 }
265
266 impl AppConfig {
267 /// The install destination for `target`, if this app declares one.
268 pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> {
269 self.deploy.iter().find(|d| d.target == target)
270 }
271
272 /// This app's release tag for `version`.
273 pub fn tag_for(&self, version: &crate::domain::Version) -> String {
274 self.tag_format.replace("{version}", &version.to_string())
275 }
276 }
277
278 fn default_branch() -> String {
279 "main".into()
280 }
281 fn default_recipe_dir() -> String {
282 "dist/recipes".into()
283 }
284
285 /// Where an app's manifest lives inside its repo.
286 pub const APP_MANIFEST: &str = "bento.toml";
287
288 /// Check one app's `[[deploy]]` tables at load time.
289 ///
290 /// Every one of these fails a release later and more expensively if it is only
291 /// caught when the recipe runs — a bad `install_path` is a root `install` to the
292 /// wrong place, and a target with no entry is a build that silently deploys
293 /// nothing. The privileged installer on each host re-checks its own arguments
294 /// (it is the thing holding the sudo grant, so it cannot trust a caller); this
295 /// is the earlier, friendlier half of the same rule.
296 fn validate_deploy(name: &str, app: &AppConfig) -> Result<()> {
297 if app.deploy.is_empty() {
298 anyhow::ensure!(
299 app.kind != Kind::Service,
300 "app `{name}` is a service but declares no [[deploy]] entries — \
301 a service that lands nowhere has no release"
302 );
303 return Ok(());
304 }
305 anyhow::ensure!(
306 app.kind == Kind::Service,
307 "app `{name}` declares [[deploy]] entries but is not `kind = \"service\"`; \
308 only a service is installed onto a host"
309 );
310 let mut seen = Vec::new();
311 for d in &app.deploy {
312 anyhow::ensure!(
313 app.targets.contains(&d.target),
314 "app `{name}`: [[deploy]] names target {} which the app does not ship",
315 d.target
316 );
317 anyhow::ensure!(
318 !seen.contains(&d.target),
319 "app `{name}`: two [[deploy]] entries for target {} — \
320 one target installs to one place",
321 d.target
322 );
323 seen.push(d.target);
324 anyhow::ensure!(
325 !d.host.trim().is_empty(),
326 "app `{name}`: [[deploy]] for {} has an empty host",
327 d.target
328 );
329 // Absolute, and no `..` to walk out of wherever it appears to point.
330 anyhow::ensure!(
331 d.install_path.starts_with('/')
332 && !Path::new(&d.install_path)
333 .components()
334 .any(|c| c == std::path::Component::ParentDir),
335 "app `{name}`: install_path `{}` must be an absolute path with no `..`",
336 d.install_path
337 );
338 // A bare unit name. Anything with a slash or whitespace is either a
339 // path or an attempt to smuggle a second argument into `systemctl`.
340 anyhow::ensure!(
341 d.service.ends_with(".service")
342 && !d.service.contains('/')
343 && !d.service.chars().any(char::is_whitespace),
344 "app `{name}`: service `{}` must be a bare unit name ending in `.service`",
345 d.service
346 );
347 }
348 // A service that ships a target it cannot install is a build with no ending.
349 for t in &app.targets {
350 anyhow::ensure!(
351 seen.contains(t),
352 "app `{name}`: target {t} has no [[deploy]] entry — \
353 every target a service ships must say where it lands"
354 );
355 }
356 Ok(())
357 }
358
359 impl Topology {
360 pub fn load(path: &Path) -> Result<Self> {
361 let raw = std::fs::read_to_string(path)
362 .with_context(|| format!("reading topology at {}", path.display()))?;
363 let raw: RawTopology = toml::from_str(&raw)
364 .with_context(|| format!("parsing topology at {}", path.display()))?;
365 Self::resolve(raw)
366 }
367
368 /// Merge each app pointer with the manifest in its repo.
369 ///
370 /// The manifest is read from the checkout on the daemon host, the same way
371 /// the version is (`engine::version_from_repo`). An app whose repo is not
372 /// checked out here cannot be released from here either, so failing at load
373 /// with the path in hand beats failing mid-run.
374 fn resolve(raw: RawTopology) -> Result<Self> {
375 let mut app = HashMap::with_capacity(raw.app.len());
376 for (name, ptr) in raw.app {
377 let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST);
378 let text = std::fs::read_to_string(&manifest_path).with_context(|| {
379 format!(
380 "app `{name}`: reading {}. Per-app build config lives in the app's repo; \
381 create it there with `targets = [...]`",
382 manifest_path.display()
383 )
384 })?;
385 let m: AppManifest = toml::from_str(&text)
386 .with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?;
387 app.insert(
388 name,
389 AppConfig {
390 repo: ptr.repo,
391 kind: m.kind,
392 branch: m.branch,
393 recipe_dir: m.recipe_dir,
394 version_path: m.version_path,
395 features: m.features,
396 require_all_targets: m.require_all_targets,
397 targets: m.targets,
398 deploy: m.deploy,
399 tag_format: m.tag_format,
400 },
401 );
402 }
403 let topo = Topology {
404 hosts: raw.hosts,
405 app,
406 };
407 topo.validate()?;
408 Ok(topo)
409 }
410
411 /// Parse a daemon-side topology from a string, resolving app manifests from
412 /// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml`
413 /// into a temp repo so they exercise the same path as production rather
414 /// than a parallel one.
415 #[cfg(test)]
416 pub fn from_str_for_tests(s: &str) -> Result<Self> {
417 Self::resolve(toml::from_str(s)?)
418 }
419
420 fn validate(&self) -> Result<()> {
421 anyhow::ensure!(
422 !self.hosts.is_empty(),
423 "topology must declare at least one host"
424 );
425 anyhow::ensure!(
426 !self.app.is_empty(),
427 "topology must declare at least one app"
428 );
429 // Every target an app ships must have a host that can build it.
430 for (name, app) in &self.app {
431 for t in &app.targets {
432 if self.host_for(*t).is_none() {
433 anyhow::bail!("app `{name}` ships target {t} but no host declares it");
434 }
435 }
436 // The tag reaches a remote login shell inside `git checkout "..."`,
437 // and it must actually vary per release.
438 anyhow::ensure!(
439 app.tag_format.contains("{version}"),
440 "app `{name}`: tag_format `{}` must contain `{{version}}`, or every \
441 release would resolve to the same tag",
442 app.tag_format
443 );
444 anyhow::ensure!(
445 !app.tag_format
446 .chars()
447 .any(|c| matches!(c, '"' | '`' | '$' | ';' | '&' | '|' | '\\' | ' ')),
448 "app `{name}`: tag_format `{}` contains shell metacharacters",
449 app.tag_format
450 );
451 validate_deploy(name, app)?;
452 }
453 // Capability/transport coherence: a host that declares buildable targets
454 // must be granted `build` (otherwise its own recipes would be denied at
455 // dispatch), and an agent-transport host must say where its agent is.
456 for h in &self.hosts {
457 if !h.targets.is_empty() && !h.actuate.iter().any(|a| a == "build") {
458 anyhow::bail!(
459 "host `{}` declares buildable targets but is not granted the `build` capability",
460 h.name
461 );
462 }
463 if h.transport == HostTransport::Agent && h.agent_url.is_none() {
464 anyhow::bail!(
465 "host `{}` uses transport = \"agent\" but sets no agent_url",
466 h.name
467 );
468 }
469 }
470 Ok(())
471 }
472
473 /// The first host that declares `target` as buildable.
474 pub fn host_for(&self, target: Target) -> Option<&Host> {
475 self.hosts.iter().find(|h| h.targets.contains(&target))
476 }
477
478 pub fn app(&self, app: &AppId) -> Option<&AppConfig> {
479 self.app.get(app.as_str())
480 }
481 }
482
483 #[cfg(test)]
484 mod tests {
485 use super::*;
486
487 const HOSTS: &str = r#"
488 [[host]]
489 name = "fw13"
490 ssh = "local"
491 targets = ["linux/x86_64"]
492
493 [[host]]
494 name = "mbp"
495 ssh = "mbp"
496 targets = ["macos/aarch64", "ios/universal"]
497 "#;
498
499 const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"]
500 "#;
501
502 /// Load a daemon-side topology whose single app's manifest is written into
503 /// a temp repo, so tests go through the same two-file path as production.
504 /// The tempdir is returned so it outlives the borrow.
505 fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> {
506 let dir = tempfile::tempdir().unwrap();
507 let repo = dir.path().join("goingson");
508 std::fs::create_dir_all(&repo).unwrap();
509 std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
510 let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display());
511 Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
512 }
513
514 fn load(hosts: &str) -> Result<Topology> {
515 load_with(hosts, MANIFEST).map(|(t, dir)| {
516 std::mem::forget(dir);
517 t
518 })
519 }
520
521 #[test]
522 fn parses_and_resolves_hosts() {
523 let t = load(HOSTS).unwrap();
524 assert_eq!(t.hosts.len(), 2);
525 let target: Target = "macos/aarch64".parse().unwrap();
526 assert_eq!(t.host_for(target).unwrap().name, "mbp");
527 assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
528 }
529
530 /// `features` is optional: every topology written before it existed must
531 /// keep loading, and an app that declares none gets an empty list rather
532 /// than a parse error.
533 #[test]
534 fn features_defaults_empty_and_parses_when_present() {
535 let t = load(HOSTS).unwrap();
536 assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
537
538 let (t, _dir) = load_with(
539 HOSTS,
540 "targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n",
541 )
542 .unwrap();
543 assert_eq!(
544 t.app(&"goingson".into()).unwrap().features,
545 vec!["supernote".to_string(), "extra".to_string()]
546 );
547 }
548
549 #[test]
550 fn rejects_target_without_a_host() {
551 let only_linux = r#"
552 [[host]]
553 name = "fw13"
554 ssh = "local"
555 targets = ["linux/x86_64"]
556 "#;
557 assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err());
558 }
559
560 #[test]
561 fn capability_defaults_make_a_build_host() {
562 let t = load(HOSTS).unwrap();
563 let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap();
564 assert_eq!(fw13.transport, HostTransport::Ssh);
565 assert!(fw13.actuate.contains(&"build".to_string()));
566 assert!(fw13.actuate.contains(&"package".to_string()));
567 }
568
569 #[test]
570 fn agent_transport_parses_with_url_and_caps() {
571 let (t, _dir) = load_with(
572 r#"
573 [[host]]
574 name = "mbp"
575 ssh = "mbp"
576 targets = ["macos/aarch64"]
577 transport = "agent"
578 agent_url = "http://mbp:8765"
579 actuate = ["build", "sign", "notarize", "staple"]
580 "#,
581 "targets = [\"macos/aarch64\"]\n",
582 )
583 .unwrap();
584 let mbp = &t.hosts[0];
585 assert_eq!(mbp.transport, HostTransport::Agent);
586 assert_eq!(mbp.agent_url.as_deref(), Some("http://mbp:8765"));
587 assert!(mbp.actuate.contains(&"sign".to_string()));
588 }
589
590 #[test]
591 fn agent_host_without_url_is_rejected() {
592 let bad = r#"
593 [[host]]
594 name = "mbp"
595 ssh = "mbp"
596 targets = ["macos/aarch64"]
597 transport = "agent"
598
599 "#;
600 assert!(load(bad).is_err());
601 }
602
603 /// A repo holding one product tags `v0.4.1`; a repo holding several has to
604 /// say which product a tag is for. MNW is one `.git` over the server, sando,
605 /// multithreaded and pom, and pom and multithreaded are BOTH at 0.4.1 — so a
606 /// bare `v0.4.1` there is ambiguous the day it is created, not eventually.
607 #[test]
608 fn tag_format_defaults_to_v_and_can_name_the_product() {
609 let v = |s: &str| crate::domain::Version::parse(s).unwrap();
610
611 let t = load(HOSTS).unwrap();
612 let app = t.app(&"goingson".into()).unwrap();
613 assert_eq!(app.tag_format, "v{version}");
614 assert_eq!(app.tag_for(&v("0.4.1")), "v0.4.1");
615
616 let (t, _dir) = load_with(
617 HOSTS,
618 "targets = [\"linux/x86_64\"]\ntag_format = \"pom-v{version}\"\n",
619 )
620 .unwrap();
621 assert_eq!(
622 t.app(&"goingson".into()).unwrap().tag_for(&v("0.4.1")),
623 "pom-v0.4.1"
624 );
625 }
626
627 /// The tag is interpolated into `git checkout "..."` on a remote host, and
628 /// it has to actually vary per release. A format with no `{version}` would
629 /// pin every release to one tag, which is worse than failing.
630 #[test]
631 fn tag_format_must_vary_and_stay_shell_safe() {
632 let bad = |f: &str| {
633 load_with(
634 HOSTS,
635 &format!("targets = [\"linux/x86_64\"]\ntag_format = \"{f}\"\n"),
636 )
637 .is_err()
638 };
639 assert!(bad("release"), "a constant tag pins every release together");
640 assert!(bad("v{version}; rm -rf /"));
641 assert!(bad("v{version}$(id)"));
642 assert!(bad("v{version} extra"));
643 assert!(!bad("pom-v{version}"));
644 assert!(!bad("release/{version}"));
645 }
646
647 /// A service's `[[deploy]]` entries resolve, and the target -> destination
648 /// binding is what the runner reads. The recipe never names a host, so this
649 /// mapping is the only thing deciding which box each binary lands on.
650 #[test]
651 fn service_deploy_entries_resolve_per_target() {
652 let (t, _dir) = load_with(
653 HOSTS,
654 r#"kind = "service"
655 targets = ["linux/x86_64", "macos/aarch64"]
656
657 [[deploy]]
658 target = "linux/x86_64"
659 host = "root@prod"
660 port = 2200
661 install_path = "/usr/local/bin/demo"
662 service = "demo.service"
663 health_url = "http://prod:9100/api/health"
664
665 [[deploy]]
666 target = "macos/aarch64"
667 host = "mbp"
668 install_path = "/usr/local/bin/demo"
669 service = "demo.service"
670 "#,
671 )
672 .unwrap();
673 let app = t.app(&"goingson".into()).unwrap();
674 assert_eq!(app.kind, Kind::Service);
675 let x86 = app.deploy_for("linux/x86_64".parse().unwrap()).unwrap();
676 assert_eq!(x86.host, "root@prod");
677 assert_eq!(x86.port, Some(2200));
678 assert_eq!(
679 x86.health_url.as_deref(),
680 Some("http://prod:9100/api/health")
681 );
682 let mac = app.deploy_for("macos/aarch64".parse().unwrap()).unwrap();
683 assert_eq!(mac.host, "mbp");
684 assert_eq!(mac.port, None);
685 }
686
687 /// The kind and the `[[deploy]]` table have to agree in both directions. An
688 /// app with deploy entries would build them and never install them; a
689 /// service without any would have no terminal step at all.
690 #[test]
691 fn kind_and_deploy_entries_must_agree() {
692 let deploy = "\n[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
693 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
694 // Deploy entries on a plain app.
695 assert!(
696 load_with(HOSTS, &format!("targets = [\"linux/x86_64\"]\n{deploy}")).is_err(),
697 "only a service installs onto a host"
698 );
699 // A service with none.
700 assert!(
701 load_with(HOSTS, "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n").is_err(),
702 "a service that lands nowhere has no release"
703 );
704 }
705
706 /// Every target a service ships must say where it lands. Without this a
707 /// half-configured service builds both arches and silently installs one.
708 #[test]
709 fn service_target_without_a_deploy_entry_is_rejected() {
710 let err = load_with(
711 HOSTS,
712 "kind = \"service\"\ntargets = [\"linux/x86_64\", \"macos/aarch64\"]\n\
713 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
714 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
715 )
716 .unwrap_err();
717 assert!(
718 format!("{err:#}").contains("macos/aarch64"),
719 "must name the target with no destination: {err:#}"
720 );
721 }
722
723 /// The install path and unit name reach a root script on a production host.
724 /// It re-checks them itself (it holds the sudo grant, so it cannot trust a
725 /// caller), but a config that could only ever be refused should fail here,
726 /// where the fix is one file away rather than mid-deploy.
727 #[test]
728 fn deploy_rejects_paths_and_units_the_installer_would_refuse() {
729 let entry = |install: &str, service: &str| {
730 format!(
731 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
732 [[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
733 install_path = \"{install}\"\nservice = \"{service}\"\n"
734 )
735 };
736 // Relative, and absolute-with-`..` — both are a root `install` somewhere
737 // other than where the config appears to say.
738 assert!(load_with(HOSTS, &entry("usr/local/bin/d", "d.service")).is_err());
739 assert!(load_with(HOSTS, &entry("/opt/../etc/systemd/system/x", "d.service")).is_err());
740 // A unit name that is really a path, or that smuggles a second argument
741 // past `systemctl restart`.
742 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "/etc/x.service")).is_err());
743 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service x")).is_err());
744 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d")).is_err());
745 // The shape that should pass.
746 assert!(load_with(HOSTS, &entry("/usr/local/bin/d", "d.service")).is_ok());
747 }
748
749 /// Two entries for one target: the second silently wins in a `find`, so the
750 /// binary lands somewhere the config's first answer says it does not.
751 #[test]
752 fn duplicate_deploy_entries_for_one_target_are_rejected() {
753 let e = "[[deploy]]\ntarget = \"linux/x86_64\"\nhost = \"h\"\n\
754 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n";
755 assert!(
756 load_with(
757 HOSTS,
758 &format!("kind = \"service\"\ntargets = [\"linux/x86_64\"]\n{e}{e}")
759 )
760 .is_err()
761 );
762 }
763
764 /// A deploy entry for a target the app does not build. It would never run,
765 /// and it reads as coverage that does not exist.
766 #[test]
767 fn deploy_entry_for_an_unshipped_target_is_rejected() {
768 assert!(
769 load_with(
770 HOSTS,
771 "kind = \"service\"\ntargets = [\"linux/x86_64\"]\n\
772 [[deploy]]\ntarget = \"macos/aarch64\"\nhost = \"h\"\n\
773 install_path = \"/usr/local/bin/d\"\nservice = \"d.service\"\n",
774 )
775 .is_err()
776 );
777 }
778
779 #[test]
780 fn build_host_without_build_capability_is_rejected() {
781 let bad = r#"
782 [[host]]
783 name = "fw13"
784 ssh = "local"
785 targets = ["linux/x86_64"]
786 actuate = ["package"]
787
788 "#;
789 assert!(load(bad).is_err());
790 }
791 }
792
793 #[cfg(test)]
794 mod live_config_smoke {
795 use super::*;
796
797 /// The real `~/.config/bento/bento.toml` plus the real in-repo manifests
798 /// must load. This is the config an actual release reads; a schema change
799 /// that parses in fixtures but not on this machine is the failure mode
800 /// worth catching. Skips when the file is absent (CI, another host).
801 #[test]
802 fn live_topology_loads_if_present() {
803 let Some(home) = std::env::var_os("HOME") else {
804 return;
805 };
806 let path = Path::new(&home).join(".config/bento/bento.toml");
807 if !path.exists() {
808 return;
809 }
810 let topo = Topology::load(&path).expect("live bento.toml must load");
811 let bb = topo
812 .app(&"balanced_breakfast".into())
813 .expect("bb configured");
814 assert!(
815 bb.features.contains(&"supernote".to_string()),
816 "bb must ship the supernote feature, got {:?}",
817 bb.features
818 );
819 let af = topo
820 .app(&"audiofiles".into())
821 .expect("audiofiles configured");
822 assert_eq!(
823 af.version_path.as_deref(),
824 Some("crates/audiofiles-app/Cargo.toml")
825 );
826
827 // The library crates resolve as libraries, so they take publish.rhai
828 // rather than a per-platform recipe.
829 for name in ["makeover", "pter", "everycycle", "supernote-push"] {
830 let c = topo
831 .app(&name.into())
832 .unwrap_or_else(|| panic!("{name} configured"));
833 assert_eq!(c.kind, Kind::Library, "{name} must be a library");
834 }
835 assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App);
836 }
837 }
838