max / makenotwork
5 files changed,
+235 insertions,
-3 deletions
| @@ -12,3 +12,80 @@ pub async fn open(path: &Path) -> Result<SqlitePool> { | |||
| 12 | 12 | sqlx::migrate!("./migrations").run(&pool).await?; | |
| 13 | 13 | Ok(pool) | |
| 14 | 14 | } | |
| 15 | + | ||
| 16 | + | /// Reconcile state left `running` by a previous process. The finalizer and the | |
| 17 | + | /// per-target tasks die with the daemon, so any `running` build/target/step row | |
| 18 | + | /// at startup is orphaned — nothing will ever stamp it terminal. Mark them all | |
| 19 | + | /// `failed` so `/state` reflects reality and a re-run isn't blocked by a ghost. | |
| 20 | + | /// Returns the number of build rows reconciled (for logging). Idempotent: a | |
| 21 | + | /// clean DB sweeps zero rows. | |
| 22 | + | pub async fn recover_orphaned_running(pool: &SqlitePool) -> Result<u64> { | |
| 23 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 24 | + | let mut tx = pool.begin().await?; | |
| 25 | + | sqlx::query("UPDATE step_runs SET status = 'failed', finished_at = ? WHERE status = 'running'") | |
| 26 | + | .bind(&now) | |
| 27 | + | .execute(&mut *tx) | |
| 28 | + | .await?; | |
| 29 | + | sqlx::query( | |
| 30 | + | "UPDATE target_runs SET status = 'failed', \ | |
| 31 | + | error = COALESCE(error, 'daemon restarted while running'), finished_at = ? \ | |
| 32 | + | WHERE status = 'running'", | |
| 33 | + | ) | |
| 34 | + | .bind(&now) | |
| 35 | + | .execute(&mut *tx) | |
| 36 | + | .await?; | |
| 37 | + | let builds = sqlx::query("UPDATE builds SET status = 'failed', finished_at = ? WHERE status = 'running'") | |
| 38 | + | .bind(&now) | |
| 39 | + | .execute(&mut *tx) | |
| 40 | + | .await? | |
| 41 | + | .rows_affected(); | |
| 42 | + | tx.commit().await?; | |
| 43 | + | Ok(builds) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[cfg(test)] | |
| 47 | + | mod tests { | |
| 48 | + | use super::*; | |
| 49 | + | ||
| 50 | + | #[tokio::test] | |
| 51 | + | async fn recovery_marks_orphaned_running_failed_and_leaves_terminal_rows() { | |
| 52 | + | let dir = tempfile::tempdir().unwrap(); | |
| 53 | + | let pool = open(&dir.path().join("t.db")).await.unwrap(); | |
| 54 | + | ||
| 55 | + | // One running build with a running target+step, and one already-ok build. | |
| 56 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 57 | + | let running: i64 = sqlx::query_scalar( | |
| 58 | + | "INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.1.0','running',?) RETURNING id", | |
| 59 | + | ) | |
| 60 | + | .bind(&now).fetch_one(&pool).await.unwrap(); | |
| 61 | + | let tr: i64 = sqlx::query_scalar( | |
| 62 | + | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) \ | |
| 63 | + | VALUES (?, 'demo','0.1.0','linux/x86_64','running',?) RETURNING id", | |
| 64 | + | ) | |
| 65 | + | .bind(running).bind(&now).fetch_one(&pool).await.unwrap(); | |
| 66 | + | sqlx::query( | |
| 67 | + | "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at) \ | |
| 68 | + | VALUES (?, 'build','running','x',?)", | |
| 69 | + | ) | |
| 70 | + | .bind(tr).bind(&now).execute(&pool).await.unwrap(); | |
| 71 | + | sqlx::query("INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.0.9','ok',?)") | |
| 72 | + | .bind(&now).execute(&pool).await.unwrap(); | |
| 73 | + | ||
| 74 | + | let reconciled = recover_orphaned_running(&pool).await.unwrap(); | |
| 75 | + | assert_eq!(reconciled, 1, "only the running build is reconciled"); | |
| 76 | + | ||
| 77 | + | // The orphaned chain is now failed; the ok build is untouched. | |
| 78 | + | let running_left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='running'") | |
| 79 | + | .fetch_one(&pool).await.unwrap(); | |
| 80 | + | assert_eq!(running_left, 0); | |
| 81 | + | let ok_left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='ok'") | |
| 82 | + | .fetch_one(&pool).await.unwrap(); | |
| 83 | + | assert_eq!(ok_left, 1); | |
| 84 | + | let err: Option<String> = sqlx::query_scalar("SELECT error FROM target_runs WHERE id = ?") | |
| 85 | + | .bind(tr).fetch_one(&pool).await.unwrap(); | |
| 86 | + | assert_eq!(err.as_deref(), Some("daemon restarted while running")); | |
| 87 | + | ||
| 88 | + | // Idempotent: a second sweep finds nothing. | |
| 89 | + | assert_eq!(recover_orphaned_running(&pool).await.unwrap(), 0); | |
| 90 | + | } | |
| 91 | + | } |
| @@ -226,8 +226,9 @@ impl FromStr for Step { | |||
| 226 | 226 | // --------------------------------------------------------------------- | |
| 227 | 227 | ||
| 228 | 228 | /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to | |
| 229 | - | /// `/build`. Stored as TEXT. | |
| 230 | - | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 229 | + | /// `/build`. Stored as TEXT. Ordered by semver precedence (not the TEXT | |
| 230 | + | /// column's lexical order) so publish can enforce version monotonicity. | |
| 231 | + | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 231 | 232 | pub struct Version(semver::Version); | |
| 232 | 233 | ||
| 233 | 234 | impl Version { | |
| @@ -340,4 +341,16 @@ mod tests { | |||
| 340 | 341 | assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1"); | |
| 341 | 342 | assert!(Version::parse("v0.4").is_err()); | |
| 342 | 343 | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn version_orders_by_semver_not_lexically() { | |
| 347 | + | let v = |s: &str| Version::parse(s).unwrap(); | |
| 348 | + | assert!(v("0.4.0") < v("0.5.0")); | |
| 349 | + | // Lexical order would put "0.4.10" < "0.4.9"; semver must not. | |
| 350 | + | assert!(v("0.4.10") > v("0.4.9")); | |
| 351 | + | assert!(v("1.0.0") > v("0.99.99")); | |
| 352 | + | let mut vs = [v("0.4.10"), v("0.4.2"), v("0.5.0")]; | |
| 353 | + | vs.sort(); | |
| 354 | + | assert_eq!(vs.iter().max().unwrap().to_string(), "0.5.0"); | |
| 355 | + | } | |
| 343 | 356 | } |
| @@ -398,6 +398,12 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 398 | 398 | } | |
| 399 | 399 | ||
| 400 | 400 | // --- sh(host, cmd) -> #{ code, stdout_tail } --- | |
| 401 | + | // | |
| 402 | + | // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero | |
| 403 | + | // exit is returned, not raised, and does NOT fail the step or bar publish — | |
| 404 | + | // use this only when the recipe inspects `code` and decides. For a command | |
| 405 | + | // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and | |
| 406 | + | // therefore bars publish via the failed-step ledger) on a non-zero exit. | |
| 401 | 407 | { | |
| 402 | 408 | let ctx = ctx.clone(); | |
| 403 | 409 | engine.register_fn("sh", move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> { | |
| @@ -409,12 +415,19 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 409 | 415 | }); | |
| 410 | 416 | } | |
| 411 | 417 | ||
| 412 | - | // --- sh_ok(host, cmd): run + assert exit 0 --- | |
| 418 | + | // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) --- | |
| 419 | + | // | |
| 420 | + | // A non-zero exit fails the current step (added to the publish-barring | |
| 421 | + | // ledger) and aborts the recipe, so an artifact is never shipped after a | |
| 422 | + | // must-succeed command failed. | |
| 413 | 423 | { | |
| 414 | 424 | let ctx = ctx.clone(); | |
| 415 | 425 | engine.register_fn("sh_ok", move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> { | |
| 416 | 426 | let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 417 | 427 | if code != 0 { | |
| 428 | + | // Attribute the failure to the current step explicitly so the | |
| 429 | + | // ledger bars publish even if a future caller swallowed the error. | |
| 430 | + | ctx.fail_current_step(); | |
| 418 | 431 | return Err(rhai_err(format!("command on `{host}` exited {code}: {cmd}"))); | |
| 419 | 432 | } | |
| 420 | 433 | Ok(()) | |
| @@ -642,6 +655,35 @@ impl RecipeCtx { | |||
| 642 | 655 | "publish channel `{channel}` does not support target {target}", | |
| 643 | 656 | ); | |
| 644 | 657 | ||
| 658 | + | // Monotonicity: never publish a version that is not strictly newer than | |
| 659 | + | // the latest already published for this (app, target, channel). Without | |
| 660 | + | // this an older build could republish over a live newer release. The | |
| 661 | + | // `releases` column is TEXT, so compare by parsed semver precedence | |
| 662 | + | // (Version: Ord), not lexically. | |
| 663 | + | { | |
| 664 | + | let (app_s, target_s, chan_s) = (app.to_string(), target.to_string(), channel.to_string()); | |
| 665 | + | let me = self.clone(); | |
| 666 | + | let latest: Option<Version> = self.rt.block_on(async move { | |
| 667 | + | let rows: Vec<(String,)> = sqlx::query_as( | |
| 668 | + | "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?", | |
| 669 | + | ) | |
| 670 | + | .bind(app_s) | |
| 671 | + | .bind(target_s) | |
| 672 | + | .bind(chan_s) | |
| 673 | + | .fetch_all(&me.pool) | |
| 674 | + | .await | |
| 675 | + | .unwrap_or_default(); | |
| 676 | + | rows.into_iter().filter_map(|(v,)| Version::parse(&v).ok()).max() | |
| 677 | + | }); | |
| 678 | + | if let Some(latest) = latest { | |
| 679 | + | anyhow::ensure!( | |
| 680 | + | version > latest, | |
| 681 | + | "refusing to publish {app} {version} to `{channel}` ({target}): \ | |
| 682 | + | not newer than the last published {latest}", | |
| 683 | + | ); | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | ||
| 645 | 687 | // Step-success ledger (the Bento analogue of Sando's gate fail-closed), | |
| 646 | 688 | // minted as an unforgeable PublishAuthority. `backend.publish` cannot be | |
| 647 | 689 | // called without one, so the unverified/post-failure ship path is sealed |
| @@ -25,6 +25,13 @@ async fn main() -> Result<()> { | |||
| 25 | 25 | tokio::fs::create_dir_all(&cfg.dist_root).await?; | |
| 26 | 26 | tokio::fs::create_dir_all(&cfg.logs_root).await?; | |
| 27 | 27 | let pool = db::open(&cfg.db_path).await?; | |
| 28 | + | // Reconcile any builds left `running` by a previous process (the finalizer | |
| 29 | + | // and target tasks don't survive a restart) so they don't orphan forever. | |
| 30 | + | match db::recover_orphaned_running(&pool).await { | |
| 31 | + | Ok(0) => {} | |
| 32 | + | Ok(n) => tracing::warn!(reconciled = n, "marked orphaned `running` builds failed after restart"), | |
| 33 | + | Err(e) => tracing::error!(error = %e, "failed to reconcile orphaned running builds"), | |
| 34 | + | } | |
| 28 | 35 | tracing::info!(hosts = topo.hosts.len(), apps = topo.app.len(), "topology loaded"); | |
| 29 | 36 | ||
| 30 | 37 | let prom = metrics::init(); |
| @@ -440,6 +440,99 @@ targets = ["linux/x86_64"] | |||
| 440 | 440 | assert!(std::fs::read_to_string(&log).unwrap().contains("compiling")); | |
| 441 | 441 | } | |
| 442 | 442 | ||
| 443 | + | /// Run 2 S5: a publish whose version is not strictly newer than the latest | |
| 444 | + | /// already published for the same (app, target, channel) is refused — an | |
| 445 | + | /// older build cannot republish over a live newer release. | |
| 446 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 447 | + | async fn publish_rejects_a_non_monotonic_version() { | |
| 448 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 449 | + | let root = tmp.path(); | |
| 450 | + | let repo = root.join("app"); | |
| 451 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 452 | + | std::fs::write(repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.2.0"}"#).unwrap(); | |
| 453 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 454 | + | let artifact = repo.join("out/app.tar.gz"); | |
| 455 | + | // Build an artifact, publish 0.2.0 (records a release), then try to | |
| 456 | + | // publish the older 0.1.0 — the second publish must fail. | |
| 457 | + | std::fs::write( | |
| 458 | + | repo.join("dist/recipes/linux.rhai"), | |
| 459 | + | r#" | |
| 460 | + | step("build"); | |
| 461 | + | sh_ok("fw13", "mkdir -p REPO/out && echo bin > ARTIFACT"); | |
| 462 | + | step("publish"); | |
| 463 | + | publish("tauri-mnw", "demo", "linux/x86_64", "0.2.0", "ARTIFACT", #{}); | |
| 464 | + | publish("tauri-mnw", "demo", "linux/x86_64", "0.1.0", "ARTIFACT", #{}); | |
| 465 | + | "# | |
| 466 | + | .replace("ARTIFACT", artifact.to_str().unwrap()) | |
| 467 | + | .replace("REPO", repo.to_str().unwrap()), | |
| 468 | + | ) | |
| 469 | + | .unwrap(); | |
| 470 | + | ||
| 471 | + | let cfg = Config::for_tests(root); | |
| 472 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 473 | + | let topo: Topology = toml::from_str(&format!( | |
| 474 | + | r#" | |
| 475 | + | [[host]] | |
| 476 | + | name = "fw13" | |
| 477 | + | ssh = "local" | |
| 478 | + | targets = ["linux/x86_64"] | |
| 479 | + | ||
| 480 | + | [app.demo] | |
| 481 | + | repo = "{}" | |
| 482 | + | targets = ["linux/x86_64"] | |
| 483 | + | "#, | |
| 484 | + | repo.display() | |
| 485 | + | )) | |
| 486 | + | .unwrap(); | |
| 487 | + | let executors = Arc::new(crate::state::build_executors(&topo)); | |
| 488 | + | let state = AppState { | |
| 489 | + | pool: pool.clone(), | |
| 490 | + | topo: Arc::new(topo), | |
| 491 | + | cfg: Arc::new(cfg), | |
| 492 | + | prom: crate::metrics::test_handle(), | |
| 493 | + | events: crate::events::channel(), | |
| 494 | + | ota: Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 495 | + | executors, | |
| 496 | + | active: Arc::new(Mutex::new(HashMap::new())), | |
| 497 | + | api_token: None, | |
| 498 | + | }; | |
| 499 | + | let build_id = start_build( | |
| 500 | + | state.clone(), | |
| 501 | + | AppId::new("demo"), | |
| 502 | + | Version::parse("0.2.0").unwrap(), | |
| 503 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 504 | + | ) | |
| 505 | + | .await | |
| 506 | + | .unwrap(); | |
| 507 | + | ||
| 508 | + | let mut status = String::new(); | |
| 509 | + | let mut error = String::new(); | |
| 510 | + | for _ in 0..100 { | |
| 511 | + | let row: Option<(String, Option<String>)> = | |
| 512 | + | sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?") | |
| 513 | + | .bind(build_id) | |
| 514 | + | .fetch_optional(&pool) | |
| 515 | + | .await | |
| 516 | + | .unwrap(); | |
| 517 | + | if let Some((s, e)) = row { | |
| 518 | + | status = s; | |
| 519 | + | error = e.unwrap_or_default(); | |
| 520 | + | if status != "running" { | |
| 521 | + | break; | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 525 | + | } | |
| 526 | + | assert_eq!(status, "failed", "non-monotonic publish must fail the run"); | |
| 527 | + | assert!(error.contains("not newer"), "expected monotonicity error, got: {error}"); | |
| 528 | + | // Exactly one release was recorded (0.2.0); 0.1.0 never landed. | |
| 529 | + | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM releases") | |
| 530 | + | .fetch_one(&pool) | |
| 531 | + | .await | |
| 532 | + | .unwrap(); | |
| 533 | + | assert_eq!(count, 1, "only the first (newer) publish should record a release"); | |
| 534 | + | } | |
| 535 | + | ||
| 443 | 536 | /// The audit fix: a `build` step dispatched to a host whose executor lacks the | |
| 444 | 537 | /// `build` capability is denied at the transport BEFORE the command runs. This | |
| 445 | 538 | /// is the structural guarantee behind "never build on prod" — a recipe naming |