max / makenotwork
16 files changed,
+1020 insertions,
-316 deletions
| @@ -34,11 +34,13 @@ pub async fn recover_orphaned_running(pool: &SqlitePool) -> Result<u64> { | |||
| 34 | 34 | .bind(&now) | |
| 35 | 35 | .execute(&mut *tx) | |
| 36 | 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(); | |
| 37 | + | let builds = sqlx::query( | |
| 38 | + | "UPDATE builds SET status = 'failed', finished_at = ? WHERE status = 'running'", | |
| 39 | + | ) | |
| 40 | + | .bind(&now) | |
| 41 | + | .execute(&mut *tx) | |
| 42 | + | .await? | |
| 43 | + | .rows_affected(); | |
| 42 | 44 | tx.commit().await?; | |
| 43 | 45 | Ok(builds) | |
| 44 | 46 | } | |
| @@ -62,27 +64,48 @@ mod tests { | |||
| 62 | 64 | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) \ | |
| 63 | 65 | VALUES (?, 'demo','0.1.0','linux/x86_64','running',?) RETURNING id", | |
| 64 | 66 | ) | |
| 65 | - | .bind(running).bind(&now).fetch_one(&pool).await.unwrap(); | |
| 67 | + | .bind(running) | |
| 68 | + | .bind(&now) | |
| 69 | + | .fetch_one(&pool) | |
| 70 | + | .await | |
| 71 | + | .unwrap(); | |
| 66 | 72 | sqlx::query( | |
| 67 | 73 | "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at) \ | |
| 68 | 74 | VALUES (?, 'build','running','x',?)", | |
| 69 | 75 | ) | |
| 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(); | |
| 76 | + | .bind(tr) | |
| 77 | + | .bind(&now) | |
| 78 | + | .execute(&pool) | |
| 79 | + | .await | |
| 80 | + | .unwrap(); | |
| 81 | + | sqlx::query( | |
| 82 | + | "INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.0.9','ok',?)", | |
| 83 | + | ) | |
| 84 | + | .bind(&now) | |
| 85 | + | .execute(&pool) | |
| 86 | + | .await | |
| 87 | + | .unwrap(); | |
| 73 | 88 | ||
| 74 | 89 | let reconciled = recover_orphaned_running(&pool).await.unwrap(); | |
| 75 | 90 | assert_eq!(reconciled, 1, "only the running build is reconciled"); | |
| 76 | 91 | ||
| 77 | 92 | // 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(); | |
| 93 | + | let running_left: i64 = | |
| 94 | + | sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='running'") | |
| 95 | + | .fetch_one(&pool) | |
| 96 | + | .await | |
| 97 | + | .unwrap(); | |
| 80 | 98 | assert_eq!(running_left, 0); | |
| 81 | 99 | let ok_left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='ok'") | |
| 82 | - | .fetch_one(&pool).await.unwrap(); | |
| 100 | + | .fetch_one(&pool) | |
| 101 | + | .await | |
| 102 | + | .unwrap(); | |
| 83 | 103 | assert_eq!(ok_left, 1); | |
| 84 | 104 | let err: Option<String> = sqlx::query_scalar("SELECT error FROM target_runs WHERE id = ?") | |
| 85 | - | .bind(tr).fetch_one(&pool).await.unwrap(); | |
| 105 | + | .bind(tr) | |
| 106 | + | .fetch_one(&pool) | |
| 107 | + | .await | |
| 108 | + | .unwrap(); | |
| 86 | 109 | assert_eq!(err.as_deref(), Some("daemon restarted while running")); | |
| 87 | 110 | ||
| 88 | 111 | // Idempotent: a second sweep finds nothing. |
| @@ -133,7 +133,9 @@ impl fmt::Display for Target { | |||
| 133 | 133 | impl FromStr for Target { | |
| 134 | 134 | type Err = String; | |
| 135 | 135 | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 136 | - | let (p, a) = s.split_once('/').ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?; | |
| 136 | + | let (p, a) = s | |
| 137 | + | .split_once('/') | |
| 138 | + | .ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?; | |
| 137 | 139 | Ok(Target::new(p.parse()?, a.parse()?)) | |
| 138 | 140 | } | |
| 139 | 141 | } |
| @@ -114,7 +114,14 @@ impl RecipeCtx { | |||
| 114 | 114 | fn published_versions(&self, name: &str) -> Vec<String> { | |
| 115 | 115 | let url = format!("https://crates.io/api/v1/crates/{name}"); | |
| 116 | 116 | let Ok(out) = std::process::Command::new("curl") | |
| 117 | - | .args(["-sS", "--max-time", "15", "-H", "User-Agent: bento-preflight", &url]) | |
| 117 | + | .args([ | |
| 118 | + | "-sS", | |
| 119 | + | "--max-time", | |
| 120 | + | "15", | |
| 121 | + | "-H", | |
| 122 | + | "User-Agent: bento-preflight", | |
| 123 | + | &url, | |
| 124 | + | ]) | |
| 118 | 125 | .output() | |
| 119 | 126 | else { | |
| 120 | 127 | return Vec::new(); | |
| @@ -238,7 +245,11 @@ impl RecipeCtx { | |||
| 238 | 245 | Box::new(move |seq, text| { | |
| 239 | 246 | events::emit( | |
| 240 | 247 | &events, | |
| 241 | - | Event::StepLogChunk { run_id: cb_run_id, seq, text: text.to_string() }, | |
| 248 | + | Event::StepLogChunk { | |
| 249 | + | run_id: cb_run_id, | |
| 250 | + | seq, | |
| 251 | + | text: text.to_string(), | |
| 252 | + | }, | |
| 242 | 253 | ); | |
| 243 | 254 | }), | |
| 244 | 255 | )); | |
| @@ -326,7 +337,12 @@ impl RecipeCtx { | |||
| 326 | 337 | /// The step currently open, or `Build` as a default for failure | |
| 327 | 338 | /// attribution before any step was declared. | |
| 328 | 339 | pub fn current_step(&self) -> Step { | |
| 329 | - | self.current.lock().unwrap().as_ref().map(|s| s.step).unwrap_or(Step::Build) | |
| 340 | + | self.current | |
| 341 | + | .lock() | |
| 342 | + | .unwrap() | |
| 343 | + | .as_ref() | |
| 344 | + | .map(|s| s.step) | |
| 345 | + | .unwrap_or(Step::Build) | |
| 330 | 346 | } | |
| 331 | 347 | ||
| 332 | 348 | /// The capability-scoped executor for `name`, or an error if the host isn't | |
| @@ -365,7 +381,14 @@ impl RecipeCtx { | |||
| 365 | 381 | })?; | |
| 366 | 382 | let code = out.status.code().unwrap_or(-1); | |
| 367 | 383 | let stdout = String::from_utf8_lossy(&out.stdout); | |
| 368 | - | let tail: String = stdout.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect(); | |
| 384 | + | let tail: String = stdout | |
| 385 | + | .chars() | |
| 386 | + | .rev() | |
| 387 | + | .take(2000) | |
| 388 | + | .collect::<Vec<_>>() | |
| 389 | + | .into_iter() | |
| 390 | + | .rev() | |
| 391 | + | .collect(); | |
| 369 | 392 | Ok((code, tail)) | |
| 370 | 393 | } | |
| 371 | 394 | } | |
| @@ -373,10 +396,12 @@ impl RecipeCtx { | |||
| 373 | 396 | // ----- error bridging: anyhow -> Rhai runtime error ----- | |
| 374 | 397 | ||
| 375 | 398 | fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> { | |
| 376 | - | Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)) | |
| 399 | + | Box::new(EvalAltResult::ErrorRuntime( | |
| 400 | + | e.to_string().into(), | |
| 401 | + | rhai::Position::NONE, | |
| 402 | + | )) | |
| 377 | 403 | } | |
| 378 | 404 | ||
| 379 | - | ||
| 380 | 405 | /// A crate's publish-relevant metadata, read from `cargo metadata`. | |
| 381 | 406 | #[derive(Debug, Clone)] | |
| 382 | 407 | pub struct CrateMeta { | |
| @@ -396,7 +421,10 @@ pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> { | |||
| 396 | 421 | .and_then(|a| a.first()) | |
| 397 | 422 | .context("cargo metadata reported no package")?; | |
| 398 | 423 | let str_field = |k: &str| { | |
| 399 | - | p.get(k).and_then(|x| x.as_str()).filter(|s| !s.is_empty()).map(str::to_string) | |
| 424 | + | p.get(k) | |
| 425 | + | .and_then(|x| x.as_str()) | |
| 426 | + | .filter(|s| !s.is_empty()) | |
| 427 | + | .map(str::to_string) | |
| 400 | 428 | }; | |
| 401 | 429 | Ok(CrateMeta { | |
| 402 | 430 | name: str_field("name").context("package has no name")?, | |
| @@ -445,7 +473,10 @@ pub fn crate_publish_problems( | |||
| 445 | 473 | out.push("no `license` or `license-file`".to_string()); | |
| 446 | 474 | } | |
| 447 | 475 | if published.iter().any(|v| v == &meta.version) { | |
| 448 | - | out.push(format!("version {} is already published; bump it", meta.version)); | |
| 476 | + | out.push(format!( | |
| 477 | + | "version {} is already published; bump it", | |
| 478 | + | meta.version | |
| 479 | + | )); | |
| 449 | 480 | } | |
| 450 | 481 | out | |
| 451 | 482 | } | |
| @@ -475,8 +506,12 @@ pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Versi | |||
| 475 | 506 | return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e)); | |
| 476 | 507 | } | |
| 477 | 508 | let cargo_toml = root.join("Cargo.toml"); | |
| 478 | - | let raw = std::fs::read_to_string(&cargo_toml) | |
| 479 | - | .with_context(|| format!("reading {} (no tauri.conf.json either)", cargo_toml.display()))?; | |
| 509 | + | let raw = std::fs::read_to_string(&cargo_toml).with_context(|| { | |
| 510 | + | format!( | |
| 511 | + | "reading {} (no tauri.conf.json either)", | |
| 512 | + | cargo_toml.display() | |
| 513 | + | ) | |
| 514 | + | })?; | |
| 480 | 515 | Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e)) | |
| 481 | 516 | } | |
| 482 | 517 | ||
| @@ -495,7 +530,11 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> { | |||
| 495 | 530 | let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?; | |
| 496 | 531 | doc.get("package") | |
| 497 | 532 | .and_then(|p| p.get("version")) | |
| 498 | - | .or_else(|| doc.get("workspace").and_then(|w| w.get("package")).and_then(|p| p.get("version"))) | |
| 533 | + | .or_else(|| { | |
| 534 | + | doc.get("workspace") | |
| 535 | + | .and_then(|w| w.get("package")) | |
| 536 | + | .and_then(|p| p.get("version")) | |
| 537 | + | }) | |
| 499 | 538 | .and_then(|v| v.as_str()) | |
| 500 | 539 | .map(str::to_owned) | |
| 501 | 540 | .context("no `[package].version` or `[workspace.package].version` in Cargo.toml") | |
| @@ -504,9 +543,10 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> { | |||
| 504 | 543 | /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`. | |
| 505 | 544 | pub fn expand_tilde(p: &str) -> PathBuf { | |
| 506 | 545 | if let Some(rest) = p.strip_prefix("~/") | |
| 507 | - | && let Ok(home) = std::env::var("HOME") { | |
| 508 | - | return Path::new(&home).join(rest); | |
| 509 | - | } | |
| 546 | + | && let Ok(home) = std::env::var("HOME") | |
| 547 | + | { | |
| 548 | + | return Path::new(&home).join(rest); | |
| 549 | + | } | |
| 510 | 550 | PathBuf::from(p) | |
| 511 | 551 | } | |
| 512 | 552 | ||
| @@ -522,10 +562,13 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 522 | 562 | // --- step(name) --- | |
| 523 | 563 | { | |
| 524 | 564 | let ctx = ctx.clone(); | |
| 525 | - | engine.register_fn("step", move |name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 526 | - | let step: Step = name.parse().map_err(rhai_err)?; | |
| 527 | - | ctx.begin_step(step).map_err(rhai_err) | |
| 528 | - | }); | |
| 565 | + | engine.register_fn( | |
| 566 | + | "step", | |
| 567 | + | move |name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 568 | + | let step: Step = name.parse().map_err(rhai_err)?; | |
| 569 | + | ctx.begin_step(step).map_err(rhai_err) | |
| 570 | + | }, | |
| 571 | + | ); | |
| 529 | 572 | } | |
| 530 | 573 | ||
| 531 | 574 | // --- sh(host, cmd) -> #{ code, stdout_tail } --- | |
| @@ -537,13 +580,16 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 537 | 580 | // therefore bars publish via the failed-step ledger) on a non-zero exit. | |
| 538 | 581 | { | |
| 539 | 582 | let ctx = ctx.clone(); | |
| 540 | - | engine.register_fn("sh", move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> { | |
| 541 | - | let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 542 | - | let mut m = Map::new(); | |
| 543 | - | m.insert("code".into(), (code as i64).into()); | |
| 544 | - | m.insert("stdout_tail".into(), tail.into()); | |
| 545 | - | Ok(m) | |
| 546 | - | }); | |
| 583 | + | engine.register_fn( | |
| 584 | + | "sh", | |
| 585 | + | move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> { | |
| 586 | + | let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 587 | + | let mut m = Map::new(); | |
| 588 | + | m.insert("code".into(), (code as i64).into()); | |
| 589 | + | m.insert("stdout_tail".into(), tail.into()); | |
| 590 | + | Ok(m) | |
| 591 | + | }, | |
| 592 | + | ); | |
| 547 | 593 | } | |
| 548 | 594 | ||
| 549 | 595 | // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) --- | |
| @@ -553,16 +599,21 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 553 | 599 | // must-succeed command failed. | |
| 554 | 600 | { | |
| 555 | 601 | let ctx = ctx.clone(); | |
| 556 | - | engine.register_fn("sh_ok", move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> { | |
| 557 | - | let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 558 | - | if code != 0 { | |
| 559 | - | // Attribute the failure to the current step explicitly so the | |
| 560 | - | // ledger bars publish even if a future caller swallowed the error. | |
| 561 | - | ctx.fail_current_step(); | |
| 562 | - | return Err(rhai_err(format!("command on `{host}` exited {code}: {cmd}"))); | |
| 563 | - | } | |
| 564 | - | Ok(()) | |
| 565 | - | }); | |
| 602 | + | engine.register_fn( | |
| 603 | + | "sh_ok", | |
| 604 | + | move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> { | |
| 605 | + | let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 606 | + | if code != 0 { | |
| 607 | + | // Attribute the failure to the current step explicitly so the | |
| 608 | + | // ledger bars publish even if a future caller swallowed the error. | |
| 609 | + | ctx.fail_current_step(); | |
| 610 | + | return Err(rhai_err(format!( | |
| 611 | + | "command on `{host}` exited {code}: {cmd}" | |
| 612 | + | ))); | |
| 613 | + | } | |
| 614 | + | Ok(()) | |
| 615 | + | }, | |
| 616 | + | ); | |
| 566 | 617 | } | |
| 567 | 618 | ||
| 568 | 619 | // --- log(msg): operator-visible line into the current step's tail --- | |
| @@ -582,13 +633,18 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 582 | 633 | // --- version_of(app) -> string --- | |
| 583 | 634 | { | |
| 584 | 635 | let ctx = ctx.clone(); | |
| 585 | - | engine.register_fn("version_of", move |app: &str| -> Result<String, Box<EvalAltResult>> { | |
| 586 | - | // Only the current app is in scope; cross-app reads aren't needed. | |
| 587 | - | if app != ctx.app.as_str() { | |
| 588 | - | return Err(rhai_err(format!("version_of: `{app}` is not the app being built"))); | |
| 589 | - | } | |
| 590 | - | Ok(ctx.version.to_string()) | |
| 591 | - | }); | |
| 636 | + | engine.register_fn( | |
| 637 | + | "version_of", | |
| 638 | + | move |app: &str| -> Result<String, Box<EvalAltResult>> { | |
| 639 | + | // Only the current app is in scope; cross-app reads aren't needed. | |
| 640 | + | if app != ctx.app.as_str() { | |
| 641 | + | return Err(rhai_err(format!( | |
| 642 | + | "version_of: `{app}` is not the app being built" | |
| 643 | + | ))); | |
| 644 | + | } | |
| 645 | + | Ok(ctx.version.to_string()) | |
| 646 | + | }, | |
| 647 | + | ); | |
| 592 | 648 | } | |
| 593 | 649 | ||
| 594 | 650 | // --- version() -> string: the version being built (no-arg form) --- | |
| @@ -615,59 +671,65 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 615 | 671 | // repository URL is permanent. pter 0.1.0 shipped with a dead one. --- | |
| 616 | 672 | { | |
| 617 | 673 | let ctx = ctx.clone(); | |
| 618 | - | engine.register_fn("crate_preflight", move || -> Result<String, Box<EvalAltResult>> { | |
| 619 | - | let repo = expand_tilde(&ctx.repo); | |
| 620 | - | ||
| 621 | - | let out = std::process::Command::new("cargo") | |
| 622 | - | .args(["metadata", "--no-deps", "--format-version", "1"]) | |
| 623 | - | .current_dir(&repo) | |
| 624 | - | .output() | |
| 625 | - | .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?; | |
| 626 | - | if !out.status.success() { | |
| 627 | - | return Err(format!( | |
| 628 | - | "cargo metadata failed in {}: {}", | |
| 629 | - | repo.display(), | |
| 630 | - | String::from_utf8_lossy(&out.stderr).trim() | |
| 631 | - | ) | |
| 632 | - | .into()); | |
| 633 | - | } | |
| 634 | - | let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout)) | |
| 635 | - | .map_err(|e| e.to_string())?; | |
| 636 | - | ||
| 637 | - | // The real question is not whether a page renders but whether a | |
| 638 | - | // stranger with no credentials can fetch the source, so ask git. | |
| 639 | - | let clonable = meta.repository.as_ref().is_some_and(|url| { | |
| 640 | - | std::process::Command::new("git") | |
| 641 | - | .args(["ls-remote", url]) | |
| 642 | - | .env("GIT_TERMINAL_PROMPT", "0") | |
| 643 | - | .output() | |
| 644 | - | .map(|o| o.status.success()) | |
| 645 | - | .unwrap_or(false) | |
| 646 | - | }); | |
| 674 | + | engine.register_fn( | |
| 675 | + | "crate_preflight", | |
| 676 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 677 | + | let repo = expand_tilde(&ctx.repo); | |
| 647 | 678 | ||
| 648 | - | // Ask the publishing host whether cargo has credentials, rather | |
| 649 | - | // than moving the token anywhere. It stays in cargo's own 0600 | |
| 650 | - | // store; a shell line carrying it would be visible in `ps`. | |
| 651 | - | let creds = ctx | |
| 652 | - | .run(&ctx.build_host.clone(), "cargo login --help >/dev/null 2>&1 && \ | |
| 679 | + | let out = std::process::Command::new("cargo") | |
| 680 | + | .args(["metadata", "--no-deps", "--format-version", "1"]) | |
| 681 | + | .current_dir(&repo) | |
| 682 | + | .output() | |
| 683 | + | .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?; | |
| 684 | + | if !out.status.success() { | |
| 685 | + | return Err(format!( | |
| 686 | + | "cargo metadata failed in {}: {}", | |
| 687 | + | repo.display(), | |
| 688 | + | String::from_utf8_lossy(&out.stderr).trim() | |
| 689 | + | ) | |
| 690 | + | .into()); | |
| 691 | + | } | |
| 692 | + | let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout)) | |
| 693 | + | .map_err(|e| e.to_string())?; | |
| 694 | + | ||
| 695 | + | // The real question is not whether a page renders but whether a | |
| 696 | + | // stranger with no credentials can fetch the source, so ask git. | |
| 697 | + | let clonable = meta.repository.as_ref().is_some_and(|url| { | |
| 698 | + | std::process::Command::new("git") | |
| 699 | + | .args(["ls-remote", url]) | |
| 700 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 701 | + | .output() | |
| 702 | + | .map(|o| o.status.success()) | |
| 703 | + | .unwrap_or(false) | |
| 704 | + | }); | |
| 705 | + | ||
| 706 | + | // Ask the publishing host whether cargo has credentials, rather | |
| 707 | + | // than moving the token anywhere. It stays in cargo's own 0600 | |
| 708 | + | // store; a shell line carrying it would be visible in `ps`. | |
| 709 | + | let creds = ctx | |
| 710 | + | .run( | |
| 711 | + | &ctx.build_host.clone(), | |
| 712 | + | "cargo login --help >/dev/null 2>&1 && \ | |
| 653 | 713 | test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \ | |
| 654 | - | || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"") | |
| 655 | - | .map(|(code, _)| code == 0) | |
| 656 | - | .unwrap_or(false); | |
| 657 | - | ||
| 658 | - | let published = ctx.published_versions(&meta.name); | |
| 659 | - | let problems = crate_publish_problems(&meta, clonable, &published, creds); | |
| 660 | - | if !problems.is_empty() { | |
| 661 | - | return Err(format!( | |
| 662 | - | "{} {} is not safe to publish:\n - {}", | |
| 663 | - | meta.name, | |
| 664 | - | meta.version, | |
| 665 | - | problems.join("\n - ") | |
| 666 | - | ) | |
| 667 | - | .into()); | |
| 668 | - | } | |
| 669 | - | Ok(format!("{} {} passed preflight", meta.name, meta.version)) | |
| 670 | - | }); | |
| 714 | + | || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"", | |
| 715 | + | ) | |
| 716 | + | .map(|(code, _)| code == 0) | |
| 717 | + | .unwrap_or(false); | |
| 718 | + | ||
| 719 | + | let published = ctx.published_versions(&meta.name); | |
| 720 | + | let problems = crate_publish_problems(&meta, clonable, &published, creds); | |
| 721 | + | if !problems.is_empty() { | |
| 722 | + | return Err(format!( | |
| 723 | + | "{} {} is not safe to publish:\n - {}", | |
| 724 | + | meta.name, | |
| 725 | + | meta.version, | |
| 726 | + | problems.join("\n - ") | |
| 727 | + | ) | |
| 728 | + | .into()); | |
| 729 | + | } | |
| 730 | + | Ok(format!("{} {} passed preflight", meta.name, meta.version)) | |
| 731 | + | }, | |
| 732 | + | ); | |
| 671 | 733 | } | |
| 672 | 734 | ||
| 673 | 735 | // --- feature_flags() -> string: `--features a,b`, or "" when the app | |
| @@ -692,11 +754,15 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 692 | 754 | } | |
| 693 | 755 | { | |
| 694 | 756 | let ctx = ctx.clone(); | |
| 695 | - | engine.register_fn("platform", move || -> String { ctx.target.platform.as_str().to_string() }); | |
| 757 | + | engine.register_fn("platform", move || -> String { | |
| 758 | + | ctx.target.platform.as_str().to_string() | |
| 759 | + | }); | |
| 696 | 760 | } | |
| 697 | 761 | { | |
| 698 | 762 | let ctx = ctx.clone(); | |
| 699 | - | engine.register_fn("arch", move || -> String { ctx.target.arch.as_str().to_string() }); | |
| 763 | + | engine.register_fn("arch", move || -> String { | |
| 764 | + | ctx.target.arch.as_str().to_string() | |
| 765 | + | }); | |
| 700 | 766 | } | |
| 701 | 767 | ||
| 702 | 768 | // --- secret(key) -> string (file under secrets_root; never logged) --- | |
| @@ -729,29 +795,35 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 729 | 795 | // --- env(host, key) -> string --- | |
| 730 | 796 | { | |
| 731 | 797 | let ctx = ctx.clone(); | |
| 732 | - | engine.register_fn("env", move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 733 | - | // The key is interpolated into a `${...}` shell expansion, so it must | |
| 734 | - | // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`) | |
| 735 | - | // could break out and run arbitrary commands on the host. Validate | |
| 736 | - | // before building the command; this is the one env read that can't | |
| 737 | - | // sh-quote its argument (a quoted var name doesn't expand). | |
| 738 | - | if key.is_empty() | |
| 739 | - | || !key.chars().next().is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) | |
| 740 | - | || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) | |
| 741 | - | { | |
| 742 | - | return Err(rhai_err(format!( | |
| 743 | - | "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)" | |
| 744 | - | ))); | |
| 745 | - | } | |
| 746 | - | // Read via the shell so it works on remote hosts too. | |
| 747 | - | let (code, tail) = ctx | |
| 748 | - | .run(host, &format!("printf '%s' \"${{{key}}}\"")) | |
| 749 | - | .map_err(rhai_err)?; | |
| 750 | - | if code != 0 { | |
| 751 | - | return Err(rhai_err(format!("env `{key}` on `{host}` failed"))); | |
| 752 | - | } | |
| 753 | - | Ok(tail.trim().to_string()) | |
| 754 | - | }); | |
| 798 | + | engine.register_fn( | |
| 799 | + | "env", | |
| 800 | + | move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 801 | + | // The key is interpolated into a `${...}` shell expansion, so it must | |
| 802 | + | // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`) | |
| 803 | + | // could break out and run arbitrary commands on the host. Validate | |
| 804 | + | // before building the command; this is the one env read that can't | |
| 805 | + | // sh-quote its argument (a quoted var name doesn't expand). | |
| 806 | + | if key.is_empty() | |
| 807 | + | || !key | |
| 808 | + | .chars() | |
| 809 | + | .next() | |
| 810 | + | .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) | |
| 811 | + | || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) | |
| 812 | + | { | |
| 813 | + | return Err(rhai_err(format!( | |
| 814 | + | "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)" | |
| 815 | + | ))); | |
| 816 | + | } | |
| 817 | + | // Read via the shell so it works on remote hosts too. | |
| 818 | + | let (code, tail) = ctx | |
| 819 | + | .run(host, &format!("printf '%s' \"${{{key}}}\"")) | |
| 820 | + | .map_err(rhai_err)?; | |
| 821 | + | if code != 0 { | |
| 822 | + | return Err(rhai_err(format!("env `{key}` on `{host}` failed"))); | |
| 823 | + | } | |
| 824 | + | Ok(tail.trim().to_string()) | |
| 825 | + | }, | |
| 826 | + | ); | |
| 755 | 827 | } | |
| 756 | 828 | ||
| 757 | 829 | // --- collect(host, glob, app, version): pull artifacts to dist_root --- | |
| @@ -759,7 +831,11 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 759 | 831 | let ctx = ctx.clone(); | |
| 760 | 832 | engine.register_fn( | |
| 761 | 833 | "collect", | |
| 762 | - | move |host: &str, glob: &str, app: &str, version: &str| -> Result<(), Box<EvalAltResult>> { | |
| 834 | + | move |host: &str, | |
| 835 | + | glob: &str, | |
| 836 | + | app: &str, | |
| 837 | + | version: &str| | |
| 838 | + | -> Result<(), Box<EvalAltResult>> { | |
| 763 | 839 | ctx.collect(host, glob, app, version).map_err(rhai_err) | |
| 764 | 840 | }, | |
| 765 | 841 | ); | |
| @@ -770,8 +846,15 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 770 | 846 | let ctx = ctx.clone(); | |
| 771 | 847 | engine.register_fn( | |
| 772 | 848 | "publish", | |
| 773 | - | move |channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: Map| -> Result<String, Box<EvalAltResult>> { | |
| 774 | - | ctx.publish(channel, app, target, version, artifact, meta).map_err(rhai_err) | |
| 849 | + | move |channel: &str, | |
| 850 | + | app: &str, | |
| 851 | + | target: &str, | |
| 852 | + | version: &str, | |
| 853 | + | artifact: &str, | |
| 854 | + | meta: Map| | |
| 855 | + | -> Result<String, Box<EvalAltResult>> { | |
| 856 | + | ctx.publish(channel, app, target, version, artifact, meta) | |
| 857 | + | .map_err(rhai_err) | |
| 775 | 858 | }, | |
| 776 | 859 | ); | |
| 777 | 860 | } | |
| @@ -796,7 +879,22 @@ impl RecipeCtx { | |||
| 796 | 879 | // Not a privilege boundary — a recipe already runs arbitrary shell via | |
| 797 | 880 | // `sh_ok` — but it keeps a malformed pattern from becoming a command. | |
| 798 | 881 | anyhow::ensure!( | |
| 799 | - | !glob.chars().any(|c| matches!(c, ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>')), | |
| 882 | + | !glob.chars().any(|c| matches!( | |
| 883 | + | c, | |
| 884 | + | ';' | '&' | |
| 885 | + | | '|' | |
| 886 | + | | '$' | |
| 887 | + | | '`' | |
| 888 | + | | '\'' | |
| 889 | + | | '"' | |
| 890 | + | | '\\' | |
| 891 | + | | ' ' | |
| 892 | + | | '\n' | |
| 893 | + | | '(' | |
| 894 | + | | ')' | |
| 895 | + | | '<' | |
| 896 | + | | '>' | |
| 897 | + | )), | |
| 800 | 898 | "collect glob `{glob}` contains shell metacharacters" | |
| 801 | 899 | ); | |
| 802 | 900 | std::fs::create_dir_all(&dest) | |
| @@ -836,7 +934,10 @@ impl RecipeCtx { | |||
| 836 | 934 | // Never let a superseded build ship. This is the last and most important | |
| 837 | 935 | // cooperative-cancel checkpoint: even if a long-running step finished | |
| 838 | 936 | // after supersession, the artifact must not reach the backend. | |
| 839 | - | anyhow::ensure!(!self.is_cancelled(), "build superseded by a newer request; refusing to publish"); | |
| 937 | + | anyhow::ensure!( | |
| 938 | + | !self.is_cancelled(), | |
| 939 | + | "build superseded by a newer request; refusing to publish" | |
| 940 | + | ); | |
| 840 | 941 | let backend = self | |
| 841 | 942 | .ota | |
| 842 | 943 | .get(channel) | |
| @@ -859,7 +960,8 @@ impl RecipeCtx { | |||
| 859 | 960 | // `releases` column is TEXT, so compare by parsed semver precedence | |
| 860 | 961 | // (Version: Ord), not lexically. | |
| 861 | 962 | { | |
| 862 | - | let (app_s, target_s, chan_s) = (app.to_string(), target.to_string(), channel.to_string()); | |
| 963 | + | let (app_s, target_s, chan_s) = | |
| 964 | + | (app.to_string(), target.to_string(), channel.to_string()); | |
| 863 | 965 | let me = self.clone(); | |
| 864 | 966 | let latest: Option<Version> = self.rt.block_on(async move { | |
| 865 | 967 | let rows: Vec<(String,)> = sqlx::query_as( | |
| @@ -871,7 +973,9 @@ impl RecipeCtx { | |||
| 871 | 973 | .fetch_all(&me.pool) | |
| 872 | 974 | .await | |
| 873 | 975 | .unwrap_or_default(); | |
| 874 | - | rows.into_iter().filter_map(|(v,)| Version::parse(&v).ok()).max() | |
| 976 | + | rows.into_iter() | |
| 977 | + | .filter_map(|(v,)| Version::parse(&v).ok()) | |
| 978 | + | .max() | |
| 875 | 979 | }); | |
| 876 | 980 | if let Some(latest) = latest { | |
| 877 | 981 | anyhow::ensure!( | |
| @@ -891,17 +995,29 @@ impl RecipeCtx { | |||
| 891 | 995 | let gatekeeper = *self.gatekeeper_ok.lock().unwrap(); |
Lines truncated
| @@ -23,11 +23,17 @@ impl IntoResponse for Error { | |||
| 23 | 23 | Error::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), | |
| 24 | 24 | Error::Db(e) => { | |
| 25 | 25 | tracing::error!(error = %e, "internal db error"); | |
| 26 | - | (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) | |
| 26 | + | ( | |
| 27 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 28 | + | "internal server error".to_string(), | |
| 29 | + | ) | |
| 27 | 30 | } | |
| 28 | 31 | Error::Other(e) => { | |
| 29 | 32 | tracing::error!(error = format!("{e:#}"), "internal error"); | |
| 30 | - | (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) | |
| 33 | + | ( | |
| 34 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 35 | + | "internal server error".to_string(), | |
| 36 | + | ) | |
| 31 | 37 | } | |
| 32 | 38 | }; | |
| 33 | 39 | (status, axum::Json(serde_json::json!({ "error": message }))).into_response() |
| @@ -31,25 +31,79 @@ pub fn emit(tx: &EventTx, event: Event) { | |||
| 31 | 31 | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 32 | 32 | pub enum Event { | |
| 33 | 33 | /// A `/build` was accepted. | |
| 34 | - | BuildRequested { app: AppId, version: Version, targets: Vec<Target> }, | |
| 34 | + | BuildRequested { | |
| 35 | + | app: AppId, | |
| 36 | + | version: Version, | |
| 37 | + | targets: Vec<Target>, | |
| 38 | + | }, | |
| 35 | 39 | /// A previous in-flight run for this `(app, target)` was aborted because a | |
| 36 | 40 | /// newer request arrived. | |
| 37 | 41 | TargetAborted { app: AppId, target: Target }, | |
| 38 | - | TargetStart { app: AppId, version: Version, target: Target }, | |
| 39 | - | StepStart { run_id: StepRunId, app: AppId, version: Version, target: Target, step: Step }, | |
| 42 | + | TargetStart { | |
| 43 | + | app: AppId, | |
| 44 | + | version: Version, | |
| 45 | + | target: Target, | |
| 46 | + | }, | |
| 47 | + | StepStart { | |
| 48 | + | run_id: StepRunId, | |
| 49 | + | app: AppId, | |
| 50 | + | version: Version, | |
| 51 | + | target: Target, | |
| 52 | + | step: Step, | |
| 53 | + | }, | |
| 40 | 54 | /// A chunk of merged stdout+stderr from the step currently running. | |
| 41 | 55 | /// `run_id` ties back to the `StepStart`; `seq` is a per-run monotonic | |
| 42 | 56 | /// counter; `text` is UTF-8-lossy and NOT line-aligned. The on-disk log is | |
| 43 | 57 | /// the byte-exact source. | |
| 44 | - | StepLogChunk { run_id: StepRunId, seq: u32, text: String }, | |
| 45 | - | StepDone { run_id: StepRunId, app: AppId, target: Target, step: Step, status: Status }, | |
| 46 | - | TargetOk { app: AppId, version: Version, target: Target, artifacts: Vec<String> }, | |
| 47 | - | TargetFailed { app: AppId, version: Version, target: Target, step: Step, error: String }, | |
| 58 | + | StepLogChunk { | |
| 59 | + | run_id: StepRunId, | |
| 60 | + | seq: u32, | |
| 61 | + | text: String, | |
| 62 | + | }, | |
| 63 | + | StepDone { | |
| 64 | + | run_id: StepRunId, | |
| 65 | + | app: AppId, | |
| 66 | + | target: Target, | |
| 67 | + | step: Step, | |
| 68 | + | status: Status, | |
| 69 | + | }, | |
| 70 | + | TargetOk { | |
| 71 | + | app: AppId, | |
| 72 | + | version: Version, | |
| 73 | + | target: Target, | |
| 74 | + | artifacts: Vec<String>, | |
| 75 | + | }, | |
| 76 | + | TargetFailed { | |
| 77 | + | app: AppId, | |
| 78 | + | version: Version, | |
| 79 | + | target: Target, | |
| 80 | + | step: Step, | |
| 81 | + | error: String, | |
| 82 | + | }, | |
| 48 | 83 | /// Notarization is the one flaky, network-bound step; retries are surfaced. | |
| 49 | - | NotarizeRetry { app: AppId, target: Target, attempt: u32, reason: String }, | |
| 50 | - | ArtifactCollected { app: AppId, target: Target, path: String, bytes: i64 }, | |
| 51 | - | PublishOk { app: AppId, target: Target, channel: String }, | |
| 52 | - | PublishFailed { app: AppId, target: Target, channel: String, error: String }, | |
| 84 | + | NotarizeRetry { | |
| 85 | + | app: AppId, | |
| 86 | + | target: Target, | |
| 87 | + | attempt: u32, | |
| 88 | + | reason: String, | |
| 89 | + | }, | |
| 90 | + | ArtifactCollected { | |
| 91 | + | app: AppId, | |
| 92 | + | target: Target, | |
| 93 | + | path: String, | |
| 94 | + | bytes: i64, | |
| 95 | + | }, | |
| 96 | + | PublishOk { | |
| 97 | + | app: AppId, | |
| 98 | + | target: Target, | |
| 99 | + | channel: String, | |
| 100 | + | }, | |
| 101 | + | PublishFailed { | |
| 102 | + | app: AppId, | |
| 103 | + | target: Target, | |
| 104 | + | channel: String, | |
| 105 | + | error: String, | |
| 106 | + | }, | |
| 53 | 107 | } | |
| 54 | 108 | ||
| 55 | 109 | #[cfg(test)] | |
| @@ -72,7 +126,14 @@ mod tests { | |||
| 72 | 126 | ||
| 73 | 127 | #[test] | |
| 74 | 128 | fn only_log_chunks_are_high_rate() { | |
| 75 | - | assert!(Event::StepLogChunk { run_id: StepRunId(1), seq: 0, text: "x".into() }.is_high_rate()); | |
| 129 | + | assert!( | |
| 130 | + | Event::StepLogChunk { | |
| 131 | + | run_id: StepRunId(1), | |
| 132 | + | seq: 0, | |
| 133 | + | text: "x".into() | |
| 134 | + | } | |
| 135 | + | .is_high_rate() | |
| 136 | + | ); | |
| 76 | 137 | assert!(!publish_failed().is_high_rate()); | |
| 77 | 138 | } | |
| 78 | 139 | ||
| @@ -86,7 +147,14 @@ mod tests { | |||
| 86 | 147 | let mut logs_rx = tx.subscribe_logs(); | |
| 87 | 148 | emit(&tx, publish_failed()); | |
| 88 | 149 | for i in 0..(LOG_CAPACITY + 50) { | |
| 89 | - | emit(&tx, Event::StepLogChunk { run_id: StepRunId(1), seq: i as u32, text: "x".into() }); | |
| 150 | + | emit( | |
| 151 | + | &tx, | |
| 152 | + | Event::StepLogChunk { | |
| 153 | + | run_id: StepRunId(1), | |
| 154 | + | seq: i as u32, | |
| 155 | + | text: "x".into(), | |
| 156 | + | }, | |
| 157 | + | ); | |
| 90 | 158 | } | |
| 91 | 159 | // The status event is intact despite the log overflow. | |
| 92 | 160 | let env = status_rx.recv().await.expect("status event survived"); | |
| @@ -101,8 +169,12 @@ mod tests { | |||
| 101 | 169 | #[tokio::test] | |
| 102 | 170 | async fn envelope_serializes_with_flat_kind() { | |
| 103 | 171 | // Contract for the WS handler + TUI's `format_event_line`. | |
| 104 | - | let env = EventEnvelope { at: chrono::Utc::now(), event: publish_failed() }; | |
| 105 | - | let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); | |
| 172 | + | let env = EventEnvelope { | |
| 173 | + | at: chrono::Utc::now(), | |
| 174 | + | event: publish_failed(), | |
| 175 | + | }; | |
| 176 | + | let v: serde_json::Value = | |
| 177 | + | serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); | |
| 106 | 178 | assert_eq!(v["kind"], "publish_failed"); | |
| 107 | 179 | assert_eq!(v["channel"], "stable"); | |
| 108 | 180 | assert!(v.get("event").is_none()); |
| @@ -29,10 +29,17 @@ async fn main() -> Result<()> { | |||
| 29 | 29 | // and target tasks don't survive a restart) so they don't orphan forever. | |
| 30 | 30 | match db::recover_orphaned_running(&pool).await { | |
| 31 | 31 | Ok(0) => {} | |
| 32 | - | Ok(n) => tracing::warn!(reconciled = n, "marked orphaned `running` builds failed after restart"), | |
| 32 | + | Ok(n) => tracing::warn!( | |
| 33 | + | reconciled = n, | |
| 34 | + | "marked orphaned `running` builds failed after restart" | |
| 35 | + | ), | |
| 33 | 36 | Err(e) => tracing::error!(error = %e, "failed to reconcile orphaned running builds"), | |
| 34 | 37 | } | |
| 35 | - | tracing::info!(hosts = topo.hosts.len(), apps = topo.app.len(), "topology loaded"); | |
| 38 | + | tracing::info!( | |
| 39 | + | hosts = topo.hosts.len(), | |
| 40 | + | apps = topo.app.len(), | |
| 41 | + | "topology loaded" | |
| 42 | + | ); | |
| 36 | 43 | ||
| 37 | 44 | let prom = metrics::init(); | |
| 38 | 45 | let addr: SocketAddr = cfg.listen.parse()?; | |
| @@ -52,7 +59,9 @@ async fn main() -> Result<()> { | |||
| 52 | 59 | } | |
| 53 | 60 | match &api_token { | |
| 54 | 61 | Some(_) => tracing::info!("build endpoints require a bearer token"), | |
| 55 | - | None => tracing::warn!(%addr, "BENTO_API_TOKEN unset; build endpoints are UNAUTHENTICATED (loopback bind)"), | |
| 62 | + | None => { | |
| 63 | + | tracing::warn!(%addr, "BENTO_API_TOKEN unset; build endpoints are UNAUTHENTICATED (loopback bind)") | |
| 64 | + | } | |
| 56 | 65 | } | |
| 57 | 66 | ||
| 58 | 67 | let executors = Arc::new(state::build_executors(&topo)); | |
| @@ -79,14 +88,15 @@ async fn main() -> Result<()> { | |||
| 79 | 88 | let mut tick = tokio::time::interval(std::time::Duration::from_secs(6 * 60 * 60)); | |
| 80 | 89 | loop { | |
| 81 | 90 | tick.tick().await; // fires immediately, then every 6h | |
| 82 | - | let active: bento_daemon::retention::ActiveVersions = sqlx::query_as::<_, (String, String)>( | |
| 83 | - | "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running'", | |
| 84 | - | ) | |
| 85 | - | .fetch_all(&pool) | |
| 86 | - | .await | |
| 87 | - | .unwrap_or_default() | |
| 88 | - | .into_iter() | |
| 89 | - | .collect(); | |
| 91 | + | let active: bento_daemon::retention::ActiveVersions = | |
| 92 | + | sqlx::query_as::<_, (String, String)>( | |
| 93 | + | "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running'", | |
| 94 | + | ) | |
| 95 | + | .fetch_all(&pool) | |
| 96 | + | .await | |
| 97 | + | .unwrap_or_default() | |
| 98 | + | .into_iter() | |
| 99 | + | .collect(); | |
| 90 | 100 | let cfg = cfg.clone(); | |
| 91 | 101 | let _ = tokio::task::spawn_blocking(move || { | |
| 92 | 102 | bento_daemon::retention::prune_once(&cfg, &active) |
| @@ -3,7 +3,9 @@ use metrics::{counter, gauge, histogram}; | |||
| 3 | 3 | use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; | |
| 4 | 4 | ||
| 5 | 5 | pub fn init() -> PrometheusHandle { | |
| 6 | - | PrometheusBuilder::new().install_recorder().expect("install prometheus recorder") | |
| 6 | + | PrometheusBuilder::new() | |
| 7 | + | .install_recorder() | |
| 8 | + | .expect("install prometheus recorder") | |
| 7 | 9 | } | |
| 8 | 10 | ||
| 9 | 11 | // --- Emission --------------------------------------------------------------- |
| @@ -52,7 +52,11 @@ impl PublishAuthority { | |||
| 52 | 52 | failed_steps.is_empty(), | |
| 53 | 53 | "refusing to publish {target}: {} prior step(s) failed ({})", | |
| 54 | 54 | failed_steps.len(), | |
| 55 | - | failed_steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "), | |
| 55 | + | failed_steps | |
| 56 | + | .iter() | |
| 57 | + | .map(|s| s.as_str()) | |
| 58 | + | .collect::<Vec<_>>() | |
| 59 | + | .join(", "), | |
| 56 | 60 | ); | |
| 57 | 61 | if matches!(target.platform, Platform::Macos | Platform::Ios) { | |
| 58 | 62 | match gatekeeper_ok { | |
| @@ -94,7 +98,12 @@ pub trait OtaBackend: Send + Sync { | |||
| 94 | 98 | /// version+artifact is a no-op, not a duplicate release). Requires a | |
| 95 | 99 | /// [`PublishAuthority`] — the type-level proof the artifact cleared the | |
| 96 | 100 | /// publish gate — so this method is uncallable for an unverified artifact. | |
| 97 | - | fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String>; | |
| 101 | + | fn publish( | |
| 102 | + | &self, | |
| 103 | + | rel: &Release, | |
| 104 | + | artifact: &Path, | |
| 105 | + | authority: &PublishAuthority, | |
| 106 | + | ) -> Result<String>; | |
| 98 | 107 | } | |
| 99 | 108 | ||
| 100 | 109 | /// Named lookup of registered backends. | |
| @@ -119,7 +128,9 @@ impl OtaRegistry { | |||
| 119 | 128 | /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint. | |
| 120 | 129 | pub fn standard(mnw_base_url: impl Into<String>) -> Self { | |
| 121 | 130 | let mut reg = Self::new(); | |
| 122 | - | reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() })); | |
| 131 | + | reg.register(Box::new(TauriMnwBackend { | |
| 132 | + | base_url: mnw_base_url.into(), | |
| 133 | + | })); | |
| 123 | 134 | reg | |
| 124 | 135 | } | |
| 125 | 136 | } | |
| @@ -144,7 +155,12 @@ impl OtaBackend for TauriMnwBackend { | |||
| 144 | 155 | matches!(target.platform, Macos | Linux | Windows) | |
| 145 | 156 | } | |
| 146 | 157 | ||
| 147 | - | fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String> { | |
| 158 | + | fn publish( | |
| 159 | + | &self, | |
| 160 | + | rel: &Release, | |
| 161 | + | artifact: &Path, | |
| 162 | + | authority: &PublishAuthority, | |
| 163 | + | ) -> Result<String> { | |
| 148 | 164 | // Defense-in-depth: the authority is proof the gate passed for *this* | |
| 149 | 165 | // target; refuse a mismatched one rather than trust the caller paired | |
| 150 | 166 | // them correctly. | |
| @@ -161,8 +177,16 @@ impl OtaBackend for TauriMnwBackend { | |||
| 161 | 177 | // pre-check that catches a failed transfer. | |
| 162 | 178 | let meta = std::fs::metadata(artifact) | |
| 163 | 179 | .with_context(|| format!("artifact {} is not accessible", artifact.display()))?; | |
| 164 | - | anyhow::ensure!(meta.is_file(), "artifact {} is not a regular file", artifact.display()); | |
| 165 | - | anyhow::ensure!(meta.len() > 0, "artifact {} is empty (0 bytes)", artifact.display()); | |
| 180 | + | anyhow::ensure!( | |
| 181 | + | meta.is_file(), | |
| 182 | + | "artifact {} is not a regular file", | |
| 183 | + | artifact.display() | |
| 184 | + | ); | |
| 185 | + | anyhow::ensure!( | |
| 186 | + | meta.len() > 0, | |
| 187 | + | "artifact {} is empty (0 bytes)", | |
| 188 | + | artifact.display() | |
| 189 | + | ); | |
| 166 | 190 | // P2: POST the release + upload the artifact + its minisign .sig to the | |
| 167 | 191 | // MNW OTA API at `self.base_url`. Until then, report what would happen | |
| 168 | 192 | // so the recipe path is exercised without a half-published release. | |
| @@ -199,7 +223,12 @@ mod tests { | |||
| 199 | 223 | let app = AppId::new("goingson"); | |
| 200 | 224 | let ver = Version::parse("0.4.1").unwrap(); | |
| 201 | 225 | let target: Target = "macos/aarch64".parse().unwrap(); | |
| 202 | - | let rel = Release { app: &app, target, version: &ver, notes: String::new() }; | |
| 226 | + | let rel = Release { | |
| 227 | + | app: &app, | |
| 228 | + | target, | |
| 229 | + | version: &ver, | |
| 230 | + | notes: String::new(), | |
| 231 | + | }; | |
| 203 | 232 | let auth = PublishAuthority::for_test(target); | |
| 204 | 233 | assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err()); | |
| 205 | 234 | } | |
| @@ -211,7 +240,12 @@ mod tests { | |||
| 211 | 240 | let app = AppId::new("goingson"); | |
| 212 | 241 | let ver = Version::parse("0.4.1").unwrap(); | |
| 213 | 242 | let target: Target = "macos/aarch64".parse().unwrap(); | |
| 214 | - | let rel = Release { app: &app, target, version: &ver, notes: String::new() }; | |
| 243 | + | let rel = Release { | |
| 244 | + | app: &app, | |
| 245 | + | target, | |
| 246 | + | version: &ver, | |
| 247 | + | notes: String::new(), | |
| 248 | + | }; | |
| 215 | 249 | let auth = PublishAuthority::for_test(target); | |
| 216 | 250 | let tmp = tempfile::tempdir().unwrap(); | |
| 217 | 251 | // Zero-byte file: exists() would pass, but a truncated collect must not publish. | |
| @@ -231,7 +265,12 @@ mod tests { | |||
| 231 | 265 | let app = AppId::new("goingson"); | |
| 232 | 266 | let ver = Version::parse("0.4.1").unwrap(); | |
| 233 | 267 | let rel_target: Target = "macos/aarch64".parse().unwrap(); | |
| 234 | - | let rel = Release { app: &app, target: rel_target, version: &ver, notes: String::new() }; | |
| 268 | + | let rel = Release { | |
| 269 | + | app: &app, | |
| 270 | + | target: rel_target, | |
| 271 | + | version: &ver, | |
| 272 | + | notes: String::new(), | |
| 273 | + | }; | |
| 235 | 274 | // Authority proven for a different target must not wave this release through. | |
| 236 | 275 | let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap()); | |
| 237 | 276 | let tmp = tempfile::tempdir().unwrap(); |
| @@ -31,7 +31,9 @@ pub fn prune_once(cfg: &Config, active: &ActiveVersions) { | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | fn prune_root(root: &Path, label: &str, active: &ActiveVersions) { | |
| 34 | - | let Ok(apps) = std::fs::read_dir(root) else { return }; | |
| 34 | + | let Ok(apps) = std::fs::read_dir(root) else { | |
| 35 | + | return; | |
| 36 | + | }; | |
| 35 | 37 | for app in apps.filter_map(|e| e.ok()) { | |
| 36 | 38 | let app_dir = app.path(); | |
| 37 | 39 | if app_dir.is_dir() { | |
| @@ -42,7 +44,9 @@ fn prune_root(root: &Path, label: &str, active: &ActiveVersions) { | |||
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVersions) { | |
| 45 | - | let Ok(entries) = std::fs::read_dir(app_dir) else { return }; | |
| 47 | + | let Ok(entries) = std::fs::read_dir(app_dir) else { | |
| 48 | + | return; | |
| 49 | + | }; | |
| 46 | 50 | // Only consider subdirectories whose name parses as a semver — anything | |
| 47 | 51 | // else (a stray file, a non-version dir) is left untouched. | |
| 48 | 52 | let mut versions: Vec<(Version, String, PathBuf)> = entries | |
| @@ -65,8 +69,12 @@ fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVer | |||
| 65 | 69 | continue; | |
| 66 | 70 | } | |
| 67 | 71 | match std::fs::remove_dir_all(&path) { | |
| 68 | - | Ok(()) => tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version"), | |
| 69 | - | Err(e) => tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir"), | |
| 72 | + | Ok(()) => { | |
| 73 | + | tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version") | |
| 74 | + | } | |
| 75 | + | Err(e) => { | |
| 76 | + | tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir") | |
| 77 | + | } | |
| 70 | 78 | } | |
| 71 | 79 | } | |
| 72 | 80 | } | |
| @@ -86,7 +94,9 @@ mod tests { | |||
| 86 | 94 | let tmp = tempfile::tempdir().unwrap(); | |
| 87 | 95 | let root = tmp.path().join("dist"); | |
| 88 | 96 | // Seven versions; lexical order would mis-rank 0.4.10 vs 0.4.9. | |
| 89 | - | for v in ["0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1"] { | |
| 97 | + | for v in [ | |
| 98 | + | "0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1", | |
| 99 | + | ] { | |
| 90 | 100 | touch_version(&root, "demo", v); | |
| 91 | 101 | } | |
| 92 | 102 | // A non-version dir must be left alone. | |
| @@ -101,7 +111,10 @@ mod tests { | |||
| 101 | 111 | .collect(); | |
| 102 | 112 | kept.sort(); | |
| 103 | 113 | // Newest 5 by semver: 0.5.1, 0.5.0, 0.4.10, 0.4.9, 0.4.2 (+ the non-version dir). | |
| 104 | - | assert_eq!(kept, vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"]); | |
| 114 | + | assert_eq!( | |
| 115 | + | kept, | |
| 116 | + | vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"] | |
| 117 | + | ); | |
| 105 | 118 | } | |
| 106 | 119 | ||
| 107 | 120 | #[test] | |
| @@ -120,15 +133,25 @@ mod tests { | |||
| 120 | 133 | let tmp = tempfile::tempdir().unwrap(); | |
| 121 | 134 | let root = tmp.path().join("dist"); | |
| 122 | 135 | // Seven versions, keep 5 -> 0.1.0 and 0.2.0 are beyond the window. | |
| 123 | - | for v in ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0"] { | |
| 136 | + | for v in [ | |
| 137 | + | "0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", | |
| 138 | + | ] { | |
| 124 | 139 | touch_version(&root, "demo", v); | |
| 125 | 140 | } | |
| 126 | 141 | // 0.1.0 is beyond the window but is being built right now. | |
| 127 | - | let active: ActiveVersions = [("demo".to_string(), "0.1.0".to_string())].into_iter().collect(); | |
| 142 | + | let active: ActiveVersions = [("demo".to_string(), "0.1.0".to_string())] | |
| 143 | + | .into_iter() | |
| 144 | + | .collect(); | |
| 128 | 145 | prune_root(&root, "dist", &active); | |
| 129 | - | assert!(root.join("demo").join("0.1.0").exists(), "in-flight version must survive"); | |
| 146 | + | assert!( | |
| 147 | + | root.join("demo").join("0.1.0").exists(), | |
| 148 | + | "in-flight version must survive" | |
| 149 | + | ); | |
| 130 | 150 | // 0.2.0 (also beyond the window, not active) is pruned instead. | |
| 131 | 151 | assert!(!root.join("demo").join("0.2.0").exists()); | |
| 132 | - | assert!(root.join("demo").join("0.3.0").exists(), "newest 5 are kept"); | |
| 152 | + | assert!( | |
| 153 | + | root.join("demo").join("0.3.0").exists(), | |
| 154 | + | "newest 5 are kept" | |
| 155 | + | ); | |
| 133 | 156 | } | |
| 134 | 157 | } |
| @@ -72,7 +72,11 @@ async fn require_bearer( | |||
| 72 | 72 | next.run(req).await | |
| 73 | 73 | } else { | |
| 74 | 74 | tracing::warn!(path = %path, "rejected unauthenticated request to a build trigger"); | |
| 75 | - | (axum::http::StatusCode::UNAUTHORIZED, "missing or invalid bearer token\n").into_response() | |
| 75 | + | ( | |
| 76 | + | axum::http::StatusCode::UNAUTHORIZED, | |
| 77 | + | "missing or invalid bearer token\n", | |
| 78 | + | ) | |
| 79 | + | .into_response() | |
| 76 | 80 | } | |
| 77 | 81 | } | |
| 78 | 82 | ||
| @@ -150,7 +154,9 @@ async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> { | |||
| 150 | 154 | let Some(b) = latest else { | |
| 151 | 155 | return Ok(Json(StateView { build: None })); | |
| 152 | 156 | }; | |
| 153 | - | Ok(Json(StateView { build: Some(build_view(&s, &b).await?) })) | |
| 157 | + | Ok(Json(StateView { | |
| 158 | + | build: Some(build_view(&s, &b).await?), | |
| 159 | + | })) | |
| 154 | 160 | } | |
| 155 | 161 | ||
| 156 | 162 | /// Hydrate one `builds` row into its target x step matrix. | |
| @@ -277,7 +283,10 @@ struct BuildBody { | |||
| 277 | 283 | targets: Vec<String>, | |
| 278 | 284 | } | |
| 279 | 285 | ||
| 280 | - | async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result<Json<serde_json::Value>> { | |
| 286 | + | async fn build( | |
| 287 | + | State(s): State<AppState>, | |
| 288 | + | Json(body): Json<BuildBody>, | |
| 289 | + | ) -> Result<Json<serde_json::Value>> { | |
| 281 | 290 | let app = AppId::new(body.app); | |
| 282 | 291 | let targets = parse_targets(body.targets)?; | |
| 283 | 292 | let version = runner::resolve_version(&s, &app, body.version)?; | |
| @@ -285,7 +294,9 @@ async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result | |||
| 285 | 294 | let build_id = runner::start_build(s, app, version.clone(), targets) | |
| 286 | 295 | .await | |
| 287 | 296 | .map_err(Error::Other)?; | |
| 288 | - | Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() }))) | |
| 297 | + | Ok(Json( | |
| 298 | + | serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() }), | |
| 299 | + | )) | |
| 289 | 300 | } | |
| 290 | 301 | ||
| 291 | 302 | #[derive(Deserialize)] | |
| @@ -296,7 +307,10 @@ struct RetryBody { | |||
| 296 | 307 | version: Option<String>, | |
| 297 | 308 | } | |
| 298 | 309 | ||
| 299 | - | async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result<Json<serde_json::Value>> { | |
| 310 | + | async fn retry( | |
| 311 | + | State(s): State<AppState>, | |
| 312 | + | Json(body): Json<RetryBody>, | |
| 313 | + | ) -> Result<Json<serde_json::Value>> { | |
| 300 | 314 | let app = AppId::new(body.app); | |
| 301 | 315 | let target: Target = body.target.parse().map_err(Error::BadRequest)?; | |
| 302 | 316 | let version = runner::resolve_version(&s, &app, body.version)?; | |
| @@ -304,11 +318,15 @@ async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result | |||
| 304 | 318 | let build_id = runner::start_build(s, app, version.clone(), targets) | |
| 305 | 319 | .await | |
| 306 | 320 | .map_err(Error::Other)?; | |
| 307 | - | Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() }))) | |
| 321 | + | Ok(Json( | |
| 322 | + | serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() }), | |
| 323 | + | )) | |
| 308 | 324 | } | |
| 309 | 325 | ||
| 310 | 326 | fn parse_targets(raw: Vec<String>) -> Result<Vec<Target>> { | |
| 311 | - | raw.into_iter().map(|t| t.parse::<Target>().map_err(Error::BadRequest)).collect() | |
| 327 | + | raw.into_iter() | |
| 328 | + | .map(|t| t.parse::<Target>().map_err(Error::BadRequest)) | |
| 329 | + | .collect() | |
| 312 | 330 | } | |
| 313 | 331 | ||
| 314 | 332 | async fn get_step_log( | |
| @@ -318,10 +336,19 @@ async fn get_step_log( | |||
| 318 | 336 | fn safe(seg: &str) -> bool { | |
| 319 | 337 | !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".." | |
| 320 | 338 | } | |
| 321 | - | if ![&app, &version, &target, &step].into_iter().all(|s| safe(s)) { | |
| 339 | + | if ![&app, &version, &target, &step] | |
| 340 | + | .into_iter() | |
| 341 | + | .all(|s| safe(s)) | |
| 342 | + | { | |
| 322 | 343 | return Err(Error::NotFound); | |
| 323 | 344 | } | |
| 324 | - | let path = s.cfg.logs_root.join(&app).join(&version).join(&target).join(format!("{step}.log")); | |
| 345 | + | let path = s | |
| 346 | + | .cfg | |
| 347 | + | .logs_root | |
| 348 | + | .join(&app) | |
| 349 | + | .join(&version) | |
| 350 | + | .join(&target) | |
| 351 | + | .join(format!("{step}.log")); | |
| 325 | 352 | // Stream the log in chunks rather than reading the whole (potentially large, | |
| 326 | 353 | // verbose-build) file into memory. | |
| 327 | 354 | let file = match tokio::fs::File::open(&path).await { | |
| @@ -329,7 +356,8 @@ async fn get_step_log( | |||
| 329 | 356 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(Error::NotFound), | |
| 330 | 357 | Err(e) => return Err(Error::Other(e.into())), | |
| 331 | 358 | }; | |
| 332 | - | let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<axum::body::Bytes, std::io::Error>>(8); | |
| 359 | + | let (tx, rx) = | |
| 360 | + | tokio::sync::mpsc::channel::<std::result::Result<axum::body::Bytes, std::io::Error>>(8); | |
| 333 | 361 | tokio::spawn(async move { | |
| 334 | 362 | use tokio::io::AsyncReadExt; | |
| 335 | 363 | let mut file = file; | |
| @@ -338,7 +366,11 @@ async fn get_step_log( | |||
| 338 | 366 | match file.read(&mut buf).await { | |
| 339 | 367 | Ok(0) => break, | |
| 340 | 368 | Ok(n) => { | |
| 341 | - | if tx.send(Ok(axum::body::Bytes::copy_from_slice(&buf[..n]))).await.is_err() { | |
| 369 | + | if tx | |
| 370 | + | .send(Ok(axum::body::Bytes::copy_from_slice(&buf[..n]))) | |
| 371 | + | .await | |
| 372 | + | .is_err() | |
| 373 | + | { | |
| 342 | 374 | break; | |
| 343 | 375 | } | |
| 344 | 376 | } | |
| @@ -351,7 +383,10 @@ async fn get_step_log( | |||
| 351 | 383 | }); | |
| 352 | 384 | let body = axum::body::Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx)); | |
| 353 | 385 | Ok(( | |
| 354 | - | [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], | |
| 386 | + | [( | |
| 387 | + | axum::http::header::CONTENT_TYPE, | |
| 388 | + | "text/plain; charset=utf-8", | |
| 389 | + | )], | |
| 355 | 390 | body, | |
| 356 | 391 | ) | |
| 357 | 392 | .into_response()) | |
| @@ -388,7 +423,9 @@ async fn events_ws(ws: WebSocketUpgrade, State(s): State<AppState>) -> impl Into | |||
| 388 | 423 | } | |
| 389 | 424 | Err(RecvError::Lagged(n)) => { | |
| 390 | 425 | let _ = socket | |
| 391 | - | .send(Message::Text(format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into())) | |
| 426 | + | .send(Message::Text( | |
| 427 | + | format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into(), | |
| 428 | + | )) | |
| 392 | 429 | .await; | |
| 393 | 430 | } | |
| 394 | 431 | Err(RecvError::Closed) => break, | |
| @@ -456,7 +493,12 @@ repo = "{}" | |||
| 456 | 493 | let tmp = tempfile::tempdir().unwrap(); | |
| 457 | 494 | let app = router(test_state(tmp.path()).await); | |
| 458 | 495 | let resp = app | |
| 459 | - | .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap()) | |
| 496 | + | .oneshot( | |
| 497 | + | Request::builder() | |
| 498 | + | .uri("/state") | |
| 499 | + | .body(Body::empty()) | |
| 500 | + | .unwrap(), | |
| 501 | + | ) | |
| 460 | 502 | .await | |
| 461 | 503 | .unwrap(); | |
| 462 | 504 | assert_eq!(resp.status(), StatusCode::OK); | |
| @@ -472,7 +514,12 @@ repo = "{}" | |||
| 472 | 514 | let tmp = tempfile::tempdir().unwrap(); | |
| 473 | 515 | let app = router(test_state(tmp.path()).await); | |
| 474 | 516 | let resp = app | |
| 475 | - | .oneshot(Request::builder().uri("/status.json").body(Body::empty()).unwrap()) | |
| 517 | + | .oneshot( | |
| 518 | + | Request::builder() | |
| 519 | + | .uri("/status.json") | |
| 520 | + | .body(Body::empty()) | |
| 521 | + | .unwrap(), | |
| 522 | + | ) | |
| 476 | 523 | .await | |
| 477 | 524 | .unwrap(); | |
| 478 | 525 | assert_eq!(resp.status(), StatusCode::OK); | |
| @@ -512,13 +559,15 @@ repo = "{}" | |||
| 512 | 559 | ||
| 513 | 560 | // Wrong token -> 401. | |
| 514 | 561 | let mut bad = build_req(); | |
| 515 | - | bad.headers_mut().insert("authorization", "Bearer nope".parse().unwrap()); | |
| 562 | + | bad.headers_mut() | |
| 563 | + | .insert("authorization", "Bearer nope".parse().unwrap()); | |
| 516 | 564 | let resp = app.clone().oneshot(bad).await.unwrap(); | |
| 517 | 565 | assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 518 | 566 | ||
| 519 | 567 | // Correct token -> passes auth (NOT 401; the build itself is accepted). | |
| 520 | 568 | let mut good = build_req(); | |
| 521 | - | good.headers_mut().insert("authorization", "Bearer s3cr3t".parse().unwrap()); | |
| 569 | + | good.headers_mut() | |
| 570 | + | .insert("authorization", "Bearer s3cr3t".parse().unwrap()); | |
| 522 | 571 | let resp = app.clone().oneshot(good).await.unwrap(); | |
| 523 | 572 | assert_ne!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 524 | 573 | } | |
| @@ -530,7 +579,12 @@ repo = "{}" | |||
| 530 | 579 | state.api_token = Some(std::sync::Arc::from("s3cr3t")); | |
| 531 | 580 | let app = router(state); | |
| 532 | 581 | let resp = app | |
| 533 | - | .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap()) | |
| 582 | + | .oneshot( | |
| 583 | + | Request::builder() | |
| 584 | + | .uri("/state") | |
| 585 | + | .body(Body::empty()) | |
| 586 | + | .unwrap(), | |
| 587 | + | ) | |
| 534 | 588 | .await | |
| 535 | 589 | .unwrap(); | |
| 536 | 590 | assert_eq!(resp.status(), StatusCode::OK); | |
| @@ -565,7 +619,12 @@ repo = "{}" | |||
| 565 | 619 | let tmp = tempfile::tempdir().unwrap(); | |
| 566 | 620 | let state = test_state(tmp.path()).await; | |
| 567 | 621 | // Write a log at the path the route resolves. | |
| 568 | - | let dir = state.cfg.logs_root.join("goingson").join("0.4.1").join("linux-x86_64"); | |
| 622 | + | let dir = state | |
| 623 | + | .cfg | |
| 624 | + | .logs_root | |
| 625 | + | .join("goingson") | |
| 626 | + | .join("0.4.1") | |
| 627 | + | .join("linux-x86_64"); | |
| 569 | 628 | std::fs::create_dir_all(&dir).unwrap(); | |
| 570 | 629 | std::fs::write(dir.join("build.log"), b"compiling\nlinked\n").unwrap(); | |
| 571 | 630 | let app = router(state); | |
| @@ -619,13 +678,18 @@ repo = "{}" | |||
| 619 | 678 | // Explicit version so resolve_version doesn't read a | |
| 620 | 679 | // (nonexistent) tauri.conf first — we're testing the target | |
| 621 | 680 | // validation specifically. | |
| 622 | - | .body(Body::from(r#"{"app":"goingson","version":"0.4.1","targets":["windows/x86_64"]}"#)) | |
| 681 | + | .body(Body::from( | |
| 682 | + | r#"{"app":"goingson","version":"0.4.1","targets":["windows/x86_64"]}"#, | |
| 683 | + | )) | |
| 623 | 684 | .unwrap(), | |
| 624 | 685 | ) | |
| 625 | 686 | .await | |
| 626 | 687 | .unwrap(); | |
| 627 | 688 | assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 628 | - | assert!(body_string(resp).await.contains("does not ship target"), "names the problem"); | |
| 689 | + | assert!( | |
| 690 | + | body_string(resp).await.contains("does not ship target"), | |
| 691 | + | "names the problem" | |
| 692 | + | ); | |
| 629 | 693 | } | |
| 630 | 694 | ||
| 631 | 695 | #[tokio::test] | |
| @@ -644,7 +708,10 @@ repo = "{}" | |||
| 644 | 708 | .await | |
| 645 | 709 | .unwrap(); | |
| 646 | 710 | assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 647 | - | assert!(body_string(resp).await.contains("unknown app"), "names the problem"); | |
| 711 | + | assert!( | |
| 712 | + | body_string(resp).await.contains("unknown app"), | |
| 713 | + | "names the problem" | |
| 714 | + | ); | |
| 648 | 715 | } | |
| 649 | 716 | ||
| 650 | 717 | #[tokio::test] | |
| @@ -658,7 +725,9 @@ repo = "{}" | |||
| 658 | 725 | .method("POST") | |
| 659 | 726 | .uri("/build") | |
| 660 | 727 | .header("content-type", "application/json") | |
| 661 | - | .body(Body::from(r#"{"app":"goingson","targets":["not-a-target"]}"#)) | |
| 728 | + | .body(Body::from( | |
| 729 | + | r#"{"app":"goingson","targets":["not-a-target"]}"#, | |
| 730 | + | )) | |
| 662 | 731 | .unwrap(), | |
| 663 | 732 | ) | |
| 664 | 733 | .await |
| @@ -53,7 +53,9 @@ pub fn resolve_targets( | |||
| 53 | 53 | } | |
| 54 | 54 | for t in &requested { | |
| 55 | 55 | if !cfg.targets.contains(t) { | |
| 56 | - | return Err(Error::BadRequest(format!("app `{app}` does not ship target {t}"))); | |
| 56 | + | return Err(Error::BadRequest(format!( | |
| 57 | + | "app `{app}` does not ship target {t}" | |
| 58 | + | ))); | |
| 57 | 59 | } | |
| 58 | 60 | if state.topo.host_for(*t).is_none() { | |
| 59 | 61 | return Err(Error::BadRequest(format!("no host can build {t}"))); | |
| @@ -81,7 +83,11 @@ pub async fn start_build( | |||
| 81 | 83 | ||
| 82 | 84 | events::emit( | |
| 83 | 85 | &state.events, | |
| 84 | - | Event::BuildRequested { app: app.clone(), version: version.clone(), targets: targets.clone() }, | |
| 86 | + | Event::BuildRequested { | |
| 87 | + | app: app.clone(), | |
| 88 | + | version: version.clone(), | |
| 89 | + | targets: targets.clone(), | |
| 90 | + | }, | |
| 85 | 91 | ); | |
| 86 | 92 | crate::metrics::build_started(); | |
| 87 | 93 | ||
| @@ -107,10 +113,30 @@ pub async fn start_build( | |||
| 107 | 113 | // so set the cooperative flag the engine checks at step boundaries. | |
| 108 | 114 | prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst); | |
| 109 | 115 | prev.abort.abort(); | |
| 110 | - | events::emit(&state.events, Event::TargetAborted { app: app.clone(), target }); | |
| 116 | + | events::emit( | |
| 117 | + | &state.events, | |
| 118 | + | Event::TargetAborted { | |
| 119 | + | app: app.clone(), | |
| 120 | + | target, | |
| 121 | + | }, | |
| 122 | + | ); | |
| 111 | 123 | } | |
| 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 }); | |
| 124 | + | let abort = set.spawn(run_target( | |
| 125 | + | state.clone(), | |
| 126 | + | build_id, | |
| 127 | + | app, | |
| 128 | + | version, | |
| 129 | + | target, | |
| 130 | + | cancel.clone(), | |
| 131 | + | )); | |
| 132 | + | active.insert( | |
| 133 | + | key, | |
| 134 | + | crate::state::ActiveSlot { | |
| 135 | + | build_id, | |
| 136 | + | abort, | |
| 137 | + | cancel, | |
| 138 | + | }, | |
| 139 | + | ); | |
| 114 | 140 | crate::metrics::set_in_flight(active.len()); | |
| 115 | 141 | } | |
| 116 | 142 | ||
| @@ -153,14 +179,31 @@ async fn run_target( | |||
| 153 | 179 | ||
| 154 | 180 | events::emit( | |
| 155 | 181 | &state.events, | |
| 156 | - | Event::TargetStart { app: app.clone(), version: version.clone(), target }, | |
| 182 | + | Event::TargetStart { | |
| 183 | + | app: app.clone(), | |
| 184 | + | version: version.clone(), | |
| 185 | + | target, | |
| 186 | + | }, | |
| 157 | 187 | ); | |
| 158 | 188 | ||
| 159 | 189 | let recipe_src = match read_recipe(&state, &app, target) { | |
| 160 | 190 | Ok(s) => s, | |
| 161 | 191 | Err(e) => { | |
| 162 | - | fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await; | |
| 163 | - | crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64()); | |
| 192 | + | fail_target( | |
| 193 | + | &state, | |
| 194 | + | target_run_id, | |
| 195 | + | &app, | |
| 196 | + | &version, | |
| 197 | + | target, | |
| 198 | + | Step::Checkout, | |
| 199 | + | &format!("{e:#}"), | |
| 200 | + | ) | |
| 201 | + | .await; | |
| 202 | + | crate::metrics::target_finished( | |
| 203 | + | &target.to_string(), | |
| 204 | + | "failed", | |
| 205 | + | started.elapsed().as_secs_f64(), | |
| 206 | + | ); | |
| 164 | 207 | return; | |
| 165 | 208 | } | |
| 166 | 209 | }; | |
| @@ -174,8 +217,21 @@ async fn run_target( | |||
| 174 | 217 | && let Some(exec) = state.executors.get(&host.name) | |
| 175 | 218 | && let Err(e) = exec.preflight().await | |
| 176 | 219 | { | |
| 177 | - | fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await; | |
| 178 | - | crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64()); | |
| 220 | + | fail_target( | |
| 221 | + | &state, | |
| 222 | + | target_run_id, | |
| 223 | + | &app, | |
| 224 | + | &version, | |
| 225 | + | target, | |
| 226 | + | Step::Checkout, | |
| 227 | + | &format!("{e:#}"), | |
| 228 | + | ) | |
| 229 | + | .await; | |
| 230 | + | crate::metrics::target_finished( | |
| 231 | + | &target.to_string(), | |
| 232 | + | "failed", | |
| 233 | + | started.elapsed().as_secs_f64(), | |
| 234 | + | ); | |
| 179 | 235 | return; | |
| 180 | 236 | } | |
| 181 | 237 | ||
| @@ -252,19 +308,56 @@ async fn run_target( | |||
| 252 | 308 | // finalize_build reconciles it, but log so the cause is visible. | |
| 253 | 309 | tracing::error!(%app, %target, error = %e, "could not stamp target_run ok"); | |
| 254 | 310 | } | |
| 255 | - | crate::metrics::target_finished(&target.to_string(), "ok", started.elapsed().as_secs_f64()); | |
| 256 | - | events::emit(&state.events, Event::TargetOk { app, version, target, artifacts }); | |
| 311 | + | crate::metrics::target_finished( | |
| 312 | + | &target.to_string(), | |
| 313 | + | "ok", | |
| 314 | + | started.elapsed().as_secs_f64(), | |
| 315 | + | ); | |
| 316 | + | events::emit( | |
| 317 | + | &state.events, | |
| 318 | + | Event::TargetOk { | |
| 319 | + | app, | |
| 320 | + | version, | |
| 321 | + | target, | |
| 322 | + | artifacts, | |
| 323 | + | }, | |
| 324 | + | ); | |
| 257 | 325 | } | |
| 258 | 326 | Ok(Err((step, msg))) => { | |
| 259 | 327 | fail_target(&state, target_run_id, &app, &version, target, step, &msg).await; | |
| 260 | - | crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64()); | |
| 328 | + | crate::metrics::target_finished( | |
| 329 | + | &target.to_string(), | |
| 330 | + | "failed", | |
| 331 | + | started.elapsed().as_secs_f64(), | |
| 332 | + | ); | |
| 261 | 333 | } | |
| 262 | 334 | Err(join_err) => { | |
| 263 | 335 | // Task was aborted (superseded) or panicked. | |
| 264 | - | let status = if join_err.is_cancelled() { "aborted" } else { "failed" }; | |
| 265 | - | crate::metrics::target_finished(&target.to_string(), status, started.elapsed().as_secs_f64()); | |
| 266 | - | let msg = if join_err.is_cancelled() { "aborted (superseded)".to_string() } else { format!("recipe task panicked: {join_err}") }; | |
| 267 | - | fail_target(&state, target_run_id, &app, &version, target, Step::Build, &msg).await; | |
| 336 | + | let status = if join_err.is_cancelled() { | |
| 337 | + | "aborted" | |
| 338 | + | } else { | |
| 339 | + | "failed" | |
| 340 | + | }; | |
| 341 | + | crate::metrics::target_finished( | |
| 342 | + | &target.to_string(), | |
| 343 | + | status, | |
| 344 | + | started.elapsed().as_secs_f64(), | |
| 345 | + | ); | |
| 346 | + | let msg = if join_err.is_cancelled() { | |
| 347 | + | "aborted (superseded)".to_string() | |
| 348 | + | } else { | |
| 349 | + | format!("recipe task panicked: {join_err}") | |
| 350 | + | }; | |
| 351 | + | fail_target( | |
| 352 | + | &state, | |
| 353 | + | target_run_id, | |
| 354 | + | &app, | |
| 355 | + | &version, | |
| 356 | + | target, | |
| 357 | + | Step::Build, | |
| 358 | + | &msg, | |
| 359 | + | ) | |
| 360 | + | .await; | |
| 268 | 361 | } | |
| 269 | 362 | } | |
| 270 | 363 | } | |
| @@ -291,7 +384,13 @@ async fn fail_target( | |||
| 291 | 384 | } | |
| 292 | 385 | events::emit( | |
| 293 | 386 | &state.events, | |
| 294 | - | Event::TargetFailed { app: app.clone(), version: version.clone(), target, step, error: error.to_string() }, | |
| 387 | + | Event::TargetFailed { | |
| 388 | + | app: app.clone(), | |
| 389 | + | version: version.clone(), | |
| 390 | + | target, | |
| 391 | + | step, | |
| 392 | + | error: error.to_string(), | |
| 393 | + | }, | |
| 295 | 394 | ); | |
| 296 | 395 | } | |
| 297 | 396 | ||
| @@ -317,7 +416,10 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo | |||
| 317 | 416 | } | |
| 318 | 417 | Ok(None) => break, // all targets settled | |
| 319 | 418 | Err(_elapsed) => { | |
| 320 | - | tracing::error!(build_id, "finalize_build deadline hit; aborting remaining target tasks"); | |
| 419 | + | tracing::error!( | |
| 420 | + | build_id, | |
| 421 | + | "finalize_build deadline hit; aborting remaining target tasks" | |
| 422 | + | ); | |
| 321 | 423 | set.abort_all(); | |
| 322 | 424 | while set.join_next().await.is_some() {} | |
| 323 | 425 | break; | |
| @@ -376,19 +478,32 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo | |||
| 376 | 478 | /// host uploads it, so it uses a single `publish.rhai` — a `linux.rhai` naming | |
| 377 | 479 | /// a registry upload would imply a per-platform artifact that does not exist. | |
| 378 | 480 | fn read_recipe(state: &AppState, app: &AppId, target: Target) -> Result<String> { | |
| 379 | - | let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 481 | + | let cfg = state | |
| 482 | + | .topo | |
| 483 | + | .app(app) | |
| 484 | + | .ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 380 | 485 | let file = match cfg.kind { | |
| 381 | 486 | crate::topology::Kind::Library => "publish.rhai".to_string(), | |
| 382 | 487 | crate::topology::Kind::App => format!("{}.rhai", target.platform.as_str()), | |
| 383 | 488 | }; | |
| 384 | - | let path: PathBuf = engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir).join(file); | |
| 489 | + | let path: PathBuf = engine::expand_tilde(&cfg.repo) | |
| 490 | + | .join(&cfg.recipe_dir) | |
| 491 | + | .join(file); | |
| 385 | 492 | std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display())) | |
| 386 | 493 | } | |
| 387 | 494 | ||
| 388 | 495 | fn collected_artifacts(state: &AppState, app: &AppId, version: &Version) -> Vec<String> { | |
| 389 | - | let dir = state.cfg.dist_root.join(app.as_str()).join(version.to_string()); | |
| 390 | - | let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() }; | |
| 391 | - | rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect() | |
| 496 | + | let dir = state | |
| 497 | + | .cfg | |
| 498 | + | .dist_root | |
| 499 | + | .join(app.as_str()) | |
| 500 | + | .join(version.to_string()); | |
| 501 | + | let Ok(rd) = std::fs::read_dir(&dir) else { | |
| 502 | + | return Vec::new(); | |
| 503 | + | }; | |
| 504 | + | rd.filter_map(|e| e.ok()) | |
| 505 | + | .map(|e| e.file_name().to_string_lossy().into_owned()) | |
| 506 | + | .collect() | |
| 392 | 507 | } | |
| 393 | 508 | ||
| 394 | 509 | #[cfg(test)] | |
| @@ -467,9 +582,14 @@ repo = "{}" | |||
| 467 | 582 | ||
| 468 | 583 | let app = AppId::new("demo"); | |
| 469 | 584 | let version = Version::parse("0.0.1").unwrap(); | |
| 470 | - | let build_id = start_build(state.clone(), app, version, vec!["linux/x86_64".parse().unwrap()]) | |
| 471 | - | .await | |
| 472 | - | .unwrap(); | |
| 585 | + | let build_id = start_build( | |
| 586 | + | state.clone(), | |
| 587 | + | app, | |
| 588 | + | version, | |
| 589 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 590 | + | ) | |
| 591 | + | .await | |
| 592 | + | .unwrap(); | |
| 473 | 593 | ||
| 474 | 594 | // Wait for the target run to settle. The row may not be inserted on the | |
| 475 | 595 | // first poll (run_target is spawned, not awaited), so treat a missing row | |
| @@ -503,7 +623,10 @@ repo = "{}" | |||
| 503 | 623 | // Artifact landed in dist_root and a step log was written. | |
| 504 | 624 | let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin"); | |
| 505 | 625 | assert!(artifact.exists(), "collect should copy the artifact"); | |
| 506 | - | let log = state.cfg.logs_root.join("demo/0.0.1/linux-x86_64/build.log"); | |
| 626 | + | let log = state | |
| 627 | + | .cfg | |
| 628 | + | .logs_root | |
| 629 | + | .join("demo/0.0.1/linux-x86_64/build.log"); | |
| 507 | 630 | assert!(log.exists(), "build step log should exist"); | |
| 508 | 631 | assert!(std::fs::read_to_string(&log).unwrap().contains("compiling")); | |
| 509 | 632 | } | |
| @@ -517,7 +640,11 @@ repo = "{}" | |||
| 517 | 640 | let root = tmp.path(); | |
| 518 | 641 | let repo = root.join("app"); | |
| 519 | 642 | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 520 | - | std::fs::write(repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.2.0"}"#).unwrap(); | |
| 643 | + | std::fs::write( | |
| 644 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 645 | + | r#"{"version":"0.2.0"}"#, | |
| 646 | + | ) | |
| 647 | + | .unwrap(); | |
| 521 | 648 | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 522 | 649 | let artifact = repo.join("out/app.tar.gz"); | |
| 523 | 650 | // Build an artifact, publish 0.2.0 (records a release), then try to | |
| @@ -594,13 +721,19 @@ repo = "{}" | |||
| 594 | 721 | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 595 | 722 | } | |
| 596 | 723 | assert_eq!(status, "failed", "non-monotonic publish must fail the run"); | |
| 597 | - | assert!(error.contains("not newer"), "expected monotonicity error, got: {error}"); | |
| 724 | + | assert!( | |
| 725 | + | error.contains("not newer"), | |
| 726 | + | "expected monotonicity error, got: {error}" | |
| 727 | + | ); | |
| 598 | 728 | // Exactly one release was recorded (0.2.0); 0.1.0 never landed. | |
| 599 | 729 | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM releases") | |
| 600 | 730 | .fetch_one(&pool) | |
| 601 | 731 | .await | |
| 602 | 732 | .unwrap(); | |
| 603 | - | assert_eq!(count, 1, "only the first (newer) publish should record a release"); | |
| 733 | + | assert_eq!( | |
| 734 | + | count, 1, | |
| 735 | + | "only the first (newer) publish should record a release" | |
| 736 | + | ); | |
| 604 | 737 | } | |
| 605 | 738 | ||
| 606 | 739 | /// The audit fix: a `build` step dispatched to a host whose executor lacks the | |
| @@ -614,7 +747,11 @@ repo = "{}" | |||
| 614 | 747 | ||
| 615 | 748 | let repo = root.join("app"); | |
| 616 | 749 | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 617 | - | std::fs::write(repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.0.1"}"#).unwrap(); | |
| 750 | + | std::fs::write( | |
| 751 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 752 | + | r#"{"version":"0.0.1"}"#, | |
| 753 | + | ) | |
| 754 | + | .unwrap(); | |
| 618 | 755 | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 619 | 756 | // The recipe (wrongly) tries to compile on `prod`, a host with no build | |
| 620 | 757 | // grant. A marker file would appear if the command actually ran. |
| @@ -109,12 +109,18 @@ pub fn build_sync(host: &Host) -> Arc<dyn Executor> { | |||
| 109 | 109 | ||
| 110 | 110 | /// Build the full host name -> exec-executor map from the topology. | |
| 111 | 111 | pub fn build_executors(topo: &Topology) -> ExecutorMap { | |
| 112 | - | topo.hosts.iter().map(|h| (h.name.clone(), build_executor(h))).collect() | |
| 112 | + | topo.hosts | |
| 113 | + | .iter() | |
| 114 | + | .map(|h| (h.name.clone(), build_executor(h))) | |
| 115 | + | .collect() | |
| 113 | 116 | } | |
| 114 | 117 | ||
| 115 | 118 | /// Build the full host name -> sync-transport map from the topology. | |
| 116 | 119 | pub fn build_syncs(topo: &Topology) -> ExecutorMap { | |
| 117 | - | topo.hosts.iter().map(|h| (h.name.clone(), build_sync(h))).collect() | |
| 120 | + | topo.hosts | |
| 121 | + | .iter() | |
| 122 | + | .map(|h| (h.name.clone(), build_sync(h))) | |
| 123 | + | .collect() | |
| 118 | 124 | } | |
| 119 | 125 | ||
| 120 | 126 | #[cfg(test)] | |
| @@ -159,7 +165,11 @@ mod tests { | |||
| 159 | 165 | // by design, so a non-refusing error proves we got an ssh transport. | |
| 160 | 166 | let sync = build_sync(&h); | |
| 161 | 167 | let err = sync | |
| 162 | - | .pull_glob("/nonexistent/*.dmg", std::path::Path::new("/tmp"), &Default::default()) | |
| 168 | + | .pull_glob( | |
| 169 | + | "/nonexistent/*.dmg", | |
| 170 | + | std::path::Path::new("/tmp"), | |
| 171 | + | &Default::default(), | |
| 172 | + | ) | |
| 163 | 173 | .await | |
| 164 | 174 | .expect_err("nothing matches, so this must error either way"); | |
| 165 | 175 | assert!( | |
| @@ -169,10 +179,17 @@ mod tests { | |||
| 169 | 179 | // ...while the exec transport for the same host still is the agent. | |
| 170 | 180 | let exec = build_executor(&h); | |
| 171 | 181 | let err = exec | |
| 172 | - | .pull_glob("/x/*.dmg", std::path::Path::new("/tmp"), &Default::default()) | |
| 182 | + | .pull_glob( | |
| 183 | + | "/x/*.dmg", | |
| 184 | + | std::path::Path::new("/tmp"), | |
| 185 | + | &Default::default(), | |
| 186 | + | ) | |
| 173 | 187 | .await | |
| 174 | 188 | .expect_err("AgentRpc has no glob form"); | |
| 175 | - | assert!(err.to_string().contains("unsupported by design"), "exec plane is the agent: {err}"); | |
| 189 | + | assert!( | |
| 190 | + | err.to_string().contains("unsupported by design"), | |
| 191 | + | "exec plane is the agent: {err}" | |
| 192 | + | ); | |
| 176 | 193 | } | |
| 177 | 194 | ||
| 178 | 195 | #[test] |
| @@ -225,7 +225,10 @@ impl Topology { | |||
| 225 | 225 | }, | |
| 226 | 226 | ); | |
| 227 | 227 | } | |
| 228 | - | let topo = Topology { hosts: raw.hosts, app }; | |
| 228 | + | let topo = Topology { | |
| 229 | + | hosts: raw.hosts, | |
| 230 | + | app, | |
| 231 | + | }; | |
| 229 | 232 | topo.validate()?; | |
| 230 | 233 | Ok(topo) | |
| 231 | 234 | } | |
| @@ -240,8 +243,14 @@ impl Topology { | |||
| 240 | 243 | } | |
| 241 | 244 | ||
| 242 | 245 | fn validate(&self) -> Result<()> { | |
| 243 | - | anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host"); | |
| 244 | - | anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app"); | |
| 246 | + | anyhow::ensure!( | |
| 247 | + | !self.hosts.is_empty(), | |
| 248 | + | "topology must declare at least one host" | |
| 249 | + | ); | |
| 250 | + | anyhow::ensure!( | |
| 251 | + | !self.app.is_empty(), | |
| 252 | + | "topology must declare at least one app" | |
| 253 | + | ); | |
| 245 | 254 | // Every target an app ships must have a host that can build it. | |
| 246 | 255 | for (name, app) in &self.app { | |
| 247 | 256 | for t in &app.targets { | |
| @@ -261,7 +270,10 @@ impl Topology { | |||
| 261 | 270 | ); | |
| 262 | 271 | } | |
| 263 | 272 | if h.transport == HostTransport::Agent && h.agent_url.is_none() { | |
| 264 | - | anyhow::bail!("host `{}` uses transport = \"agent\" but sets no agent_url", h.name); | |
| 273 | + | anyhow::bail!( | |
| 274 | + | "host `{}` uses transport = \"agent\" but sets no agent_url", | |
| 275 | + | h.name | |
| 276 | + | ); | |
| 265 | 277 | } | |
| 266 | 278 | } | |
| 267 | 279 | Ok(()) | |
| @@ -421,25 +433,36 @@ mod live_config_smoke { | |||
| 421 | 433 | /// worth catching. Skips when the file is absent (CI, another host). | |
| 422 | 434 | #[test] | |
| 423 | 435 | fn live_topology_loads_if_present() { | |
| 424 | - | let Some(home) = std::env::var_os("HOME") else { return }; | |
| 436 | + | let Some(home) = std::env::var_os("HOME") else { | |
| 437 | + | return; | |
| 438 | + | }; | |
| 425 | 439 | let path = Path::new(&home).join(".config/bento/bento.toml"); | |
| 426 | 440 | if !path.exists() { | |
| 427 | 441 | return; | |
| 428 | 442 | } | |
| 429 | 443 | let topo = Topology::load(&path).expect("live bento.toml must load"); | |
| 430 | - | let bb = topo.app(&"balanced_breakfast".into()).expect("bb configured"); | |
| 444 | + | let bb = topo | |
| 445 | + | .app(&"balanced_breakfast".into()) | |
| 446 | + | .expect("bb configured"); | |
| 431 | 447 | assert!( | |
| 432 | 448 | bb.features.contains(&"supernote".to_string()), | |
| 433 | 449 | "bb must ship the supernote feature, got {:?}", | |
| 434 | 450 | bb.features | |
| 435 | 451 | ); | |
| 436 | - | let af = topo.app(&"audiofiles".into()).expect("audiofiles configured"); | |
| 437 | - | assert_eq!(af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml")); | |
| 452 | + | let af = topo | |
| 453 | + | .app(&"audiofiles".into()) | |
| 454 | + | .expect("audiofiles configured"); | |
| 455 | + | assert_eq!( | |
| 456 | + | af.version_path.as_deref(), | |
| 457 | + | Some("crates/audiofiles-app/Cargo.toml") | |
| 458 | + | ); | |
| 438 | 459 | ||
| 439 | 460 | // The library crates resolve as libraries, so they take publish.rhai | |
| 440 | 461 | // rather than a per-platform recipe. | |
| 441 | 462 | for name in ["makeover", "pter", "everycycle", "supernote-push"] { | |
| 442 | - | let c = topo.app(&name.into()).unwrap_or_else(|| panic!("{name} configured")); | |
| 463 | + | let c = topo | |
| 464 | + | .app(&name.into()) | |
| 465 | + | .unwrap_or_else(|| panic!("{name} configured")); | |
| 443 | 466 | assert_eq!(c.kind, Kind::Library, "{name} must be a library"); | |
| 444 | 467 | } | |
| 445 | 468 | assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App); |
| @@ -118,7 +118,10 @@ impl ReleasePlan { | |||
| 118 | 118 | pub fn checkout_step(&self) -> Step { | |
| 119 | 119 | Step::shell( | |
| 120 | 120 | Action::Build, | |
| 121 | - | format!("set -e; git fetch --all --tags --prune && git checkout v{}", self.version), | |
| 121 | + | format!( | |
| 122 | + | "set -e; git fetch --all --tags --prune && git checkout v{}", | |
| 123 | + | self.version | |
| 124 | + | ), | |
| 122 | 125 | ) | |
| 123 | 126 | .with_cwd(&self.repo_path) | |
| 124 | 127 | } | |
| @@ -141,7 +144,10 @@ impl ReleasePlan { | |||
| 141 | 144 | pub fn verify_step(&self) -> Step { | |
| 142 | 145 | Step::shell( | |
| 143 | 146 | Action::Observe(ObserveKind::Custom("gatekeeper".into())), | |
| 144 | - | format!("spctl --assess -vv --type install {} 2>&1", shell_quote(&self.dmg_remote_path())), | |
| 147 | + | format!( | |
| 148 | + | "spctl --assess -vv --type install {} 2>&1", | |
| 149 | + | shell_quote(&self.dmg_remote_path()) | |
| 150 | + | ), | |
| 145 | 151 | ) | |
| 146 | 152 | } | |
| 147 | 153 | ||
| @@ -181,7 +187,10 @@ async fn run_step( | |||
| 181 | 187 | anyhow::ensure!( | |
| 182 | 188 | out.status.success(), | |
| 183 | 189 | "{label} failed (exit {}): {}", | |
| 184 | - | out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()), | |
| 190 | + | out.status | |
| 191 | + | .code() | |
| 192 | + | .map(|c| c.to_string()) | |
| 193 | + | .unwrap_or_else(|| "signal".into()), | |
| 185 | 194 | failure_tail(&out), | |
| 186 | 195 | ); | |
| 187 | 196 | Ok(out) | |
| @@ -197,7 +206,11 @@ fn failure_tail(out: &RunOutput) -> String { | |||
| 197 | 206 | const MAX_LINES: usize = 5; | |
| 198 | 207 | let stderr = String::from_utf8_lossy(&out.stderr); | |
| 199 | 208 | let stdout = String::from_utf8_lossy(&out.stdout); | |
| 200 | - | let source = if stderr.trim().is_empty() { stdout } else { stderr }; | |
| 209 | + | let source = if stderr.trim().is_empty() { | |
| 210 | + | stdout | |
| 211 | + | } else { | |
| 212 | + | stderr | |
| 213 | + | }; | |
| 201 | 214 | let tail: Vec<&str> = source | |
| 202 | 215 | .lines() | |
| 203 | 216 | .filter(|l| !l.trim().is_empty()) | |
| @@ -220,7 +233,13 @@ pub async fn run_release( | |||
| 220 | 233 | sink: &mut dyn LogSink, | |
| 221 | 234 | ) -> Result<ReleaseOutcome> { | |
| 222 | 235 | run_step(exec, &plan.checkout_step(), sink, "checkout").await?; | |
| 223 | - | run_step(exec, &plan.release_step(), sink, "release-macos.sh --keychain").await?; | |
| 236 | + | run_step( | |
| 237 | + | exec, | |
| 238 | + | &plan.release_step(), | |
| 239 | + | sink, | |
| 240 | + | "release-macos.sh --keychain", | |
| 241 | + | ) | |
| 242 | + | .await?; | |
| 224 | 243 | let verify = run_step(exec, &plan.verify_step(), sink, "verify gatekeeper").await?; | |
| 225 | 244 | ||
| 226 | 245 | // spctl writes its verdict to stderr/stdout (we merged with 2>&1). | |
| @@ -286,7 +305,12 @@ mod tests { | |||
| 286 | 305 | /// driver must not depend on that per-host state. Only the tag is needed. | |
| 287 | 306 | #[test] | |
| 288 | 307 | fn checkout_step_does_not_depend_on_a_tracking_branch() { | |
| 289 | - | let script = go_plan("/repo", "/dist").checkout_step().argv.last().unwrap().clone(); | |
| 308 | + | let script = go_plan("/repo", "/dist") | |
| 309 | + | .checkout_step() | |
| 310 | + | .argv | |
| 311 | + | .last() | |
| 312 | + | .unwrap() | |
| 313 | + | .clone(); | |
| 290 | 314 | assert!(script.contains("git fetch"), "{script}"); | |
| 291 | 315 | assert!(!script.contains("git pull"), "must not use pull: {script}"); | |
| 292 | 316 | } | |
| @@ -312,18 +336,34 @@ mod tests { | |||
| 312 | 336 | #[test] | |
| 313 | 337 | fn expand_local_tilde_resolves_against_our_own_home() { | |
| 314 | 338 | let home = std::env::var("HOME").expect("HOME set in the test env"); | |
| 315 | - | assert_eq!(expand_local_tilde("~/Dist/goingson").unwrap(), PathBuf::from(&home).join("Dist/goingson")); | |
| 339 | + | assert_eq!( | |
| 340 | + | expand_local_tilde("~/Dist/goingson").unwrap(), | |
| 341 | + | PathBuf::from(&home).join("Dist/goingson") | |
| 342 | + | ); | |
| 316 | 343 | assert_eq!(expand_local_tilde("~").unwrap(), PathBuf::from(&home)); | |
| 317 | 344 | // The bug this closes: the tilde must not survive into the path. | |
| 318 | - | assert!(!expand_local_tilde("~/Dist/goingson").unwrap().starts_with("~")); | |
| 345 | + | assert!( | |
| 346 | + | !expand_local_tilde("~/Dist/goingson") | |
| 347 | + | .unwrap() | |
| 348 | + | .starts_with("~") | |
| 349 | + | ); | |
| 319 | 350 | } | |
| 320 | 351 | ||
| 321 | 352 | #[test] | |
| 322 | 353 | fn expand_local_tilde_leaves_other_paths_alone() { | |
| 323 | - | assert_eq!(expand_local_tilde("/Users/max/Dist").unwrap(), PathBuf::from("/Users/max/Dist")); | |
| 324 | - | assert_eq!(expand_local_tilde("Dist/goingson").unwrap(), PathBuf::from("Dist/goingson")); | |
| 354 | + | assert_eq!( | |
| 355 | + | expand_local_tilde("/Users/max/Dist").unwrap(), | |
| 356 | + | PathBuf::from("/Users/max/Dist") | |
| 357 | + | ); | |
| 358 | + | assert_eq!( | |
| 359 | + | expand_local_tilde("Dist/goingson").unwrap(), | |
| 360 | + | PathBuf::from("Dist/goingson") | |
| 361 | + | ); | |
| 325 | 362 | // A `~` that isn't leading is just a character. | |
| 326 | - | assert_eq!(expand_local_tilde("/tmp/a~b").unwrap(), PathBuf::from("/tmp/a~b")); | |
| 363 | + | assert_eq!( | |
| 364 | + | expand_local_tilde("/tmp/a~b").unwrap(), | |
| 365 | + | PathBuf::from("/tmp/a~b") | |
| 366 | + | ); | |
| 327 | 367 | } | |
| 328 | 368 | ||
| 329 | 369 | #[test] | |
| @@ -334,7 +374,10 @@ mod tests { | |||
| 334 | 374 | ||
| 335 | 375 | #[test] | |
| 336 | 376 | fn validate_accepts_absolute_build_host_paths() { | |
| 337 | - | let mut plan = go_plan("/Users/max/Code/Apps/goingson", "/Users/max/Dist/goingson/macos"); | |
| 377 | + | let mut plan = go_plan( | |
| 378 | + | "/Users/max/Code/Apps/goingson", | |
| 379 | + | "/Users/max/Dist/goingson/macos", | |
| 380 | + | ); | |
| 338 | 381 | plan.env_file = "/Users/max/.tauri/passwords.env".into(); | |
| 339 | 382 | assert!(plan.validate().is_ok()); | |
| 340 | 383 | } | |
| @@ -373,7 +416,10 @@ mod tests { | |||
| 373 | 416 | #[test] | |
| 374 | 417 | fn verify_step_is_an_observe_action_on_the_dmg() { | |
| 375 | 418 | let s = go_plan("/repo", "/dist").verify_step(); | |
| 376 | - | assert_eq!(s.action, Action::Observe(ObserveKind::Custom("gatekeeper".into()))); | |
| 419 | + | assert_eq!( | |
| 420 | + | s.action, | |
| 421 | + | Action::Observe(ObserveKind::Custom("gatekeeper".into())) | |
| 422 | + | ); | |
| 377 | 423 | let script = s.argv.last().unwrap(); | |
| 378 | 424 | assert!(script.contains("spctl --assess")); | |
| 379 | 425 | assert!(script.contains("/dist/GoingsOn_0.4.1_aarch64.dmg")); | |
| @@ -390,8 +436,12 @@ mod tests { | |||
| 390 | 436 | assert!(gatekeeper_accepted( | |
| 391 | 437 | "GoingsOn.dmg: accepted\nsource=Notarized Developer ID\norigin=Developer ID Application: ..." | |
| 392 | 438 | )); | |
| 393 | - | assert!(gatekeeper_accepted("X.dmg: accepted\nsource=Notarized Developer ID")); | |
| 394 | - | assert!(!gatekeeper_accepted("X.dmg: rejected\nsource=no usable signature")); | |
| 439 | + | assert!(gatekeeper_accepted( | |
| 440 | + | "X.dmg: accepted\nsource=Notarized Developer ID" | |
| 441 | + | )); | |
| 442 | + | assert!(!gatekeeper_accepted( | |
| 443 | + | "X.dmg: rejected\nsource=no usable signature" | |
| 444 | + | )); | |
| 395 | 445 | assert!(!gatekeeper_accepted("")); | |
| 396 | 446 | } | |
| 397 | 447 | ||
| @@ -424,13 +474,18 @@ mod tests { | |||
| 424 | 474 | // The fake release script writes the DMG into dist_root. | |
| 425 | 475 | write_shim( | |
| 426 | 476 | &repo.join("dist/release-macos.sh"), | |
| 427 | - | &format!("#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n", dmg.display()), | |
| 477 | + | &format!( | |
| 478 | + | "#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n", | |
| 479 | + | dmg.display() | |
| 480 | + | ), | |
| 428 | 481 | ) | |
| 429 | 482 | .await; | |
| 430 | 483 | ||
| 431 | 484 | let orig_path = std::env::var("PATH").unwrap_or_default(); | |
| 432 | 485 | // SAFETY: serialized by PATH_LOCK; restored before the guard drops. | |
| 433 | - | unsafe { std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path)); } | |
| 486 | + | unsafe { | |
| 487 | + | std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path)); | |
| 488 | + | } | |
| 434 | 489 | ||
| 435 | 490 | let plan = ReleasePlan { | |
| 436 | 491 | app: "goingson".into(), | |
| @@ -446,21 +501,33 @@ mod tests { | |||
| 446 | 501 | )); | |
| 447 | 502 | ||
| 448 | 503 | let mut sink = Discard; | |
| 449 | - | let outcome = run_release(&exec, &plan, &mut sink).await.expect("recipe should run"); | |
| 504 | + | let outcome = run_release(&exec, &plan, &mut sink) | |
| 505 | + | .await | |
| 506 | + | .expect("recipe should run"); | |
| 450 | 507 | ||
| 451 | 508 | // SAFETY: serialized by PATH_LOCK. | |
| 452 | - | unsafe { std::env::set_var("PATH", orig_path); } | |
| 509 | + | unsafe { | |
| 510 | + | std::env::set_var("PATH", orig_path); | |
| 511 | + | } | |
| 453 | 512 | ||
| 454 | 513 | assert!(dmg.exists(), "release step should produce the DMG"); | |
| 455 | 514 | assert!(outcome.gatekeeper_accepted, "fake spctl reports notarized"); | |
| 456 | - | assert_eq!(outcome.dmg_remote, dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy()); | |
| 515 | + | assert_eq!( | |
| 516 | + | outcome.dmg_remote, | |
| 517 | + | dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy() | |
| 518 | + | ); | |
| 457 | 519 | } | |
| 458 | 520 | ||
| 459 | 521 | static PATH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); | |
| 460 | 522 | ||
| 461 | 523 | async fn write_shim(path: &std::path::Path, body: &str) { | |
| 462 | 524 | tokio::fs::write(path, body).await.unwrap(); | |
| 463 | - | let out = tokio::process::Command::new("chmod").arg("+x").arg(path).output().await.unwrap(); | |
| 525 | + | let out = tokio::process::Command::new("chmod") | |
| 526 | + | .arg("+x") | |
| 527 | + | .arg(path) | |
| 528 | + | .output() | |
| 529 | + | .await | |
| 530 | + | .unwrap(); | |
| 464 | 531 | assert!(out.status.success()); | |
| 465 | 532 | } | |
| 466 | 533 |
| @@ -115,7 +115,10 @@ async fn main() -> Result<()> { | |||
| 115 | 115 | )?; | |
| 116 | 116 | tracing::info!(actuate = ?health.actuate, "agent reachable"); | |
| 117 | 117 | ||
| 118 | - | println!("==> releasing {} {} via {}", plan.app, plan.version, cfg.agent.host_label); | |
| 118 | + | println!( | |
| 119 | + | "==> releasing {} {} via {}", | |
| 120 | + | plan.app, plan.version, cfg.agent.host_label | |
| 121 | + | ); | |
| 119 | 122 | let mut sink = StdoutSink; | |
| 120 | 123 | let outcome = run_release(&exec, &plan, &mut sink).await?; | |
| 121 | 124 | if !outcome.gatekeeper_accepted { | |
| @@ -158,7 +161,9 @@ impl Args { | |||
| 158 | 161 | match a.as_str() { | |
| 159 | 162 | "--config" | "-c" => config = it.next(), | |
| 160 | 163 | "--version" | "-v" => version = it.next(), | |
| 161 | - | other => anyhow::bail!("unexpected arg `{other}` (usage: --config <toml> [--version <ver>])"), | |
| 164 | + | other => anyhow::bail!( | |
| 165 | + | "unexpected arg `{other}` (usage: --config <toml> [--version <ver>])" | |
| 166 | + | ), | |
| 162 | 167 | } | |
| 163 | 168 | } | |
| 164 | 169 | Ok(Self { |
| @@ -20,6 +20,8 @@ | |||
| 20 | 20 | //! <!-- wiki: bento-overview --> | |
| 21 | 21 | ||
| 22 | 22 | use anyhow::{Context, Result}; | |
| 23 | + | use bento_daemon::domain::{Step, StepRunId}; | |
| 24 | + | use bento_daemon::events::{Event, EventEnvelope}; | |
| 23 | 25 | use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers}; | |
| 24 | 26 | use crossterm::terminal::{ | |
| 25 | 27 | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, | |
| @@ -27,8 +29,6 @@ use crossterm::terminal::{ | |||
| 27 | 29 | use futures_util::StreamExt; | |
| 28 | 30 | use ratatui::prelude::*; | |
| 29 | 31 | use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}; | |
| 30 | - | use bento_daemon::domain::{Step, StepRunId}; | |
| 31 | - | use bento_daemon::events::{Event, EventEnvelope}; | |
| 32 | 32 | use serde::Deserialize; | |
| 33 | 33 | use std::collections::{BTreeMap, VecDeque}; | |
| 34 | 34 | use std::io; | |
| @@ -269,7 +269,12 @@ async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) { | |||
| 269 | 269 | let url = format!("{daemon}/state"); | |
| 270 | 270 | let client = reqwest::Client::new(); | |
| 271 | 271 | loop { | |
| 272 | - | match client.get(&url).timeout(Duration::from_secs(2)).send().await { | |
| 272 | + | match client | |
| 273 | + | .get(&url) | |
| 274 | + | .timeout(Duration::from_secs(2)) | |
| 275 | + | .send() | |
| 276 | + | .await | |
| 277 | + | { | |
| 273 | 278 | Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await { | |
| 274 | 279 | Ok(s) => { | |
| 275 | 280 | let mut g = shared.lock().unwrap(); | |
| @@ -317,7 +322,10 @@ async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) { | |||
| 317 | 322 | } | |
| 318 | 323 | ||
| 319 | 324 | fn ws_url_from(daemon: &str) -> String { | |
| 320 | - | daemon.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1) + "/events" | |
| 325 | + | daemon | |
| 326 | + | .replacen("https://", "wss://", 1) | |
| 327 | + | .replacen("http://", "ws://", 1) | |
| 328 | + | + "/events" | |
| 321 | 329 | } | |
| 322 | 330 | ||
| 323 | 331 | /// Route a WS frame: log chunks update the per-step tail; everything else | |
| @@ -333,7 +341,12 @@ fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) { | |||
| 333 | 341 | return; | |
| 334 | 342 | }; | |
| 335 | 343 | match &env.event { | |
| 336 | - | Event::StepStart { run_id, target, step, .. } => { | |
| 344 | + | Event::StepStart { | |
| 345 | + | run_id, | |
| 346 | + | target, | |
| 347 | + | step, | |
| 348 | + | .. | |
| 349 | + | } => { | |
| 337 | 350 | let mut g = shared.lock().unwrap(); | |
| 338 | 351 | g.open_tail(*run_id, target.to_string(), step.to_string()); | |
| 339 | 352 | let line = format_event_line(&env); | |
| @@ -363,27 +376,72 @@ fn format_event_line(env: &EventEnvelope) -> String { | |||
| 363 | 376 | ||
| 364 | 377 | fn format_event_body(e: &Event) -> String { | |
| 365 | 378 | match e { | |
| 366 | - | Event::BuildRequested { app, version, targets } => { | |
| 367 | - | format!("build_requested {app} {version} [{}]", targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join(",")) | |
| 379 | + | Event::BuildRequested { | |
| 380 | + | app, | |
| 381 | + | version, | |
| 382 | + | targets, | |
| 383 | + | } => { | |
| 384 | + | format!( | |
| 385 | + | "build_requested {app} {version} [{}]", | |
| 386 | + | targets | |
| 387 | + | .iter() | |
| 388 | + | .map(|t| t.to_string()) | |
| 389 | + | .collect::<Vec<_>>() | |
| 390 | + | .join(",") | |
| 391 | + | ) | |
| 368 | 392 | } | |
| 369 | 393 | Event::TargetAborted { target, .. } => format!("target_aborted {target}"), | |
| 370 | 394 | Event::TargetStart { target, .. } => format!("target_start {target}"), | |
| 371 | 395 | Event::StepStart { target, step, .. } => format!("step_start {target} {step}"), | |
| 372 | - | Event::StepLogChunk { run_id, text, .. } => format!("log[{run_id}] {}", truncate(text.trim_end(), 80)), | |
| 373 | - | Event::StepDone { target, step, status, .. } => format!("step_done {target} {step} {}", status.as_str()), | |
| 374 | - | Event::TargetOk { target, artifacts, .. } => format!("target_ok {target} ({} artifacts)", artifacts.len()), | |
| 375 | - | Event::TargetFailed { target, step, error, .. } => format!("target_failed {target} {step}: {}", truncate(error, 80)), | |
| 376 | - | Event::NotarizeRetry { target, attempt, reason, .. } => format!("notarize_retry {target} #{attempt} {reason}"), | |
| 377 | - | Event::ArtifactCollected { target, path, bytes, .. } => format!("artifact {target} {path} {bytes}B"), | |
| 378 | - | Event::PublishOk { target, channel, .. } => format!("publish_ok {target} {channel}"), | |
| 379 | - | Event::PublishFailed { target, channel, error, .. } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)), | |
| 396 | + | Event::StepLogChunk { run_id, text, .. } => { | |
| 397 | + | format!("log[{run_id}] {}", truncate(text.trim_end(), 80)) | |
| 398 | + | } | |
| 399 | + | Event::StepDone { | |
| 400 | + | target, | |
| 401 | + | step, | |
| 402 | + | status, | |
| 403 | + | .. | |
| 404 | + | } => format!("step_done {target} {step} {}", status.as_str()), | |
| 405 | + | Event::TargetOk { | |
| 406 | + | target, artifacts, .. | |
| 407 | + | } => format!("target_ok {target} ({} artifacts)", artifacts.len()), | |
| 408 | + | Event::TargetFailed { | |
| 409 | + | target, | |
| 410 | + | step, | |
| 411 | + | error, | |
| 412 | + | .. | |
| 413 | + | } => format!("target_failed {target} {step}: {}", truncate(error, 80)), | |
| 414 | + | Event::NotarizeRetry { | |
| 415 | + | target, | |
| 416 | + | attempt, | |
| 417 | + | reason, | |
| 418 | + | .. | |
| 419 | + | } => format!("notarize_retry {target} #{attempt} {reason}"), | |
| 420 | + | Event::ArtifactCollected { | |
| 421 | + | target, | |
| 422 | + | path, | |
| 423 | + | bytes, | |
| 424 | + | .. | |
| 425 | + | } => format!("artifact {target} {path} {bytes}B"), | |
| 426 | + | Event::PublishOk { | |
| 427 | + | target, channel, .. | |
| 428 | + | } => format!("publish_ok {target} {channel}"), | |
| 429 | + | Event::PublishFailed { | |
| 430 | + | target, | |
| 431 | + | channel, | |
| 432 | + | error, | |
| 433 | + | .. | |
| 434 | + | } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)), | |
| 380 | 435 | } | |
| 381 | 436 | } | |
| 382 | 437 | ||
| 383 | 438 | fn format_lagged(raw: &str) -> Option<String> { | |
| 384 | 439 | let v: serde_json::Value = serde_json::from_str(raw).ok()?; | |
| 385 | 440 | if v.get("kind")?.as_str()? == "lagged" { | |
| 386 | - | Some(format!("(lagged, skipped {})", v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0))) | |
| 441 | + | Some(format!( | |
| 442 | + | "(lagged, skipped {})", | |
| 443 | + | v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0) | |
| 444 | + | )) | |
| 387 | 445 | } else { | |
| 388 | 446 | None | |
| 389 | 447 | } | |
| @@ -410,11 +468,15 @@ async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> { | |||
| 410 | 468 | let client = reqwest::Client::new(); | |
| 411 | 469 | let (url, body) = match act { | |
| 412 | 470 | Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })), | |
| 413 | - | Action::Retry { app, target } => { | |
| 414 | - | (format!("{daemon}/retry"), serde_json::json!({ "app": app, "target": target })) | |
| 415 | - | } | |
| 471 | + | Action::Retry { app, target } => ( | |
| 472 | + | format!("{daemon}/retry"), | |
| 473 | + | serde_json::json!({ "app": app, "target": target }), | |
| 474 | + | ), | |
| 416 | 475 | }; | |
| 417 | - | let mut req = client.post(&url).timeout(Duration::from_secs(30)).json(&body); | |
| 476 | + | let mut req = client | |
| 477 | + | .post(&url) | |
| 478 | + | .timeout(Duration::from_secs(30)) | |
| 479 | + | .json(&body); | |
| 418 | 480 | // Build triggers require a bearer token when the daemon is configured with | |
| 419 | 481 | // one (CF2). Operator supplies it via BENTO_API_TOKEN. | |
| 420 | 482 | if let Ok(tok) = std::env::var("BENTO_API_TOKEN") { | |
| @@ -430,9 +492,13 @@ async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> { | |||
| 430 | 492 | // turning a read hiccup into "" renders as a contentless "HTTP 409: ". | |
| 431 | 493 | match (status.is_success(), resp.text().await) { | |
| 432 | 494 | (true, Ok(text)) => Ok(truncate(&text, 120)), | |
| 433 | - | (true, Err(e)) => Err(anyhow::anyhow!("HTTP {status} but response body unreadable: {e}")), | |
| 495 | + | (true, Err(e)) => Err(anyhow::anyhow!( | |
| 496 | + | "HTTP {status} but response body unreadable: {e}" | |
| 497 | + | )), | |
| 434 | 498 | (false, Ok(text)) => Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200))), | |
| 435 | - | (false, Err(e)) => Err(anyhow::anyhow!("HTTP {status} (response body unreadable: {e})")), | |
| 499 | + | (false, Err(e)) => Err(anyhow::anyhow!( | |
| 500 | + | "HTTP {status} (response body unreadable: {e})" | |
| 501 | + | )), | |
| 436 | 502 | } | |
| 437 | 503 | } | |
| 438 | 504 | ||
| @@ -542,8 +608,10 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |||
| 542 | 608 | .and_then(|s| s.build.as_ref()) | |
| 543 | 609 | .map(|b| format!("{} {} ({})", b.app, b.version, b.status)) | |
| 544 | 610 | .unwrap_or_else(|| "no builds yet".into()); | |
| 545 | - | let header = Paragraph::new(format!("bento -> {daemon} ({ws_label}) build: {build_label}")) | |
| 546 | - | .block(Block::default().title("daemon").borders(Borders::ALL)); | |
| 611 | + | let header = Paragraph::new(format!( | |
| 612 | + | "bento -> {daemon} ({ws_label}) build: {build_label}" | |
| 613 | + | )) | |
| 614 | + | .block(Block::default().title("daemon").borders(Borders::ALL)); | |
| 547 | 615 | f.render_widget(header, chunks[0]); | |
| 548 | 616 | ||
| 549 | 617 | // Matrix: rows = targets, columns = steps. | |
| @@ -559,8 +627,7 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |||
| 559 | 627 | .enumerate() | |
| 560 | 628 | .map(|(i, t)| { | |
| 561 | 629 | let marker = if i == g.selected { ">" } else { " " }; | |
| 562 | - | let mut cells: Vec<Cell> = | |
| 563 | - | vec![format!("{marker} {}", t.target).into()]; | |
| 630 | + | let mut cells: Vec<Cell> = vec![format!("{marker} {}", t.target).into()]; | |
| 564 | 631 | for (step, _) in STEP_COLS { | |
| 565 | 632 | let status = t | |
| 566 | 633 | .steps | |
| @@ -589,8 +656,12 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |||
| 589 | 656 | f.render_widget(table, chunks[1]); | |
| 590 | 657 | } | |
| 591 | 658 | _ => { | |
| 592 | - | let msg = g.last_err.clone().unwrap_or_else(|| "no active build — press [b] to start".into()); | |
| 593 | - | let placeholder = Paragraph::new(msg).block(Block::default().title("matrix").borders(Borders::ALL)); | |
| 659 | + | let msg = g | |
| 660 | + | .last_err | |
| 661 | + | .clone() | |
| 662 | + | .unwrap_or_else(|| "no active build — press [b] to start".into()); | |
| 663 | + | let placeholder = | |
| 664 | + | Paragraph::new(msg).block(Block::default().title("matrix").borders(Borders::ALL)); | |
| 594 | 665 | f.render_widget(placeholder, chunks[1]); | |
| 595 | 666 | } | |
| 596 | 667 | } | |
| @@ -598,9 +669,18 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |||
| 598 | 669 | // Events. | |
| 599 | 670 | let area = chunks[2]; | |
| 600 | 671 | let visible = area.height.saturating_sub(2) as usize; | |
| 601 | - | let items: Vec<ListItem> = g.events.iter().rev().take(visible).rev().map(|l| ListItem::new(l.clone())).collect(); | |
| 672 | + | let items: Vec<ListItem> = g | |
| 673 | + | .events | |
| 674 | + | .iter() | |
| 675 | + | .rev() | |
| 676 | + | .take(visible) | |
| 677 | + | .rev() | |
| 678 | + | .map(|l| ListItem::new(l.clone())) | |
| 679 | + | .collect(); | |
| 602 | 680 | let events_block = List::new(items).block( | |
| 603 | - | Block::default().title(format!("events ({})", g.events.len())).borders(Borders::ALL), | |
| 681 | + | Block::default() | |
| 682 | + | .title(format!("events ({})", g.events.len())) | |
| 683 | + | .borders(Borders::ALL), | |
| 604 | 684 | ); | |
| 605 | 685 | f.render_widget(events_block, area); | |
| 606 | 686 | ||
| @@ -621,8 +701,14 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |||
| 621 | 701 | g.tails.len(), | |
| 622 | 702 | ); | |
| 623 | 703 | let visible = tail_area.height.saturating_sub(2) as usize; | |
| 624 | - | let items: Vec<ListItem> = | |
| 625 | - | t.lines.iter().rev().take(visible).rev().map(|l| ListItem::new(truncate(l, 200))).collect(); | |
| 704 | + | let items: Vec<ListItem> = t | |
| 705 | + | .lines | |
| 706 | + | .iter() | |
| 707 | + | .rev() | |
| 708 | + | .take(visible) | |
| 709 | + | .rev() | |
| 710 | + | .map(|l| ListItem::new(truncate(l, 200))) | |
| 711 | + | .collect(); | |
| 626 | 712 | List::new(items).block(Block::default().title(title).borders(Borders::ALL)) | |
| 627 | 713 | } | |
| 628 | 714 | None => List::new(vec![ListItem::new("no recent step runs")]) | |
| @@ -656,14 +742,23 @@ fn step_mark(status: Option<&str>) -> (&'static str, Style) { | |||
| 656 | 742 | match status { | |
| 657 | 743 | Some("ok") => ("O", Style::default().fg(Color::Green)), | |
| 658 | 744 | Some("failed") => ("X", Style::default().fg(Color::Red)), | |
| 659 | - | Some("running") => (">", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), | |
| 745 | + | Some("running") => ( | |
| 746 | + | ">", | |
| 747 | + | Style::default() | |
| 748 | + | .fg(Color::Yellow) | |
| 749 | + | .add_modifier(Modifier::BOLD), | |
| 750 | + | ), | |
| 660 | 751 | Some("pending") => (".", Style::default().fg(Color::DarkGray)), | |
| 661 | 752 | _ => ("-", Style::default().fg(Color::DarkGray)), | |
| 662 | 753 | } | |
| 663 | 754 | } | |
| 664 | 755 | ||
| 665 | 756 | fn tail_position(tails: &BTreeMap<StepRunId, StepTail>, id: StepRunId) -> usize { | |
| 666 | - | tails.keys().position(|k| *k == id).map(|i| i + 1).unwrap_or(0) | |
| 757 | + | tails | |
| 758 | + | .keys() | |
| 759 | + | .position(|k| *k == id) | |
| 760 | + | .map(|i| i + 1) | |
| 761 | + | .unwrap_or(0) | |
| 667 | 762 | } | |
| 668 | 763 | ||
| 669 | 764 | fn cycle_focus( | |
| @@ -724,14 +819,24 @@ mod tests { | |||
| 724 | 819 | let mut tails = BTreeMap::new(); | |
| 725 | 820 | tails.insert(StepRunId(1), StepTail::new("a".into(), "build".into())); | |
| 726 | 821 | tails.insert(StepRunId(2), StepTail::new("b".into(), "sign".into())); | |
| 727 | - | assert_eq!(cycle_focus(&tails, Some(StepRunId(2)), 1), Some(StepRunId(1))); | |
| 728 | - | assert_eq!(cycle_focus(&tails, Some(StepRunId(1)), -1), Some(StepRunId(2))); | |
| 822 | + | assert_eq!( | |
| 823 | + | cycle_focus(&tails, Some(StepRunId(2)), 1), | |
| 824 | + | Some(StepRunId(1)) | |
| 825 | + | ); | |
| 826 | + | assert_eq!( | |
| 827 | + | cycle_focus(&tails, Some(StepRunId(1)), -1), | |
| 828 | + | Some(StepRunId(2)) | |
| 829 | + | ); | |
| 729 | 830 | assert_eq!(cycle_focus(&BTreeMap::new(), None, 1), None); | |
| 730 | 831 | } | |
| 731 | 832 | ||
| 732 | 833 | #[test] | |
| 733 | 834 | fn format_lagged_detects() { | |
| 734 | - | assert!(format_lagged(r#"{"kind":"lagged","skipped":5}"#).unwrap().contains("5")); | |
| 835 | + | assert!( | |
| 836 | + | format_lagged(r#"{"kind":"lagged","skipped":5}"#) | |
| 837 | + | .unwrap() | |
| 838 | + | .contains("5") | |
| 839 | + | ); | |
| 735 | 840 | assert!(format_lagged(r#"{"kind":"step_start"}"#).is_none()); | |
| 736 | 841 | } | |
| 737 | 842 | } |