//! 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::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: String, pub git_sha: String, pub worktree: PathBuf, pub binary_path: PathBuf, } pub async fn run( pool: SqlitePool, cfg: Arc, topo: Arc, sha: String, ) -> Result { let worktree = cfg.workdir.join(&sha); let bare = PathBuf::from(&topo.repo.bare_path); git::checkout_worktree(&bare, &sha, &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()))?; tracing::info!(sha = %sha, version = %version, dir = %server_dir.display(), "cargo build --release start"); let started = std::time::Instant::now(); let out = Command::new("cargo") .arg("build") .arg("--release") .current_dir(&server_dir) .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"); } else { tracing::info!(sha = %sha, version = %version, elapsed_s, "cargo build --release ok"); } anyhow::ensure!( out.status.success(), "cargo build --release failed:\n{}", tail(&out.stderr, 4_000), ); let binary_path = server_dir.join("target/release/server"); anyhow::ensure!( binary_path.exists(), "expected binary at {} after build", binary_path.display(), ); 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(binary_path.to_string_lossy().as_ref()) .execute(&pool) .await?; Ok(BuildArtifact { version, git_sha: sha, worktree, binary_path }) } /// Full MM-tier pipeline: build, deploy the binary into MM's release_root, /// run MM's configured gates against the worktree, set tier_state.mm if all /// pass. Errors propagate back to the spawned task and get logged. pub async fn build_and_run_mm( pool: SqlitePool, cfg: Arc, topo: Arc, sha: String, ) -> Result<()> { let art = run(pool.clone(), cfg.clone(), topo.clone(), sha).await?; // Stage the binary in MM's release_root so future gates and the MM // self-deploy point at a stable path, not the worktree's target/. let mm_release_root = &cfg.release_root; let staged = deploy::deploy_local(mm_release_root, &art.version, &art.binary_path).await?; let staged_bin = staged.join("server"); sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") .bind(staged_bin.to_string_lossy().as_ref()) .bind(&art.version) .execute(&pool) .await?; // Find the MM tier's gate list. MM is conventionally named "mm". let mm = topo.tiers.iter().find(|t| t.name == "mm") .context("topology has no `mm` tier")?; let ctx = GateCtx { pool: pool.clone(), cfg: cfg.clone(), tier: "mm".to_string(), version: art.version.clone(), worktree: art.worktree.clone(), }; let ok = gates::run_all(&ctx, &mm.gates).await?; if ok { let prev: Option = sqlx::query_scalar( "SELECT current_version FROM tier_state WHERE tier = 'mm'", ) .fetch_optional(&pool).await?.flatten(); sqlx::query( "UPDATE tier_state SET previous_version = ?, current_version = ?, burn_in_started_at = ? WHERE tier = 'mm'", ) .bind(prev) .bind(&art.version) .bind(Utc::now().to_rfc3339()) .execute(&pool) .await?; tracing::info!(version = %art.version, "MM pipeline green; ready to promote to next tier"); } else { tracing::warn!(version = %art.version, "MM 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")?; Ok(v.to_string()) } 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() } }