Skip to main content

max / makenotwork

10.0 KB · 259 lines History Blame Raw
1 //! Build orchestration: resolve a sha to a worktree, read the server version,
2 //! shell out to `cargo build --release`, record a `versions` row.
3 //!
4 //! Runs as a tokio task spawned from `POST /rebuild`; the HTTP request
5 //! returns the version id immediately and the task drives the rest.
6
7 use crate::config::Config;
8 use crate::deploy;
9 use crate::domain::{GitSha, TierId, Version};
10 use crate::gates::{self, GateCtx};
11 use crate::git;
12 use crate::topology::Topology;
13 use anyhow::{Context, Result};
14 use chrono::Utc;
15 use sqlx::SqlitePool;
16 use std::path::{Path, PathBuf};
17 use std::sync::Arc;
18 use tokio::process::Command;
19
20 #[derive(Debug, Clone)]
21 pub struct BuildArtifact {
22 pub version: Version,
23 pub git_sha: GitSha,
24 pub worktree: PathBuf,
25 /// One entry per `cfg.bin_names` in declared order. First is the primary
26 /// (referenced by the systemd unit's ExecStart). Paths are inside the
27 /// worktree's `target/release/`.
28 pub binary_paths: Vec<PathBuf>,
29 }
30
31 pub async fn run(
32 pool: SqlitePool,
33 cfg: Arc<Config>,
34 topo: Arc<Topology>,
35 sha: GitSha,
36 events: crate::events::EventTx,
37 ) -> Result<BuildArtifact> {
38 let worktree = cfg.workdir.join(sha.as_str());
39 let bare = PathBuf::from(&topo.repo.bare_path);
40 git::checkout_worktree(&bare, sha.as_str(), &worktree).await?;
41
42 let server_dir = worktree.join("server");
43 let version = read_pkg_version(&server_dir.join("Cargo.toml")).await
44 .with_context(|| format!("reading version from {}/Cargo.toml", server_dir.display()))?;
45
46 // sqlx compile-time query checking needs a live DB with the current schema.
47 // We point cargo at the scratch DB and prep it (drop public, re-migrate)
48 // before invoking cargo build. The same DB is reset again by
49 // `migration_dry_run` later if it runs as a gate.
50 let mut cargo_cmd = Command::new("cargo");
51 cargo_cmd
52 .arg("build")
53 .arg("--release")
54 .current_dir(&server_dir)
55 .kill_on_drop(true);
56 if let Some(scratch_url) = cfg.scratch_db_url.as_deref() {
57 tracing::info!(sha = %sha.as_str(), "preparing scratch DB schema for sqlx compile-time checks");
58 crate::gates::reset_scratch(scratch_url).await
59 .context("scratch DB reset before build")?;
60 crate::gates::run_migrator(scratch_url, &server_dir.join("migrations")).await
61 .context("applying MNW migrations to scratch DB before build")?;
62 cargo_cmd.env("DATABASE_URL", scratch_url);
63 } else {
64 tracing::warn!("scratch_db_url unset; sqlx will fall back to offline mode and may fail");
65 }
66
67 tracing::info!(sha = %sha, version = %version, dir = %server_dir.display(), "cargo build --release start");
68 crate::events::emit(&events, crate::events::Event::BuildStart {
69 sha: sha.clone(), version: version.clone(),
70 });
71 let started = std::time::Instant::now();
72 let out = cargo_cmd
73 .output()
74 .await
75 .context("spawning cargo build")?;
76 let elapsed_s = started.elapsed().as_secs();
77 if !out.status.success() {
78 tracing::error!(sha = %sha, version = %version, elapsed_s, "cargo build --release failed");
79 crate::events::emit(&events, crate::events::Event::BuildFailed {
80 sha: sha.clone(), version: version.clone(), elapsed_s,
81 });
82 } else {
83 tracing::info!(sha = %sha, version = %version, elapsed_s, "cargo build --release ok");
84 crate::events::emit(&events, crate::events::Event::BuildOk {
85 sha: sha.clone(), version: version.clone(), elapsed_s,
86 });
87 }
88 anyhow::ensure!(
89 out.status.success(),
90 "cargo build --release failed:\n{}",
91 tail(&out.stderr, 4_000),
92 );
93
94 let release_dir = server_dir.join("target/release");
95 let mut binary_paths = Vec::with_capacity(cfg.bin_names.len());
96 for name in &cfg.bin_names {
97 let p = release_dir.join(name);
98 anyhow::ensure!(p.exists(), "expected binary at {} after build", p.display());
99 binary_paths.push(p);
100 }
101 // Primary binary path is the one we record in `versions.artifact_path`
102 // (everything downstream — promote, rollback — looks it up by version).
103 let primary = binary_paths[0].clone();
104
105 sqlx::query(
106 "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path)
107 VALUES (?, ?, ?, ?)",
108 )
109 .bind(&version)
110 .bind(&sha)
111 .bind(Utc::now().to_rfc3339())
112 .bind(primary.to_string_lossy().as_ref())
113 .execute(&pool)
114 .await?;
115
116 Ok(BuildArtifact { version, git_sha: sha, worktree, binary_paths })
117 }
118
119 /// Full host-tier pipeline: build, stage the bundle into the host's
120 /// release_root, run the host tier's configured gates, advance tier_state
121 /// for "host" if all pass. Errors propagate back to the spawned task and
122 /// get logged. (Tier was called "mm" pre-Session-1; renamed to "host"
123 /// since sandod runs on whatever machine ends up being the Sando host.)
124 pub async fn build_and_run_host(
125 pool: SqlitePool,
126 cfg: Arc<Config>,
127 topo: Arc<Topology>,
128 sha: GitSha,
129 events: crate::events::EventTx,
130 ) -> Result<()> {
131 let art = run(pool.clone(), cfg.clone(), topo.clone(), sha, events.clone()).await?;
132
133 // Stage the binary in the host's release_root so future gates and the
134 // host self-deploy point at a stable path, not the worktree's target/.
135 let host_release_root = &cfg.release_root;
136 let staged = deploy::deploy_local(host_release_root, &art.version, &art.binary_paths).await?;
137
138 // Stage every entry from cfg.release_contents into the staged release dir.
139 // This is how non-binary version-coupled content (static assets, docs,
140 // error-pages, ...) makes it into the atomic deploy bundle. Projects opt
141 // in via daemon config — the sando code carries no MNW-specific knowledge.
142 for entry in &cfg.release_contents {
143 stage_entry(&art.worktree, &staged, entry).await?;
144 }
145
146 let staged_bin = staged.join(cfg.primary_bin());
147 sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?")
148 .bind(staged_bin.to_string_lossy().as_ref())
149 .bind(&art.version)
150 .execute(&pool)
151 .await?;
152
153 let host = topo.tiers.iter().find(|t| t.name.as_str() == "host")
154 .context("topology has no `host` tier")?;
155
156 let ctx = GateCtx {
157 pool: pool.clone(),
158 cfg: cfg.clone(),
159 tier: TierId::new("host"),
160 version: art.version.clone(),
161 worktree: art.worktree.clone(),
162 events: events.clone(),
163 };
164 let ok = gates::run_all(&ctx, &host.gates).await?;
165
166 if ok {
167 let prev: Option<String> = sqlx::query_scalar(
168 "SELECT current_version FROM tier_state WHERE tier = 'host'",
169 )
170 .fetch_optional(&pool).await?.flatten();
171 sqlx::query(
172 "UPDATE tier_state SET previous_version = ?, current_version = ?, burn_in_started_at = ?
173 WHERE tier = 'host'",
174 )
175 .bind(prev)
176 .bind(&art.version)
177 .bind(Utc::now().to_rfc3339())
178 .execute(&pool)
179 .await?;
180 tracing::info!(version = %art.version, "host pipeline green; ready to promote to next tier");
181 } else {
182 tracing::warn!(version = %art.version, "host pipeline red; not advancing tier_state");
183 }
184 Ok(())
185 }
186
187 async fn read_pkg_version(cargo_toml: &Path) -> Result<Version> {
188 let raw = tokio::fs::read_to_string(cargo_toml).await?;
189 let parsed: toml::Value = toml::from_str(&raw)?;
190 let v = parsed
191 .get("package")
192 .and_then(|p| p.get("version"))
193 .and_then(|v| v.as_str())
194 .context("package.version not found")?;
195 Version::parse(v).with_context(|| format!("parsing package.version `{v}`"))
196 }
197
198 fn tail(buf: &[u8], max: usize) -> String {
199 let s = String::from_utf8_lossy(buf);
200 if s.len() <= max { s.into_owned() } else { s[s.len() - max..].to_string() }
201 }
202
203 /// Copy `worktree/<entry.src>` into `staged/<entry.dst>`. Handles file or
204 /// directory sources transparently. Missing source policy depends on
205 /// `entry.required`:
206 /// - required=true -> error (build fails)
207 /// - required=false -> log warn + skip (e.g. older shas missing a dir)
208 ///
209 /// Uses `cp -a` to preserve modes/symlinks/etc; parent of dst is created if
210 /// needed so entries like `dst = "docs/assumptions.toml"` work without
211 /// extra config.
212 async fn stage_entry(
213 worktree: &Path,
214 staged: &Path,
215 entry: &crate::config::ReleaseEntry,
216 ) -> Result<()> {
217 let src = worktree.join(&entry.src);
218 let dst = staged.join(&entry.dst);
219 if !src.exists() {
220 if entry.required {
221 anyhow::bail!("required release_contents source missing: {}", src.display());
222 }
223 tracing::warn!(src = %src.display(), "release_contents source missing (optional); skipping");
224 return Ok(());
225 }
226 if let Some(parent) = dst.parent() {
227 tokio::fs::create_dir_all(parent).await
228 .with_context(|| format!("create staged parent {}", parent.display()))?;
229 }
230 // Multiple entries with the same dst (e.g. site-docs/public/ +
231 // site-docs/examples/ both landing under docs/) need additive merging.
232 // `cp -a SRC/. DST/` copies SRC's contents into DST without overwriting
233 // the dst dir itself; that's the merge-friendly form when dst is a dir
234 // that may already exist from a prior entry. For non-dir sources or a
235 // missing dst we fall back to the plain `cp -a SRC DST` form.
236 let merge_into_existing_dir = src.is_dir() && dst.is_dir();
237 let mut cmd = Command::new("cp");
238 cmd.arg("-a");
239 if merge_into_existing_dir {
240 let mut src_arg = src.clone().into_os_string();
241 src_arg.push("/.");
242 cmd.arg(src_arg);
243 let mut dst_arg = dst.clone().into_os_string();
244 dst_arg.push("/");
245 cmd.arg(dst_arg);
246 } else {
247 cmd.arg(&src).arg(&dst);
248 }
249 let out = cmd.output().await
250 .with_context(|| format!("spawning cp for {} -> {}", src.display(), dst.display()))?;
251 anyhow::ensure!(
252 out.status.success(),
253 "stage {} -> {}: {}",
254 src.display(), dst.display(),
255 String::from_utf8_lossy(&out.stderr),
256 );
257 Ok(())
258 }
259