Skip to main content

max / makenotwork

sando: extract stage_and_gate seam and test the host pipeline Split build_and_run_host at the run() boundary: the post-build half (stage the bundle, run host gates, advance tier_state) moves into a public stage_and_gate(). Pure extraction, no behavior change -- but the staging/gating/advance logic is now reachable from a synthetic BuildArtifact, no real cargo build --release required. Two tests off a shared in-memory fixture: green host gates advance the tier, settle the run passed, write versions.artifact_path, and flip the current symlink; a red ManualConfirm gate marks the run failed and does not advance. Non-vacuity mutation-checked (nulling advance_tier fails the green test). Closes the last open item of the state-machine test task and unblocks the e2e single-binary smoke. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 16:20 UTC
Commit: 926fa16c389e70929bd7a428cfaf1b381a57e048
Parent: 6fb966c
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() {