max / makenotwork
5 files changed,
+160 insertions,
-54 deletions
| @@ -27,7 +27,7 @@ pub async fn recover_orphaned_running(pool: &SqlitePool) -> Result<u64> { | |||
| 27 | 27 | .execute(&mut *tx) | |
| 28 | 28 | .await?; | |
| 29 | 29 | sqlx::query( | |
| 30 | - | "UPDATE target_runs SET status = 'failed', \ | |
| 30 | + | "UPDATE target_runs SET status = 'failed', current_step = NULL, \ | |
| 31 | 31 | error = COALESCE(error, 'daemon restarted while running'), finished_at = ? \ | |
| 32 | 32 | WHERE status = 'running'", | |
| 33 | 33 | ) |
| @@ -25,6 +25,7 @@ use rhai::{Engine, EvalAltResult, Map}; | |||
| 25 | 25 | use sqlx::SqlitePool; | |
| 26 | 26 | use std::collections::HashMap; | |
| 27 | 27 | use std::path::{Path, PathBuf}; | |
| 28 | + | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 28 | 29 | use std::sync::{Arc, Mutex}; | |
| 29 | 30 | use tokio::runtime::Handle; | |
| 30 | 31 | use tokio::sync::Mutex as AsyncMutex; | |
| @@ -97,6 +98,10 @@ pub struct RecipeCtx { | |||
| 97 | 98 | /// `publish` — an artifact is never shipped after a step failed, even if the | |
| 98 | 99 | /// recipe ignored the failure and ran on. | |
| 99 | 100 | failed_steps: Mutex<Vec<Step>>, | |
| 101 | + | /// Set when a newer build supersedes this run. Checked at step boundaries | |
| 102 | + | /// and before publish so a superseded recipe stops promptly rather than | |
| 103 | + | /// running to completion (the blocking Rhai body can't be `abort()`ed). | |
| 104 | + | cancel: Arc<AtomicBool>, | |
| 100 | 105 | } | |
| 101 | 106 | ||
| 102 | 107 | impl RecipeCtx { | |
| @@ -115,6 +120,7 @@ impl RecipeCtx { | |||
| 115 | 120 | cfg: Arc<Config>, | |
| 116 | 121 | ota: Arc<OtaRegistry>, | |
| 117 | 122 | rt: Handle, | |
| 123 | + | cancel: Arc<AtomicBool>, | |
| 118 | 124 | ) -> Self { | |
| 119 | 125 | Self { | |
| 120 | 126 | app, | |
| @@ -133,9 +139,15 @@ impl RecipeCtx { | |||
| 133 | 139 | current: Mutex::new(None), | |
| 134 | 140 | gatekeeper_ok: Mutex::new(None), | |
| 135 | 141 | failed_steps: Mutex::new(Vec::new()), | |
| 142 | + | cancel, | |
| 136 | 143 | } | |
| 137 | 144 | } | |
| 138 | 145 | ||
| 146 | + | /// Whether a newer build has superseded this run. | |
| 147 | + | fn is_cancelled(&self) -> bool { | |
| 148 | + | self.cancel.load(Ordering::SeqCst) | |
| 149 | + | } | |
| 150 | + | ||
| 139 | 151 | fn now() -> String { | |
| 140 | 152 | chrono::Utc::now().to_rfc3339() | |
| 141 | 153 | } | |
| @@ -154,6 +166,11 @@ impl RecipeCtx { | |||
| 154 | 166 | /// Close the previous step (as `Ok`), open a new one: insert its DB row, | |
| 155 | 167 | /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`. | |
| 156 | 168 | fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> { | |
| 169 | + | anyhow::ensure!( | |
| 170 | + | !self.is_cancelled(), | |
| 171 | + | "build superseded by a newer request; aborting before `{}`", | |
| 172 | + | step.as_str() | |
| 173 | + | ); | |
| 157 | 174 | self.finish_step(Status::Ok)?; | |
| 158 | 175 | let me = self.clone(); | |
| 159 | 176 | let run_id = self.rt.block_on(async move { | |
| @@ -595,22 +612,20 @@ impl RecipeCtx { | |||
| 595 | 612 | let dest_s = dest.to_string_lossy().into_owned(); | |
| 596 | 613 | let ssh = self.host_ssh(host)?; | |
| 597 | 614 | let is_local = ssh == "local" || ssh.is_empty(); | |
| 598 | - | // The daemon does the transfer locally: cp for a local host, scp for a | |
| 599 | - | // remote one. The glob is sh-quoted on the local side; on the remote side | |
| 600 | - | // the wildcard must stay live for the remote shell to expand, so we quote | |
| 601 | - | // the host prefix and leave the glob unquoted but validated. | |
| 615 | + | // Both branches run the transfer through `sh -c`, and both need the | |
| 616 | + | // wildcard (`*?[]`) to reach the shell live — so the glob is left | |
| 617 | + | // UNQUOTED in both and validated against shell metacharacters instead. | |
| 618 | + | // (Quoting it on the local side, as before, neutered the wildcard so | |
| 619 | + | // `cp '*.dmg'` matched nothing.) Path/wildcard chars are allowed; command | |
| 620 | + | // metacharacters are not. | |
| 621 | + | anyhow::ensure!( | |
| 622 | + | !glob.chars().any(|c| matches!(c, ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>')), | |
| 623 | + | "collect glob `{glob}` contains shell metacharacters" | |
| 624 | + | ); | |
| 602 | 625 | let d = ops_core::remote::sh_quote(&dest_s); | |
| 603 | 626 | let cmd = if is_local { | |
| 604 | - | let g = ops_core::remote::sh_quote(glob); | |
| 605 | - | format!("mkdir -p {d} && cp -vR {g} {d}/") | |
| 627 | + | format!("mkdir -p {d} && cp -vR {glob} {d}/") | |
| 606 | 628 | } else { | |
| 607 | - | // Reject shell metacharacters that would break out of the scp source | |
| 608 | - | // (the wildcard chars `*?[]` and path chars are allowed; `;`, `$`, | |
| 609 | - | // backticks, quotes, whitespace are not). | |
| 610 | - | anyhow::ensure!( | |
| 611 | - | !glob.chars().any(|c| matches!(c, ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>')), | |
| 612 | - | "collect glob `{glob}` contains shell metacharacters" | |
| 613 | - | ); | |
| 614 | 629 | format!( | |
| 615 | 630 | "mkdir -p {d} && scp -r {flags} {tgt}:{glob} {d}/", | |
| 616 | 631 | flags = ops_core::remote::SSH_FLAGS.join(" "), | |
| @@ -647,6 +662,10 @@ impl RecipeCtx { | |||
| 647 | 662 | artifact: &str, | |
| 648 | 663 | meta: Map, | |
| 649 | 664 | ) -> Result<String> { | |
| 665 | + | // Never let a superseded build ship. This is the last and most important | |
| 666 | + | // cooperative-cancel checkpoint: even if a long-running step finished | |
| 667 | + | // after supersession, the artifact must not reach the backend. | |
| 668 | + | anyhow::ensure!(!self.is_cancelled(), "build superseded by a newer request; refusing to publish"); | |
| 650 | 669 | let backend = self | |
| 651 | 670 | .ota | |
| 652 | 671 | .get(channel) | |
| @@ -715,23 +734,31 @@ impl RecipeCtx { | |||
| 715 | 734 | let receipt = backend | |
| 716 | 735 | .publish(&rel, &artifact_path, &authority) | |
| 717 | 736 | .with_context(|| format!("publish to `{channel}`"))?; | |
| 718 | - | // Record for idempotency / monotonicity. | |
| 737 | + | // Record for idempotency / monotonicity. This write is CHECKED, not | |
| 738 | + | // fire-and-forget: a swallowed failure here would silently re-arm the | |
| 739 | + | // monotonicity guard (which reads this same table), letting an older | |
| 740 | + | // version republish over a live release. Concurrent same-(app,target) | |
| 741 | + | // publishers can't race the read-then-insert because the latest-wins slot | |
| 742 | + | // (state::ActiveSlot) serializes them and a superseded run is cancelled | |
| 743 | + | // before it reaches publish. | |
| 719 | 744 | let me = self.clone(); | |
| 720 | 745 | let (app_s, target_s, ver_s, chan_s) = | |
| 721 | 746 | (app.to_string(), target.to_string(), version.to_string(), channel.to_string()); | |
| 722 | - | self.rt.block_on(async move { | |
| 723 | - | let _ = sqlx::query( | |
| 724 | - | "INSERT OR IGNORE INTO releases (app, target, version, channel, published_at) | |
| 725 | - | VALUES (?, ?, ?, ?, ?)", | |
| 726 | - | ) | |
| 727 | - | .bind(app_s) | |
| 728 | - | .bind(target_s) | |
| 729 | - | .bind(ver_s) | |
| 730 | - | .bind(chan_s) | |
| 731 | - | .bind(Self::now()) | |
| 732 | - | .execute(&me.pool) | |
| 733 | - | .await; | |
| 734 | - | }); | |
| 747 | + | self.rt | |
| 748 | + | .block_on(async move { | |
| 749 | + | sqlx::query( | |
| 750 | + | "INSERT OR IGNORE INTO releases (app, target, version, channel, published_at) | |
| 751 | + | VALUES (?, ?, ?, ?, ?)", | |
| 752 | + | ) | |
| 753 | + | .bind(app_s) | |
| 754 | + | .bind(target_s) | |
| 755 | + | .bind(ver_s) | |
| 756 | + | .bind(chan_s) | |
| 757 | + | .bind(Self::now()) | |
| 758 | + | .execute(&me.pool) | |
| 759 | + | .await | |
| 760 | + | }) | |
| 761 | + | .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?; | |
| 735 | 762 | events::emit( | |
| 736 | 763 | &self.events, | |
| 737 | 764 | Event::PublishOk { app: self.app.clone(), target: self.target, channel: channel.to_string() }, | |
| @@ -747,6 +774,9 @@ fn dir_size(p: &Path) -> Option<i64> { | |||
| 747 | 774 | let md = entry.metadata().ok()?; | |
| 748 | 775 | if md.is_file() { | |
| 749 | 776 | total += md.len() as i64; | |
| 777 | + | } else if md.is_dir() { | |
| 778 | + | // Recurse so a bundle dir (a `.app`) reports its real size, not ~0. | |
| 779 | + | total += dir_size(&entry.path()).unwrap_or(0); | |
| 750 | 780 | } | |
| 751 | 781 | } | |
| 752 | 782 | Some(total) | |
| @@ -917,6 +947,38 @@ fn notary_accepted(output: &str) -> bool { | |||
| 917 | 947 | mod tests { | |
| 918 | 948 | use super::*; | |
| 919 | 949 | ||
| 950 | + | /// Run 3 S1: once the cooperative cancel flag is set (a newer build | |
| 951 | + | /// superseded this run), a step boundary refuses to proceed — the blocking | |
| 952 | + | /// recipe stops at the next `step()` instead of running on and publishing. | |
| 953 | + | #[tokio::test] | |
| 954 | + | async fn begin_step_bails_when_cancelled() { | |
| 955 | + | let dir = tempfile::tempdir().unwrap(); | |
| 956 | + | let cfg = Arc::new(Config::for_tests(dir.path())); | |
| 957 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 958 | + | let cancel = Arc::new(AtomicBool::new(true)); | |
| 959 | + | let ctx = Arc::new(RecipeCtx::new( | |
| 960 | + | AppId::new("demo"), | |
| 961 | + | Version::parse("0.1.0").unwrap(), | |
| 962 | + | "linux/x86_64".parse().unwrap(), | |
| 963 | + | "fw13".into(), | |
| 964 | + | "/tmp".into(), | |
| 965 | + | 1, | |
| 966 | + | Arc::new(HashMap::new()), | |
| 967 | + | HashMap::new(), | |
| 968 | + | pool, | |
| 969 | + | crate::events::channel(), | |
| 970 | + | cfg, | |
| 971 | + | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 972 | + | tokio::runtime::Handle::current(), | |
| 973 | + | cancel.clone(), | |
| 974 | + | )); | |
| 975 | + | // Cancelled: begin_step refuses before touching the DB (the ensure! is | |
| 976 | + | // ahead of any block_on, so this is safe to call from the async test). | |
| 977 | + | let err = ctx.begin_step(Step::Build).unwrap_err(); | |
| 978 | + | assert!(err.to_string().contains("supersede"), "got: {err}"); | |
| 979 | + | assert!(ctx.is_cancelled()); | |
| 980 | + | } | |
| 981 | + | ||
| 920 | 982 | #[test] | |
| 921 | 983 | fn expand_tilde_handles_home() { | |
| 922 | 984 | unsafe { std::env::set_var("HOME", "/home/test") }; |
| @@ -93,23 +93,24 @@ pub async fn start_build( | |||
| 93 | 93 | let app = app.clone(); | |
| 94 | 94 | let version = version.clone(); | |
| 95 | 95 | let key = (app.clone(), target); | |
| 96 | + | let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); | |
| 96 | 97 | ||
| 97 | - | // Latest-wins: abort any in-flight run for this (app, target). | |
| 98 | + | // Latest-wins, done as ONE critical section: supersede the prior occupant | |
| 99 | + | // (cooperatively cancel its recipe + abort its task) and install ours | |
| 100 | + | // without ever releasing the lock, so two concurrent /build+/retry for the | |
| 101 | + | // same (app, target) can't both pass "nothing to abort" and run together. | |
| 102 | + | let mut active = state.active.lock().await; | |
| 103 | + | if let Some(prev) = active.remove(&key) | |
| 104 | + | && !prev.abort.is_finished() | |
| 98 | 105 | { | |
| 99 | - | let mut active = state.active.lock().await; | |
| 100 | - | if let Some((_, prev)) = active.remove(&key) | |
| 101 | - | && !prev.is_finished() { | |
| 102 | - | events::emit( | |
| 103 | - | &state.events, | |
| 104 | - | Event::TargetAborted { app: app.clone(), target }, | |
| 105 | - | ); | |
| 106 | - | prev.abort(); | |
| 107 | - | } | |
| 106 | + | // The recipe runs on a blocking thread; abort() alone can't stop it, | |
| 107 | + | // so set the cooperative flag the engine checks at step boundaries. | |
| 108 | + | prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst); | |
| 109 | + | prev.abort.abort(); | |
| 110 | + | events::emit(&state.events, Event::TargetAborted { app: app.clone(), target }); | |
| 108 | 111 | } | |
| 109 | - | ||
| 110 | - | let abort = set.spawn(run_target(state.clone(), build_id, app, version, target)); | |
| 111 | - | let mut active = state.active.lock().await; | |
| 112 | - | active.insert(key, (build_id, abort)); | |
| 112 | + | let abort = set.spawn(run_target(state.clone(), build_id, app, version, target, cancel.clone())); | |
| 113 | + | active.insert(key, crate::state::ActiveSlot { build_id, abort, cancel }); | |
| 113 | 114 | crate::metrics::set_in_flight(active.len()); | |
| 114 | 115 | } | |
| 115 | 116 | ||
| @@ -119,8 +120,17 @@ pub async fn start_build( | |||
| 119 | 120 | Ok(build_id) | |
| 120 | 121 | } | |
| 121 | 122 | ||
| 122 | - | /// Run one target's recipe end to end, updating its `target_runs` row. | |
| 123 | - | async fn run_target(state: AppState, build_id: i64, app: AppId, version: Version, target: Target) { | |
| 123 | + | /// Run one target's recipe end to end, updating its `target_runs` row. `cancel` | |
| 124 | + | /// is set when a newer build supersedes this one; the engine checks it at step | |
| 125 | + | /// boundaries and before publish so a superseded recipe can't advance or ship. | |
| 126 | + | async fn run_target( | |
| 127 | + | state: AppState, | |
| 128 | + | build_id: i64, | |
| 129 | + | app: AppId, | |
| 130 | + | version: Version, | |
| 131 | + | target: Target, | |
| 132 | + | cancel: Arc<std::sync::atomic::AtomicBool>, | |
| 133 | + | ) { | |
| 124 | 134 | let started = std::time::Instant::now(); | |
| 125 | 135 | let target_run_id: i64 = match sqlx::query_scalar( | |
| 126 | 136 | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) | |
| @@ -198,6 +208,7 @@ async fn run_target(state: AppState, build_id: i64, app: AppId, version: Version | |||
| 198 | 208 | state.cfg.clone(), | |
| 199 | 209 | state.ota.clone(), | |
| 200 | 210 | tokio::runtime::Handle::current(), | |
| 211 | + | cancel, | |
| 201 | 212 | )); | |
| 202 | 213 | ||
| 203 | 214 | // Rhai is synchronous; run the recipe (and its final step finalization) on | |
| @@ -262,7 +273,7 @@ async fn fail_target( | |||
| 262 | 273 | error: &str, | |
| 263 | 274 | ) { | |
| 264 | 275 | if let Err(e) = sqlx::query( | |
| 265 | - | "UPDATE target_runs SET status = 'failed', error = ?, finished_at = ? WHERE id = ?", | |
| 276 | + | "UPDATE target_runs SET status = 'failed', current_step = NULL, error = ?, finished_at = ? WHERE id = ?", | |
| 266 | 277 | ) | |
| 267 | 278 | .bind(error) | |
| 268 | 279 | .bind(chrono::Utc::now().to_rfc3339()) | |
| @@ -312,7 +323,7 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo | |||
| 312 | 323 | // owns a different build_id and is left intact). | |
| 313 | 324 | { | |
| 314 | 325 | let mut active = state.active.lock().await; | |
| 315 | - | active.retain(|_, (bid, _)| *bid != build_id); | |
| 326 | + | active.retain(|_, slot| slot.build_id != build_id); | |
| 316 | 327 | crate::metrics::set_in_flight(active.len()); | |
| 317 | 328 | } | |
| 318 | 329 |
| @@ -8,6 +8,7 @@ use ops_exec::{AgentRpc, CapabilitySet, Executor, LocalExec, SshExec}; | |||
| 8 | 8 | use sqlx::SqlitePool; | |
| 9 | 9 | use std::collections::HashMap; | |
| 10 | 10 | use std::sync::Arc; | |
| 11 | + | use std::sync::atomic::AtomicBool; | |
| 11 | 12 | use tokio::sync::Mutex; | |
| 12 | 13 | use tokio::task::AbortHandle; | |
| 13 | 14 | ||
| @@ -18,11 +19,21 @@ use tokio::task::AbortHandle; | |||
| 18 | 19 | /// `ExecutorMap`. | |
| 19 | 20 | pub type ExecutorMap = HashMap<String, Arc<dyn Executor>>; | |
| 20 | 21 | ||
| 21 | - | /// Latest-wins guard map: per `(app, target)`, the owning `build_id` and an | |
| 22 | - | /// [`AbortHandle`] for the in-flight target task. A newer build supersedes the | |
| 23 | - | /// slot (aborting the prior handle); a finishing build reaps only the slots it | |
| 24 | - | /// still owns. | |
| 25 | - | pub type ActiveBuilds = Arc<Mutex<HashMap<(AppId, Target), (i64, AbortHandle)>>>; | |
| 22 | + | /// One occupant of the latest-wins guard: the owning `build_id`, an | |
| 23 | + | /// [`AbortHandle`] for the spawned target task (stops the async wrapper), and a | |
| 24 | + | /// cooperative `cancel` flag the recipe checks at step boundaries and before | |
| 25 | + | /// publish (stops the *blocking* Rhai body, which `abort()` alone cannot reach). | |
| 26 | + | #[derive(Clone)] | |
| 27 | + | pub struct ActiveSlot { | |
| 28 | + | pub build_id: i64, | |
| 29 | + | pub abort: AbortHandle, | |
| 30 | + | pub cancel: Arc<AtomicBool>, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Latest-wins guard map: per `(app, target)`, the in-flight occupant. A newer | |
| 34 | + | /// build supersedes the slot (setting the prior `cancel` and aborting its | |
| 35 | + | /// handle); a finishing build reaps only the slots it still owns. | |
| 36 | + | pub type ActiveBuilds = Arc<Mutex<HashMap<(AppId, Target), ActiveSlot>>>; | |
| 26 | 37 | ||
| 27 | 38 | #[derive(Clone)] | |
| 28 | 39 | pub struct AppState { |
| @@ -36,10 +36,21 @@ impl CapabilitySet { | |||
| 36 | 36 | O: IntoIterator, | |
| 37 | 37 | O::Item: AsRef<str>, | |
| 38 | 38 | { | |
| 39 | - | Self { | |
| 40 | - | actuate: actuate.into_iter().map(|t| t.as_ref().to_string()).collect(), | |
| 41 | - | observe: observe.into_iter().map(|t| ObserveKind::from_token(t.as_ref())).collect(), | |
| 39 | + | let actuate: BTreeSet<String> = actuate.into_iter().map(|t| t.as_ref().to_string()).collect(); | |
| 40 | + | let mut observe: BTreeSet<ObserveKind> = | |
| 41 | + | observe.into_iter().map(|t| ObserveKind::from_token(t.as_ref())).collect(); | |
| 42 | + | // A host that can `sign` can, by definition, verify the signature (spctl / | |
| 43 | + | // Gatekeeper). The publish gate *requires* that verification — | |
| 44 | + | // `verify_gatekeeper` dispatches `Observe("gatekeeper")` and | |
| 45 | + | // `PublishAuthority::prove` bars every macOS/iOS publish without a | |
| 46 | + | // Some(true) verdict. Grant the observe implicitly here, the one chokepoint | |
| 47 | + | // all three grant sources (topology executor, agent self-grant, agent | |
| 48 | + | // caller allow-list) flow through, so a single missing token in any of them | |
| 49 | + | // can't silently dead-end signing. | |
| 50 | + | if actuate.contains("sign") { | |
| 51 | + | observe.insert(ObserveKind::Custom("gatekeeper".into())); | |
| 42 | 52 | } | |
| 53 | + | Self { actuate, observe } | |
| 43 | 54 | } | |
| 44 | 55 | ||
| 45 | 56 | /// A set that permits exactly the given actuate actions and no observe. | |
| @@ -131,6 +142,17 @@ mod tests { | |||
| 131 | 142 | } | |
| 132 | 143 | ||
| 133 | 144 | #[test] | |
| 145 | + | fn sign_grant_implies_gatekeeper_observe() { | |
| 146 | + | // A sign host can run verify_gatekeeper without declaring the observe | |
| 147 | + | // token — otherwise every macOS/iOS publish dead-ends at the gate. | |
| 148 | + | let caps = CapabilitySet::from_tokens(["build", "sign", "notarize", "staple"], ["build-log"]); | |
| 149 | + | assert!(caps.permits(&Action::Observe(ObserveKind::Custom("gatekeeper".into())))); | |
| 150 | + | // A non-signing host gets no such implicit grant. | |
| 151 | + | let plain = CapabilitySet::from_tokens(["build"], ["build-log"]); | |
| 152 | + | assert!(!plain.permits(&Action::Observe(ObserveKind::Custom("gatekeeper".into())))); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 134 | 156 | fn custom_action_needs_exact_grant() { | |
| 135 | 157 | let caps = CapabilitySet::from_tokens(["smoke-test"], Vec::<&str>::new()); | |
| 136 | 158 | assert!(caps.permits(&Action::Custom("smoke-test".into()))); |