max / makenotwork
1 file changed,
+252 insertions,
-1 deletion
| @@ -303,6 +303,23 @@ pub async fn build_and_run_host( | |||
| 303 | 303 | ) | |
| 304 | 304 | .await?; | |
| 305 | 305 | ||
| 306 | + | stage_and_gate(pool, cfg, topo, art, events, run_id, deploy_lock).await | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// Post-build half of the host pipeline: stage the artifact into the host's | |
| 310 | + | /// release_root, run the host tier's gates, and advance `tier_state` for | |
| 311 | + | /// "host" iff all pass. Split from [`build_and_run_host`] at the `run()` | |
| 312 | + | /// boundary so the staging/gating/advance logic is reachable in tests from a | |
| 313 | + | /// synthetic [`BuildArtifact`] — no real `cargo build --release` required. | |
| 314 | + | pub async fn stage_and_gate( | |
| 315 | + | pool: SqlitePool, | |
| 316 | + | cfg: Arc<Config>, | |
| 317 | + | topo: Arc<Topology>, | |
| 318 | + | art: BuildArtifact, | |
| 319 | + | events: crate::events::EventTx, | |
| 320 | + | run_id: RunId, | |
| 321 | + | deploy_lock: Arc<tokio::sync::Mutex<()>>, | |
| 322 | + | ) -> Result<()> { | |
| 306 | 323 | crate::runs::set_phase(&pool, run_id, crate::runs::Phase::Staging) | |
| 307 | 324 | .await | |
| 308 | 325 | .ok(); | |
| @@ -496,7 +513,241 @@ async fn stage_entry( | |||
| 496 | 513 | ||
| 497 | 514 | #[cfg(test)] | |
| 498 | 515 | mod tests { | |
| 499 | - | use super::{check_build_host, runtime_hostname, tail}; | |
| 516 | + | use super::{BuildArtifact, check_build_host, runtime_hostname, stage_and_gate, tail}; | |
| 517 | + | use crate::config::{Config, TestTarget}; | |
| 518 | + | use crate::domain::{GitSha, RunId, Version}; | |
| 519 | + | use crate::topology::{BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology}; | |
| 520 | + | use sqlx::SqlitePool; | |
| 521 | + | use sqlx::sqlite::SqlitePoolOptions; | |
| 522 | + | use std::path::PathBuf; | |
| 523 | + | use std::sync::Arc; | |
| 524 | + | ||
| 525 | + | /// Post-build pipeline fixture: an in-memory store with the `host` tier | |
| 526 | + | /// seeded, a synthetic worktree holding a fake primary binary, and a | |
| 527 | + | /// build_runs row in flight. Returns everything `stage_and_gate` needs plus | |
| 528 | + | /// the tempdir root (drop it to clean up) and the run/version it seeded. | |
| 529 | + | /// | |
| 530 | + | /// `gates` is the host tier's gate list: `[]` is the green path; | |
| 531 | + | /// `[Gate::ManualConfirm]` is a deterministic red — with no prior operator | |
| 532 | + | /// confirmation row that gate blocks, and it shells out to nothing. | |
| 533 | + | async fn stage_fixture( | |
| 534 | + | gates: Vec<Gate>, | |
| 535 | + | ) -> ( | |
| 536 | + | SqlitePool, | |
| 537 | + | Arc<Config>, | |
| 538 | + | Arc<Topology>, | |
| 539 | + | BuildArtifact, | |
| 540 | + | RunId, | |
| 541 | + | Version, | |
| 542 | + | tempfile::TempDir, | |
| 543 | + | ) { | |
| 544 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 545 | + | let release_root = tmp.path().join("release-root"); | |
| 546 | + | let worktree = tmp.path().join("worktree"); | |
| 547 | + | let bin_dir = worktree.join("target").join("release"); | |
| 548 | + | tokio::fs::create_dir_all(&bin_dir).await.unwrap(); | |
| 549 | + | let bin_path = bin_dir.join("makenotwork"); | |
| 550 | + | tokio::fs::write(&bin_path, b"#!/bin/false\nfake sando artifact\n") | |
| 551 | + | .await | |
| 552 | + | .unwrap(); | |
| 553 | + | ||
| 554 | + | let pool = SqlitePoolOptions::new() | |
| 555 | + | .max_connections(1) | |
| 556 | + | .connect("sqlite::memory:") | |
| 557 | + | .await | |
| 558 | + | .unwrap(); | |
| 559 | + | sqlx::migrate!("./migrations").run(&pool).await.unwrap(); | |
| 560 | + | ||
| 561 | + | // gate_runs and tier_state FK into `tiers`; the pipeline only touches host. | |
| 562 | + | sqlx::query("INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')") | |
| 563 | + | .execute(&pool) | |
| 564 | + | .await | |
| 565 | + | .unwrap(); | |
| 566 | + | sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')") | |
| 567 | + | .execute(&pool) | |
| 568 | + | .await | |
| 569 | + | .unwrap(); | |
| 570 | + | ||
| 571 | + | let version = Version::parse("1.2.3").unwrap(); | |
| 572 | + | let git_sha = GitSha::parse("abc1234").unwrap(); | |
| 573 | + | // gate_runs.version and the `SET artifact_path` UPDATE both need the row. | |
| 574 | + | sqlx::query( | |
| 575 | + | "INSERT INTO versions (version, git_sha, built_at, artifact_path) | |
| 576 | + | VALUES (?, ?, datetime('now'), '')", | |
| 577 | + | ) | |
| 578 | + | .bind(version.to_string()) | |
| 579 | + | .bind(git_sha.to_string()) | |
| 580 | + | .execute(&pool) | |
| 581 | + | .await | |
| 582 | + | .unwrap(); | |
| 583 | + | ||
| 584 | + | let run_id = crate::runs::create(&pool, &git_sha.to_string()) | |
| 585 | + | .await | |
| 586 | + | .unwrap(); | |
| 587 | + | ||
| 588 | + | let cfg = Config { | |
| 589 | + | listen: "127.0.0.1:0".into(), | |
| 590 | + | db_path: PathBuf::from(":memory:"), | |
| 591 | + | topology_path: PathBuf::from("/tmp/test-sando.toml"), | |
| 592 | + | build_host: "test-host".into(), | |
| 593 | + | workdir: tmp.path().to_path_buf(), | |
| 594 | + | release_root: release_root.clone(), | |
| 595 | + | scratch_db_url: None, | |
| 596 | + | scratch_owner_role: "makenotwork".into(), | |
| 597 | + | boot_smoke_port: 18181, | |
| 598 | + | code_smoke_port: 18182, | |
| 599 | + | bin_names: vec!["makenotwork".into()], | |
| 600 | + | logs_root: tmp.path().join("logs"), | |
| 601 | + | release_contents: vec![], | |
| 602 | + | cargo_target_dir: None, | |
| 603 | + | gate_timeout_secs: 2400, | |
| 604 | + | companions: Vec::new(), | |
| 605 | + | test_targets: vec![TestTarget { | |
| 606 | + | dir: PathBuf::from("server"), | |
| 607 | + | features: vec!["fast-tests".into()], | |
| 608 | + | all_features: false, | |
| 609 | + | scratch_db: true, | |
| 610 | + | }], | |
| 611 | + | }; | |
| 612 | + | ||
| 613 | + | let topo = Topology { | |
| 614 | + | repo: RepoConfig { | |
| 615 | + | bare_path: "/tmp/test.git".into(), | |
| 616 | + | branch: "main".into(), | |
| 617 | + | upstream: None, | |
| 618 | + | }, | |
| 619 | + | backup: BackupConfig { | |
| 620 | + | source: "file:///tmp/test-backup.sql".into(), | |
| 621 | + | local_path: "/tmp/local-backup.sql".into(), | |
| 622 | + | }, | |
| 623 | + | tiers: vec![Tier { | |
| 624 | + | name: "host".into(), | |
| 625 | + | provisioned: true, | |
| 626 | + | gates, | |
| 627 | + | canary: CanaryPolicy::Sequential, | |
| 628 | + | nodes: Vec::new(), | |
| 629 | + | }], | |
| 630 | + | }; | |
| 631 | + | ||
| 632 | + | let art = BuildArtifact { | |
| 633 | + | version: version.clone(), | |
| 634 | + | git_sha, | |
| 635 | + | worktree, | |
| 636 | + | binary_paths: vec![bin_path], | |
| 637 | + | companion_paths: Vec::new(), | |
| 638 | + | }; | |
| 639 | + | ||
| 640 | + | ( | |
| 641 | + | pool, | |
| 642 | + | Arc::new(cfg), | |
| 643 | + | Arc::new(topo), | |
| 644 | + | art, | |
| 645 | + | run_id, | |
| 646 | + | version, | |
| 647 | + | tmp, | |
| 648 | + | ) | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | async fn tier_versions(pool: &SqlitePool, tier: &str) -> (Option<String>, Option<String>) { | |
| 652 | + | sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?") | |
| 653 | + | .bind(tier) | |
| 654 | + | .fetch_one(pool) | |
| 655 | + | .await | |
| 656 | + | .unwrap() | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | async fn run_result(pool: &SqlitePool, run_id: RunId) -> (String, Option<String>) { | |
| 660 | + | sqlx::query_as("SELECT result, failure_summary FROM build_runs WHERE id = ?") | |
| 661 | + | .bind(run_id.0) | |
| 662 | + | .fetch_one(pool) | |
| 663 | + | .await | |
| 664 | + | .unwrap() | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | #[tokio::test] | |
| 668 | + | async fn stage_and_gate_stages_advances_and_flips_the_symlink_when_gates_are_green() { | |
| 669 | + | let (pool, cfg, topo, art, run_id, version, tmp) = stage_fixture(vec![]).await; | |
| 670 | + | let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); | |
| 671 | + | ||
| 672 | + | stage_and_gate( | |
| 673 | + | pool.clone(), | |
| 674 | + | cfg.clone(), | |
| 675 | + | topo, | |
| 676 | + | art, | |
| 677 | + | crate::events::channel(), | |
| 678 | + | run_id, | |
| 679 | + | deploy_lock, | |
| 680 | + | ) | |
| 681 | + | .await | |
| 682 | + | .expect("green host pipeline returns Ok"); | |
| 683 | + | ||
| 684 | + | // Tier advanced to the built version (previous was NULL -> stays NULL). | |
| 685 | + | let (current, previous) = tier_versions(&pool, "host").await; | |
| 686 | + | assert_eq!(current.as_deref(), Some(version.to_string().as_str())); | |
| 687 | + | assert_eq!(previous, None); | |
| 688 | + | ||
| 689 | + | // Run settled green. | |
| 690 | + | let (result, summary) = run_result(&pool, run_id).await; | |
| 691 | + | assert_eq!(result, "passed"); | |
| 692 | + | assert_eq!(summary, None); | |
| 693 | + | ||
| 694 | + | // versions.artifact_path now points at the staged primary binary, and | |
| 695 | + | // that file actually exists on disk under release_root. | |
| 696 | + | let staged_bin: String = | |
| 697 | + | sqlx::query_scalar("SELECT artifact_path FROM versions WHERE version = ?") | |
| 698 | + | .bind(version.to_string()) | |
| 699 | + | .fetch_one(&pool) | |
| 700 | + | .await | |
| 701 | + | .unwrap(); | |
| 702 | + | let expected = tmp | |
| 703 | + | .path() | |
| 704 | + | .join("release-root") | |
| 705 | + | .join("releases") | |
| 706 | + | .join(version.to_string()) | |
| 707 | + | .join("makenotwork"); | |
| 708 | + | assert_eq!(staged_bin, expected.to_string_lossy()); | |
| 709 | + | assert!(expected.exists(), "staged binary missing at {expected:?}"); | |
| 710 | + | ||
| 711 | + | // The `current` symlink flipped to the new release. | |
| 712 | + | let link = tmp.path().join("release-root").join("current"); | |
| 713 | + | let target = std::fs::read_link(&link).expect("current is a symlink"); | |
| 714 | + | assert_eq!(target, PathBuf::from(format!("releases/{version}"))); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | #[tokio::test] | |
| 718 | + | async fn stage_and_gate_marks_the_run_failed_and_does_not_advance_when_a_gate_is_red() { | |
| 719 | + | // ManualConfirm with no prior confirmation row blocks deterministically. | |
| 720 | + | let (pool, cfg, topo, art, run_id, _version, _tmp) = | |
| 721 | + | stage_fixture(vec![Gate::ManualConfirm]).await; | |
| 722 | + | let deploy_lock = Arc::new(tokio::sync::Mutex::new(())); | |
| 723 | + | ||
| 724 | + | // A red gate is a pipeline outcome, not an error: the fn records the | |
| 725 | + | // failure and returns Ok so the spawned task settles the run cleanly. | |
| 726 | + | stage_and_gate( | |
| 727 | + | pool.clone(), | |
| 728 | + | cfg, | |
| 729 | + | topo, | |
| 730 | + | art, | |
| 731 | + | crate::events::channel(), | |
| 732 | + | run_id, | |
| 733 | + | deploy_lock, | |
| 734 | + | ) | |
| 735 | + | .await | |
| 736 | + | .expect("a red gate settles the run, it does not error out"); | |
| 737 | + | ||
| 738 | + | // Tier did NOT advance — still the seeded NULL/NULL. | |
| 739 | + | let (current, previous) = tier_versions(&pool, "host").await; | |
| 740 | + | assert_eq!(current, None); | |
| 741 | + | assert_eq!(previous, None); | |
| 742 | + | ||
| 743 | + | // Run settled red with a non-empty summary. | |
| 744 | + | let (result, summary) = run_result(&pool, run_id).await; | |
| 745 | + | assert_eq!(result, "failed"); | |
| 746 | + | assert!( | |
| 747 | + | summary.as_deref().is_some_and(|s| !s.is_empty()), | |
| 748 | + | "failed run must carry a summary, got {summary:?}" | |
| 749 | + | ); | |
| 750 | + | } | |
| 500 | 751 | ||
| 501 | 752 | #[test] | |
| 502 | 753 | fn check_build_host_accepts_matching_host() { |