Skip to main content

max / makenotwork

Split engine.rs: three things under one name, only one an engine 3741 lines. RecipeCtx is a 23-field struct with 24 methods across three impl blocks; build_engine is one 527-line function registering thirty Rhai host functions; and about 600 lines are free, ctx-independent helpers with no business beside either. Sixty percent of the tests exercised those helpers, which is the evidence they were a separate unit. Ten modules: ctx and host_fns for the two halves that are the engine, then one per job behind them, collect, publish, deploy, macos, git, version, crates_io. The facade re-exports every path runner.rs, artifact_record.rs and topology.rs name, so all three compile unedited. `published_versions` took `&str`, not `&self`. It moves to crates_io as a free function and stops pretending to be a method. Four prose references to engine paths are hand-edited in the same commit: nothing but reading catches them, since none is a rustdoc link. One finding worth stating rather than papering over: `check_version_consistency` has no caller anywhere in the tree, only its own four tests. Narrowing it as the plan called for turns it into a dead-code error, so it stays re-exported exactly as reachable as it was, with a comment saying so. Whether a version-drift check with no caller should be wired up or deleted is a decision, not a cleanup. Same 48 tests, all 198 passing. Clippy and rustdoc with -D warnings both clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 03:28 UTC
Signed with PGP, not checked
Commit: b3d5df8038ea8f7ecbc39dcea9705e9c47a59e0a
Parent: a1410d7
14 files changed, +3617 insertions, -504 deletions
@@ -35,7 +35,7 @@
35 35 #[serde(default = "default_logs_root")]
36 36 pub logs_root: PathBuf,
37 37 /// Override the per-step wall-clock budget (in seconds) for EVERY step,
38 - /// replacing the per-kind defaults in `engine::step_budget`. Unset (the
38 + /// replacing the per-kind defaults in `engine::ctx::RecipeCtx::step_budget`. Unset (the
39 39 /// default) uses those. Mainly an escape hatch for a constrained host or a
40 40 /// test that needs a short deadline.
41 41 #[serde(default)]
@@ -105,7 +105,7 @@
105 105 /// The value half of `token`, split out so the rules can be tested without
106 106 /// touching the process environment. `set_var` is global and unsynchronized, and
107 107 /// a test that sets one affects every other test in the binary (see
108 - /// `engine::tests::expand_tilde_handles_home`).
108 + /// `engine::git::tests::expand_tilde_handles_home`).
109 109 fn token_from(name: &str, raw: Option<String>) -> anyhow::Result<Option<String>> {
110 110 // Unset and empty are one case on purpose. `EnvironmentFile` turns a line
111 111 // whose value was never filled in into an empty variable rather than no
@@ -441,7 +441,7 @@
441 441 let tmp = tempfile::tempdir().unwrap();
442 442 let mut cfg = Config::for_tests(tmp.path());
443 443 // The one place this binary sets a variable. Distinct from the HOME
444 - // lesson in `engine::tests`: the name is this test's own, so no other
444 + // lesson in `engine::git::tests`: the name is this test's own, so no other
445 445 // test reads it, and it is never removed — the value it holds is the
446 446 // whole point of the assertion at the bottom.
447 447 unsafe { std::env::set_var("BENTO_HANDOFF_WIRE_TEST_TOKEN", "s3cr3t\n") };
@@ -910,7 +910,7 @@
910 910 /// outcome is supervised — a panicked task is logged (and its still-`running`
911 911 /// row is reconciled below), an aborted (superseded) task is expected.
912 912 ///
913 - /// Per-step deadlines (see `engine::step_budget`) are the real bound on a wedged
913 + /// Per-step deadlines (see `engine::ctx::RecipeCtx::step_budget`) are the real bound on a wedged
914 914 /// step now; this overall deadline is only a generous last-resort backstop for a
915 915 /// hang outside a bounded command. When it trips it sets each still-running
916 916 /// target's cooperative cancel FIRST (the blocking recipe bodies observe that at
@@ -1,3785 +1,0 @@
1 - //! Rhai recipe engine + host-function API.
2 - //!
3 - //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step
4 - //! vocabulary. The daemon embeds Rhai and registers the host functions recipes
5 - //! call; the recipe is the orchestration, the host functions are the
6 - //! privileged primitives (run a command, read a secret, collect artifacts,
7 - //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except
8 - //! through these functions — matching the Balanced Breakfast plugin model.
9 - //!
10 - //! Rhai is synchronous; the engine runs each recipe on a blocking thread
11 - //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async
12 - //! work via `Handle::block_on`. That is sound only off a runtime worker thread,
13 - //! which `spawn_blocking` guarantees.
14 -
15 - use crate::config::Config;
16 - use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
17 - use crate::events::{self, Event, EventTx};
18 - use crate::ota::{OtaRegistry, PublishAuthority, Release};
19 - use crate::state::ExecutorMap;
20 - use crate::topology::{DeployTarget, Kind};
21 - use anyhow::{Context as _, Result};
22 - use ops_core::live_log::LiveLog;
23 - use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts};
24 - use rhai::{Engine, EvalAltResult, Map};
25 - use sha2::{Digest, Sha256};
26 - use sqlx::SqlitePool;
27 - use std::collections::HashMap;
28 - use std::path::{Path, PathBuf};
29 - use std::sync::atomic::{AtomicBool, Ordering};
30 - use std::sync::{Arc, Mutex};
31 - use tokio::runtime::Handle;
32 - use tokio::sync::Mutex as AsyncMutex;
33 -
34 - /// The capability label for a command, derived from the open recipe step. A
35 - /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated
36 - /// by the mac host's `sign` grant — so recipes stay unchanged while every command
37 - /// is capability-checked at its transport. `Verify` is read-only (an observe).
38 - /// The capability a step's commands are gated on.
39 - ///
40 - /// `Verify` depends on what is being released, which is the one place this is
41 - /// not a property of the step alone. An app's verify is a Gatekeeper check on a
42 - /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts
43 - /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing
44 - /// holds. A library's verify is a crate preflight: it runs `cargo` on the build
45 - /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux
46 - /// host for a macOS code-signing capability it can never honestly hold, and the
47 - /// only way to satisfy it would be to declare the capability falsely.
48 - fn action_for(step: Step, kind: Kind) -> Action {
49 - match step {
50 - Step::Checkout | Step::Prebuild | Step::Build => Action::Build,
51 - Step::Sign => Action::Sign,
52 - Step::Notarize => Action::Notarize,
53 - Step::Staple => Action::Staple,
54 - Step::Package => Action::Package,
55 - Step::Verify => match kind {
56 - Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())),
57 - // Running the build toolchain to inspect a crate or a service
58 - // binary, which is what `build` means on a host. Neither has a
59 - // bundle for Gatekeeper to have an opinion about.
60 - Kind::Library | Kind::Service => Action::Build,
61 - },
62 - // Publish/Collect/Handoff run on the daemon, not through a host
63 - // executor; this label only applies if a recipe runs a bare `sh` while
64 - // one is open. `handoff` is the daemon's own post-recipe motion and no
65 - // recipe should open it at all — naming it here costs nothing and beats
66 - // a wildcard that would silently absorb the next step somebody adds.
67 - Step::Publish | Step::Collect | Step::Handoff => Action::Package,
68 - // The one step that dispatches to a host OUTSIDE the build topology.
69 - // Every command a recipe runs while `deploy` is open — the install, the
70 - // restart, the health assertion — carries this action, so it reaches the
71 - // service host only through the deploy grant and reaches a build host
72 - // not at all (no build host is granted `deploy`).
73 - Step::Deploy => Action::Deploy,
74 - }
75 - }
76 -
77 - /// The currently-open step within a recipe run: its DB row id, which step it
78 - /// is, and the live-log sink that `sh`/`log` stream into.
79 - struct StepState {
80 - run_id: StepRunId,
81 - step: Step,
82 - log: Arc<AsyncMutex<LiveLog>>,
83 - /// Set when something in the step recorded a hard failure the recipe did
84 - /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the
85 - /// recipe ignored the bool). Forces the step's recorded status to `Failed`
86 - /// and bars `publish` (the step-success ledger).
87 - failed: bool,
88 - /// Wall-clock deadline for this step. A command that runs past it fails the
89 - /// step (and unwinds the recipe) rather than wedging under the old
90 - /// whole-build guillotine, which a legitimate 5-target fan-out plus notary
91 - /// queueing could trip — mismarking every target failed while the blocking
92 - /// recipe bodies kept signing.
93 - deadline: std::time::Instant,
94 - }
95 -
96 - /// Per-step wall-clock budget: a generous ceiling that catches a wedged command
97 - /// (a hung ssh, a stuck notary poll) without killing legitimately slow work.
98 - /// Bounding each step, rather than the whole build, keeps one slow step from
99 - /// being blamed on another and keeps a fan-out of slow-but-fine targets from
100 - /// being guillotined. `Config::step_timeout_secs` overrides these per-kind
101 - /// defaults for every step; see [`RecipeCtx::step_budget`].
102 - fn default_step_budget(step: Step) -> std::time::Duration {
103 - use std::time::Duration;
104 - let mins = match step {
105 - Step::Checkout => 10,
106 - // clippy + full test suite, cold, on a workspace.
107 - Step::Prebuild => 45,
108 - // cargo tauri build, cold, universal bundles.
109 - Step::Build => 90,
110 - Step::Sign => 15,
111 - // Apple's notary queue + this step's bounded retries.
112 - Step::Notarize => 60,
113 - Step::Staple => 10,
114 - Step::Package => 30,
115 - Step::Verify => 10,
116 - // rsync of multi-GiB artifacts off the build host.
117 - Step::Collect => 30,
118 - Step::Publish => 20,
119 - // A binary push, an install, a unit restart, and a health poll. Minutes
120 - // of work; the ceiling is for a wedged transport, not slow work.
121 - Step::Deploy => 15,
122 - // Unused by any recipe — the daemon runs the handoff itself, outside a
123 - // step's clock — and matched to `collect`, since it moves the same
124 - // bytes the same way and would wedge for the same reasons.
125 - Step::Handoff => 30,
126 - };
127 - Duration::from_secs(mins * 60)
128 - }
129 -
130 - /// Where a service's binary is staged on the host that will run it, before the
131 - /// privileged installer moves it into place.
132 - ///
133 - /// A fixed, unguessable-by-accident path rather than a recipe-chosen one,
134 - /// because the installer refuses any source outside it. That refusal is the
135 - /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to
136 - /// an arbitrary path, so both ends have to name the same constant. `/var/tmp`
137 - /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a
138 - /// systemd tmpfiles sweep between staging and install.
139 - pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy";
140 -
141 - /// Everything a recipe's host functions need, shared (Arc) into each closure.
142 - pub struct RecipeCtx {
143 - pub app: AppId,
144 - pub version: Version,
145 - pub target: Target,
146 - /// Name of the host this target builds on (resolved from the topology by the
147 - /// runner). Recipes read it via `build_host()` so one per-platform recipe can
148 - /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 ->
149 - /// astra) without hard-coding a host name.
150 - pub build_host: String,
151 - /// The build host's SSH destination (topology `ssh`), as opposed to its
152 - /// name. Deploy compares it against the service host's to tell "build and
153 - /// run on the same box" from "two hosts that need a transfer" — a question
154 - /// the host NAMES cannot answer, since a build host and a deploy
155 - /// destination are declared in different files and need not agree on one.
156 - pub build_host_ssh: String,
157 - /// This release's git tag, rendered from the app's `tag_format`. Held here
158 - /// rather than derived from the version because a repo holding several
159 - /// products spells it per product (`pom-v0.4.1`), and the recipe, the
160 - /// preflight barrier and the failure message all have to agree on it.
161 - pub tag: String,
162 - /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it
163 - /// through [`RecipeCtx::repo_for`] for anything that runs on a build host;
164 - /// this field alone is the daemon-local answer.
165 - pub repo: String,
166 - /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app
167 - /// that has not declared one, which is all of them but the Windows-shipping
168 - /// ones.
169 - pub repo_by_host: HashMap<String, String>,
170 - /// Cargo features this app's release builds enable (topology `features`).
171 - /// Recipes read it via `feature_flags()`.
172 - pub features: Vec<String>,
173 - /// App or library. Decides which capability a `verify` step is gated on;
174 - /// see `action_for`.
175 - pub kind: Kind,
176 - pub target_run_id: i64,
177 - /// Capability-scoped executor per build host. Recipe commands dispatch
178 - /// through these — the transport (local / ssh / in-session agent) and the
179 - /// capability gate are the executor's, not the engine's.
180 - pub execs: Arc<ExecutorMap>,
181 - /// Sync transport per build host, used only by `collect` to pull artifacts
182 - /// back. Never the agent, even for an agent host — see `state::build_sync`.
183 - /// Not an execution path.
184 - pub syncs: Arc<ExecutorMap>,
185 - /// Where this target installs, for a `kind = "service"` app. `None` for an
186 - /// app or a library, which makes every deploy host function fail with that
187 - /// as the reason rather than with a missing-host error.
188 - ///
189 - /// The runner resolves it from the app manifest's `[[deploy]]` table and
190 - /// registers its executor into `execs` under the destination's host string,
191 - /// so `sh_ok(deploy_host(), ...)` reaches the service host through the same
192 - /// capability gate as everything else.
193 - pub deploy: Option<DeployTarget>,
194 - pub pool: SqlitePool,
195 - pub events: EventTx,
196 - pub cfg: Arc<Config>,
197 - pub ota: Arc<OtaRegistry>,
198 - pub rt: Handle,
199 - current: Mutex<Option<StepState>>,
200 - /// Gatekeeper verdict recorded by `verify_gatekeeper`: `None` = never run,
201 - /// `Some(false)` = ran and rejected, `Some(true)` = accepted. `publish`
202 - /// requires `Some(true)` for a macOS/iOS artifact (the proof it is signed +
203 - /// notarized).
204 - gatekeeper_ok: Mutex<Option<bool>>,
205 - /// Steps finalized as `Failed` during this run. A non-empty ledger bars
206 - /// `publish` — an artifact is never shipped after a step failed, even if the
207 - /// recipe ignored the failure and ran on.
208 - failed_steps: Mutex<Vec<Step>>,
209 - /// Set when a newer build supersedes this run. Checked at step boundaries
210 - /// and before publish so a superseded recipe stops promptly rather than
211 - /// running to completion (the blocking Rhai body can't be `abort()`ed).
212 - cancel: Arc<AtomicBool>,
213 - /// The all-targets-green publish gate (topology `require_all_targets`).
214 - /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a
215 - /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each
216 - /// target publishes independently.
217 - all_green_required: Option<Vec<Target>>,
218 - /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish`
219 - /// reads it to record `releases.artifact_hash` for the bytes it ships, so the
220 - /// hash is the one computed when the artifact landed rather than a re-read
221 - /// that could see a different file. Absent ⇒ `publish` hashes on demand.
222 - artifact_hashes: Mutex<HashMap<String, String>>,
223 - }
224 -
225 - impl RecipeCtx {
226 - /// Versions of `name` already on crates.io. A network failure yields an
227 - /// empty list: preflight then cannot claim a version is a duplicate, and
228 - /// `cargo publish` still refuses one, so the check degrades to advisory
229 - /// rather than blocking a release on registry availability.
230 - fn published_versions(name: &str) -> Vec<String> {
231 - let url = format!("https://crates.io/api/v1/crates/{name}");
232 - let Ok(out) = std::process::Command::new("curl")
233 - .args([
234 - "-sS",
235 - "--max-time",
236 - "15",
237 - "-H",
238 - "User-Agent: bento-preflight",
239 - &url,
240 - ])
241 - .output()
242 - else {
243 - return Vec::new();
244 - };
245 - let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
246 - return Vec::new();
247 - };
248 - v.get("versions")
249 - .and_then(|x| x.as_array())
250 - .map(|a| {
251 - a.iter()
252 - .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
253 - .collect()
254 - })
255 - .unwrap_or_default()
256 - }
257 -
258 - #[allow(clippy::too_many_arguments)]
259 - pub fn new(
260 - app: AppId,
261 - version: Version,
262 - target: Target,
263 - build_host: String,
264 - build_host_ssh: String,
265 - tag: String,
266 - repo: String,
267 - features: Vec<String>,
268 - kind: Kind,
269 - target_run_id: i64,
270 - execs: Arc<ExecutorMap>,
271 - syncs: Arc<ExecutorMap>,
272 - deploy: Option<DeployTarget>,
273 - pool: SqlitePool,
274 - events: EventTx,
275 - cfg: Arc<Config>,
276 - ota: Arc<OtaRegistry>,
277 - rt: Handle,
278 - cancel: Arc<AtomicBool>,
279 - all_green_required: Option<Vec<Target>>,
280 - ) -> Self {
281 - Self {
282 - app,
283 - version,
284 - target,
285 - build_host,
286 - build_host_ssh,
287 - tag,
288 - repo,
289 - repo_by_host: HashMap::new(),
290 - features,
291 - kind,
292 - target_run_id,
293 - execs,
294 - syncs,
295 - deploy,
296 - pool,
297 - events,
298 - cfg,
299 - ota,
300 - rt,
301 - current: Mutex::new(None),
302 - gatekeeper_ok: Mutex::new(None),
303 - failed_steps: Mutex::new(Vec::new()),
304 - cancel,
305 - all_green_required,
306 - artifact_hashes: Mutex::new(HashMap::new()),
307 - }
308 - }
309 -
310 - /// Declare the app's per-host checkout overrides (topology `repo_by_host`).
311 - ///
312 - /// Separate from `new` because it is empty for every app that has not opted
313 - /// in, and `new` already carries twenty arguments no test wants a
314 - /// twenty-first of.
315 - #[must_use]
316 - pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self {
317 - self.repo_by_host = repo_by_host;
318 - self
319 - }
320 -
321 - /// Where this app is checked out on `host` (topology `AppConfig::repo_for`).
322 - ///
323 - /// Every git command a recipe or the engine runs on a build host goes
324 - /// through this. The bare `repo` field is the daemon-local path.
325 - pub fn repo_for(&self, host: &str) -> &str {
326 - self.repo_by_host
327 - .get(host)
328 - .map_or(self.repo.as_str(), String::as_str)
329 - }
330 -
331 - /// Whether a newer build has superseded this run.
332 - fn is_cancelled(&self) -> bool {
333 - self.cancel.load(Ordering::SeqCst)
334 - }
335 -
336 - fn now() -> String {
337 - chrono::Utc::now().to_rfc3339()
338 - }
339 -
340 - /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`.
341 - ///
342 - /// The run id is in the filename because the rest of the key is not unique:
343 - /// re-running an app at a version it already built (a retry, or a rebuild of
344 - /// an already-published release) reopens the same path, and appending would
345 - /// show two runs' output with nothing marking the boundary. The
346 - /// step ledger records a run id per step, so keying the file on it makes a
347 - /// ledger row resolve to exactly one file and keeps the earlier run readable.
348 - fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf {
349 - self.log_dir()
350 - .join(format!("{}.{}.log", step.as_str(), run_id.0))
351 - }
352 -
353 - /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`.
354 - fn log_dir(&self) -> PathBuf {
355 - let target_dir = self.target.to_string().replace('/', "-");
356 - self.cfg
357 - .logs_root
358 - .join(self.app.as_str())
359 - .join(self.version.to_string())
360 - .join(target_dir)
361 - }
362 -
363 - /// Close the previous step (as `Ok`), open a new one: insert its DB row,
364 - /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`.
365 - fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> {
366 - anyhow::ensure!(
367 - !self.is_cancelled(),
368 - "build superseded by a newer request; aborting before `{}`",
369 - step.as_str()
370 - );
371 - self.finish_step(Status::Ok)?;
372 - let me = self.clone();
373 - let started = Self::now();
374 - let started_for_header = started.clone();
375 - let run_id = self.rt.block_on(async move {
376 - // The step row and the target's current_step pointer are one logical
377 - // state — write them atomically so a failure can't leave a `running`
378 - // step row while current_step still names the previous step.
379 - let mut tx = me.pool.begin().await.context("begin step tx")?;
380 - // log_ref names the run id, which only exists once the row does, so
381 - // the path is written back inside the same transaction rather than
382 - // guessed beforehand.
383 - let id: i64 = sqlx::query_scalar(
384 - "INSERT INTO step_runs (target_run_id, step, status, started_at)
385 - VALUES (?, ?, 'running', ?) RETURNING id",
386 - )
387 - .bind(me.target_run_id)
388 - .bind(step.as_str())
389 - .bind(&started)
390 - .fetch_one(&mut *tx)
391 - .await
392 - .context("insert step_run")?;
393 - let log_ref = me
394 - .log_path(step, StepRunId(id))
395 - .to_string_lossy()
396 - .into_owned();
397 - sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?")
398 - .bind(&log_ref)
399 - .bind(id)
400 - .execute(&mut *tx)
401 - .await
402 - .context("set step_run log_ref")?;
403 - sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?")
404 - .bind(step.as_str())
405 - .bind(me.target_run_id)
406 - .execute(&mut *tx)
407 - .await
408 - .context("update current_step")?;
409 - tx.commit().await.context("commit step tx")?;
410 - anyhow::Ok(StepRunId(id))
411 - })?;
412 -
413 - // Live log: each chunk fans out as a StepLogChunk event keyed by run_id.
414 - let events = self.events.clone();
415 - let cb_run_id = run_id;
416 - let mut log = self.rt.block_on(LiveLog::open(
417 - self.log_path(step, run_id),
418 - Box::new(move |seq, text| {
419 - events::emit(
420 - &events,
421 - Event::StepLogChunk {
422 - run_id: cb_run_id,
423 - seq,
424 - text: text.to_string(),
425 - },
426 - );
427 - }),
428 - ));
429 -
430 - events::emit(
431 - &self.events,
432 - Event::StepStart {
433 - run_id,
434 - app: self.app.clone(),
435 - version: self.version.clone(),
436 - target: self.target,
437 - step,
438 - },
439 - );
440 -
441 - // A log that names its own run: reading one tells you which ledger row
442 - // it belongs to without going back to the DB, and a file that somehow
443 - // does get appended to still shows where the second run began. Written
444 - // after `StepStart` so its chunk event cannot precede the step it
445 - // belongs to.
446 - let header = format!(
447 - "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n",
448 - app = self.app.as_str(),
449 - version = self.version,
450 - target = self.target,
451 - step = step.as_str(),
452 - );
453 - self.rt.block_on(async {
454 - use ops_core::remote::LogSink as _;
455 - log.write_chunk(header.as_bytes()).await;
456 - });
457 -
458 - *self.current.lock().unwrap() = Some(StepState {
459 - run_id,
460 - step,
461 - log: Arc::new(AsyncMutex::new(log)),
462 - failed: false,
463 - deadline: std::time::Instant::now() + self.step_budget(step),
464 - });
465 - Ok(())
466 - }
467 -
468 - /// This build's budget for `step`: the `Config` override if set, else the
469 - /// per-kind default.
470 - fn step_budget(&self, step: Step) -> std::time::Duration {
471 - self.cfg
472 - .step_timeout_secs
473 - .map_or_else(|| default_step_budget(step), std::time::Duration::from_secs)
474 - }
475 -
476 - /// The current step's wall-clock deadline (or a default if no step is open,
477 - /// which only happens before the first `step()` — commands then run under an
478 - /// implicit `Build` step opened by `ensure_step`).
479 - fn step_deadline(&self) -> std::time::Instant {
480 - self.current.lock().unwrap().as_ref().map_or_else(
481 - || std::time::Instant::now() + self.step_budget(Step::Build),
482 - |s| s.deadline,
483 - )
484 - }
485 -
486 - /// Drive `fut` on the runtime, but stop early on two conditions the recipe
487 - /// bodies otherwise cannot observe (they run synchronously on a blocking
488 - /// thread): the current step's deadline, and supersession by a newer build.
489 - /// Either turns into an error that fails the step and unwinds the recipe, so
490 - /// a wedged command cannot run unbounded and a superseded build stops
491 - /// mid-step instead of only at the next step boundary.
492 - fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T>
493 - where
494 - F: std::future::Future<Output = Result<T>>,
495 - {
496 - let deadline = self.step_deadline();
497 - let cancel = self.cancel.clone();
498 - self.rt.block_on(async move {
499 - tokio::pin!(fut);
500 - let watch = async {
Lines truncated
@@ -1,0 +1,470 @@
1 + //! Collecting a build's artifacts: finding them, proving they are the version
2 + //! that was asked for, hashing them, and pulling them back.
3 +
4 + use super::RecipeCtx;
5 + use crate::domain::Version;
6 + use crate::events::{self, Event};
7 + use anyhow::{Context as _, Result};
8 + use ops_exec::SyncOpts;
9 + use sha2::{Digest, Sha256};
10 + use std::path::{Path, PathBuf};
11 + use std::sync::Arc;
12 +
13 + /// Every `X.Y.Z`-shaped version embedded in an artifact file name. Each maximal
14 + /// run of digits-and-dots contributes its first three numeric fields:
15 + /// `GoingsOn_0.5.0_aarch64.dmg` and `demo-9.9.9.bin` both yield one version (the
16 + /// trailing `.bin`/`.dmg` dot is tolerated), while `latest.json` yields `[]` and
17 + /// the `64` in `x86_64` is not three fields. Only the `major.minor.patch` core is
18 + /// taken; a prerelease/build suffix is separated by `-`/`+` and not needed here.
19 + pub(super) fn versions_in_filename(name: &str) -> Vec<Version> {
20 + name.split(|c: char| !(c.is_ascii_digit() || c == '.'))
21 + .filter_map(|run| {
22 + let f: Vec<&str> = run.split('.').filter(|s| !s.is_empty()).collect();
23 + if f.len() >= 3 && f[..3].iter().all(|s| s.chars().all(|c| c.is_ascii_digit())) {
24 + Version::parse(&format!("{}.{}.{}", f[0], f[1], f[2])).ok()
25 + } else {
26 + None
27 + }
28 + })
29 + .collect()
30 + }
31 +
32 + /// Fail when a collected file's name embeds a version whose `major.minor.patch`
33 + /// is not the one being built. This is the guard against a stale checked-in
34 + /// artifact winning a glob: `ls -t <glob>` once let
35 + /// `AudioFiles-0.4.0-x86_64.AppImage` ship against 0.5.0. A file whose name
36 + /// carries no version (an updater `latest.json`, a `.sig`) is not asserted —
37 + /// there is nothing to compare. Compared on the core so a prerelease build's
38 + /// plain `X.Y.Z` in the filename still matches.
39 + pub(super) fn assert_artifact_version(name: &str, expected: &Version) -> Result<()> {
40 + let versions = versions_in_filename(name);
41 + anyhow::ensure!(
42 + versions.is_empty() || versions.iter().any(|v| v.core() == expected.core()),
43 + "collected artifact `{name}` carries version {} but the build is {expected}; \
44 + a stale artifact was left in the output dir — clean it so only {expected} remains",
45 + versions
46 + .iter()
47 + .map(ToString::to_string)
48 + .collect::<Vec<_>>()
49 + .join("/"),
50 + );
51 + Ok(())
52 + }
53 +
54 + /// sha256 of a file, lowercase hex. Streams in 64 KiB chunks so a multi-GiB
55 + /// bundle never lands in memory whole.
56 + pub(super) fn sha256_file(path: &Path) -> Result<String> {
57 + let mut file =
58 + std::fs::File::open(path).with_context(|| format!("hashing {}", path.display()))?;
59 + let mut hasher = Sha256::new();
60 + std::io::copy(&mut file, &mut hasher)
61 + .with_context(|| format!("reading {} to hash", path.display()))?;
62 + Ok(hex_lower(&hasher.finalize()))
63 + }
64 +
65 + /// Every regular file under `root`, as `(path relative to root, absolute path)`,
66 + /// sorted by the relative path.
67 + ///
68 + /// **This walk has to match `bundle::digest_dir` in sando, file for file.** That
69 + /// function re-hashes an incoming bundle and refuses it when the bytes disagree
70 + /// with the manifest they arrived with, so a producer that walks differently
71 + /// produces a manifest the consumer will reject for an artifact nothing is wrong
72 + /// with. Three properties carry that agreement, and none is incidental:
73 + ///
74 + /// - **Recursive.** A bundle may carry a directory (migrations, resources), and
75 + /// a top-level-only listing would omit its contents from the manifest while
76 + /// the verifier hashed them.
77 + /// - **Symlinks are not followed, and not recorded.** Following one would let
78 + /// content from outside the bundle into its identity; recording the link
79 + /// itself would name a file the verifier does not hash.
80 + /// - **Relative paths, `/`-separated, sorted.** Readdir order is not guaranteed,
81 + /// so an unsorted manifest would differ run to run on one machine, never mind
82 + /// between two.
83 + pub(super) fn collected_files(root: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
84 + fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {
85 + for entry in std::fs::read_dir(dir)? {
86 + let entry = entry?;
87 + let ft = entry.file_type()?;
88 + let path = entry.path();
89 + if ft.is_dir() {
90 + walk(&path, root, out)?;
91 + } else if ft.is_file() {
92 + let rel = path
93 + .strip_prefix(root)
94 + .unwrap_or(&path)
95 + .components()
96 + .map(|c| c.as_os_str().to_string_lossy())
97 + .collect::<Vec<_>>()
98 + .join("/");
99 + out.push((rel, path));
100 + }
101 + // Symlinks and other special files are intentionally ignored,
102 + // matching the verifier.
103 + }
104 + Ok(())
105 + }
106 + let mut out = Vec::new();
107 + walk(root, root, &mut out)?;
108 + out.sort_by(|a, b| a.0.cmp(&b.0));
109 + Ok(out)
110 + }
111 +
112 + /// Lowercase-hex encode without pulling in a hex crate.
113 + pub(super) fn hex_lower(bytes: &[u8]) -> String {
114 + use std::fmt::Write as _;
115 + let mut s = String::with_capacity(bytes.len() * 2);
116 + for b in bytes {
117 + let _ = write!(s, "{b:02x}");
118 + }
119 + s
120 + }
121 +
122 + /// Reject a glob that carries shell command metacharacters. Path and wildcard
123 + /// characters (`/ . * ? [ ] ~` etc.) are fine — the pattern reaches a login
124 + /// shell to be expanded — but a `;` or `$(...)` must not ride along and run.
125 + /// Not a privilege boundary (a recipe already runs arbitrary shell via `sh_ok`)
126 + /// but it keeps a malformed pattern from turning into a command. Shared by
127 + /// `collect` and `resolve_artifact`.
128 + pub(super) fn ensure_glob_safe(glob: &str) -> Result<()> {
129 + anyhow::ensure!(
130 + !glob.chars().any(|c| matches!(
131 + c,
132 + ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>'
133 + )),
134 + "glob `{glob}` contains shell metacharacters"
135 + );
136 + Ok(())
137 + }
138 +
139 + /// Decide the single artifact a glob resolves to from a newline-separated
140 + /// listing of the paths that matched it.
141 + ///
142 + /// Demands exactly one match. Zero matches fail when `required` (return `""`
143 + /// when optional); more than one is always an error rather than an arbitrary
144 + /// newest-wins pick, because an ambiguous match means the build left stale
145 + /// artifacts behind and the wrong one could ship.
146 + pub(super) fn resolve_artifact_match(listing: &str, glob: &str, required: bool) -> Result<String> {
147 + let matches: Vec<&str> = listing
148 + .lines()
149 + .map(str::trim)
150 + .filter(|l| !l.is_empty())
151 + .collect();
152 + match matches.as_slice() {
153 + [] if required => anyhow::bail!("no artifact matched glob `{glob}`"),
154 + [] => Ok(String::new()),
155 + [one] => Ok((*one).to_string()),
156 + many => anyhow::bail!(
157 + "glob `{glob}` is ambiguous: {} artifacts matched ({}). \
158 + The build left more than one behind; clean stale artifacts so exactly one remains.",
159 + many.len(),
160 + many.join(", ")
161 + ),
162 + }
163 + }
164 +
165 + pub(super) fn dir_size(p: &Path) -> Option<i64> {
166 + let mut total = 0i64;
167 + for entry in std::fs::read_dir(p).ok()? {
168 + let entry = entry.ok()?;
169 + let md = entry.metadata().ok()?;
170 + if md.is_file() {
171 + total += md.len() as i64;
172 + } else if md.is_dir() {
173 + // Recurse so a bundle dir (a `.app`) reports its real size, not ~0.
174 + total += dir_size(&entry.path()).unwrap_or(0);
175 + }
176 + }
177 + Some(total)
178 + }
179 +
180 + impl RecipeCtx {
181 + /// Resolve `glob` on `host` to the single artifact it names. The `for` loop
182 + /// lists each existing match on its own line (and prints nothing — rather
183 + /// than a literal unexpanded pattern — when the glob matches no file), so
184 + /// the count is unambiguous. `required` controls whether zero matches is an
185 + /// error; more than one always is. See `resolve_artifact_match`.
186 + pub(super) fn resolve_artifact(
187 + self: &Arc<Self>,
188 + host: &str,
189 + glob: &str,
190 + required: bool,
191 + ) -> Result<String> {
192 + ensure_glob_safe(glob)?;
193 + // `[ -e ]` guards against a non-matching glob surviving as its literal
194 + // self, and lists one path per line for the count.
195 + let cmd = format!("for __f in {glob}; do [ -e \"$__f\" ] && printf '%s\\n' \"$__f\"; done");
196 + let (code, tail) = self.run(host, &cmd)?;
197 + anyhow::ensure!(
198 + code == 0,
199 + "resolving artifact glob `{glob}` on `{host}` exited {code}"
200 + );
201 + resolve_artifact_match(&tail, glob, required)
202 + }
203 +
204 + /// Where this run's collected files land locally.
205 + ///
206 + /// Per target, not per version. Sharing one `dist_root/<app>/<version>/`
207 + /// across targets would have the hash loop below (which lists the directory)
208 + /// attribute a sibling's AppImage to the mac build's artifact record. It is
209 + /// also the layout the archive uses, and the two have to agree or the local
210 + /// copy and the deposited one are different shapes.
211 + pub(super) fn collect_dest(&self, app: &str, version: &str) -> PathBuf {
212 + self.cfg
213 + .dist_root
214 + .join(app)
215 + .join(version)
216 + .join(crate::archive::target_slug(self.target))
217 + }
218 +
219 + pub(super) fn collect(
220 + self: &Arc<Self>,
221 + host: &str,
222 + glob: &str,
223 + app: &str,
224 + version: &str,
225 + ) -> Result<()> {
226 + let dest = self.collect_dest(app, version);
227 + let dest_s = dest.to_string_lossy().into_owned();
228 + // The glob reaches a remote login shell intact (that's what expands it),
229 + // so command metacharacters stay barred. Path/wildcard chars are fine.
230 + ensure_glob_safe(glob)?;
231 + std::fs::create_dir_all(&dest)
232 + .with_context(|| format!("creating collect dest {dest_s}"))?;
233 + // The SYNC transport, not the host's exec executor: artifacts move over
234 + // ssh/rsync even from an agent host, whose `/pull` is confined to a
235 + // narrow `pull_root` that deliberately excludes the repo checkout these
236 + // artifacts are built in (see `state::build_sync`). The daemon still
237 + // runs the transfer itself, as it always has.
238 + let sync = self.host_sync(host)?;
239 + let opts = SyncOpts::precompressed();
240 + // Bounded by the collect step's deadline (rsync of a multi-GiB artifact
241 + // can wedge on a stalled transport) and interruptible on supersession.
242 + let dest_pull = dest.clone();
243 + self.run_bounded(&format!("collect {glob} from `{host}`"), async move {
244 + sync.pull_glob(glob, &dest_pull, &opts).await
245 + })
246 + .with_context(|| format!("collect {glob} from `{host}`"))?;
247 + // Assert the version and hash every collected file. This is where a
248 + // stale artifact is caught: a file whose name embeds a different version
249 + // fails the collect (rather than silently winning a later glob), and the
250 + // sha256 recorded here is what `publish` writes into the release ledger
251 + // and what the artifact record's manifest is built from.
252 + //
253 + // Recursive, and keyed by path relative to the collect dir. That is not
254 + // a preference: Sando's intake re-hashes the bundle with its own walker,
255 + // which recurses and keys the same way, and refuses a bundle whose bytes
256 + // do not match the manifest it was handed. A top-level `read_dir` keyed
257 + // by file name agrees with that walker for a flat directory and diverges
258 + // the moment a bundle carries a subdirectory — the honest artifact would
259 + // be refused for a manifest that omitted everything nested. The two
260 + // walkers have to be the same walk. See `bundle::digest_dir` in sando.
261 + for (rel, path) in
262 + collected_files(&dest).with_context(|| format!("listing collect dest {dest_s}"))?
263 + {
264 + // The version check stays on the file NAME rather than the relative
265 + // path: it is looking for a stale `app_1.2.3.AppImage` beside the
266 + // one this release built, and a directory component is not that.
267 + let name = path
268 + .file_name()
269 + .map_or_else(|| rel.clone(), |n| n.to_string_lossy().into_owned());
270 + assert_artifact_version(&name, &self.version)?;
271 + let digest = sha256_file(&path)?;
272 + self.record_artifact_hash(rel, digest);
273 + }
274 + // Deposit at the archive path, so this target's bytes have one address
275 + // whichever host produced them. A no-op when no archive is configured.
276 + //
277 + // Inside `collect`, not after the recipe: a failure here fails the
278 + // collect step, before sign and publish, rather than putting a red mark
279 + // on a release that has already shipped. And it is a failure, not a
280 + // warning — a deposit that is quietly skipped leaves the archive path
281 + // wrong for exactly the release nobody was watching, which is the thing
282 + // having one address is for.
283 + let (cfg, app_id, version, target) = (
284 + self.cfg.clone(),
285 + self.app.clone(),
286 + self.version.clone(),
287 + self.target,
288 + );
289 + let dest_archive = dest.clone();
290 + self.run_bounded("deposit in the archive", async move {
291 + crate::archive::deposit(&cfg, &dest_archive, &app_id, &version, target).await
292 + })?;
293 + // Best-effort size accounting for the event.
294 + events::emit(
295 + &self.events,
296 + Event::ArtifactCollected {
297 + app: self.app.clone(),
298 + target: self.target,
299 + path: dest_s,
300 + bytes: dir_size(&dest).unwrap_or(0),
301 + },
302 + );
303 + Ok(())
304 + }
305 + }
306 +
307 + #[cfg(test)]
308 + mod tests {
309 + use super::*;
310 +
311 + /// A parsed version, for the cases below.
312 + fn ver(s: &str) -> Version {
313 + Version::parse(s).unwrap()
314 + }
315 +
316 + /// Build the shared cross-crate bundle fixture under `root`.
317 + ///
318 + /// A binary at the top and two files in a subdirectory: the shape a service
319 + /// that ships its migrations has, and the case a flat walk gets wrong.
320 + pub(crate) fn write_bundle_fixture(root: &Path) {
321 + std::fs::create_dir_all(root.join("migrations")).unwrap();
322 + std::fs::write(root.join("pom"), b"binary-bytes").unwrap();
323 + std::fs::write(root.join("migrations/001_init.sql"), b"create table a;").unwrap();
324 + std::fs::write(root.join("migrations/002_next.sql"), b"alter table a;").unwrap();
325 + }
326 +
327 + /// The manifest text the fixture must produce, in BOTH crates.
328 + ///
329 + /// Sando's `bundle::digest_dir` has the identical constant and the identical
330 + /// fixture. That is the whole point: bento writes this text into the artifact
331 + /// record, sando recomputes it from the bytes that arrive, and an artifact is
332 + /// refused when they differ. Two walks, one answer, pinned from both ends —
333 + /// if either crate's walk drifts, its own test fails and names the drift
334 + /// rather than a release failing intake for a bundle nothing is wrong with.
335 + pub(crate) const BUNDLE_FIXTURE_MANIFEST: &str = concat!(
336 + "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n",
337 + "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n",
338 + "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n",
339 + );
340 +
341 + /// The producer half of the contract above: what `collect` hashes, turned
342 + /// into a manifest, is exactly the text the verifier will recompute.
343 + ///
344 + /// Nested files are included and addressed by relative path. Before this,
345 + /// `collect` listed only the top level, so `migrations/` contributed nothing
346 + /// to the manifest while sando's walker hashed both files in it — and the
347 + /// honest bundle was refused for a manifest that had omitted them.
348 + #[test]
349 + fn a_collected_bundle_manifests_exactly_as_the_verifier_will_read_it() {
350 + let dir = tempfile::tempdir().unwrap();
351 + write_bundle_fixture(dir.path());
352 +
353 + let files = collected_files(dir.path()).unwrap();
354 + assert_eq!(
355 + files.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>(),
356 + vec!["migrations/001_init.sql", "migrations/002_next.sql", "pom"],
357 + "recursive, relative, sorted"
358 + );
359 +
360 + let hashes: Vec<(String, String)> = files
361 + .into_iter()
362 + .map(|(rel, path)| (rel, sha256_file(&path).unwrap()))
363 + .collect();
364 + let manifest = ops_artifact::Manifest::new(hashes).unwrap();
365 + assert_eq!(manifest.to_text(), BUNDLE_FIXTURE_MANIFEST);
366 + }
367 +
368 + /// A symlink is neither followed nor named. Following one would let bytes
369 + /// from outside the bundle into its identity; naming it would put a path in
370 + /// the manifest the verifier does not hash, which reads as a corrupt bundle.
371 + #[test]
372 + #[cfg(unix)]
373 + fn a_symlink_in_the_collect_dir_is_not_part_of_the_bundle() {
374 + let dir = tempfile::tempdir().unwrap();
375 + write_bundle_fixture(dir.path());
376 + let outside = dir.path().join("..").join("secret.env");
377 + std::fs::write(&outside, b"TOKEN=1").ok();
378 + std::os::unix::fs::symlink(&outside, dir.path().join("link.env")).unwrap();
379 +
380 + let files = collected_files(dir.path()).unwrap();
381 + assert!(
382 + !files.iter().any(|(rel, _)| rel.contains("link.env")),
383 + "{files:?}"
384 + );
385 + }
386 +
387 + #[test]
388 + fn resolve_artifact_match_wants_exactly_one() {
389 + // Exactly one match: the path, trimmed of the listing's line noise.
390 + assert_eq!(
391 + resolve_artifact_match(" /d/App.AppImage \n", "*.AppImage", true).unwrap(),
392 + "/d/App.AppImage"
393 + );
394 + }
395 +
396 + #[test]
397 + fn resolve_artifact_match_zero_depends_on_required() {
398 + // Required + zero matches is the case the old empty-string guard caught;
399 + // keep failing it.
400 + let err = resolve_artifact_match("", "*.dmg", true).unwrap_err();
401 + assert!(err.to_string().contains("no artifact matched"), "{err}");
402 + // Optional + zero matches resolves to empty (recipe skips the collect).
403 + assert_eq!(
404 + resolve_artifact_match("\n \n", "*.deb", false).unwrap(),
405 + ""
406 + );
407 + }
408 +
409 + #[test]
410 + fn resolve_artifact_match_rejects_ambiguous() {
411 + // Two matches must throw rather than silently pick one — this is the
412 + // stale-newest-mtime hole the audit flagged. Applies even when optional.
413 + for required in [true, false] {
414 + let err =
415 + resolve_artifact_match("/d/old.deb\n/d/new.deb\n", "*.deb", required).unwrap_err();
416 + let msg = err.to_string();
417 + assert!(msg.contains("ambiguous"), "{msg}");
418 + assert!(msg.contains("old.deb") && msg.contains("new.deb"), "{msg}");
419 + }
420 + }
421 +
422 + #[test]
423 + fn ensure_glob_safe_allows_paths_bars_commands() {
424 + // Path and wildcard characters pass.
425 + assert!(ensure_glob_safe("~/Code/app/dist/*.AppImage").is_ok());
426 + assert!(ensure_glob_safe("/t/App_1.2.3-x86_64.dmg").is_ok());
427 + // A command substitution or separator does not.
428 + for bad in ["*.dmg; rm -rf /", "$(evil)", "a|b", "a b"] {
429 + assert!(ensure_glob_safe(bad).is_err(), "should reject {bad:?}");
430 + }
431 + }
432 +
433 + #[test]
434 + fn versions_in_filename_extracts_only_real_semvers() {
435 + assert_eq!(
436 + versions_in_filename("GoingsOn_0.5.0_aarch64.dmg"),
437 + vec![ver("0.5.0")]
438 + );
439 + assert_eq!(
440 + versions_in_filename("AudioFiles-0.4.0-x86_64.AppImage"),
441 + vec![ver("0.4.0")]
442 + );
443 + // No three-part token ⇒ nothing (an updater manifest, a bare signature).
444 + assert!(versions_in_filename("latest.json").is_empty());
445 + assert!(versions_in_filename("app.sig").is_empty());
446 + }
447 +
448 + #[test]
449 + fn assert_artifact_version_rejects_a_stale_artifact() {
450 + // The 0.4.0 file sitting in the output dir against a 0.5.0 build.
451 + let err =
452 + assert_artifact_version("AudioFiles-0.4.0-x86_64.AppImage", &ver("0.5.0")).unwrap_err();
453 + assert!(format!("{err:#}").contains("stale artifact"), "{err:#}");
454 + // The matching version passes, and a versionless file is not asserted.
455 + assert_artifact_version("GoingsOn_0.5.0_aarch64.dmg", &ver("0.5.0")).unwrap();
456 + assert_artifact_version("latest.json", &ver("0.5.0")).unwrap();
457 + }
458 +
459 + #[test]
460 + fn sha256_file_is_lowercase_hex_of_contents() {
461 + let tmp = tempfile::tempdir().unwrap();
462 + let f = tmp.path().join("a.bin");
463 + std::fs::write(&f, b"abc").unwrap();
464 + // Known SHA-256 of "abc".
465 + assert_eq!(
466 + sha256_file(&f).unwrap(),
467 + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
468 + );
469 + }
470 + }
@@ -1,0 +1,228 @@
1 + //! Talking to crates.io: what is already published, and what the registry will
2 + //! refuse before `cargo publish` gets there.
3 +
4 + use anyhow::{Context as _, Result};
5 +
6 + /// A crate's publish-relevant metadata, read from `cargo metadata`.
7 + #[derive(Debug, Clone)]
8 + pub(super) struct CrateMeta {
9 + pub name: String,
10 + pub version: String,
11 + pub repository: Option<String>,
12 + pub description: Option<String>,
13 + pub licensed: bool,
14 + }
15 +
16 + /// Parse the fields that matter for publishing out of `cargo metadata` JSON.
17 + pub(super) fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
18 + let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?;
19 + let p = v
20 + .get("packages")
21 + .and_then(|p| p.as_array())
22 + .and_then(|a| a.first())
23 + .context("cargo metadata reported no package")?;
24 + let str_field = |k: &str| {
25 + p.get(k)
26 + .and_then(|x| x.as_str())
27 + .filter(|s| !s.is_empty())
28 + .map(str::to_string)
29 + };
30 + Ok(CrateMeta {
31 + name: str_field("name").context("package has no name")?,
32 + version: str_field("version").context("package has no version")?,
33 + repository: str_field("repository"),
34 + description: str_field("description"),
35 + licensed: str_field("license").is_some() || str_field("license_file").is_some(),
36 + })
37 + }
38 +
39 + /// Everything wrong with a crate's metadata, as messages. Empty means publishable.
40 + ///
41 + /// Checks only what crates.io records permanently. A published version cannot
42 + /// be edited, only yanked, and yanking does not correct a wrong URL — so these
43 + /// are the last moment any of it can be fixed.
44 + pub(super) fn crate_publish_problems(
45 + meta: &CrateMeta,
46 + repo_clonable: bool,
47 + published: &[String],
48 + credentials_present: bool,
49 + ) -> Vec<String> {
50 + let mut out = Vec::new();
51 + if !credentials_present {
52 + out.push(
53 + "no crates.io credentials on the publishing host: `cargo login` there first. \
54 + Checked now rather than at the upload, so this fails in seconds instead of \
55 + after a full build and verify."
56 + .to_string(),
57 + );
58 + }
59 + match &meta.repository {
60 + None => out.push(
61 + "no `repository` field: the crates.io page will show no source link, permanently"
62 + .to_string(),
63 + ),
64 + Some(url) if !repo_clonable => out.push(format!(
65 + "`repository` is not publicly clonable: {url} \
66 + (wrong URL, or the repo is private)"
67 + )),
68 + Some(_) => {}
69 + }
70 + if meta.description.is_none() {
71 + out.push("no `description`: crates.io requires one".to_string());
72 + }
73 + if !meta.licensed {
74 + out.push("no `license` or `license-file`".to_string());
75 + }
76 + if published.iter().any(|v| v == &meta.version) {
77 + out.push(format!(
78 + "version {} is already published; bump it",
79 + meta.version
80 + ));
81 + }
82 + out
83 + }
84 +
85 + /// Versions of `name` already on crates.io. A network failure yields an
86 + /// empty list: preflight then cannot claim a version is a duplicate, and
87 + /// `cargo publish` still refuses one, so the check degrades to advisory
88 + /// rather than blocking a release on registry availability.
89 + pub(super) fn published_versions(name: &str) -> Vec<String> {
90 + let url = format!("https://crates.io/api/v1/crates/{name}");
91 + let Ok(out) = std::process::Command::new("curl")
92 + .args([
93 + "-sS",
94 + "--max-time",
95 + "15",
96 + "-H",
97 + "User-Agent: bento-preflight",
98 + &url,
99 + ])
100 + .output()
101 + else {
102 + return Vec::new();
103 + };
104 + let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
105 + return Vec::new();
106 + };
107 + v.get("versions")
108 + .and_then(|x| x.as_array())
109 + .map(|a| {
110 + a.iter()
111 + .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
112 + .collect()
113 + })
114 + .unwrap_or_default()
115 + }
116 +
117 + #[cfg(test)]
118 + mod tests {
119 + use super::*;
120 +
121 + /// The two failures that actually shipped, as regression cases.
122 + #[test]
123 + fn preflight_catches_a_dead_repository_url() {
124 + // pter 0.1.0: repository pointed at a URL that does not exist. It
125 + // published clean and the link is now permanent for that version.
126 + let meta = CrateMeta {
127 + name: "pter".into(),
128 + version: "0.1.0".into(),
129 + repository: Some("https://github.com/maxjacobson/pter".into()),
130 + description: Some("d".into()),
131 + licensed: true,
132 + };
133 + let problems = crate_publish_problems(&meta, false, &[], true);
134 + assert_eq!(problems.len(), 1, "{problems:?}");
135 + assert!(
136 + problems[0].contains("not publicly clonable"),
137 + "{problems:?}"
138 + );
139 +
140 + // Same metadata, reachable URL: nothing to report.
141 + assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
142 + }
143 +
144 + #[test]
145 + fn preflight_requires_the_fields_crates_io_bakes_in() {
146 + let bare = CrateMeta {
147 + name: "x".into(),
148 + version: "0.1.0".into(),
149 + repository: None,
150 + description: None,
151 + licensed: false,
152 + };
153 + let problems = crate_publish_problems(&bare, false, &[], true);
154 + assert_eq!(problems.len(), 3, "{problems:?}");
155 + assert!(problems.iter().any(|p| p.contains("repository")));
156 + assert!(problems.iter().any(|p| p.contains("description")));
157 + assert!(problems.iter().any(|p| p.contains("license")));
158 + }
159 +
160 + #[test]
161 + fn preflight_rejects_republishing_the_same_version() {
162 + let meta = CrateMeta {
163 + name: "makeover".into(),
164 + version: "0.10.0".into(),
165 + repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
166 + description: Some("d".into()),
167 + licensed: true,
168 + };
169 + let problems =
170 + crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true);
171 + assert_eq!(problems.len(), 1, "{problems:?}");
172 + assert!(problems[0].contains("already published"), "{problems:?}");
173 +
174 + // An unreleased version against the same history is fine.
175 + let mut next = meta.clone();
176 + next.version = "0.11.0".into();
177 + assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty());
178 + }
179 +
180 + /// Missing credentials must surface at preflight, not at the upload. The
181 + /// publish step is the irreversible one and runs last, after a full build
182 + /// and verify; discovering there that cargo cannot authenticate wastes the
183 + /// whole run.
184 + #[test]
185 + fn preflight_reports_missing_credentials_up_front() {
186 + let meta = CrateMeta {
187 + name: "makeover".into(),
188 + version: "0.11.0".into(),
189 + repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
190 + description: Some("d".into()),
191 + licensed: true,
192 + };
193 + // Metadata is perfect; only the token is absent.
194 + let problems = crate_publish_problems(&meta, true, &[], false);
195 + assert_eq!(problems.len(), 1, "{problems:?}");
196 + assert!(problems[0].contains("credentials"), "{problems:?}");
197 + assert!(
198 + problems[0].contains("cargo login"),
199 + "should say how to fix it"
200 + );
201 +
202 + // Present: nothing to report.
203 + assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
204 + }
205 +
206 + #[test]
207 + fn crate_meta_reads_cargo_metadata_json() {
208 + let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0",
209 + "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes",
210 + "license":"MIT"}]}"#;
211 + let m = crate_meta_from_json(raw).unwrap();
212 + assert_eq!(m.name, "makeover");
213 + assert_eq!(m.version, "0.10.0");
214 + assert!(m.licensed);
215 + assert_eq!(
216 + m.repository.as_deref(),
217 + Some("https://git.sr.ht/~maxmj/makeover")
218 + );
219 +
220 + // license_file alone also counts as licensed; empty strings do not
221 + // count as present.
222 + let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"",
223 + "license_file":"LICENSE","description":""}]}"#;
224 + let m = crate_meta_from_json(lf).unwrap();
225 + assert!(m.licensed);
226 + assert!(m.description.is_none());
227 + }
228 + }
@@ -1,0 +1,733 @@
1 + //! [`RecipeCtx`]: everything a recipe's host functions are handed, and the
2 + //! step lifecycle they drive.
3 + //!
4 + //! The four private run-state fields are reached from the sibling modules
5 + //! through the accessors at the bottom of this file, which hand out values and
6 + //! never the guard.
7 +
8 + use super::git::{
9 + git_fetch_cmd, git_rev_parse_cmd, git_tag_exists_cmd, git_worktree_pin_cmd,
10 + worktree_failure_reason,
11 + };
12 + use super::{StepState, action_for, default_step_budget};
13 + use crate::config::Config;
14 + use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
15 + use crate::events::{self, Event, EventTx};
16 + use crate::ota::OtaRegistry;
17 + use crate::state::ExecutorMap;
18 + use crate::topology::{DeployTarget, Kind};
19 + use anyhow::{Context as _, Result};
20 + use ops_core::live_log::LiveLog;
21 + use ops_exec::{Action, Executor, Step as OpStep};
22 + use sqlx::SqlitePool;
23 + use std::collections::HashMap;
24 + use std::path::PathBuf;
25 + use std::sync::atomic::{AtomicBool, Ordering};
26 + use std::sync::{Arc, Mutex};
27 + use tokio::runtime::Handle;
28 + use tokio::sync::Mutex as AsyncMutex;
29 +
30 + /// Everything a recipe's host functions need, shared (Arc) into each closure.
31 + pub struct RecipeCtx {
32 + pub app: AppId,
33 + pub version: Version,
34 + pub target: Target,
35 + /// Name of the host this target builds on (resolved from the topology by the
36 + /// runner). Recipes read it via `build_host()` so one per-platform recipe can
37 + /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 ->
38 + /// astra) without hard-coding a host name.
39 + pub build_host: String,
40 + /// The build host's SSH destination (topology `ssh`), as opposed to its
41 + /// name. Deploy compares it against the service host's to tell "build and
42 + /// run on the same box" from "two hosts that need a transfer" — a question
43 + /// the host NAMES cannot answer, since a build host and a deploy
44 + /// destination are declared in different files and need not agree on one.
45 + pub build_host_ssh: String,
46 + /// This release's git tag, rendered from the app's `tag_format`. Held here
47 + /// rather than derived from the version because a repo holding several
48 + /// products spells it per product (`pom-v0.4.1`), and the recipe, the
49 + /// preflight barrier and the failure message all have to agree on it.
50 + pub tag: String,
51 + /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it
52 + /// through [`RecipeCtx::repo_for`] for anything that runs on a build host;
53 + /// this field alone is the daemon-local answer.
54 + pub repo: String,
55 + /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app
56 + /// that has not declared one, which is all of them but the Windows-shipping
57 + /// ones.
58 + pub repo_by_host: HashMap<String, String>,
59 + /// Cargo features this app's release builds enable (topology `features`).
60 + /// Recipes read it via `feature_flags()`.
61 + pub features: Vec<String>,
62 + /// App or library. Decides which capability a `verify` step is gated on;
63 + /// see `action_for`.
64 + pub kind: Kind,
65 + pub target_run_id: i64,
66 + /// Capability-scoped executor per build host. Recipe commands dispatch
67 + /// through these — the transport (local / ssh / in-session agent) and the
68 + /// capability gate are the executor's, not the engine's.
69 + pub execs: Arc<ExecutorMap>,
70 + /// Sync transport per build host, used only by `collect` to pull artifacts
71 + /// back. Never the agent, even for an agent host — see `state::build_sync`.
72 + /// Not an execution path.
73 + pub syncs: Arc<ExecutorMap>,
74 + /// Where this target installs, for a `kind = "service"` app. `None` for an
75 + /// app or a library, which makes every deploy host function fail with that
76 + /// as the reason rather than with a missing-host error.
77 + ///
78 + /// The runner resolves it from the app manifest's `[[deploy]]` table and
79 + /// registers its executor into `execs` under the destination's host string,
80 + /// so `sh_ok(deploy_host(), ...)` reaches the service host through the same
81 + /// capability gate as everything else.
82 + pub deploy: Option<DeployTarget>,
83 + pub pool: SqlitePool,
84 + pub events: EventTx,
85 + pub cfg: Arc<Config>,
86 + pub ota: Arc<OtaRegistry>,
87 + pub rt: Handle,
88 + current: Mutex<Option<StepState>>,
89 + /// Gatekeeper verdict recorded by `verify_gatekeeper`: `None` = never run,
90 + /// `Some(false)` = ran and rejected, `Some(true)` = accepted. `publish`
91 + /// requires `Some(true)` for a macOS/iOS artifact (the proof it is signed +
92 + /// notarized).
93 + gatekeeper_ok: Mutex<Option<bool>>,
94 + /// Steps finalized as `Failed` during this run. A non-empty ledger bars
95 + /// `publish` — an artifact is never shipped after a step failed, even if the
96 + /// recipe ignored the failure and ran on.
97 + failed_steps: Mutex<Vec<Step>>,
98 + /// Set when a newer build supersedes this run. Checked at step boundaries
99 + /// and before publish so a superseded recipe stops promptly rather than
100 + /// running to completion (the blocking Rhai body can't be `abort()`ed).
101 + cancel: Arc<AtomicBool>,
102 + /// The all-targets-green publish gate (topology `require_all_targets`).
103 + /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a
104 + /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each
105 + /// target publishes independently.
106 + all_green_required: Option<Vec<Target>>,
107 + /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish`
108 + /// reads it to record `releases.artifact_hash` for the bytes it ships, so the
109 + /// hash is the one computed when the artifact landed rather than a re-read
110 + /// that could see a different file. Absent ⇒ `publish` hashes on demand.
111 + artifact_hashes: Mutex<HashMap<String, String>>,
112 + }
113 +
114 + impl RecipeCtx {
115 + #[allow(clippy::too_many_arguments)]
116 + pub fn new(
117 + app: AppId,
118 + version: Version,
119 + target: Target,
120 + build_host: String,
121 + build_host_ssh: String,
122 + tag: String,
123 + repo: String,
124 + features: Vec<String>,
125 + kind: Kind,
126 + target_run_id: i64,
127 + execs: Arc<ExecutorMap>,
128 + syncs: Arc<ExecutorMap>,
129 + deploy: Option<DeployTarget>,
130 + pool: SqlitePool,
131 + events: EventTx,
132 + cfg: Arc<Config>,
133 + ota: Arc<OtaRegistry>,
134 + rt: Handle,
135 + cancel: Arc<AtomicBool>,
136 + all_green_required: Option<Vec<Target>>,
137 + ) -> Self {
138 + Self {
139 + app,
140 + version,
141 + target,
142 + build_host,
143 + build_host_ssh,
144 + tag,
145 + repo,
146 + repo_by_host: HashMap::new(),
147 + features,
148 + kind,
149 + target_run_id,
150 + execs,
151 + syncs,
152 + deploy,
153 + pool,
154 + events,
155 + cfg,
156 + ota,
157 + rt,
158 + current: Mutex::new(None),
159 + gatekeeper_ok: Mutex::new(None),
160 + failed_steps: Mutex::new(Vec::new()),
161 + cancel,
162 + all_green_required,
163 + artifact_hashes: Mutex::new(HashMap::new()),
164 + }
165 + }
166 +
167 + /// Declare the app's per-host checkout overrides (topology `repo_by_host`).
168 + ///
169 + /// Separate from `new` because it is empty for every app that has not opted
170 + /// in, and `new` already carries twenty arguments no test wants a
171 + /// twenty-first of.
172 + #[must_use]
173 + pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self {
174 + self.repo_by_host = repo_by_host;
175 + self
176 + }
177 +
178 + /// Where this app is checked out on `host` (topology `AppConfig::repo_for`).
179 + ///
180 + /// Every git command a recipe or the engine runs on a build host goes
181 + /// through this. The bare `repo` field is the daemon-local path.
182 + pub fn repo_for(&self, host: &str) -> &str {
183 + self.repo_by_host
184 + .get(host)
185 + .map_or(self.repo.as_str(), String::as_str)
186 + }
187 +
188 + /// Whether a newer build has superseded this run.
189 + pub(super) fn is_cancelled(&self) -> bool {
190 + self.cancel.load(Ordering::SeqCst)
191 + }
192 +
193 + pub(super) fn now() -> String {
194 + chrono::Utc::now().to_rfc3339()
195 + }
196 +
197 + /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`.
198 + ///
199 + /// The run id is in the filename because the rest of the key is not unique:
200 + /// re-running an app at a version it already built (a retry, or a rebuild of
201 + /// an already-published release) reopens the same path, and appending would
202 + /// show two runs' output with nothing marking the boundary. The
203 + /// step ledger records a run id per step, so keying the file on it makes a
204 + /// ledger row resolve to exactly one file and keeps the earlier run readable.
205 + fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf {
206 + self.log_dir()
207 + .join(format!("{}.{}.log", step.as_str(), run_id.0))
208 + }
209 +
210 + /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`.
211 + fn log_dir(&self) -> PathBuf {
212 + let target_dir = self.target.to_string().replace('/', "-");
213 + self.cfg
214 + .logs_root
215 + .join(self.app.as_str())
216 + .join(self.version.to_string())
217 + .join(target_dir)
218 + }
219 +
220 + /// Close the previous step (as `Ok`), open a new one: insert its DB row,
221 + /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`.
222 + pub(super) fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> {
223 + anyhow::ensure!(
224 + !self.is_cancelled(),
225 + "build superseded by a newer request; aborting before `{}`",
226 + step.as_str()
227 + );
228 + self.finish_step(Status::Ok)?;
229 + let me = self.clone();
230 + let started = Self::now();
231 + let started_for_header = started.clone();
232 + let run_id = self.rt.block_on(async move {
233 + // The step row and the target's current_step pointer are one logical
234 + // state — write them atomically so a failure can't leave a `running`
235 + // step row while current_step still names the previous step.
236 + let mut tx = me.pool.begin().await.context("begin step tx")?;
237 + // log_ref names the run id, which only exists once the row does, so
238 + // the path is written back inside the same transaction rather than
239 + // guessed beforehand.
240 + let id: i64 = sqlx::query_scalar(
241 + "INSERT INTO step_runs (target_run_id, step, status, started_at)
242 + VALUES (?, ?, 'running', ?) RETURNING id",
243 + )
244 + .bind(me.target_run_id)
245 + .bind(step.as_str())
246 + .bind(&started)
247 + .fetch_one(&mut *tx)
248 + .await
249 + .context("insert step_run")?;
250 + let log_ref = me
251 + .log_path(step, StepRunId(id))
252 + .to_string_lossy()
253 + .into_owned();
254 + sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?")
255 + .bind(&log_ref)
256 + .bind(id)
257 + .execute(&mut *tx)
258 + .await
259 + .context("set step_run log_ref")?;
260 + sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?")
261 + .bind(step.as_str())
262 + .bind(me.target_run_id)
263 + .execute(&mut *tx)
264 + .await
265 + .context("update current_step")?;
266 + tx.commit().await.context("commit step tx")?;
267 + anyhow::Ok(StepRunId(id))
268 + })?;
269 +
270 + // Live log: each chunk fans out as a StepLogChunk event keyed by run_id.
271 + let events = self.events.clone();
272 + let cb_run_id = run_id;
273 + let mut log = self.rt.block_on(LiveLog::open(
274 + self.log_path(step, run_id),
275 + Box::new(move |seq, text| {
276 + events::emit(
277 + &events,
278 + Event::StepLogChunk {
279 + run_id: cb_run_id,
280 + seq,
281 + text: text.to_string(),
282 + },
283 + );
284 + }),
285 + ));
286 +
287 + events::emit(
288 + &self.events,
289 + Event::StepStart {
290 + run_id,
291 + app: self.app.clone(),
292 + version: self.version.clone(),
293 + target: self.target,
294 + step,
295 + },
296 + );
297 +
298 + // A log that names its own run: reading one tells you which ledger row
299 + // it belongs to without going back to the DB, and a file that somehow
300 + // does get appended to still shows where the second run began. Written
301 + // after `StepStart` so its chunk event cannot precede the step it
302 + // belongs to.
303 + let header = format!(
304 + "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n",
305 + app = self.app.as_str(),
306 + version = self.version,
307 + target = self.target,
308 + step = step.as_str(),
309 + );
310 + self.rt.block_on(async {
311 + use ops_core::remote::LogSink as _;
312 + log.write_chunk(header.as_bytes()).await;
313 + });
314 +
315 + *self.current.lock().unwrap() = Some(StepState {
316 + run_id,
317 + step,
318 + log: Arc::new(AsyncMutex::new(log)),
319 + failed: false,
320 + deadline: std::time::Instant::now() + self.step_budget(step),
321 + });
322 + Ok(())
323 + }
324 +
325 + /// This build's budget for `step`: the `Config` override if set, else the
326 + /// per-kind default.
327 + fn step_budget(&self, step: Step) -> std::time::Duration {
328 + self.cfg
329 + .step_timeout_secs
330 + .map_or_else(|| default_step_budget(step), std::time::Duration::from_secs)
331 + }
332 +
333 + /// The current step's wall-clock deadline (or a default if no step is open,
334 + /// which only happens before the first `step()` — commands then run under an
335 + /// implicit `Build` step opened by `ensure_step`).
336 + fn step_deadline(&self) -> std::time::Instant {
337 + self.current.lock().unwrap().as_ref().map_or_else(
338 + || std::time::Instant::now() + self.step_budget(Step::Build),
339 + |s| s.deadline,
340 + )
341 + }
342 +
343 + /// Drive `fut` on the runtime, but stop early on two conditions the recipe
344 + /// bodies otherwise cannot observe (they run synchronously on a blocking
345 + /// thread): the current step's deadline, and supersession by a newer build.
346 + /// Either turns into an error that fails the step and unwinds the recipe, so
347 + /// a wedged command cannot run unbounded and a superseded build stops
348 + /// mid-step instead of only at the next step boundary.
349 + pub(super) fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T>
350 + where
351 + F: std::future::Future<Output = Result<T>>,
352 + {
353 + let deadline = self.step_deadline();
354 + let cancel = self.cancel.clone();
355 + self.rt.block_on(async move {
356 + tokio::pin!(fut);
357 + let watch = async {
358 + // Poll the cooperative cancel flag; the finalizer and a
359 + // superseding build both set it. Cheap next to a build step.
360 + while !cancel.load(Ordering::SeqCst) {
361 + tokio::time::sleep(std::time::Duration::from_millis(250)).await;
362 + }
363 + };
364 + tokio::select! {
365 + r = &mut fut => r,
366 + () = tokio::time::sleep_until(deadline.into()) => {
367 + Err(anyhow::anyhow!("`{what}` exceeded its per-step deadline"))
368 + }
369 + () = watch => {
370 + Err(anyhow::anyhow!("build superseded by a newer request; aborting `{what}`"))
371 + }
372 + }
373 + })
374 + }
375 +
376 + /// Flag the currently-open step as failed (no-op if none is open). Forces
377 + /// its recorded status to `Failed` at `finish_step` and adds it to the
378 + /// publish-barring ledger, even though the recipe kept running.
379 + pub(super) fn fail_current_step(&self) {
380 + if let Some(st) = self.current.lock().unwrap().as_mut() {
381 + st.failed = true;
382 + }
383 + }
384 +
385 + /// Finalize the open step (if any): close its log, stamp the DB row, emit
386 + /// `StepDone`. Idempotent when no step is open. A step flagged via
387 + /// `fail_current_step` is recorded `Failed` regardless of the requested
388 + /// status, and added to the ledger `publish` consults.
389 + pub fn finish_step(self: &Arc<Self>, status: Status) -> Result<()> {
390 + let st = self.current.lock().unwrap().take();
391 + let Some(st) = st else { return Ok(()) };
392 + let status = if st.failed { Status::Failed } else { status };
393 + if status == Status::Failed {
394 + self.failed_steps.lock().unwrap().push(st.step);
395 + }
396 + let me = self.clone();
397 + self.rt.block_on(async move {
398 + // Drop all log refs so the sink can be owned + flushed.
399 + if let Ok(m) = Arc::try_unwrap(st.log) {
400 + m.into_inner().close().await;
401 + }
402 + if let Err(e) = sqlx::query(
403 + "UPDATE step_runs SET status = ?, finished_at = ? WHERE id = ?",
404 + )
405 + .bind(status.as_str())
406 + .bind(Self::now())
407 + .bind(st.run_id.0)
408 + .execute(&me.pool)
409 + .await
410 + {
411 + tracing::error!(step = st.step.as_str(), error = %e, "could not stamp step_run status");
412 + }
413 + });
414 + events::emit(
415 + &self.events,
416 + Event::StepDone {
417 + run_id: st.run_id,
418 + app: self.app.clone(),
419 + target: self.target,
420 + step: st.step,
421 + status,
422 + },
423 + );
424 + Ok(())
425 + }
426 +
427 + /// Ensure a step is open; default to `Build` if a recipe runs a command
428 + /// before declaring one.
429 + pub(super) fn ensure_step(self: &Arc<Self>) -> Result<Arc<AsyncMutex<LiveLog>>> {
430 + if self.current.lock().unwrap().is_none() {
431 + self.begin_step(Step::Build)?;
432 + }
433 + Ok(self.current.lock().unwrap().as_ref().unwrap().log.clone())
434 + }
435 +
436 + /// The step currently open, or `Build` as a default for failure
437 + /// attribution before any step was declared.
438 + pub fn current_step(&self) -> Step {
439 + self.current
440 + .lock()
441 + .unwrap()
442 + .as_ref()
443 + .map_or(Step::Build, |s| s.step)
444 + }
445 +
446 + /// The capability-scoped executor for `name`, or an error if the host isn't
447 + /// in the topology.
448 + pub(super) fn exec(&self, name: &str) -> Result<Arc<dyn ops_exec::Executor>> {
449 + self.execs
450 + .get(name)
451 + .cloned()
452 + .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
453 + }
454 +
455 + /// The transport that moves artifacts off `name`, for `collect`'s remote
456 + /// scp source. Distinct from `RecipeCtx::exec`'s executor: an agent host
457 + /// signs over `AgentRpc`
458 + /// but is collected from over ssh (`state::build_sync`).
459 + pub(super) fn host_sync(&self, name: &str) -> Result<Arc<dyn Executor>> {
460 + self.syncs
461 + .get(name)
462 + .cloned()
463 + .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
464 + }
465 +
466 + /// Run `cmd` on `host` through its capability-scoped executor, streaming into
467 + /// the current step's log. The command's [`Action`] is derived from the open
468 + /// step (see [`action_for`]) and gated at the transport before dispatch — so a
469 + /// `build` step on a host without the `build` grant is denied, and the macOS
470 + /// sign steps ride the in-session `AgentRpc` transport automatically. Returns
471 + /// exit code + a tail of stdout for the recipe to branch on.
472 + pub(super) fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> {
473 + // The service host is addressed as a service host whatever step is open.
474 + // Deriving the action from the step is right for a build host, where the
475 + // step IS the work; on a service host it would ask for `build` during a
476 + // `verify` and be denied for a reason unrelated to what was attempted.
477 + let action = match &self.deploy {
478 + Some(d) if d.host == host => Action::Deploy,
479 + _ => action_for(self.current_step(), self.kind),
480 + };
481 + self.run_as(host, cmd, action)
482 + }
483 +
484 + /// `run`, with the [`Action`] stated rather than resolved. Used where the
485 + /// caller already knows which plane it is on.
486 + fn run_as(self: &Arc<Self>, host: &str, cmd: &str, action: Action) -> Result<(i32, String)> {
487 + let sink = self.ensure_step()?;
488 + let exec = self.exec(host)?;
489 + let cur = self.current_step();
490 + let step = OpStep::shell(action, cmd.to_string());
491 + // Echo the command before running it. Without this a log says what
492 + // happened but not what was asked, and a gate that prints nothing when
493 + // it passes (`cargo fmt --all --check`) is indistinguishable from a gate
494 + // that never ran.
495 + let echo = format!("$ [{host}] {cmd}\n");
496 + // Bounded by the step's deadline and interruptible on supersession, so a
497 + // hung command fails its step instead of running unbounded, and a
498 + // superseded build stops mid-step rather than only at the next boundary.
499 + let label = format!("{cur} command on `{host}`");
500 + let out = self.run_bounded(&label, async move {
Lines truncated
@@ -1,0 +1,489 @@
1 + //! Deploying a service target, and the glibc floor that says whether the host
2 + //! can run what was built.
3 +
4 + use super::DEPLOY_STAGING_ROOT;
5 + use super::RecipeCtx;
6 + use super::collect::ensure_glob_safe;
7 + use super::git::expand_tilde;
8 + use crate::topology::{DeployTarget, Kind};
9 + use anyhow::{Context as _, Result};
10 + use ops_exec::{Action, SyncOpts};
11 + use std::path::{Path, PathBuf};
12 + use std::sync::Arc;
13 +
14 + /// Highest `GLIBC_x.y` version referenced by a built binary, and the glibc a
15 + /// host actually has, both parsed from the text the commands print.
16 + ///
17 + /// Native-per-architecture builds remove the cross-compile hazard `deploy.sh`
18 + /// was written against, but not this one: fw13 tracks a newer glibc than the
19 + /// Ubuntu 24.04 box in Hetzner, so a binary built here can reference a symbol
20 + /// version that box does not have and fail at exec — after the unit has already
21 + /// been restarted onto it. Comparing the two before the install is what makes
22 + /// that a failed step instead of a downed service.
23 + pub(super) fn max_glibc_symbol(objdump_out: &str) -> Option<(u64, u64)> {
24 + objdump_out
25 + .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '_' || c.is_ascii_alphabetic()))
26 + .filter_map(|tok| tok.strip_prefix("GLIBC_"))
27 + .filter_map(parse_glibc_version)
28 + .max()
29 + }
30 +
31 + /// Parse `2.39` (or `2.39.1`, keeping major/minor) into a comparable pair.
32 + pub(super) fn parse_glibc_version(s: &str) -> Option<(u64, u64)> {
33 + let mut parts = s.split('.');
34 + let major = parts.next()?.parse().ok()?;
35 + let minor = parts.next()?.parse().ok()?;
36 + Some((major, minor))
37 + }
38 +
39 + /// The glibc version out of `ldd --version`'s first line, whose tail is the
40 + /// version however the distro decorates the rest (`ldd (Ubuntu GLIBC
41 + /// 2.39-0ubuntu8.8) 2.39`).
42 + pub(super) fn glibc_from_ldd(ldd_out: &str) -> Option<(u64, u64)> {
43 + let first = ldd_out.lines().find(|l| !l.trim().is_empty())?;
44 + parse_glibc_version(first.split_whitespace().last()?)
45 + }
46 +
47 + impl RecipeCtx {
48 + /// This target's install destination, or an error naming why there is none.
49 + pub(super) fn deploy_target(&self) -> Result<&DeployTarget> {
50 + self.deploy.as_ref().ok_or_else(|| {
51 + anyhow::anyhow!(
52 + "no deploy destination for {} {}: the app is `kind = \"{}\"`, and only a \
53 + service declares [[deploy]] entries",
54 + self.app,
55 + self.target,
56 + match self.kind {
57 + Kind::App => "app",
58 + Kind::Library => "library",
59 + Kind::Service => "service",
60 + }
61 + )
62 + })
63 + }
64 +
65 + /// Compare the built binary's glibc requirement against the service host's.
66 + /// Returns the two versions for the recipe to log.
67 + pub(super) fn glibc_check(self: &Arc<Self>, binary: &str) -> Result<(String, String)> {
68 + let d = self.deploy_target()?.clone();
69 + // `objdump -T` on the build host; no symbols at all (a static binary)
70 + // means nothing to check, which is a pass rather than a failure.
71 + let (code, out) = self.run(
72 + &self.build_host.clone(),
73 + &format!(
74 + "objdump -T {binary} 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sort -uV || true"
75 + ),
76 + )?;
77 + anyhow::ensure!(code == 0, "reading glibc symbols from {binary} failed");
78 + let Some(needs) = max_glibc_symbol(&out) else {
79 + return Ok(("none".into(), "n/a".into()));
80 + };
81 + let (code, ldd) = self.run(&d.host, "ldd --version")?;
82 + anyhow::ensure!(
83 + code == 0,
84 + "could not read glibc version on service host `{}`",
85 + d.host
86 + );
87 + let has = glibc_from_ldd(&ldd).ok_or_else(|| {
88 + anyhow::anyhow!(
89 + "could not parse glibc version from `ldd --version` on `{}`",
90 + d.host
91 + )
92 + })?;
93 + anyhow::ensure!(
94 + needs <= has,
95 + "binary needs glibc {}.{} but `{}` has {}.{} — it would fail to exec after the \
96 + unit restarted onto it. Build on a host no newer than the service host.",
97 + needs.0,
98 + needs.1,
99 + d.host,
100 + has.0,
101 + has.1,
102 + );
103 + Ok((
104 + format!("{}.{}", needs.0, needs.1),
105 + format!("{}.{}", has.0, has.1),
106 + ))
107 + }
108 +
109 + /// Install `binary` (a path on the BUILD host) onto the service host and
110 + /// restart its unit, via the privileged installer the host holds a scoped
111 + /// sudo grant for.
112 + ///
113 + /// Bento never runs the install itself. It stages the bytes and calls a
114 + /// root script whose arguments are re-checked on the far side — the same
115 + /// shape as Sando's `install-companion.sh`, and for the same reason: the
116 + /// sudoers grant is then ONE auditable script rather than a broad
117 + /// `install`+`systemctl` grant on a production box.
118 + ///
119 + /// Only the binary moves. Config is deliberately untouched: pom's
120 + /// `pom-astra.toml` / `pom-hetzner.toml` differ per instance, and prod's
121 + /// carried a `[targets.mnw.tests]` block the repo did not have. A deploy
122 + /// that copies config over is how that block gets silently deleted.
123 + pub(super) fn deploy(self: &Arc<Self>, binary: &str) -> Result<String> {
124 + anyhow::ensure!(
125 + !self.is_cancelled(),
126 + "build superseded by a newer request; refusing to deploy"
127 + );
128 + // A failed earlier step bars a deploy exactly as it bars a publish. An
129 + // artifact that failed its gates must not reach a production host just
130 + // because the recipe kept running.
131 + let failed = self.failed_steps_snapshot();
132 + anyhow::ensure!(
133 + failed.is_empty(),
134 + "refusing to deploy {} {}: {} failed earlier in this run",
135 + self.app,
136 + self.version,
137 + failed
138 + .iter()
139 + .map(ToString::to_string)
140 + .collect::<Vec<_>>()
141 + .join(", "),
142 + );
143 + let d = self.deploy_target()?.clone();
144 + ensure_glob_safe(binary)?;
145 +
146 + // Stage under a fixed root the installer also insists on, so "what was
147 + // checked" and "what is installed" cannot drift apart.
148 + let staged = format!("{DEPLOY_STAGING_ROOT}/{}", self.app);
149 + let staged_bin = format!("{staged}/{}", self.app);
150 + let deploy_exec = self.exec(&d.host)?;
151 + anyhow::ensure!(
152 + deploy_exec.capabilities().permits(&Action::Deploy),
153 + "service host `{}` is not granted the `deploy` capability",
154 + d.host
155 + );
156 +
157 + self.run_ok(&d.host, &format!("mkdir -p {staged}"))?;
158 + if self.build_host_ssh == d.host {
159 + // Same box: the binary is already there. Routing it through the
160 + // daemon would be two transfers to end up where it started. This is
161 + // pom's aarch64 leg — astra builds it and astra runs it.
162 + self.run_ok(&d.host, &format!("cp -f {binary} {staged_bin}"))?;
163 + } else {
164 + // Build host -> daemon -> service host. Two hops because an executor
165 + // reaches one host; a direct host-to-host transport would mean the
166 + // build host holding a credential for the production box.
167 + let tmp = tempfile::tempdir().context("staging dir for deploy")?;
168 + let local = tmp.path().join(self.app.as_str());
169 + self.pull_for_deploy(binary, &local)?;
170 + let (dest, opts) = (PathBuf::from(&staged), SyncOpts::default());
171 + let dir = tmp.path().to_path_buf();
172 + self.run_bounded(&format!("stage {} on `{}`", self.app, d.host), async move {
173 + deploy_exec.push_dir(&dir, &dest, &opts).await
174 + })
175 + .with_context(|| format!("staging {} onto `{}`", self.app, d.host))?;
176 + }
177 +
178 + // The privileged half. Every argument is re-validated by the script,
179 + // which is the thing actually holding the sudo grant.
180 + self.run_ok(
181 + &d.host,
182 + &format!(
183 + "{} {staged_bin} {} {}",
184 + self.cfg.deploy_installer, d.install_path, d.service
185 + ),
186 + )?;
187 + Ok(format!(
188 + "{} {} installed at {} on `{}`; {} restarted",
189 + self.app, self.version, d.install_path, d.host, d.service
190 + ))
191 + }
192 +
193 + /// Fetch one file off a host into a daemon-local path for re-pushing.
194 + ///
195 + /// A local build host is read directly: `fw13` is the daemon's own box, so
196 + /// the file is already on this filesystem. Routing it through the
197 + /// artifact-pull gate instead would demand a `pull_root` covering every repo
198 + /// a service could be built in — today that is `~/Code/Apps`, and pom lives
199 + /// in `~/Code/MNW`. Widening it to `~/Code` would put `_private`, the
200 + /// secrets root, inside the collectable tree. This is pom's x86_64 leg.
201 + fn pull_for_deploy(self: &Arc<Self>, remote: &str, local: &Path) -> Result<()> {
202 + let host = self.build_host.clone();
203 + let remote_path = expand_tilde(remote);
204 + if self.build_host_ssh == "local" || self.build_host_ssh.is_empty() {
205 + std::fs::copy(&remote_path, local).with_context(|| {
206 + format!("staging {} from the daemon host", remote_path.display())
207 + })?;
208 + return Ok(());
209 + }
210 + let sync = self.host_sync(&host)?;
211 + let (src, dst, opts) = (remote_path, local.to_path_buf(), SyncOpts::default());
212 + self.run_bounded(&format!("fetch {remote} from `{host}`"), async move {
213 + sync.pull_file(&src, &dst, &opts).await
214 + })
215 + .with_context(|| format!("fetching {remote} from `{host}` to deploy"))
216 + }
217 +
218 + /// `run`, failing the step on a non-zero exit. The Rust-side twin of the
219 + /// recipe's `sh_ok`, for commands the deploy machinery issues itself.
220 + fn run_ok(self: &Arc<Self>, host: &str, cmd: &str) -> Result<String> {
221 + let (code, tail) = self.run(host, cmd)?;
222 + if code != 0 {
223 + self.fail_current_step();
224 + anyhow::bail!("command on `{host}` exited {code}: {cmd}\n{tail}");
225 + }
226 + Ok(tail)
227 + }
228 + }
229 +
230 + #[cfg(test)]
231 + mod tests {
232 + use super::super::action_for;
233 + use super::super::build_engine;
234 + use super::*;
235 + use crate::config::Config;
236 + use crate::domain::{AppId, Status, Step, Version};
237 + use crate::ota::OtaRegistry;
238 + use std::sync::atomic::AtomicBool;
239 +
240 + /// The comparison that decides whether a binary can exec on the box that is
241 + /// about to be restarted onto it. Both sides are parsed out of text a tool
242 + /// printed, so both parsers are worth pinning: fw13 tracks a newer glibc
243 + /// than the Ubuntu 24.04 host in Hetzner, and getting this backwards means a
244 + /// dead unit rather than a failed step.
245 + #[test]
246 + fn glibc_versions_parse_from_what_the_tools_actually_print() {
247 + // `objdump -T | grep -o 'GLIBC_[0-9.]*'` output: highest wins, and the
248 + // comparison is numeric (2.9 must not beat 2.34 lexically).
249 + let objdump = "GLIBC_2.2.5\nGLIBC_2.34\nGLIBC_2.9\nGLIBC_2.17\n";
250 + assert_eq!(max_glibc_symbol(objdump), Some((2, 34)));
251 + // A static binary references none: nothing to check.
252 + assert_eq!(max_glibc_symbol(""), None);
253 +
254 + // `ldd --version` first line, however the distro decorates it.
255 + assert_eq!(
256 + glibc_from_ldd("ldd (Ubuntu GLIBC 2.39-0ubuntu8.8) 2.39\nCopyright...\n"),
257 + Some((2, 39))
258 + );
259 + assert_eq!(
260 + glibc_from_ldd("ldd (GNU libc) 2.41\nCopyright (C) 2025\n"),
261 + Some((2, 41))
262 + );
263 + assert_eq!(glibc_from_ldd(""), None);
264 + }
265 +
266 + /// A binary needing MORE than the host has is the failure this check exists
267 + /// for; equal and less are both fine (glibc symbol versioning is backward
268 + /// compatible, so an older requirement runs on a newer host).
269 + #[test]
270 + fn glibc_requirement_is_satisfied_by_equal_or_newer_only() {
271 + let needs = max_glibc_symbol("GLIBC_2.41").unwrap();
272 + assert!(needs > glibc_from_ldd("ldd (Ubuntu GLIBC 2.39) 2.39").unwrap());
273 + assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.41").unwrap());
274 + assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.42").unwrap());
275 + assert!(needs <= glibc_from_ldd("ldd (GNU libc) 3.0").unwrap());
276 + }
277 +
278 + /// Every deploy host function fails with the app's KIND as the reason when
279 + /// there is no destination, rather than with a missing-host error from
280 + /// somewhere deeper. A recipe calling `deploy()` on a library is a recipe
281 + /// written against the wrong kind, and the message should say so.
282 + #[tokio::test]
283 + async fn deploy_host_fns_explain_a_missing_destination_by_kind() {
284 + let dir = tempfile::tempdir().unwrap();
285 + let cfg = Arc::new(Config::for_tests(dir.path()));
286 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
287 + let ctx = Arc::new(RecipeCtx::new(
288 + AppId::new("demo"),
289 + Version::parse("0.1.0").unwrap(),
290 + "linux/x86_64".parse().unwrap(),
291 + "fw13".into(),
292 + "local".into(),
293 + "v0.1.0".into(),
294 + "/tmp".into(),
295 + vec![],
296 + Kind::Library,
297 + 1,
298 + Arc::new(std::collections::HashMap::new()),
299 + Arc::new(std::collections::HashMap::new()),
300 + None,
301 + pool,
302 + crate::events::channel(),
303 + cfg,
304 + Arc::new(OtaRegistry::standard("https://makenot.work")),
305 + tokio::runtime::Handle::current(),
306 + Arc::new(AtomicBool::new(false)),
307 + None,
308 + ));
309 + let engine = build_engine(&ctx);
310 + for call in [
311 + "deploy_host()",
312 + "service_name()",
313 + "install_path()",
314 + "health_url()",
315 + r#"deploy("/tmp/x")"#,
316 + ] {
317 + let err = engine.eval::<String>(call).unwrap_err().to_string();
318 + assert!(
319 + err.contains("library") && err.contains("no deploy destination"),
320 + "`{call}` must fail on the kind, got: {err}"
321 + );
322 + }
323 + }
324 +
325 + /// A service host is addressed on the DEPLOY plane whatever step is open.
326 + ///
327 + /// The subtle one. Actions are normally derived from the step, which is
328 + /// right for a build host — the step is what that host is being asked to do.
329 + /// A service host is granted `deploy`/`restart` and must never be granted
330 + /// `build`, so the same rule would have `glibc_check` ask it for `build`
331 + /// during a `verify` step and get denied for a reason unrelated to what was
332 + /// attempted. `verify` is the step that check belongs in, so without this
333 + /// routing the glibc gate cannot run at all.
334 + #[tokio::test]
335 + async fn a_service_host_is_addressed_on_the_deploy_plane_in_any_step() {
336 + let dir = tempfile::tempdir().unwrap();
337 + let cfg = Arc::new(Config::for_tests(dir.path()));
338 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
339 + sqlx::query(
340 + "INSERT INTO builds (id, app, version, status, created_at) \
341 + VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')",
342 + )
343 + .execute(&pool)
344 + .await
345 + .unwrap();
346 + sqlx::query(
347 + "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \
348 + VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')",
349 + )
350 + .execute(&pool)
351 + .await
352 + .unwrap();
353 +
354 + let deploy = crate::topology::DeployTarget {
355 + target: "linux/x86_64".parse().unwrap(),
356 + host: "local".into(),
357 + port: None,
358 + install_path: "/usr/local/bin/demo".into(),
359 + service: "demo.service".into(),
360 + health_url: None,
361 + };
362 + let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new();
363 + execs.insert("local".into(), crate::state::build_deploy_executor(&deploy));
364 + // The service host's grant is exactly deploy + restart. If this ever
365 + // widens to include `build`, the test below stops proving anything.
366 + assert!(!execs["local"].capabilities().permits(&Action::Build));
367 + assert!(execs["local"].capabilities().permits(&Action::Deploy));
368 +
369 + let ctx = Arc::new(RecipeCtx::new(
370 + AppId::new("demo"),
371 + Version::parse("0.1.0").unwrap(),
372 + "linux/x86_64".parse().unwrap(),
373 + "fw13".into(),
374 + "local".into(),
375 + "v0.1.0".into(),
376 + "/tmp".into(),
377 + vec![],
378 + Kind::Service,
379 + 1,
380 + Arc::new(execs),
381 + Arc::new(std::collections::HashMap::new()),
382 + Some(deploy),
383 + pool,
384 + crate::events::channel(),
385 + cfg,
386 + Arc::new(OtaRegistry::standard("https://makenot.work")),
387 + tokio::runtime::Handle::current(),
388 + Arc::new(AtomicBool::new(false)),
389 + None,
390 + ));
391 +
392 + let ctx_blocking = ctx.clone();
393 + tokio::task::spawn_blocking(move || {
394 + // `verify` on a service derives Action::Build — which the service
395 + // host does not grant. The command must still run.
396 + ctx_blocking.begin_step(Step::Verify).unwrap();
397 + assert_eq!(
398 + action_for(Step::Verify, Kind::Service),
399 + Action::Build,
400 + "the step's own action is the one that would be denied",
401 + );
402 + let (code, out) = ctx_blocking
403 + .run("local", "echo reached-the-service-host")
404 + .expect("a service host must be reachable during a verify step");
405 + assert_eq!(code, 0, "{out}");
406 + assert!(out.contains("reached-the-service-host"), "{out}");
407 + })
408 + .await
409 + .unwrap();
410 + }
411 +
412 + /// A step that finalized `Failed` bars the deploy, exactly as it bars a
413 + /// publish. Without this, a recipe that inspects `sh(...).code` and carries
414 + /// on regardless still lands a binary on a production host — the precise
415 + /// hazard a pipeline exists to remove. The check is the ledger, not the
416 + /// control flow, so it holds whether or not the recipe noticed.
417 + #[tokio::test]
418 + async fn a_failed_step_bars_the_deploy() {
419 + let dir = tempfile::tempdir().unwrap();
420 + let cfg = Arc::new(Config::for_tests(dir.path()));
421 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
422 + let deploy = crate::topology::DeployTarget {
423 + target: "linux/x86_64".parse().unwrap(),
424 + host: "local".into(),
425 + port: None,
426 + install_path: "/usr/local/bin/demo".into(),
427 + service: "demo.service".into(),
428 + health_url: None,
429 + };
430 + // A real build + target run, so the step rows this test finalizes have
431 + // the parents the schema requires.
432 + sqlx::query(
433 + "INSERT INTO builds (id, app, version, status, created_at) \
434 + VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')",
435 + )
436 + .execute(&pool)
437 + .await
438 + .unwrap();
439 + sqlx::query(
440 + "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \
441 + VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')",
442 + )
443 + .execute(&pool)
444 + .await
445 + .unwrap();
446 +
447 + let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new();
448 + execs.insert("local".into(), crate::state::build_deploy_executor(&deploy));
449 + let ctx = Arc::new(RecipeCtx::new(
450 + AppId::new("demo"),
451 + Version::parse("0.1.0").unwrap(),
452 + "linux/x86_64".parse().unwrap(),
453 + "fw13".into(),
454 + "local".into(),
455 + "v0.1.0".into(),
456 + "/tmp".into(),
457 + vec![],
458 + Kind::Service,
459 + 1,
460 + Arc::new(execs),
461 + Arc::new(std::collections::HashMap::new()),
462 + Some(deploy),
463 + pool,
464 + crate::events::channel(),
465 + cfg,
466 + Arc::new(OtaRegistry::standard("https://makenot.work")),
467 + tokio::runtime::Handle::current(),
468 + Arc::new(AtomicBool::new(false)),
469 + None,
470 + ));
471 +
472 + // A gate ran, failed, and the recipe did not abort — the swallowed
473 + // failure. Finalizing it is what puts it in the ledger.
474 + let ctx_blocking = ctx.clone();
475 + tokio::task::spawn_blocking(move || {
476 + ctx_blocking.begin_step(Step::Prebuild).unwrap();
477 + ctx_blocking.fail_current_step();
478 + ctx_blocking.finish_step(Status::Ok).unwrap();
479 +
480 + let err = ctx_blocking.deploy("/tmp/demo").unwrap_err().to_string();
481 + assert!(
482 + err.contains("refusing to deploy") && err.contains("prebuild"),
483 + "must refuse and name the failed step, got: {err}"
484 + );
485 + })
486 + .await
487 + .unwrap();
488 + }
489 + }
@@ -1,0 +1,337 @@
1 + //! Building the git command lines the engine runs, and reading their output.
2 + //!
3 + //! Command builders rather than command runners, which is what makes them
4 + //! testable without a repository.
5 +
6 + use std::path::{Path, PathBuf};
7 +
8 + /// Refresh every remote's refs and tags, so the tag a release names is present
9 + /// locally however it was pushed. No branch/upstream assumptions — a bare
10 + /// `git pull --ff-only` needs a tracking branch the release path shouldn't
11 + /// depend on.
12 + ///
13 + /// Best-effort on purpose. `fetch --all` exits non-zero if ANY remote fails, and
14 + /// the library repos carry three (`astra`, `mnw`, `srht`), so chaining this into
15 + /// the checkout with `&&` meant one unreachable mirror aborted the release and
16 + /// reported it as a missing tag. The checkout below is the step allowed to fail;
17 + /// this one only has to try. See [`git_worktree_pin_cmd`].
18 + ///
19 + /// `repo` is interpolated UNQUOTED so a leading `~` is expanded by the remote
20 + /// host's shell (the checkout path is trusted topology config, not user input),
21 + /// matching how the recipes `cd` into it.
22 + pub fn git_fetch_cmd(repo: &str) -> String {
23 + format!("git -C {repo} fetch --all --tags --prune")
24 + }
25 +
26 + /// Probe for the one failure a tracked `Cargo.lock` hits inside Bento's
27 + /// worktree. Exits 0 when the crate tracks a lock AND the checkout sits under a
28 + /// `.cargo/config.toml` declaring `[patch]`; 1 otherwise.
29 + ///
30 + /// Both halves are needed and cargo reports neither. Under a `[patch]` block
31 + /// cargo re-resolves and rewrites the lock's `[[patch.unused]]` entries, so
32 + /// `cargo publish --dry-run` refuses the tree with "1 files in the working
33 + /// directory contain changes that were not yet committed into git: Cargo.lock"
34 + /// -- naming the lock and nothing about why it moved. pter 0.2.1 lost half an
35 + /// hour to that message on build 325.
36 + ///
37 + /// `~/Code/.bento` is under `~/Code` deliberately, so that the patch block
38 + /// reaches the build (see [`crate::topology::Host::worktree_root`]). The patch
39 + /// block is therefore not the half to remove, which is why this is worth
40 + /// saying rather than leaving cargo to be cryptic about it.
41 + ///
42 + /// `repo` is interpolated unquoted for the `~`, matching [`git_fetch_cmd`];
43 + /// `pwd -P` then hands the loop an absolute path to walk up from.
44 + pub(super) fn tracked_lock_under_patch_cmd(repo: &str) -> String {
45 + format!(
46 + "git -C {repo} ls-files --error-unmatch Cargo.lock >/dev/null 2>&1 || exit 1; \
47 + d=$(cd {repo} && pwd -P) || exit 1; \
48 + while [ -n \"$d\" ] && [ \"$d\" != / ]; do \
49 + for c in \"$d/.cargo/config.toml\" \"$d/.cargo/config\"; do \
50 + [ -f \"$c\" ] && grep -q '^\\[patch' \"$c\" && exit 0; \
51 + done; d=$(dirname \"$d\"); done; exit 1"
52 + )
53 + }
54 +
55 + /// What to say when [`tracked_lock_under_patch_cmd`] answers yes. Names both
56 + /// facts, because the error cargo would otherwise print names neither, and
57 + /// closes off the two wrong fixes that are both one flag away.
58 + pub(super) fn tracked_lock_under_patch_problem(repo: &str) -> String {
59 + format!(
60 + "`Cargo.lock` is tracked and {repo} sits under a `.cargo/config.toml` \
61 + declaring `[patch]`. Cargo re-resolves there and rewrites the lock, so \
62 + `cargo publish --dry-run` will refuse the tree as dirty and name only \
63 + the lock. Untrack it: `git rm --cached Cargo.lock`. Every other library \
64 + in this tree already does. Not `--allow-dirty`, which publishes a lock \
65 + nobody reviewed, and not committing the rewritten lock, which resolves \
66 + differently on the next machine and fails there instead. The build \
67 + worktree is under `~/Code` on purpose so the patch block applies to it; \
68 + that is not the half to change."
69 + )
70 + }
71 +
72 + /// Does `tag` resolve to a commit in this checkout? Run only when the checkout
73 + /// has already failed, to say WHY: an absent tag is an untagged or unpushed
74 + /// release, while a tag that resolves fine means the checkout was refused for a
75 + /// local reason (a dirty tree, most often) and the operator needs to hear that
76 + /// instead.
77 + pub fn git_tag_exists_cmd(repo: &str, tag: &str) -> String {
78 + format!("git -C {repo} rev-parse -q --verify \"refs/tags/{tag}^{{commit}}\"")
79 + }
80 +
81 + /// Which repository `repo` belongs to, and where `repo` sits inside it, in one
82 + /// call: `--show-toplevel` then `--show-prefix`, one per line.
83 + ///
84 + /// Both halves are needed to build in a worktree of a repo holding several
85 + /// products. The worktree is made of the repository (`~/Code/MNW`), and the
86 + /// recipe has to be pointed at the app inside it (`<worktree>/pom`).
87 + pub fn git_toplevel_and_prefix_cmd(repo: &str) -> String {
88 + format!("git -C {repo} rev-parse --show-toplevel --show-prefix")
89 + }
90 +
91 + /// Read [`git_toplevel_and_prefix_cmd`]'s two lines.
92 + ///
93 + /// The prefix is empty for a repo holding one product, where `repo` IS the
94 + /// repository root — and git prints an empty second line for it, so a missing
95 + /// line is a malformed answer rather than that case.
96 + pub fn parse_toplevel_and_prefix(out: &str) -> Option<(String, String)> {
97 + let mut lines = out.split('\n');
98 + let toplevel = lines.next()?.trim().to_string();
99 + let prefix = lines.next()?.trim().to_string();
100 + (!toplevel.is_empty()).then_some((toplevel, prefix))
101 + }
102 +
103 + /// The repository's own directory name, which is what names its worktrees:
104 + /// `MNW` for `/home/max/Code/MNW`.
105 + ///
106 + /// Splits on `/` only. Git reports `--show-toplevel` with forward slashes on
107 + /// every platform, Windows included, so this is the separator to read.
108 + pub fn repo_dir_name(toplevel: &str) -> &str {
109 + toplevel
110 + .trim_end_matches('/')
111 + .rsplit('/')
112 + .next()
113 + .unwrap_or(toplevel)
114 + }
115 +
116 + /// Where the app being released sits inside its worktree: the worktree root for
117 + /// a repo holding one product, `<worktree>/pom` for one holding several.
118 + pub fn app_dir_in_worktree(worktree: &str, prefix: &str) -> String {
119 + let prefix = prefix.trim_matches('/');
120 + if prefix.is_empty() {
121 + worktree.to_string()
122 + } else {
123 + format!("{}/{prefix}", worktree.trim_end_matches('/'))
124 + }
125 + }
126 +
127 + /// Does this worktree already exist? Run before deciding whether to create one.
128 + ///
129 + /// `rev-parse --git-dir` rather than a shell test, because the one non-unix
130 + /// build host has no `test`: every command Bento renders for a host is a git
131 + /// command or something a recipe wrote.
132 + pub fn git_worktree_probe_cmd(worktree: &str) -> String {
133 + format!("git -C \"{worktree}\" rev-parse --git-dir")
134 + }
135 +
136 + /// Forget worktrees whose directories are gone. Run before creating one: a
137 + /// directory somebody deleted by hand is still registered in the repository, and
138 + /// `worktree add` refuses the path as in use rather than rebuilding it.
139 + pub fn git_worktree_prune_cmd(toplevel: &str) -> String {
140 + format!("git -C \"{toplevel}\" worktree prune")
141 + }
142 +
143 + /// Create this app's build worktree, detached at the release tag. Git creates
144 + /// the leading directories, so the worktree root needs no preparation.
145 + pub fn git_worktree_add_cmd(toplevel: &str, worktree: &str, tag: &str) -> String {
146 + format!("git -C \"{toplevel}\" worktree add --detach --force \"{worktree}\" \"{tag}\"")
147 + }
148 +
149 + /// Put an existing build worktree at the release tag.
150 + ///
151 + /// `--force` discards whatever the last release left in it — a rewritten
152 + /// `Cargo.lock`, most often — and that is safe here in a way it never was in the
153 + /// ordinary checkout: nothing but Bento writes in this tree, so there is no edit
154 + /// of anybody's to lose. Owning the tree is what buys the forcing.
155 + pub fn git_worktree_pin_cmd(worktree: &str, tag: &str) -> String {
156 + format!("git -C \"{worktree}\" checkout --detach --force \"{tag}\"")
157 + }
158 +
159 + /// The operator-facing explanation for a worktree that could not be put at the
160 + /// tag.
161 + ///
162 + /// An absent tag is an untagged or unpushed release and is the common case, so
163 + /// it is answered plainly. Anything else is git's own stderr, which says more
164 + /// about a path that is not a worktree, or a worktree another release holds,
165 + /// than a guess would.
166 + pub fn worktree_failure_reason(tag: &str, tag_exists: bool, stderr: &str) -> String {
167 + if !tag_exists {
168 + return format!("tag {tag} does not exist there (is it created and pushed?)");
169 + }
170 + let stderr = stderr.trim();
171 + if stderr.is_empty() {
172 + format!("tag {tag} exists, and git said nothing about why")
173 + } else {
174 + stderr.to_string()
175 + }
176 + }
177 +
178 + /// The command a host runs to report the commit it has checked out, for the
179 + /// release preflight barrier.
180 + pub fn git_rev_parse_cmd(repo: &str) -> String {
181 + format!("git -C {repo} rev-parse HEAD")
182 + }
183 +
184 + /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
185 + pub fn expand_tilde(p: &str) -> PathBuf {
186 + if let Some(rest) = p.strip_prefix("~/")
187 + && let Ok(home) = std::env::var("HOME")
188 + {
189 + return Path::new(&home).join(rest);
190 + }
191 + PathBuf::from(p)
192 + }
193 +
194 + #[cfg(test)]
195 + mod tests {
196 + use super::*;
197 +
198 + /// Reads the ambient `HOME` rather than setting one. `set_var` is
199 + /// process-global and unsynchronized, so a test that overwrote HOME changed
200 + /// it for every other test in the binary — which is what silently disabled
201 + /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is
202 + /// absent, and `/home/test` always is).
203 + #[test]
204 + fn expand_tilde_handles_home() {
205 + let home = PathBuf::from(std::env::var("HOME").expect("HOME is set"));
206 + assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x"));
207 + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
208 + }
209 +
210 + /// Both shapes of repo: one holding several products, and one holding a
211 + /// single crate, where git prints an empty prefix line.
212 + #[test]
213 + fn toplevel_and_prefix_read_both_shapes_of_repo() {
214 + let (top, prefix) =
215 + parse_toplevel_and_prefix("/home/max/Code/MNW\npom/\n").expect("two lines");
216 + assert_eq!(top, "/home/max/Code/MNW");
217 + assert_eq!(prefix, "pom/");
218 + // A repo holding one product: git prints an empty second line.
219 + let (top, prefix) =
220 + parse_toplevel_and_prefix("/home/max/Code/Libraries/pter\n\n").expect("two lines");
221 + assert_eq!(top, "/home/max/Code/Libraries/pter");
222 + assert_eq!(prefix, "");
223 + assert!(
224 + parse_toplevel_and_prefix("").is_none(),
225 + "no answer is not an answer"
226 + );
227 + }
228 +
229 + #[test]
230 + fn repo_dir_name_is_the_last_segment_on_every_platform() {
231 + assert_eq!(repo_dir_name("/home/max/Code/MNW"), "MNW");
232 + assert_eq!(repo_dir_name("/home/max/Code/MNW/"), "MNW");
233 + // Git reports forward slashes on Windows too.
234 + assert_eq!(repo_dir_name("C:/Users/me/Code/Apps/goingson"), "goingson");
235 + }
236 +
237 + /// The app's directory inside its worktree, for both repo shapes.
238 + #[test]
239 + fn app_dir_in_worktree_follows_the_prefix() {
240 + assert_eq!(
241 + app_dir_in_worktree("/home/max/Code/.bento/MNW/pom", "pom/"),
242 + "/home/max/Code/.bento/MNW/pom/pom"
243 + );
244 + assert_eq!(
245 + app_dir_in_worktree("/home/max/Code/.bento/pter/pter", ""),
246 + "/home/max/Code/.bento/pter/pter"
247 + );
248 + }
249 +
250 + /// A missing tag is the common failure and gets a plain answer; anything
251 + /// else is git's own stderr, which says more than a guess.
252 + #[test]
253 + fn worktree_failure_reason_names_the_tag_or_repeats_git() {
254 + let missing = worktree_failure_reason("pom-v0.4.5", false, "irrelevant");
255 + assert!(missing.contains("does not exist"), "{missing}");
256 + let held = worktree_failure_reason(
257 + "pom-v0.4.5",
258 + true,
259 + "fatal: '/home/max/Code/.bento/MNW/pom' already exists",
260 + );
261 + assert!(held.contains("already exists"), "{held}");
262 + let silent = worktree_failure_reason("pom-v0.4.5", true, " ");
263 + assert!(silent.contains("said nothing"), "{silent}");
264 + }
265 +
266 + /// Run the probe for real rather than asserting on its text: it is shell,
267 + /// and the thing worth knowing is whether `sh` agrees, not whether the
268 + /// string looks right.
269 + fn probe(repo: &std::path::Path) -> bool {
270 + std::process::Command::new("sh")
271 + .arg("-c")
272 + .arg(tracked_lock_under_patch_cmd(&repo.display().to_string()))
273 + .status()
274 + .unwrap()
275 + .success()
276 + }
277 +
278 + /// A crate under a `[patch]` root, with and without its lock tracked.
279 + ///
280 + /// pter 0.2.1 is the case: the tracked half was true, the patch half was
281 + /// true, and cargo reported only "Cargo.lock" (build 325). Untracking the
282 + /// lock in `a9969a9` is what fixed it, and this asserts the probe agrees
283 + /// with that fix in both directions.
284 + #[test]
285 + fn a_tracked_lock_is_only_a_problem_under_a_patch_block() {
286 + let root = tempfile::tempdir().unwrap();
287 + let repo = root.path().join("crate");
288 + std::fs::create_dir_all(&repo).unwrap();
289 + let git = |args: &[&str]| {
290 + std::process::Command::new("git")
291 + .args(args)
292 + .current_dir(&repo)
293 + .env("GIT_AUTHOR_NAME", "t")
294 + .env("GIT_AUTHOR_EMAIL", "t@t")
295 + .env("GIT_COMMITTER_NAME", "t")
296 + .env("GIT_COMMITTER_EMAIL", "t@t")
297 + .output()
298 + .unwrap()
299 + };
300 + git(&["init", "-q", "."]);
301 + std::fs::write(repo.join("Cargo.lock"), "# lock\n").unwrap();
302 +
303 + // Lock present but untracked, no patch anywhere: nothing to say.
304 + assert!(!probe(&repo));
305 +
306 + // Tracked, still no patch root. Every library that commits a lock and
307 + // builds outside `~/Code` lives here, and it publishes fine.
308 + git(&["add", "Cargo.lock"]);
309 + git(&["commit", "-qm", "lock"]);
310 + assert!(!probe(&repo));
311 +
312 + // The ancestor declares `[patch]`. Both halves now hold.
313 + std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
314 + std::fs::write(
315 + root.path().join(".cargo/config.toml"),
316 + "[patch.\"https://makenot.work/git/max/docengine.git\"]\ndocengine = { path = \"x\" }\n",
317 + )
318 + .unwrap();
319 + assert!(probe(&repo));
320 +
321 + // Untracking the lock is the fix, and the probe has to agree that it is.
322 + git(&["rm", "-q", "--cached", "Cargo.lock"]);
323 + assert!(!probe(&repo));
324 + }
325 +
326 + /// The message exists because cargo's names neither fact and both wrong
327 + /// fixes are one flag away. Assert it still says all four things.
328 + #[test]
329 + fn the_tracked_lock_message_names_both_facts_and_refuses_both_wrong_fixes() {
330 + let msg = tracked_lock_under_patch_problem("~/Code/.bento/pter/pter");
331 + assert!(msg.contains("Cargo.lock"), "{msg}");
332 + assert!(msg.contains("[patch]"), "{msg}");
333 + assert!(msg.contains("git rm --cached"), "{msg}");
334 + assert!(msg.contains("--allow-dirty"), "{msg}");
335 + assert!(msg.contains("~/Code/.bento/pter/pter"), "{msg}");
336 + }
337 + }
@@ -1,0 +1,621 @@
1 + //! Registering the host functions a recipe may call.
2 + //!
3 + //! One function, because it is one act: the thirty names a `.rhai` file can
4 + //! reach are the engine's whole API surface, and reading them as a list is the
5 + //! point.
6 +
7 + use super::RecipeCtx;
8 + use super::crates_io;
9 + use super::crates_io::{crate_meta_from_json, crate_publish_problems};
10 + use super::git::{expand_tilde, tracked_lock_under_patch_cmd, tracked_lock_under_patch_problem};
11 + use super::macos::register_macos_fns;
12 + use super::rhai_err;
13 + use crate::domain::Step;
14 + use anyhow::Result;
15 + use rhai::{Engine, EvalAltResult, Map};
16 + use std::sync::Arc;
17 +
18 + /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes
19 + /// touch the outside world only through these functions.
20 + pub fn build_engine(ctx: &Arc<RecipeCtx>) -> Engine {
21 + let mut engine = Engine::new();
22 + // Defensive caps — recipes are first-party but bound the blast radius.
23 + engine.set_max_operations(5_000_000);
24 + engine.set_max_call_levels(64);
25 + engine.set_max_string_size(0);
26 +
27 + // --- step(name) ---
28 + {
29 + let ctx = ctx.clone();
30 + engine.register_fn(
31 + "step",
32 + move |name: &str| -> Result<(), Box<EvalAltResult>> {
33 + let step: Step = name.parse().map_err(rhai_err)?;
34 + ctx.begin_step(step).map_err(rhai_err)
35 + },
36 + );
37 + }
38 +
39 + // --- sh(host, cmd) -> #{ code, stdout_tail } ---
40 + //
41 + // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero
42 + // exit is returned, not raised, and does NOT fail the step or bar publish —
43 + // use this only when the recipe inspects `code` and decides. For a command
44 + // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and
45 + // therefore bars publish via the failed-step ledger) on a non-zero exit.
46 + {
47 + let ctx = ctx.clone();
48 + engine.register_fn(
49 + "sh",
50 + move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
51 + let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
52 + let mut m = Map::new();
53 + m.insert("code".into(), (code as i64).into());
54 + m.insert("stdout_tail".into(), tail.into());
55 + Ok(m)
56 + },
57 + );
58 + }
59 +
60 + // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) ---
61 + //
62 + // A non-zero exit fails the current step (added to the publish-barring
63 + // ledger) and aborts the recipe, so an artifact is never shipped after a
64 + // must-succeed command failed.
65 + {
66 + let ctx = ctx.clone();
67 + engine.register_fn(
68 + "sh_ok",
69 + move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
70 + let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
71 + if code != 0 {
72 + // Attribute the failure to the current step explicitly so the
73 + // ledger bars publish even if a future caller swallowed the error.
74 + ctx.fail_current_step();
75 + return Err(rhai_err(format!(
76 + "command on `{host}` exited {code}: {cmd}"
77 + )));
78 + }
79 + Ok(())
80 + },
81 + );
82 + }
83 +
84 + // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob ---
85 + //
86 + // The artifact-selection primitive. Replaces `sh(host, "ls -t <glob> | head
87 + // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero
88 + // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the
89 + // glob on the host and demands exactly one match: zero matches or more than
90 + // one both throw (an ambiguous match means the build left stale artifacts,
91 + // and silently picking the newest is how the wrong bytes ship). Use
92 + // `resolve_artifact_opt` for an artifact that may legitimately be absent.
93 + {
94 + let ctx = ctx.clone();
95 + engine.register_fn(
96 + "resolve_artifact",
97 + move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
98 + ctx.resolve_artifact(host, glob, true).map_err(rhai_err)
99 + },
100 + );
101 + }
102 +
103 + // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match ---
104 + //
105 + // Same strict resolution as `resolve_artifact` but tolerates zero matches
106 + // (returns ""); more than one is still an error. For optional outputs like a
107 + // `.deb` or an updater bundle a recipe collects only when present.
108 + {
109 + let ctx = ctx.clone();
110 + engine.register_fn(
111 + "resolve_artifact_opt",
112 + move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
113 + ctx.resolve_artifact(host, glob, false).map_err(rhai_err)
114 + },
115 + );
116 + }
117 +
118 + // --- log(msg): operator-visible line into the current step's tail ---
119 + {
120 + let ctx = ctx.clone();
121 + engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> {
122 + let sink = ctx.ensure_step().map_err(rhai_err)?;
123 + let line = format!("[recipe] {msg}\n");
124 + ctx.rt.block_on(async {
125 + use ops_core::remote::LogSink;
126 + sink.lock().await.write_chunk(line.as_bytes()).await;
127 + });
128 + Ok(())
129 + });
130 + }
131 +
132 + // --- version_of(app) -> string ---
133 + {
134 + let ctx = ctx.clone();
135 + engine.register_fn(
136 + "version_of",
137 + move |app: &str| -> Result<String, Box<EvalAltResult>> {
138 + // Only the current app is in scope; cross-app reads aren't needed.
139 + if app != ctx.app.as_str() {
140 + return Err(rhai_err(format!(
141 + "version_of: `{app}` is not the app being built"
142 + )));
143 + }
144 + Ok(ctx.version.to_string())
145 + },
146 + );
147 + }
148 +
149 + // --- version() -> string: the version being built (no-arg form) ---
150 + {
151 + let ctx = ctx.clone();
152 + engine.register_fn("version", move || -> String { ctx.version.to_string() });
153 + }
154 +
155 + // --- build_host() -> string: the host this target builds on ---
156 + {
157 + let ctx = ctx.clone();
158 + engine.register_fn("build_host", move || -> String { ctx.build_host.clone() });
159 + }
160 +
161 + // --- repo() -> string: the app's checkout path on this target's build host
162 + // (`~`-prefixed on a unix host). Host-correct rather than one path per
163 + // app, so a recipe for a host whose checkout is elsewhere still calls
164 + // this instead of hard-coding the path — which is what kept the Windows
165 + // recipes off `checkout_sha`. ---
166 + {
167 + let ctx = ctx.clone();
168 + engine.register_fn("repo", move || -> String {
169 + ctx.repo_for(&ctx.build_host).to_string()
170 + });
171 + }
172 +
173 + // --- checkout_sha(host) -> sha: pin this host to the release tag and report
174 + // its commit. Replaces a recipe's `git pull --ff-only`, which builds
175 + // whatever `main` is at pull time; the daemon also runs the same pin as
176 + // a cross-host preflight barrier before any target builds. ---
177 + {
178 + let ctx = ctx.clone();
179 + engine.register_fn(
180 + "checkout_sha",
181 + move |host: &str| -> Result<String, Box<EvalAltResult>> {
182 + ctx.checkout_sha(host).map_err(rhai_err)
183 + },
184 + );
185 + }
186 +
187 + // --- crate_preflight() -> string: verify this crate is safe to publish,
188 + // or abort the run. Everything it checks is immutable once published:
189 + // crates.io versions can be yanked but never edited, so a wrong
190 + // repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
191 + {
192 + let ctx = ctx.clone();
193 + engine.register_fn(
194 + "crate_preflight",
195 + move || -> Result<String, Box<EvalAltResult>> {
196 + // `repo`, not `repo_for(...)`: `cargo metadata` runs on the
197 + // daemon's own box, so this is the one checkout that is always
198 + // the local one. It is not a missed call site.
199 + let repo = expand_tilde(&ctx.repo);
200 +
201 + let out = std::process::Command::new("cargo")
202 + .args(["metadata", "--no-deps", "--format-version", "1"])
203 + .current_dir(&repo)
204 + .output()
205 + .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
206 + if !out.status.success() {
207 + return Err(format!(
208 + "cargo metadata failed in {}: {}",
209 + repo.display(),
210 + String::from_utf8_lossy(&out.stderr).trim()
211 + )
212 + .into());
213 + }
214 + let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
215 + .map_err(|e| e.to_string())?;
216 +
217 + // The real question is not whether a page renders but whether a
218 + // stranger with no credentials can fetch the source, so ask git.
219 + let clonable = meta.repository.as_ref().is_some_and(|url| {
220 + std::process::Command::new("git")
221 + .args(["ls-remote", url])
222 + .env("GIT_TERMINAL_PROMPT", "0")
223 + .output()
224 + .is_ok_and(|o| o.status.success())
225 + });
226 +
227 + // Ask the publishing host whether cargo has credentials, rather
228 + // than moving the token anywhere. It stays in cargo's own 0600
229 + // store; a shell line carrying it would be visible in `ps`.
230 + // An exit code answers "are there credentials"; an Err answers
231 + // "the question could not be asked". Collapsing the second into
232 + // the first reported a capability denial as "no crates.io
233 + // credentials", which sent a real diagnosis three rounds the
234 + // wrong way. A check that cannot run is not a failed check.
235 + let creds =
236 + ctx.run(
237 + &ctx.build_host.clone(),
238 + "cargo login --help >/dev/null 2>&1 && \
239 + test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
240 + || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"",
241 + )
242 + .map_err(|e| {
243 + format!(
244 + "could not check crates.io credentials on `{}`: {e}",
245 + ctx.build_host
246 + )
247 + })?
248 + .0 == 0;
249 +
250 + // Asked of the build host rather than the daemon: the tree
251 + // that gets published is the worktree over there, and it is the
252 + // one whose `[patch]` ancestry decides this. An Err is "the
253 + // question could not be asked" and is not a finding -- same
254 + // rule as the credentials check above, for the same reason it
255 + // was written that way.
256 + let build_repo = ctx.repo_for(&ctx.build_host).to_string();
257 + let patched_lock =
258 + ctx.run(
259 + &ctx.build_host.clone(),
260 + &tracked_lock_under_patch_cmd(&build_repo),
261 + )
262 + .map_err(|e| {
263 + format!(
264 + "could not check for a tracked Cargo.lock on `{}`: {e}",
265 + ctx.build_host
266 + )
267 + })?
268 + .0 == 0;
269 +
270 + let published = crates_io::published_versions(&meta.name);
271 + let mut problems = crate_publish_problems(&meta, clonable, &published, creds);
272 + if patched_lock {
273 + problems.push(tracked_lock_under_patch_problem(&build_repo));
274 + }
275 + if !problems.is_empty() {
276 + return Err(format!(
277 + "{} {} is not safe to publish:\n - {}",
278 + meta.name,
279 + meta.version,
280 + problems.join("\n - ")
281 + )
282 + .into());
283 + }
284 + Ok(format!("{} {} passed preflight", meta.name, meta.version))
285 + },
286 + );
287 + }
288 +
289 + // --- feature_flags() -> string: `--features a,b`, or "" when the app
290 + // declares none. Returns the whole flag rather than a bare list so an
291 + // app with no features cannot produce a dangling `--features`. ---
292 + {
293 + let ctx = ctx.clone();
294 + engine.register_fn("feature_flags", move || -> String {
295 + if ctx.features.is_empty() {
296 + String::new()
297 + } else {
298 + format!("--features {}", ctx.features.join(","))
299 + }
300 + });
301 + }
302 +
303 + // --- target() / platform() / arch(): the target axis, for one per-platform
304 + // recipe to branch on arch (bundle paths differ between x86_64/aarch64). ---
305 + {
306 + let ctx = ctx.clone();
307 + engine.register_fn("target", move || -> String { ctx.target.to_string() });
308 + }
309 + {
310 + let ctx = ctx.clone();
311 + engine.register_fn("platform", move || -> String {
312 + ctx.target.platform.as_str().to_string()
313 + });
314 + }
315 + {
316 + let ctx = ctx.clone();
317 + engine.register_fn("arch", move || -> String {
318 + ctx.target.arch.as_str().to_string()
319 + });
320 + }
321 +
322 + // --- secret(key) -> string (file under secrets_root; never logged) ---
323 + {
324 + let ctx = ctx.clone();
325 + engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> {
326 + // Guard against traversal out of secrets_root. Require every path
327 + // component to be `Normal` (rejects `..`, `.`, absolute roots and
328 + // drive prefixes) and forbid backslashes (a literal filename char on
329 + // Linux, but a separator elsewhere) — the per-component strength of
330 + // Sando's `safe()`. A multi-segment key like `app/token` is still
331 + // allowed; `foo..bar` (a legit filename) is no longer falsely blocked.
332 + let safe = !key.is_empty()
333 + && !key.contains('\\')
334 + && std::path::Path::new(key)
335 + .components()
336 + .all(|c| matches!(c, std::path::Component::Normal(_)));
337 + if !safe {
338 + return Err(rhai_err(
339 + "secret key must be a relative path under secrets_root (no `..`, `.`, absolute paths, or backslashes)",
340 + ));
341 + }
342 + let path = ctx.cfg.secrets_root.join(key);
343 + std::fs::read_to_string(&path)
344 + .map(|s| s.trim_end().to_string())
345 + .map_err(|e| rhai_err(format!("secret `{key}`: {e}")))
346 + });
347 + }
348 +
349 + // --- env(host, key) -> string ---
350 + {
351 + let ctx = ctx.clone();
352 + engine.register_fn(
353 + "env",
354 + move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
355 + // The key is interpolated into a `${...}` shell expansion, so it must
356 + // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`)
357 + // could break out and run arbitrary commands on the host. Validate
358 + // before building the command; this is the one env read that can't
359 + // sh-quote its argument (a quoted var name doesn't expand).
360 + if key.is_empty()
361 + || !key
362 + .chars()
363 + .next()
364 + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
365 + || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
366 + {
367 + return Err(rhai_err(format!(
368 + "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
369 + )));
370 + }
371 + // Read via the shell so it works on remote hosts too.
372 + let (code, tail) = ctx
373 + .run(host, &format!("printf '%s' \"${{{key}}}\""))
374 + .map_err(rhai_err)?;
375 + if code != 0 {
376 + return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
377 + }
378 + Ok(tail.trim().to_string())
379 + },
380 + );
381 + }
382 +
383 + // --- collect(host, glob, app, version): pull artifacts to dist_root ---
384 + {
385 + let ctx = ctx.clone();
386 + engine.register_fn(
387 + "collect",
388 + move |host: &str,
389 + glob: &str,
390 + app: &str,
391 + version: &str|
392 + -> Result<(), Box<EvalAltResult>> {
393 + ctx.collect(host, glob, app, version).map_err(rhai_err)
394 + },
395 + );
396 + }
397 +
398 + // --- publish(channel, app, target, version, artifact, meta) ---
399 + {
400 + let ctx = ctx.clone();
401 + engine.register_fn(
402 + "publish",
403 + move |channel: &str,
404 + app: &str,
405 + target: &str,
406 + version: &str,
407 + artifact: &str,
408 + meta: Map|
409 + -> Result<String, Box<EvalAltResult>> {
410 + ctx.publish(channel, app, target, version, artifact, &meta)
411 + .map_err(rhai_err)
412 + },
413 + );
414 + }
415 +
416 + // --- deploy(binary) -> summary: install a service binary and restart its
417 + // unit. The terminal step for `kind = "service"`, the counterpart of
418 + // `publish` for something that is run rather than distributed.
419 + //
420 + // Takes only the binary's path on the build host: where it lands, on
421 + // which machine, and which unit restarts all come from the `[[deploy]]`
422 + // entry for the target already being built. A recipe cannot deploy the
423 + // aarch64 binary to the x86_64 box by naming the wrong host, because it
424 + // never names a host at all.
425 + {
426 + let ctx = ctx.clone();
427 + engine.register_fn(
428 + "deploy",
429 + move |binary: &str| -> Result<String, Box<EvalAltResult>> {
430 + ctx.deploy(binary).map_err(rhai_err)
431 + },
432 + );
433 + }
434 +
435 + // --- deploy_host() -> string: the service host's ssh destination, so a
436 + // recipe can run its own assertions there (`sh_ok(deploy_host(), ...)`).
437 + // Commands run through it while the `deploy` step is open, so they are
438 + // gated on the deploy grant like the install itself. ---
439 + {
440 + let ctx = ctx.clone();
441 + engine.register_fn(
442 + "deploy_host",
443 + move || -> Result<String, Box<EvalAltResult>> {
444 + ctx.deploy_target()
445 + .map(|d| d.host.clone())
446 + .map_err(rhai_err)
447 + },
448 + );
449 + }
450 +
451 + // --- service_name() / install_path() / health_url(): the rest of the
452 + // `[[deploy]]` entry, so a recipe asserts against the configured values
453 + // rather than repeating them as literals that can drift. `health_url`
454 + // is "" when unset. ---
455 + {
456 + let ctx = ctx.clone();
457 + engine.register_fn(
458 + "service_name",
459 + move || -> Result<String, Box<EvalAltResult>> {
460 + ctx.deploy_target()
461 + .map(|d| d.service.clone())
462 + .map_err(rhai_err)
463 + },
464 + );
465 + }
466 + {
467 + let ctx = ctx.clone();
468 + engine.register_fn(
469 + "install_path",
470 + move || -> Result<String, Box<EvalAltResult>> {
471 + ctx.deploy_target()
472 + .map(|d| d.install_path.clone())
473 + .map_err(rhai_err)
474 + },
475 + );
476 + }
477 + {
478 + let ctx = ctx.clone();
479 + engine.register_fn(
480 + "health_url",
481 + move || -> Result<String, Box<EvalAltResult>> {
482 + ctx.deploy_target()
483 + .map(|d| d.health_url.clone().unwrap_or_default())
484 + .map_err(rhai_err)
485 + },
486 + );
487 + }
488 +
489 + // --- glibc_check(binary) -> string: assert the build host did not produce
490 + // a binary the service host's glibc is too old to exec. Aborts the run
491 + // if it did; returns "needs X, host has Y" for the log if it did not.
492 + //
493 + // WHICH RECIPES CALL THIS, AND WHY THE OTHERS MUST NOT. The rule is not
494 + // a style preference and it is not optional: this reads the recipe's
495 + // `[[deploy]]` entry to learn which machine runs the bytes, so a recipe
496 + // with no `[[deploy]]` cannot call it at all.
497 + //
498 + // - A service that installs ITSELF (`[[deploy]]` present: magicmirror,
499 + // wam, mnw-cli) SHOULD call it. Bento is both builder and installer
500 + // there, so it knows the service host, and nothing downstream will
Lines truncated
@@ -1,0 +1,227 @@
1 + //! The Apple-only host functions: notarization, and reading what the notary
2 + //! said.
3 +
4 + use super::RecipeCtx;
5 + use super::rhai_err;
6 + use crate::events::{self, Event};
7 + use anyhow::Result;
8 + use rhai::{Engine, EvalAltResult};
9 + use std::sync::Arc;
10 +
11 + /// macOS signing/notarization host functions. Thin wrappers over the right
12 + /// shell incantations, dispatched through the named host's executor. On the mac
13 + /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only
14 + /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a
15 + /// plain SSH session cannot. Each is gated by the host's `sign` capability.
16 + pub(super) fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) {
17 + {
18 + let ctx = ctx.clone();
19 + engine.register_fn(
20 + "verify_gatekeeper",
21 + move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> {
22 + // spctl has no JSON mode, so assess on-host and decide there,
23 + // emitting an unambiguous sentinel as the final line. We match the
24 + // sentinel rather than substring-hunting `source=Notarized...` in a
25 + // 2000-char tail: truncation only drops the front, so the sentinel
26 + // is always present, and it can't be spoofed by spctl's own prose.
27 + // The full assess output is still streamed to the step log.
28 + let q = ops_core::remote::sh_quote(path);
29 + let cmd = format!(
30 + "out=$(spctl --assess -vv --type install {q} 2>&1); printf '%s\\n' \"$out\"; \
31 + printf '%s' \"$out\" | grep -q 'source=Notarized Developer ID' \
32 + && echo BENTO_GATEKEEPER_OK || echo BENTO_GATEKEEPER_FAIL",
33 + );
34 + let (_, tail) = ctx.run(host, &cmd).map_err(rhai_err)?;
35 + let accepted = tail.contains("BENTO_GATEKEEPER_OK");
36 + // Record the verdict for the publish gate. A rejection also
37 + // fails the step, so the matrix shows red and `publish` is barred
38 + // even if the recipe ignores the returned bool.
39 + ctx.set_gatekeeper_ok(accepted);
40 + if !accepted {
41 + ctx.fail_current_step();
42 + }
43 + Ok(accepted)
44 + },
45 + );
46 + }
47 + {
48 + let ctx = ctx.clone();
49 + engine.register_fn(
50 + "codesign",
51 + move |host: &str, identity: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
52 + let cmd = format!(
53 + "codesign --force --options runtime --timestamp --sign {} {}",
54 + ops_core::remote::sh_quote(identity),
55 + ops_core::remote::sh_quote(path),
56 + );
57 + let (code, _) = ctx.run(host, &cmd).map_err(rhai_err)?;
58 + if code != 0 {
59 + return Err(rhai_err("codesign failed"));
60 + }
61 + Ok(())
62 + },
63 + );
64 + }
65 + {
66 + let ctx = ctx.clone();
67 + engine.register_fn(
68 + "staple",
69 + move |host: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
70 + let (code, _) = ctx
71 + .run(
72 + host,
73 + &format!("xcrun stapler staple {}", ops_core::remote::sh_quote(path)),
74 + )
75 + .map_err(rhai_err)?;
76 + if code != 0 {
77 + return Err(rhai_err("stapler failed"));
78 + }
79 + Ok(())
80 + },
81 + );
82 + }
83 + {
84 + let ctx = ctx.clone();
85 + engine.register_fn(
86 + "notarize",
87 + move |host: &str, path: &str| -> Result<String, Box<EvalAltResult>> {
88 + ctx.notarize(host, path).map_err(rhai_err)
89 + },
90 + );
91 + }
92 + {
93 + let ctx = ctx.clone();
94 + engine.register_fn(
95 + "keychain_open",
96 + move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
97 + // The full build-keychain lifecycle lives in dist/build-keychain.sh
98 + // (design §7); this drives it by name so the recipe stays short.
99 + let (code, _) = ctx
100 + .run(
101 + host,
102 + &format!(
103 + ". ~/.tauri/passwords.env && ./dist/build-keychain.sh open {}",
104 + ops_core::remote::sh_quote(name)
105 + ),
106 + )
107 + .map_err(rhai_err)?;
108 + if code != 0 {
109 + return Err(rhai_err("keychain_open failed"));
110 + }
111 + Ok(())
112 + },
113 + );
114 + }
115 + {
116 + let ctx = ctx.clone();
117 + engine.register_fn(
118 + "keychain_close",
119 + move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
120 + let _ = ctx.run(
121 + host,
122 + &format!(
123 + "./dist/build-keychain.sh close {}",
124 + ops_core::remote::sh_quote(name)
125 + ),
126 + );
127 + Ok(())
128 + },
129 + );
130 + }
131 + }
132 +
133 + /// True iff `notarytool --output-format json` output reports `status: Accepted`.
134 + /// Isolates the JSON object (`{`..`}`) from any shell-sourcing noise and reads
135 + /// the typed `status` field, rather than substring-matching `"status":"Accepted"`
136 + /// in a possibly-truncated tail — which could match the literal inside an error
137 + /// message or miss it across a whitespace variant. Fails closed: any parse or
138 + /// field miss returns false.
139 + pub(super) fn notary_accepted(output: &str) -> bool {
140 + let (Some(start), Some(end)) = (output.find('{'), output.rfind('}')) else {
141 + return false;
142 + };
143 + if start > end {
144 + return false;
145 + }
146 + serde_json::from_str::<serde_json::Value>(&output[start..=end])
147 + .ok()
148 + .and_then(|v| {
149 + v.get("status")
150 + .and_then(|s| s.as_str())
151 + .map(|s| s.eq_ignore_ascii_case("accepted"))
152 + })
153 + .unwrap_or(false)
154 + }
155 +
156 + impl RecipeCtx {
157 + /// `xcrun notarytool submit --wait` with bounded retry (the one flaky,
158 + /// network-bound step). Emits `NotarizeRetry` per attempt.
159 + fn notarize(self: &Arc<Self>, host: &str, path: &str) -> Result<String> {
160 + const MAX_ATTEMPTS: u32 = 3;
161 + let backoff = self
162 + .cfg
163 + .notarize_backoff_secs
164 + .map_or(std::time::Duration::from_secs(15), |s| {
165 + std::time::Duration::from_secs(s)
166 + });
167 + let cmd = format!(
168 + ". ~/.tauri/passwords.env && xcrun notarytool submit {} \
169 + --key \"$NOTARY_KEY\" --key-id \"$NOTARY_KEY_ID\" --issuer \"$NOTARY_ISSUER\" \
170 + --wait --output-format json",
171 + ops_core::remote::sh_quote(path),
172 + );
173 + let mut last = String::new();
174 + for attempt in 1..=MAX_ATTEMPTS {
175 + let (code, tail) = self.run(host, &cmd)?;
176 + if code == 0 && notary_accepted(&tail) {
177 + return Ok(tail);
178 + }
179 + last = tail;
180 + if attempt < MAX_ATTEMPTS {
181 + events::emit(
182 + &self.events,
183 + Event::NotarizeRetry {
184 + app: self.app.clone(),
185 + target: self.target,
186 + attempt,
187 + reason: format!("exit {code}"),
188 + },
189 + );
190 + self.rt.block_on(tokio::time::sleep(backoff));
191 + }
192 + }
193 + anyhow::bail!("notarization failed after {MAX_ATTEMPTS} attempts: {last}")
194 + }
195 + }
196 +
197 + #[cfg(test)]
198 + mod tests {
199 + use super::*;
200 +
201 + #[test]
202 + fn notary_accepted_parses_status_field() {
203 + assert!(notary_accepted(
204 + r#"{"id":"abc","status":"Accepted","message":"ok"}"#
205 + ));
206 + // Embedded in shell-sourcing noise: the object is isolated and parsed.
207 + assert!(notary_accepted(
208 + "sourcing env...\n{\n \"status\": \"Accepted\"\n}\nbye"
209 + ));
210 + // Whitespace variant that a tight substring `"status":"Accepted"` misses.
211 + assert!(notary_accepted(r#"{ "status" : "Accepted" }"#));
212 + }
213 +
214 + #[test]
215 + fn notary_accepted_rejects_non_accepted_and_garbage() {
216 + assert!(!notary_accepted(r#"{"status":"Invalid"}"#));
217 + assert!(!notary_accepted(r#"{"status":"In Progress"}"#));
218 + assert!(!notary_accepted("no json here"));
219 + assert!(!notary_accepted("")); // empty / truncated -> fail closed
220 + // A truncated tail whose opening brace was cut off cannot parse -> closed.
221 + assert!(!notary_accepted(r#""status":"Accepted"}"#));
222 + // The literal appearing inside an error string must NOT pass as success.
223 + assert!(!notary_accepted(
224 + r#"{"status":"Invalid","message":"expected status:Accepted"}"#
225 + ));
226 + }
227 + }
@@ -1,0 +1,246 @@
1 + //! Rhai recipe engine + host-function API.
2 + //!
3 + //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step
4 + //! vocabulary. The daemon embeds Rhai and registers the host functions recipes
5 + //! call; the recipe is the orchestration, the host functions are the
6 + //! privileged primitives (run a command, read a secret, collect artifacts,
7 + //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except
8 + //! through these functions — matching the Balanced Breakfast plugin model.
9 + //!
10 + //! Rhai is synchronous; the engine runs each recipe on a blocking thread
11 + //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async
12 + //! work via `Handle::block_on`. That is sound only off a runtime worker thread,
13 + //! which `spawn_blocking` guarantees.
14 + //!
15 + //! One file per thing the engine actually does. `ctx` holds the state a recipe
16 + //! is handed; `host_fns` is the API surface it may call; the rest are the jobs
17 + //! behind those names.
18 +
19 + use crate::domain::{Step, StepRunId};
20 + use crate::topology::Kind;
21 + use ops_core::live_log::LiveLog;
22 + use ops_exec::{Action, ObserveKind};
23 + use rhai::EvalAltResult;
24 + use std::sync::Arc;
25 + use tokio::sync::Mutex as AsyncMutex;
26 +
27 + mod crates_io;
28 + mod ctx;
29 + mod deploy;
30 + mod git;
31 + mod host_fns;
32 + mod macos;
33 + mod publish;
34 + mod version;
35 +
36 + pub mod collect;
37 +
38 + pub use ctx::RecipeCtx;
39 + pub use git::{
40 + app_dir_in_worktree, expand_tilde, git_fetch_cmd, git_rev_parse_cmd, git_tag_exists_cmd,
41 + git_toplevel_and_prefix_cmd, git_worktree_add_cmd, git_worktree_pin_cmd,
42 + git_worktree_probe_cmd, git_worktree_prune_cmd, parse_toplevel_and_prefix, repo_dir_name,
43 + worktree_failure_reason,
44 + };
45 + pub use host_fns::build_engine;
46 + pub use version::{
47 + // `check_version_consistency` has no caller anywhere in the tree, only its
48 + // own four tests. It is re-exported to keep it exactly as reachable as it
49 + // was before this split rather than to hide that: narrowing it to
50 + // `pub(super)` turns it into a dead-code error, which is the finding.
51 + check_version_consistency,
52 + git_show_file_cmd,
53 + version_from_repo,
54 + version_sources,
55 + versions_agree,
56 + };
57 +
58 + /// The capability label for a command, derived from the open recipe step. A
59 + /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated
60 + /// by the mac host's `sign` grant — so recipes stay unchanged while every command
61 + /// is capability-checked at its transport. `Verify` is read-only (an observe).
62 + /// The capability a step's commands are gated on.
63 + ///
64 + /// `Verify` depends on what is being released, which is the one place this is
65 + /// not a property of the step alone. An app's verify is a Gatekeeper check on a
66 + /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts
67 + /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing
68 + /// holds. A library's verify is a crate preflight: it runs `cargo` on the build
69 + /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux
70 + /// host for a macOS code-signing capability it can never honestly hold, and the
71 + /// only way to satisfy it would be to declare the capability falsely.
72 + fn action_for(step: Step, kind: Kind) -> Action {
73 + match step {
74 + Step::Checkout | Step::Prebuild | Step::Build => Action::Build,
75 + Step::Sign => Action::Sign,
76 + Step::Notarize => Action::Notarize,
77 + Step::Staple => Action::Staple,
78 + Step::Package => Action::Package,
79 + Step::Verify => match kind {
80 + Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())),
81 + // Running the build toolchain to inspect a crate or a service
82 + // binary, which is what `build` means on a host. Neither has a
83 + // bundle for Gatekeeper to have an opinion about.
84 + Kind::Library | Kind::Service => Action::Build,
85 + },
86 + // Publish/Collect/Handoff run on the daemon, not through a host
87 + // executor; this label only applies if a recipe runs a bare `sh` while
88 + // one is open. `handoff` is the daemon's own post-recipe motion and no
89 + // recipe should open it at all — naming it here costs nothing and beats
90 + // a wildcard that would silently absorb the next step somebody adds.
91 + Step::Publish | Step::Collect | Step::Handoff => Action::Package,
92 + // The one step that dispatches to a host OUTSIDE the build topology.
93 + // Every command a recipe runs while `deploy` is open — the install, the
94 + // restart, the health assertion — carries this action, so it reaches the
95 + // service host only through the deploy grant and reaches a build host
96 + // not at all (no build host is granted `deploy`).
97 + Step::Deploy => Action::Deploy,
98 + }
99 + }
100 +
101 + /// The currently-open step within a recipe run: its DB row id, which step it
102 + /// is, and the live-log sink that `sh`/`log` stream into.
103 + struct StepState {
104 + run_id: StepRunId,
105 + step: Step,
106 + log: Arc<AsyncMutex<LiveLog>>,
107 + /// Set when something in the step recorded a hard failure the recipe did
108 + /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the
109 + /// recipe ignored the bool). Forces the step's recorded status to `Failed`
110 + /// and bars `publish` (the step-success ledger).
111 + failed: bool,
112 + /// Wall-clock deadline for this step. A command that runs past it fails the
113 + /// step (and unwinds the recipe) rather than wedging under the old
114 + /// whole-build guillotine, which a legitimate 5-target fan-out plus notary
115 + /// queueing could trip — mismarking every target failed while the blocking
116 + /// recipe bodies kept signing.
117 + deadline: std::time::Instant,
118 + }
119 +
120 + /// Per-step wall-clock budget: a generous ceiling that catches a wedged command
121 + /// (a hung ssh, a stuck notary poll) without killing legitimately slow work.
122 + /// Bounding each step, rather than the whole build, keeps one slow step from
123 + /// being blamed on another and keeps a fan-out of slow-but-fine targets from
124 + /// being guillotined. `Config::step_timeout_secs` overrides these per-kind
125 + /// defaults for every step; see [`RecipeCtx::step_budget`].
126 + fn default_step_budget(step: Step) -> std::time::Duration {
127 + use std::time::Duration;
128 + let mins = match step {
129 + Step::Checkout => 10,
130 + // clippy + full test suite, cold, on a workspace.
131 + Step::Prebuild => 45,
132 + // cargo tauri build, cold, universal bundles.
133 + Step::Build => 90,
134 + Step::Sign => 15,
135 + // Apple's notary queue + this step's bounded retries.
136 + Step::Notarize => 60,
137 + Step::Staple => 10,
138 + Step::Package => 30,
139 + Step::Verify => 10,
140 + // rsync of multi-GiB artifacts off the build host.
141 + Step::Collect => 30,
142 + Step::Publish => 20,
143 + // A binary push, an install, a unit restart, and a health poll. Minutes
144 + // of work; the ceiling is for a wedged transport, not slow work.
145 + Step::Deploy => 15,
146 + // Unused by any recipe — the daemon runs the handoff itself, outside a
147 + // step's clock — and matched to `collect`, since it moves the same
148 + // bytes the same way and would wedge for the same reasons.
149 + Step::Handoff => 30,
150 + };
151 + Duration::from_secs(mins * 60)
152 + }
153 +
154 + /// Where a service's binary is staged on the host that will run it, before the
155 + /// privileged installer moves it into place.
156 + ///
157 + /// A fixed, unguessable-by-accident path rather than a recipe-chosen one,
158 + /// because the installer refuses any source outside it. That refusal is the
159 + /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to
160 + /// an arbitrary path, so both ends have to name the same constant. `/var/tmp`
161 + /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a
162 + /// systemd tmpfiles sweep between staging and install.
163 + pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy";
164 +
165 + // Rhai host functions return `Result<_, Box<EvalAltResult>>` by convention, so
166 + // this bridge must yield the boxed form to be usable with `.map_err(rhai_err)`.
167 + #[allow(
168 + clippy::unnecessary_box_returns,
169 + reason = "rhai's error type is used boxed throughout its host-function API"
170 + )]
171 + fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
172 + Box::new(EvalAltResult::ErrorRuntime(
173 + e.to_string().into(),
174 + rhai::Position::NONE,
175 + ))
176 + }
177 +
178 + #[cfg(test)]
179 + mod tests {
180 + use super::*;
181 +
182 + // A library's verify is a crate preflight, not a Gatekeeper check on a
183 + // signed bundle. Gating it on `gatekeeper` asked a Linux host for a macOS
184 + // code-signing capability it can never hold, so the step was denied before
185 + // it ran a command; the denial then surfaced as "no crates.io credentials",
186 + // which is not what went wrong. The only way to satisfy the old gate was to
187 + // declare the capability falsely in the topology.
188 + #[test]
189 + fn a_library_verify_is_not_gated_on_gatekeeper() {
190 + assert_eq!(
191 + action_for(Step::Verify, Kind::Library),
192 + Action::Build,
193 + "a crate preflight runs the build toolchain; that is what it needs",
194 + );
195 + assert_eq!(
196 + action_for(Step::Verify, Kind::App),
197 + Action::Observe(ObserveKind::Custom("gatekeeper".into())),
198 + "an app's verify still proves the bundle is signed and notarized",
199 + );
200 + }
201 +
202 + // The capability the default host grant actually carries. Without this the
203 + // fix above is only true by inspection.
204 + #[test]
205 + fn a_default_host_can_run_a_library_verify_and_not_an_app_one() {
206 + let caps =
207 + ops_exec::CapabilitySet::from_tokens(["build", "package"], ["build-log", "artifact"]);
208 + assert!(caps.permits(&action_for(Step::Verify, Kind::Library)));
209 + assert!(!caps.permits(&action_for(Step::Verify, Kind::App)));
210 + }
211 +
212 + // Every other step is a property of the step alone; verify is the one that
213 + // depends on what is being released.
214 + #[test]
215 + fn no_other_step_changes_with_the_kind() {
216 + for step in [
217 + Step::Checkout,
218 + Step::Prebuild,
219 + Step::Build,
220 + Step::Sign,
221 + Step::Notarize,
222 + Step::Staple,
223 + Step::Package,
224 + Step::Publish,
225 + Step::Collect,
226 + ] {
227 + assert_eq!(
228 + action_for(step, Kind::App),
229 + action_for(step, Kind::Library),
230 + "{step:?} should not depend on the kind",
231 + );
232 + }
233 + }
234 +
235 + #[test]
236 + fn every_step_has_a_nonzero_default_budget() {
237 + // A zero/missing budget would deadline-fail a step instantly. Cover the
238 + // whole matrix so a new Step variant can't silently get a 0 budget.
239 + for step in Step::ALL {
240 + assert!(
241 + default_step_budget(step) >= std::time::Duration::from_mins(1),
242 + "{step} budget must be a sane ceiling",
243 + );
244 + }
245 + }
246 + }
@@ -1,0 +1,275 @@
1 + //! Shipping: the all-targets-green gate, and the publish that records a
2 + //! release.
3 +
4 + use super::RecipeCtx;
5 + use super::collect::sha256_file;
6 + use crate::domain::{AppId, Target, Version};
7 + use crate::events::{self, Event};
8 + use crate::ota::{PublishAuthority, Release};
9 + use anyhow::{Context as _, Result};
10 + use rhai::Map;
11 + use std::path::PathBuf;
12 + use std::sync::Arc;
13 +
14 + impl RecipeCtx {
15 + /// The all-targets-green gate: err unless every declared target OTHER than
16 + /// the one publishing has a latest `target_runs` row of `ok` for this
17 + /// `(app, version)`. A sibling with no run, a running run, or a failed
18 + /// latest run all block the publish, naming what is not green.
19 + fn assert_siblings_green(self: &Arc<Self>, declared: &[Target]) -> Result<()> {
20 + let me = self.clone();
21 + let (app_s, ver_s) = (self.app.to_string(), self.version.to_string());
22 + let rows: Vec<(String, String)> = self.rt.block_on(async move {
23 + sqlx::query_as(
24 + "SELECT target, status FROM target_runs tr
25 + WHERE app = ?1 AND version = ?2
26 + AND id = (SELECT MAX(id) FROM target_runs
27 + WHERE app = ?1 AND version = ?2 AND target = tr.target)",
28 + )
29 + .bind(app_s)
30 + .bind(ver_s)
31 + .fetch_all(&me.pool)
32 + .await
33 + .unwrap_or_default()
34 + });
35 + let status_of = |t: &Target| -> Option<String> {
36 + let key = t.to_string();
37 + rows.iter()
38 + .find(|(name, _)| name == &key)
39 + .map(|(_, s)| s.clone())
40 + };
41 + let not_green: Vec<String> = declared
42 + .iter()
43 + .filter(|t| **t != self.target) // the publishing target is the last mile
44 + .filter(|t| status_of(t).as_deref() != Some("ok"))
45 + .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into())))
46 + .collect();
47 + anyhow::ensure!(
48 + not_green.is_empty(),
49 + "all-targets-green gate: refusing to publish {} {} — not green: {}",
50 + self.app,
51 + self.version,
52 + not_green.join(", "),
53 + );
54 + Ok(())
55 + }
56 +
57 + pub(super) fn publish(
58 + self: &Arc<Self>,
59 + channel: &str,
60 + app: &str,
61 + target: &str,
62 + version: &str,
63 + artifact: &str,
64 + meta: &Map,
65 + ) -> Result<String> {
66 + // Never let a superseded build ship. This is the last and most important
67 + // cooperative-cancel checkpoint: even if a long-running step finished
68 + // after supersession, the artifact must not reach the backend.
69 + anyhow::ensure!(
70 + !self.is_cancelled(),
71 + "build superseded by a newer request; refusing to publish"
72 + );
73 + // Opt-in all-targets-green gate: refuse a partial release. Every OTHER
74 + // declared target of this (app, version) must have a successful latest
75 + // run before this one ships, so macOS can't publish while windows is red
76 + // or still building. The publishing target itself is the last mile (it
77 + // reached publish, so its steps passed) and is not required to be green
78 + // in the ledger yet.
79 + if let Some(declared) = self.all_green_required() {
80 + self.assert_siblings_green(&declared)?;
81 + }
82 + let backend = self
83 + .ota
84 + .get(channel)
85 + .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?;
86 + let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?;
87 + let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?;
88 + let app = AppId::new(app);
89 +
90 + // The backend must actually handle this target (e.g. the desktop updater
91 + // disclaims iOS) — otherwise publish would push an artifact through a
92 + // backend that does not support it.
93 + anyhow::ensure!(
94 + backend.supports(target),
95 + "publish channel `{channel}` does not support target {target}",
96 + );
97 +
98 + // Monotonicity: never publish a version that is not strictly newer than
99 + // the latest already published for this (app, target, channel). Without
100 + // this an older build could republish over a live newer release. The
101 + // `releases` column is TEXT, so compare by parsed semver precedence
102 + // (Version: Ord), not lexically.
103 + {
104 + let (app_s, target_s, chan_s) =
105 + (app.to_string(), target.to_string(), channel.to_string());
106 + let me = self.clone();
107 + let latest: Option<Version> = self.rt.block_on(async move {
108 + let rows: Vec<(String,)> = sqlx::query_as(
109 + "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?",
110 + )
111 + .bind(app_s)
112 + .bind(target_s)
113 + .bind(chan_s)
114 + .fetch_all(&me.pool)
115 + .await
116 + .unwrap_or_default();
117 + rows.into_iter()
118 + .filter_map(|(v,)| Version::parse(&v).ok())
119 + .max()
120 + });
121 + if let Some(latest) = latest {
122 + anyhow::ensure!(
123 + version > latest,
124 + "refusing to publish {app} {version} to `{channel}` ({target}): \
125 + not newer than the last published {latest}",
126 + );
127 + }
128 + }
129 +
130 + // Step-success ledger (the Bento analogue of Sando's gate fail-closed),
131 + // minted as an unforgeable PublishAuthority. `backend.publish` cannot be
132 + // called without one, so the unverified/post-failure ship path is sealed
133 + // at the type level rather than guarded by a separate runtime check.
134 + let authority = {
135 + let failed = self.failed_steps_snapshot();
136 + let gatekeeper = self.gatekeeper_ok();
137 + PublishAuthority::prove(target, failed.as_slice(), gatekeeper)?
138 + };
139 + let notes = meta
140 + .get("notes")
141 + .and_then(|v| v.clone().into_string().ok())
142 + .unwrap_or_default();
143 + // Resolve the artifact relative to the collected dist dir if not absolute.
144 + let artifact_path = {
145 + let p = PathBuf::from(artifact);
146 + if p.is_absolute() {
147 + p
148 + } else {
149 + self.collect_dest(app.as_str(), &version.to_string())
150 + .join(artifact)
151 + }
152 + };
153 + let rel = Release {
154 + app: &app,
155 + target,
156 + version: &version,
157 + notes,
158 + };
159 + let receipt = backend
160 + .publish(&rel, &artifact_path, &authority)
161 + .with_context(|| format!("publish to `{channel}`"))?;
162 + // Record for idempotency / monotonicity. This write is CHECKED, not
163 + // fire-and-forget: a swallowed failure here would silently re-arm the
164 + // monotonicity guard (which reads this same table), letting an older
165 + // version republish over a live release. Concurrent same-(app,target)
166 + // publishers can't race the read-then-insert because the latest-wins slot
167 + // (state::ActiveSlot) serializes them and a superseded run is cancelled
168 + // before it reaches publish.
169 + // The artifact's hash, recorded so the release ledger says exactly which
170 + // bytes shipped. Prefer the digest computed at `collect`; fall back to
171 + // hashing the file now (an absolute-path artifact never routed through
172 + // `collect`). A hash failure must not fail an already-published release,
173 + // so degrade to NULL rather than erroring.
174 + let artifact_hash: Option<String> = artifact_path
175 + .file_name()
176 + .and_then(|n| n.to_str())
177 + .and_then(|n| self.artifact_hash(n))
178 + .or_else(|| sha256_file(&artifact_path).ok());
179 + let me = self.clone();
180 + let (app_s, target_s, ver_s, chan_s) = (
181 + app.to_string(),
182 + target.to_string(),
183 + version.to_string(),
184 + channel.to_string(),
185 + );
186 + self.rt
187 + .block_on(async move {
188 + sqlx::query(
189 + "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at)
190 + VALUES (?, ?, ?, ?, ?, ?)",
191 + )
192 + .bind(app_s)
193 + .bind(target_s)
194 + .bind(ver_s)
195 + .bind(chan_s)
196 + .bind(artifact_hash)
197 + .bind(Self::now())
198 + .execute(&me.pool)
199 + .await
200 + })
201 + .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?;
202 + events::emit(
203 + &self.events,
204 + Event::PublishOk {
205 + app: self.app.clone(),
206 + target: self.target,
207 + channel: channel.to_string(),
208 + },
209 + );
210 + Ok(receipt)
211 + }
212 + }
213 +
214 + #[cfg(test)]
215 + mod tests {
216 + use super::*;
217 + use crate::domain::Step;
218 +
219 + fn target(s: &str) -> Target {
220 + s.parse().unwrap()
221 + }
222 +
223 + #[test]
224 + fn publish_gate_blocks_macos_without_verification() {
225 + // Never verified -> blocked, with a message pointing at verify_gatekeeper.
226 + let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err();
227 + assert!(format!("{err:#}").contains("never verified"), "{err:#}");
228 + }
229 +
230 + #[test]
231 + fn publish_gate_blocks_macos_when_gatekeeper_rejected() {
232 + let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err();
233 + assert!(
234 + format!("{err:#}").contains("Gatekeeper rejected"),
235 + "{err:#}"
236 + );
237 + }
238 +
239 + #[test]
240 + fn publish_gate_allows_macos_when_gatekeeper_accepted() {
241 + PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap();
242 + // iOS is gated the same way.
243 + PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap();
244 + assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err());
245 + }
246 +
247 + #[test]
248 + fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() {
249 + // Linux/Windows aren't notarized; no gatekeeper proof needed.
250 + PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap();
251 + PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap();
252 + }
253 +
254 + #[test]
255 + fn publish_gate_blocks_when_any_prior_step_failed() {
256 + // A failed step bars publish on every target, even a verified macOS one.
257 + let err =
258 + PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err();
259 + assert!(
260 + format!("{err:#}").contains("prior step(s) failed"),
261 + "{err:#}"
262 + );
263 + assert!(
264 + format!("{err:#}").contains("build"),
265 + "names the failed step: {err:#}"
266 + );
267 +
268 + let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true))
269 + .unwrap_err();
270 + assert!(
271 + format!("{err:#}").contains("prior step(s) failed"),
272 + "{err:#}"
273 + );
274 + }
275 + }
@@ -1,0 +1,341 @@
1 + //! What version this release is, read from the repository rather than taken on
2 + //! trust, and the drift check between the places it is written down.
3 +
4 + use super::git::expand_tilde;
5 + use crate::domain::Version;
6 + use anyhow::{Context as _, Result};
7 +
8 + /// Read the app's version from its checkout on the daemon host. With
9 + /// `version_path` set (topology `version_path`), read exactly that file — a
10 + /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
11 + /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by
12 + /// the runner's default-version path.
13 + pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Version> {
14 + let root = expand_tilde(repo);
15 + if let Some(vp) = version_path {
16 + let path = root.join(vp);
17 + let raw = std::fs::read_to_string(&path)
18 + .with_context(|| format!("reading version file {}", path.display()))?;
19 + let ver = if std::path::Path::new(vp)
20 + .extension()
21 + .is_some_and(|e| e.eq_ignore_ascii_case("json"))
22 + {
23 + version_from_tauri_json(&raw)?
24 + } else {
25 + version_from_cargo_toml(&raw)?
26 + };
27 + return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e));
28 + }
29 + let tauri_conf = root.join("src-tauri").join("tauri.conf.json");
30 + if tauri_conf.exists() {
31 + let raw = std::fs::read_to_string(&tauri_conf)
32 + .with_context(|| format!("reading {}", tauri_conf.display()))?;
33 + return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e));
34 + }
35 + let cargo_toml = root.join("Cargo.toml");
36 + let raw = std::fs::read_to_string(&cargo_toml).with_context(|| {
37 + format!(
38 + "reading {} (no tauri.conf.json either)",
39 + cargo_toml.display()
40 + )
41 + })?;
42 + Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e))
43 + }
44 +
45 + /// Extract `version` from raw `tauri.conf.json` text.
46 + fn version_from_tauri_json(raw: &str) -> Result<String> {
47 + let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?;
48 + v.get("version")
49 + .and_then(|x| x.as_str())
50 + .map(str::to_owned)
51 + .context("no `version` in tauri.conf.json")
52 + }
53 +
54 + /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf
55 + /// crate) or `[workspace.package].version` (a workspace that sets it).
56 + fn version_from_cargo_toml(raw: &str) -> Result<String> {
57 + let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?;
58 + doc.get("package")
59 + .and_then(|p| p.get("version"))
60 + .or_else(|| {
61 + doc.get("workspace")
62 + .and_then(|w| w.get("package"))
63 + .and_then(|p| p.get("version"))
64 + })
65 + .and_then(|v| v.as_str())
66 + .map(str::to_owned)
67 + .context("no `[package].version` or `[workspace.package].version` in Cargo.toml")
68 + }
69 +
70 + /// Cross-check every version source in a repo and confirm they all agree with
71 + /// the version being built, before a single host pulls or compiles.
72 + ///
73 + /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0
74 + /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under
75 + /// whichever the runner happened to read. This reads every source present —
76 + /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root
77 + /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its
78 + /// version. A source that is absent is skipped (a library crate with only a
79 + /// `Cargo.toml` has nothing to disagree with); the check never invents drift.
80 + ///
81 + /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml`
82 + /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at
83 + /// its own build step — but the same drift class motivated this guard.
84 + pub fn check_version_consistency(
85 + repo: &str,
86 + version_path: Option<&str>,
87 + expected: &Version,
88 + ) -> Result<()> {
89 + let root = expand_tilde(repo);
90 + let mut sources: Vec<(String, String)> = Vec::new();
91 + for rel in version_sources(version_path) {
92 + let path = root.join(&rel);
93 + // A source that is absent is skipped — a library crate with only a
94 + // `Cargo.toml` has nothing to disagree with — but one the app NAMES
95 + // must be readable, or the check would pass by failing to look.
96 + match std::fs::read_to_string(&path) {
97 + Ok(raw) => sources.push((rel, raw)),
98 + Err(e) if version_path == Some(rel.as_str()) => {
99 + return Err(e).with_context(|| format!("reading version file {}", path.display()));
100 + }
101 + Err(_) => {}
102 + }
103 + }
104 + versions_agree(repo, &sources, version_path, expected)
105 + }
106 +
107 + /// The files a repo can state its version in, in the order they are read:
108 + /// whatever the app names, then the two conventional ones.
109 + ///
110 + /// The app's own `version_path` is never read twice, which is why this is a
111 + /// function rather than a constant.
112 + pub fn version_sources(version_path: Option<&str>) -> Vec<String> {
113 + let mut rels: Vec<String> = version_path.into_iter().map(str::to_string).collect();
114 + for conventional in ["src-tauri/tauri.conf.json", "Cargo.toml"] {
115 + if version_path != Some(conventional) {
116 + rels.push(conventional.to_string());
117 + }
118 + }
119 + rels
120 + }
121 +
122 + /// The judgement half of [`check_version_consistency`], over sources somebody
123 + /// else read.
124 + ///
125 + /// Split out so the same rule can be applied to files read out of the release
126 + /// TAG on a build host, which is where the question actually belongs: the tree
127 + /// a release compiles is the tag's, so a `Cargo.toml` that disagrees with the
128 + /// tag it is tagged in is the drift worth refusing. Reading the working copy
129 + /// instead answered a question about a tree the release does not build.
130 + ///
131 + /// `where_` is only for the error message — a path, or a tag and a host.
132 + pub fn versions_agree(
133 + where_: &str,
134 + sources: &[(String, String)],
135 + version_path: Option<&str>,
136 + expected: &Version,
137 + ) -> Result<()> {
138 + let mut found: Vec<(String, Version)> = Vec::new();
139 + for (rel, raw) in sources {
140 + // The app's own `version_path` can be either shape, so it is decided by
141 + // extension; the two conventional sources are what they are.
142 + let as_json = std::path::Path::new(rel)
143 + .extension()
144 + .is_some_and(|e| e.eq_ignore_ascii_case("json"));
145 + let ver = if as_json {
146 + version_from_tauri_json(raw)
147 + } else {
148 + // A Cargo.toml with neither `[package].version` nor
149 + // `[workspace.package].version` (a pure virtual workspace) carries
150 + // no version to check — skip it rather than fail. An app that NAMED
151 + // this file is held to it.
152 + match version_from_cargo_toml(raw) {
153 + Ok(v) => Ok(v),
154 + Err(e) if version_path == Some(rel.as_str()) => Err(e),
155 + Err(_) => continue,
156 + }
157 + }?;
158 + found.push((
159 + rel.clone(),
160 + Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?,
161 + ));
162 + }
163 +
164 + let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect();
165 + anyhow::ensure!(
166 + disagree.is_empty(),
167 + "version drift in {where_}: building {expected} but {}",
168 + disagree
169 + .iter()
170 + .map(|(src, v)| format!("{src} says {v}"))
171 + .collect::<Vec<_>>()
172 + .join(", ")
173 + );
174 + Ok(())
175 + }
176 +
177 + /// Read one file as it exists in `tag`, without checking anything out.
178 + ///
179 + /// `<rev>:./<path>` resolves the path relative to `-C`, so this is asked from
180 + /// the app's own directory and needs no knowledge of where that sits inside the
181 + /// repository. A non-zero exit means the file is not in the tag, which is the
182 + /// same "absent, so nothing to disagree with" the local read treats it as.
183 + pub fn git_show_file_cmd(dir: &str, tag: &str, rel: &str) -> String {
184 + format!("git -C \"{dir}\" show \"{tag}:./{rel}\"")
185 + }
186 +
187 + #[cfg(test)]
188 + mod tests {
189 + use super::*;
190 +
191 + #[test]
192 + fn version_from_tauri_json_reads_version() {
193 + assert_eq!(
194 + version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(),
195 + "0.4.2"
196 + );
197 + assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err());
198 + }
199 +
200 + #[test]
201 + fn version_from_cargo_toml_prefers_package_then_workspace() {
202 + // A leaf crate's [package].version.
203 + assert_eq!(
204 + version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(),
205 + "0.5.0"
206 + );
207 + // A workspace that sets [workspace.package].version.
208 + assert_eq!(
209 + version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(),
210 + "1.2.3"
211 + );
212 + // No version anywhere -> error, not a panic.
213 + assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err());
214 + }
215 +
216 + #[test]
217 + fn version_from_repo_default_and_explicit_paths() {
218 + let tmp = tempfile::tempdir().unwrap();
219 + let root = tmp.path();
220 +
221 + // Tauri app: default path reads src-tauri/tauri.conf.json.
222 + let tauri = root.join("tauri");
223 + std::fs::create_dir_all(tauri.join("src-tauri")).unwrap();
224 + std::fs::write(
225 + tauri.join("src-tauri/tauri.conf.json"),
226 + r#"{"version":"0.4.2"}"#,
227 + )
228 + .unwrap();
229 + assert_eq!(
230 + version_from_repo(tauri.to_str().unwrap(), None)
231 + .unwrap()
232 + .to_string(),
233 + "0.4.2"
234 + );
235 +
236 + // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate.
237 + let ws = root.join("ws");
238 + std::fs::create_dir_all(ws.join("crates/app")).unwrap();
239 + std::fs::write(
240 + ws.join("Cargo.toml"),
241 + "[workspace]\nmembers = [\"crates/app\"]\n",
242 + )
243 + .unwrap();
244 + std::fs::write(
245 + ws.join("crates/app/Cargo.toml"),
246 + "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
247 + )
248 + .unwrap();
249 + assert_eq!(
250 + version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml"))
251 + .unwrap()
252 + .to_string(),
253 + "0.5.0"
254 + );
255 + }
256 +
257 + fn ver(s: &str) -> Version {
258 + Version::parse(s).unwrap()
259 + }
260 +
261 + #[test]
262 + fn version_consistency_passes_when_all_sources_agree() {
263 + let tmp = tempfile::tempdir().unwrap();
264 + let repo = tmp.path();
265 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
266 + std::fs::write(
267 + repo.join("src-tauri/tauri.conf.json"),
268 + r#"{"version":"0.5.0"}"#,
269 + )
270 + .unwrap();
271 + std::fs::write(
272 + repo.join("Cargo.toml"),
273 + "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
274 + )
275 + .unwrap();
276 + check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap();
277 + }
278 +
279 + #[test]
280 + fn version_consistency_flags_tauri_vs_cargo_drift() {
281 + // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root
282 + // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it.
283 + let tmp = tempfile::tempdir().unwrap();
284 + let repo = tmp.path();
285 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
286 + std::fs::write(
287 + repo.join("src-tauri/tauri.conf.json"),
288 + r#"{"version":"0.5.0"}"#,
289 + )
290 + .unwrap();
291 + std::fs::write(
292 + repo.join("Cargo.toml"),
293 + "[package]\nname = \"app\"\nversion = \"0.4.0\"\n",
294 + )
295 + .unwrap();
296 + let err =
297 + check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err();
298 + let msg = format!("{err:#}");
299 + assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}");
300 + }
301 +
302 + #[test]
303 + fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() {
304 + let tmp = tempfile::tempdir().unwrap();
305 + let repo = tmp.path();
306 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
307 + std::fs::write(
308 + repo.join("src-tauri/tauri.conf.json"),
309 + r#"{"version":"0.5.0"}"#,
310 + )
311 + .unwrap();
312 + let err =
313 + check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err();
314 + assert!(format!("{err:#}").contains("building 9.9.9"));
315 + }
316 +
317 + #[test]
318 + fn version_consistency_single_source_never_invents_drift() {
319 + // A virtual-workspace root Cargo.toml (no version) alongside the member
320 + // crate the version_path points at: only one real source, so no drift.
321 + let tmp = tempfile::tempdir().unwrap();
322 + let repo = tmp.path();
323 + std::fs::create_dir_all(repo.join("crates/app")).unwrap();
324 + std::fs::write(
325 + repo.join("Cargo.toml"),
326 + "[workspace]\nmembers = [\"crates/app\"]\n",
327 + )
328 + .unwrap();
329 + std::fs::write(
330 + repo.join("crates/app/Cargo.toml"),
331 + "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
332 + )
333 + .unwrap();
334 + check_version_consistency(
335 + repo.to_str().unwrap(),
336 + Some("crates/app/Cargo.toml"),
337 + &ver("0.5.0"),
338 + )
339 + .unwrap();
340 + }
341 + }