//! Build orchestration: resolve a sha to a worktree, read the server version, //! shell out to `cargo build --release`, record a `versions` row. //! //! Runs as a tokio task spawned from `POST /rebuild`; the HTTP request //! returns the version id immediately and the task drives the rest. use crate::config::Config; use crate::deploy; use crate::domain::{GitSha, TierId, Version}; use crate::gates::{self, GateCtx}; use crate::git; use crate::topology::Topology; use anyhow::{Context, Result}; use chrono::Utc; use sqlx::SqlitePool; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::process::Command; #[derive(Debug, Clone)] pub struct BuildArtifact { pub version: Version, pub git_sha: GitSha, pub worktree: PathBuf, /// One entry per `cfg.bin_names` in declared order. First is the primary /// (referenced by the systemd unit's ExecStart). Paths are inside the /// worktree's `target/release/`. pub binary_paths: Vec, } pub async fn run( pool: SqlitePool, cfg: Arc, topo: Arc, sha: GitSha, events: crate::events::EventTx, ) -> Result { let worktree = cfg.workdir.join(sha.as_str()); let bare = PathBuf::from(&topo.repo.bare_path); git::checkout_worktree(&bare, sha.as_str(), &worktree).await?; let server_dir = worktree.join("server"); let version = read_pkg_version(&server_dir.join("Cargo.toml")).await .with_context(|| format!("reading version from {}/Cargo.toml", server_dir.display()))?; // sqlx compile-time query checking needs a live DB with the current schema. // We point cargo at the scratch DB and prep it (drop public, re-migrate) // before invoking cargo build. The same DB is reset again by // `migration_dry_run` later if it runs as a gate. let mut cargo_cmd = Command::new("cargo"); cargo_cmd .arg("build") .arg("--release") .current_dir(&server_dir) .kill_on_drop(true); if let Some(scratch_url) = cfg.scratch_db_url.as_deref() { tracing::info!(sha = %sha.as_str(), "preparing scratch DB schema for sqlx compile-time checks"); crate::gates::reset_scratch(scratch_url).await .context("scratch DB reset before build")?; crate::gates::run_migrator(scratch_url, &server_dir.join("migrations")).await .context("applying MNW migrations to scratch DB before build")?; cargo_cmd.env("DATABASE_URL", scratch_url); } else { tracing::warn!("scratch_db_url unset; sqlx will fall back to offline mode and may fail"); } tracing::info!(sha = %sha, version = %version, dir = %server_dir.display(), "cargo build --release start"); crate::events::emit(&events, crate::events::Event::BuildStart { sha: sha.clone(), version: version.clone(), }); let started = std::time::Instant::now(); let out = cargo_cmd .output() .await .context("spawning cargo build")?; let elapsed_s = started.elapsed().as_secs(); if !out.status.success() { tracing::error!(sha = %sha, version = %version, elapsed_s, "cargo build --release failed"); crate::events::emit(&events, crate::events::Event::BuildFailed { sha: sha.clone(), version: version.clone(), elapsed_s, }); } else { tracing::info!(sha = %sha, version = %version, elapsed_s, "cargo build --release ok"); crate::events::emit(&events, crate::events::Event::BuildOk { sha: sha.clone(), version: version.clone(), elapsed_s, }); } anyhow::ensure!( out.status.success(), "cargo build --release failed:\n{}", tail(&out.stderr, 4_000), ); let release_dir = server_dir.join("target/release"); let mut binary_paths = Vec::with_capacity(cfg.bin_names.len()); for name in &cfg.bin_names { let p = release_dir.join(name); anyhow::ensure!(p.exists(), "expected binary at {} after build", p.display()); binary_paths.push(p); } // Primary binary path is the one we record in `versions.artifact_path` // (everything downstream — promote, rollback — looks it up by version). let primary = binary_paths[0].clone(); sqlx::query( "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, ?, ?, ?)", ) .bind(&version) .bind(&sha) .bind(Utc::now().to_rfc3339()) .bind(primary.to_string_lossy().as_ref()) .execute(&pool) .await?; Ok(BuildArtifact { version, git_sha: sha, worktree, binary_paths }) } /// Full host-tier pipeline: build, stage the bundle into the host's /// release_root, run the host tier's configured gates, advance tier_state /// for "host" if all pass. Errors propagate back to the spawned task and /// get logged. (Tier was called "mm" pre-Session-1; renamed to "host" /// since sandod runs on whatever machine ends up being the Sando host.) pub async fn build_and_run_host( pool: SqlitePool, cfg: Arc, topo: Arc, sha: GitSha, events: crate::events::EventTx, ) -> Result<()> { let art = run(pool.clone(), cfg.clone(), topo.clone(), sha, events.clone()).await?; // Stage the binary in the host's release_root so future gates and the // host self-deploy point at a stable path, not the worktree's target/. let host_release_root = &cfg.release_root; let staged = deploy::deploy_local(host_release_root, &art.version, &art.binary_paths).await?; // Stage every entry from cfg.release_contents into the staged release dir. // This is how non-binary version-coupled content (static assets, docs, // error-pages, ...) makes it into the atomic deploy bundle. Projects opt // in via daemon config — the sando code carries no MNW-specific knowledge. for entry in &cfg.release_contents { stage_entry(&art.worktree, &staged, entry).await?; } let staged_bin = staged.join(cfg.primary_bin()); sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") .bind(staged_bin.to_string_lossy().as_ref()) .bind(&art.version) .execute(&pool) .await?; let host = topo.tiers.iter().find(|t| t.name.as_str() == "host") .context("topology has no `host` tier")?; let ctx = GateCtx { pool: pool.clone(), cfg: cfg.clone(), tier: TierId::new("host"), version: art.version.clone(), worktree: art.worktree.clone(), events: events.clone(), }; let ok = gates::run_all(&ctx, &host.gates).await?; if ok { let prev: Option = sqlx::query_scalar( "SELECT current_version FROM tier_state WHERE tier = 'host'", ) .fetch_optional(&pool).await?.flatten(); sqlx::query( "UPDATE tier_state SET previous_version = ?, current_version = ?, burn_in_started_at = ? WHERE tier = 'host'", ) .bind(prev) .bind(&art.version) .bind(Utc::now().to_rfc3339()) .execute(&pool) .await?; tracing::info!(version = %art.version, "host pipeline green; ready to promote to next tier"); } else { tracing::warn!(version = %art.version, "host pipeline red; not advancing tier_state"); } Ok(()) } async fn read_pkg_version(cargo_toml: &Path) -> Result { let raw = tokio::fs::read_to_string(cargo_toml).await?; let parsed: toml::Value = toml::from_str(&raw)?; let v = parsed .get("package") .and_then(|p| p.get("version")) .and_then(|v| v.as_str()) .context("package.version not found")?; Version::parse(v).with_context(|| format!("parsing package.version `{v}`")) } fn tail(buf: &[u8], max: usize) -> String { let s = String::from_utf8_lossy(buf); if s.len() <= max { s.into_owned() } else { s[s.len() - max..].to_string() } } /// Copy `worktree/` into `staged/`. Handles file or /// directory sources transparently. Missing source policy depends on /// `entry.required`: /// - required=true -> error (build fails) /// - required=false -> log warn + skip (e.g. older shas missing a dir) /// /// Uses `cp -a` to preserve modes/symlinks/etc; parent of dst is created if /// needed so entries like `dst = "docs/assumptions.toml"` work without /// extra config. async fn stage_entry( worktree: &Path, staged: &Path, entry: &crate::config::ReleaseEntry, ) -> Result<()> { let src = worktree.join(&entry.src); let dst = staged.join(&entry.dst); if !src.exists() { if entry.required { anyhow::bail!("required release_contents source missing: {}", src.display()); } tracing::warn!(src = %src.display(), "release_contents source missing (optional); skipping"); return Ok(()); } if let Some(parent) = dst.parent() { tokio::fs::create_dir_all(parent).await .with_context(|| format!("create staged parent {}", parent.display()))?; } // Multiple entries with the same dst (e.g. site-docs/public/ + // site-docs/examples/ both landing under docs/) need additive merging. // `cp -a SRC/. DST/` copies SRC's contents into DST without overwriting // the dst dir itself; that's the merge-friendly form when dst is a dir // that may already exist from a prior entry. For non-dir sources or a // missing dst we fall back to the plain `cp -a SRC DST` form. let merge_into_existing_dir = src.is_dir() && dst.is_dir(); let mut cmd = Command::new("cp"); cmd.arg("-a"); if merge_into_existing_dir { let mut src_arg = src.clone().into_os_string(); src_arg.push("/."); cmd.arg(src_arg); let mut dst_arg = dst.clone().into_os_string(); dst_arg.push("/"); cmd.arg(dst_arg); } else { cmd.arg(&src).arg(&dst); } let out = cmd.output().await .with_context(|| format!("spawning cp for {} -> {}", src.display(), dst.display()))?; anyhow::ensure!( out.status.success(), "stage {} -> {}: {}", src.display(), dst.display(), String::from_utf8_lossy(&out.stderr), ); Ok(()) }