Skip to main content

max / makenotwork

30.5 KB · 734 lines History Blame Raw
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 {
501 use ops_core::remote::LogSink as _;
502 let mut guard = sink.lock().await;
503 guard.write_chunk(echo.as_bytes()).await;
504 exec.run_streaming(&step, &mut *guard).await
505 })?;
506 let code = out.status.code().unwrap_or(-1);
507 let stdout = String::from_utf8_lossy(&out.stdout);
508 let tail: String = stdout
509 .chars()
510 .rev()
511 .take(2000)
512 .collect::<Vec<_>>()
513 .into_iter()
514 .rev()
515 .collect();
516 Ok((code, tail))
517 }
518
519 /// Assert `host`'s build tree is at the release tag and return the commit,
520 /// which is what a recipe's `checkout` step logs. Fetch + checkout stream
521 /// into the current step's log; the sha comes from a separate `rev-parse` so
522 /// its stdout is only the sha.
523 ///
524 /// The preflight has already put this tree at the tag before any recipe ran —
525 /// this is the same operation again, on purpose, so that a recipe's own
526 /// `checkout` step is a real step with a real log rather than a claim about
527 /// something that happened elsewhere. Re-running it is cheap and, because the
528 /// tree is Bento's own worktree, forcing: a build that has already dirtied it
529 /// must not be able to fail its own retry.
530 pub(super) fn checkout_sha(self: &Arc<Self>, host: &str) -> Result<String> {
531 // Every command below runs ON `host`, so the path is that host's, not the
532 // daemon's. Windows is why: its worktree is under `C:/Users/me/Code/...`.
533 let repo = self.repo_for(host).to_string();
534 // A failing mirror is not a failing release: fetch is advisory, and only
535 // the checkout decides. Its output still streams into the step log, so an
536 // unreachable remote stays visible without being fatal.
537 let _ = self.run(host, &git_fetch_cmd(&repo))?;
538 let (code, err) = self.run(host, &git_worktree_pin_cmd(&repo, &self.tag))?;
539 if code != 0 {
540 let (probe, _) = self.run(host, &git_tag_exists_cmd(&repo, &self.tag))?;
541 anyhow::bail!(
542 "checkout of {} failed on `{host}`: {}",
543 self.tag,
544 worktree_failure_reason(&self.tag, probe == 0, &err)
545 );
546 }
547 let (code, tail) = self.run(host, &git_rev_parse_cmd(&repo))?;
548 anyhow::ensure!(code == 0, "rev-parse failed on `{host}`");
549 Ok(tail.trim().to_string())
550 }
551
552 /// Every artifact this run collected, `file name -> sha256`.
553 ///
554 /// Already computed at `collect`, which is the only moment the bytes are
555 /// known to be the ones that landed.
556 pub fn artifact_hashes(&self) -> HashMap<String, String> {
557 self.artifact_hashes.lock().unwrap().clone()
558 }
559
560 /// The steps finalized as `Failed` so far, as a snapshot.
561 ///
562 /// Cloned rather than borrowed because `PublishAuthority::prove` wants a
563 /// slice and the caller slices this, which is the shape that keeps the lock
564 /// out of the caller's scope.
565 pub(super) fn failed_steps_snapshot(&self) -> Vec<Step> {
566 self.failed_steps.lock().unwrap().clone()
567 }
568
569 /// Record the digest `collect` computed for one artifact, by file name.
570 pub(super) fn record_artifact_hash(&self, name: String, digest: String) {
571 self.artifact_hashes.lock().unwrap().insert(name, digest);
572 }
573
574 /// One artifact's recorded digest, if `collect` hashed it.
575 ///
576 /// The targeted read beside [`RecipeCtx::artifact_hashes`], which clones the
577 /// whole map; `publish` wants exactly one entry.
578 pub(super) fn artifact_hash(&self, name: &str) -> Option<String> {
579 self.artifact_hashes.lock().unwrap().get(name).cloned()
580 }
581
582 /// The all-targets-green publish gate, if this app declares one.
583 pub(super) fn all_green_required(&self) -> Option<Vec<Target>> {
584 self.all_green_required.clone()
585 }
586
587 /// The gatekeeper verdict: `None` = never run, `Some(false)` = rejected.
588 pub(super) fn gatekeeper_ok(&self) -> Option<bool> {
589 *self.gatekeeper_ok.lock().unwrap()
590 }
591
592 /// Record what the gatekeeper said about this artifact.
593 pub(super) fn set_gatekeeper_ok(&self, accepted: bool) {
594 *self.gatekeeper_ok.lock().unwrap() = Some(accepted);
595 }
596 }
597
598 #[cfg(test)]
599 mod tests {
600 use super::super::build_engine;
601 use super::*;
602
603 /// Run 3 S1: once the cooperative cancel flag is set (a newer build
604 /// superseded this run), a step boundary refuses to proceed — the blocking
605 /// recipe stops at the next `step()` instead of running on and publishing.
606 #[tokio::test]
607 async fn begin_step_bails_when_cancelled() {
608 let dir = tempfile::tempdir().unwrap();
609 let cfg = Arc::new(Config::for_tests(dir.path()));
610 let pool = crate::db::open(&cfg.db_path).await.unwrap();
611 let cancel = Arc::new(AtomicBool::new(true));
612 let ctx = Arc::new(RecipeCtx::new(
613 AppId::new("demo"),
614 Version::parse("0.1.0").unwrap(),
615 "linux/x86_64".parse().unwrap(),
616 "fw13".into(),
617 "local".into(),
618 "v0.1.0".into(),
619 "/tmp".into(),
620 vec![],
621 Kind::App,
622 1,
623 Arc::new(std::collections::HashMap::new()),
624 Arc::new(std::collections::HashMap::new()),
625 None,
626 pool,
627 crate::events::channel(),
628 cfg,
629 Arc::new(OtaRegistry::standard("https://makenot.work")),
630 tokio::runtime::Handle::current(),
631 cancel.clone(),
632 None,
633 ));
634 // Cancelled: begin_step refuses before touching the DB (the ensure! is
635 // ahead of any block_on, so this is safe to call from the async test).
636 let err = ctx.begin_step(Step::Build).unwrap_err();
637 assert!(err.to_string().contains("supersede"), "got: {err}");
638 assert!(ctx.is_cancelled());
639 }
640
641 /// `feature_flags()` returns a whole flag or nothing at all. An app with
642 /// no declared features must not yield a bare `--features`, which would
643 /// swallow the next word of the build command as its argument.
644 #[tokio::test]
645 async fn feature_flags_renders_whole_flag_or_empty() {
646 async fn flags_for(features: Vec<String>) -> String {
647 let dir = tempfile::tempdir().unwrap();
648 let cfg = Arc::new(Config::for_tests(dir.path()));
649 let pool = crate::db::open(&cfg.db_path).await.unwrap();
650 let ctx = Arc::new(RecipeCtx::new(
651 AppId::new("demo"),
652 Version::parse("0.1.0").unwrap(),
653 "linux/x86_64".parse().unwrap(),
654 "fw13".into(),
655 "local".into(),
656 "v0.1.0".into(),
657 "/tmp".into(),
658 features,
659 Kind::App,
660 1,
661 Arc::new(std::collections::HashMap::new()),
662 Arc::new(std::collections::HashMap::new()),
663 None,
664 pool,
665 crate::events::channel(),
666 cfg,
667 Arc::new(OtaRegistry::standard("https://makenot.work")),
668 tokio::runtime::Handle::current(),
669 Arc::new(AtomicBool::new(false)),
670 None,
671 ));
672 let engine = build_engine(&ctx);
673 engine.eval::<String>("feature_flags()").unwrap()
674 }
675
676 assert_eq!(flags_for(vec![]).await, "");
677 assert_eq!(
678 flags_for(vec!["supernote".into()]).await,
679 "--features supernote"
680 );
681 assert_eq!(
682 flags_for(vec!["supernote".into(), "extra".into()]).await,
683 "--features supernote,extra"
684 );
685 }
686
687 /// `repo()` answers for the host this target builds on, not for the daemon.
688 ///
689 /// This is what lets a Windows recipe call `repo()` and `checkout_sha(h)`
690 /// instead of hard-coding `C:/Users/me/...` — and hard-coding it is what
691 /// kept those recipes off the release-tag pin, since `checkout_sha` builds
692 /// its git commands from the app's path and takes no override.
693 #[tokio::test]
694 async fn repo_resolves_per_build_host() {
695 async fn repo_on(build_host: &str) -> String {
696 let dir = tempfile::tempdir().unwrap();
697 let cfg = Arc::new(Config::for_tests(dir.path()));
698 let pool = crate::db::open(&cfg.db_path).await.unwrap();
699 let ctx = Arc::new(
700 RecipeCtx::new(
701 AppId::new("demo"),
702 Version::parse("0.1.0").unwrap(),
703 "linux/x86_64".parse().unwrap(),
704 build_host.into(),
705 "local".into(),
706 "v0.1.0".into(),
707 "~/Code/Apps/demo".into(),
708 vec![],
709 Kind::App,
710 1,
711 Arc::new(std::collections::HashMap::new()),
712 Arc::new(std::collections::HashMap::new()),
713 None,
714 pool,
715 crate::events::channel(),
716 cfg,
717 Arc::new(OtaRegistry::standard("https://makenot.work")),
718 tokio::runtime::Handle::current(),
719 Arc::new(AtomicBool::new(false)),
720 None,
721 )
722 .with_repo_by_host(HashMap::from([(
723 "windows-x86".to_string(),
724 "C:/Users/me/Code/Apps/demo".to_string(),
725 )])),
726 );
727 build_engine(&ctx).eval::<String>("repo()").unwrap()
728 }
729
730 assert_eq!(repo_on("windows-x86").await, "C:/Users/me/Code/Apps/demo");
731 assert_eq!(repo_on("fw13").await, "~/Code/Apps/demo");
732 }
733 }
734