build.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. use super::ServeOpts;
  2. use super::shq;
  3. use crate::config::{Cluster, Config, Node, Recipe};
  4. use crate::ssh::run_ssh;
  5. use anyhow::{Context, Result};
  6. use base64::Engine;
  7. use base64::engine::general_purpose::STANDARD;
  8. use sha2::{Digest, Sha256};
  9. use std::collections::{BTreeMap, HashMap};
  10. use std::io::Read;
  11. use std::path::Path;
  12. use tracing::info;
  13. /// Default upper bound on a single remote image build before the launcher
  14. /// gives up (90 min). A hung build (stuck tmux, network stall) would
  15. /// otherwise loop the 30s poll forever, hanging the launcher.
  16. pub const DEFAULT_MAX_BUILD_SECS: u64 = 90 * 60;
  17. /// Compute a SHA-256 checksum over all files in `dir` (recursive).
  18. /// Sorted by relative path to ensure reproducibility.
  19. pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
  20. let mut hasher = Sha256::new();
  21. let mut entries: Vec<_> = Vec::new();
  22. for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
  23. let entry = entry?;
  24. if entry.file_type().is_dir() {
  25. continue;
  26. }
  27. let rel = entry
  28. .path()
  29. .strip_prefix(dir)
  30. .unwrap_or(entry.path())
  31. .to_string_lossy()
  32. .to_string();
  33. entries.push((rel, entry.path().to_path_buf()));
  34. }
  35. entries.sort_by(|a, b| a.0.cmp(&b.0));
  36. for (rel, path) in &entries {
  37. let mut file = std::fs::File::open(path)?;
  38. let mut buf = Vec::new();
  39. file.read_to_end(&mut buf)?;
  40. hasher.update(rel.as_bytes());
  41. hasher.update(b"\0");
  42. hasher.update(&buf);
  43. }
  44. let hash = hasher.finalize();
  45. Ok(hash.iter().map(|b| format!("{:02x}", b)).collect())
  46. }
  47. /// Parse a `versions.env` file into a sorted map. Only `KEY=VAL` lines are
  48. /// captured; comments (`#`) and blank lines are dropped. The value is
  49. /// everything after the first `=` (so values may contain `=`).
  50. fn parse_versions_env(raw: &str) -> BTreeMap<String, String> {
  51. let mut pins = BTreeMap::new();
  52. for line in raw.lines() {
  53. let line = line.trim();
  54. if line.is_empty() || line.starts_with('#') {
  55. continue;
  56. }
  57. if let Some((k, v)) = line.split_once('=') {
  58. let k = k.trim();
  59. if !k.is_empty() {
  60. pins.insert(k.to_string(), v.trim().to_string());
  61. }
  62. }
  63. }
  64. pins
  65. }
  66. /// Serialize a pin map back to `versions.env` text (sorted by key; one
  67. /// `KEY=VAL` per line). Comments are not preserved — this is a generated
  68. /// artifact written to the remote build dir, not the source of truth.
  69. fn serialize_versions_env(pins: &BTreeMap<String, String>) -> String {
  70. let mut out = String::from(
  71. "# Merged versions.env \u{2014} generated by sm121 (recipe [build.versions] overrides applied)\n",
  72. );
  73. for (k, v) in pins {
  74. out.push_str(k);
  75. out.push('=');
  76. out.push_str(v);
  77. out.push('\n');
  78. }
  79. out
  80. }
  81. /// Compute the build checksum, folding in recipe `[build.versions]` overrides.
  82. ///
  83. /// Returns `(merged_versions_env, checksum)`:
  84. /// - No overrides -> `(None, dir_checksum)`: the image's `versions.env` is
  85. /// uploaded as-is and the checksum is unchanged (existing images skip
  86. /// rebuild, backward-compatible).
  87. /// - Overrides present -> `(Some(merged), combined)`: `merged` is the image's
  88. /// `versions.env` with recipe pins applied (recipe wins) for the launcher
  89. /// to write to the remote build dir; `combined` hashes the dir checksum
  90. /// together with the merged env so any override change forces a rebuild.
  91. fn build_checksum(
  92. local_dir: &Path,
  93. overrides: &HashMap<String, String>,
  94. ) -> Result<(Option<String>, String)> {
  95. let dir_checksum = compute_dir_checksum(local_dir)?;
  96. if overrides.is_empty() {
  97. return Ok((None, dir_checksum));
  98. }
  99. let env_path = local_dir.join("versions.env");
  100. let raw = std::fs::read_to_string(&env_path).context(format!(
  101. "recipe carries [build.versions] overrides but '{}' has no versions.env",
  102. env_path.display()
  103. ))?;
  104. let mut pins = parse_versions_env(&raw);
  105. for (k, v) in overrides {
  106. pins.insert(k.clone(), v.clone());
  107. }
  108. let merged = serialize_versions_env(&pins);
  109. let mut h = Sha256::new();
  110. h.update(dir_checksum.as_bytes());
  111. h.update(b"\0");
  112. h.update(merged.as_bytes());
  113. let combined: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
  114. Ok((Some(merged), combined))
  115. }
  116. /// Start a Docker image build on a remote node in a tmux session.
  117. /// After the build completes, stamps the checksum label on the image.
  118. pub async fn build_image_on_node(
  119. config: &Config,
  120. node: &Node,
  121. local_dir: &Path,
  122. recipe_name: &str,
  123. image_tag: &str,
  124. checksum: &str,
  125. merged_versions_env: Option<&str>,
  126. ) -> Result<()> {
  127. let session_name = format!("sm121-build-{}", recipe_name);
  128. let logfile = format!("/tmp/{}-build.log", recipe_name);
  129. let remote_dir = format!("/tmp/{}-build", recipe_name);
  130. info!(
  131. "Building image '{}' on '{}' (tmux: {})...",
  132. image_tag, node.name, session_name
  133. );
  134. crate::ssh::upload_dir(config, node, local_dir, &remote_dir, false).await?;
  135. // If the recipe overrode any `versions.env` pins, overwrite the uploaded
  136. // copy on the node with the merged content (recipe pins win over the
  137. // image's defaults). `build.sh` sources `versions.env`, so this is how
  138. // recipe overrides reach the Dockerfile `--build-arg`s.
  139. if let Some(merged) = merged_versions_env {
  140. let b64 = STANDARD.encode(merged.as_bytes());
  141. run_ssh(
  142. config,
  143. node,
  144. format!(
  145. "printf '%s' '{}' | base64 -d > {}/versions.env",
  146. b64,
  147. shq(&remote_dir)
  148. ),
  149. false,
  150. )
  151. .await?;
  152. info!(
  153. "Applied recipe [build.versions] overrides to versions.env on '{}'",
  154. node.name
  155. );
  156. }
  157. // Kill any stale session from a previous (interrupted or falsely reported
  158. // dead) run so `tmux new-session` does not collide with "duplicate session".
  159. run_ssh(
  160. config,
  161. node,
  162. format!(
  163. "tmux kill-session -t {} 2>/dev/null || true",
  164. shq(&session_name)
  165. ),
  166. false,
  167. )
  168. .await?;
  169. let tmux_cmd = format!(
  170. "tmux new-session -d -s {} -c {} \"TAG={} bash build.sh 2>&1 | tee {}\"",
  171. shq(&session_name),
  172. shq(&remote_dir),
  173. shq(image_tag),
  174. shq(&logfile)
  175. );
  176. run_ssh(config, node, tmux_cmd, false).await?;
  177. let start = std::time::Instant::now();
  178. loop {
  179. tokio::time::sleep(std::time::Duration::from_secs(30)).await;
  180. let elapsed = start.elapsed();
  181. if elapsed.as_secs() >= DEFAULT_MAX_BUILD_SECS {
  182. anyhow::bail!(
  183. "Build on '{}' of '{}' timed out after {} — no completion marker. \
  184. Inspect tmux session '{}' / log '{}' on the node.",
  185. node.name,
  186. image_tag,
  187. format_elapsed(elapsed),
  188. session_name,
  189. logfile
  190. );
  191. }
  192. // `tmux has-session` exits 0 and prints nothing when the session is
  193. // alive; the `|| echo DEAD` makes it print DEAD when the session is
  194. // gone (still exit 0, so run_ssh stays Ok). An empty `alive` therefore
  195. // means ALIVE — it must NOT be treated as dead (that was the original
  196. // bug: a still-running build was reported "failed after 0m" at the
  197. // first 30s poll, orphaning the tmux session).
  198. let alive = run_ssh(
  199. config,
  200. node,
  201. format!(
  202. "tmux has-session -t {} 2>&1 || echo DEAD",
  203. shq(&session_name)
  204. ),
  205. false,
  206. )
  207. .await?;
  208. let dead = alive.trim().contains("DEAD");
  209. if let Ok(logs) = run_ssh(
  210. config,
  211. node,
  212. format!(
  213. "tail -10 {} 2>/dev/null || echo '(no log yet)'",
  214. shq(&logfile)
  215. ),
  216. false,
  217. )
  218. .await
  219. {
  220. for line in logs.lines().filter(|l| !l.is_empty()) {
  221. info!(" [build] {}", line.trim());
  222. }
  223. }
  224. if dead {
  225. // Detect the success marker anywhere in the full log (a `tail -5`
  226. // window can miss it if trailing output pushes it past the end).
  227. let marker = format!("Done: {}", image_tag);
  228. let ok = run_ssh(
  229. config,
  230. node,
  231. format!(
  232. "grep -F -- {} {} >/dev/null 2>&1 && echo OK || echo NO",
  233. shq(&marker),
  234. shq(&logfile)
  235. ),
  236. false,
  237. )
  238. .await
  239. .map(|s| s.trim() == "OK")
  240. .unwrap_or(false);
  241. if ok {
  242. info!(
  243. "Image '{}' built on '{}' ({})",
  244. image_tag,
  245. node.name,
  246. format_elapsed(elapsed)
  247. );
  248. } else {
  249. let exit_log = run_ssh(config, node, format!("tail -20 {}", shq(&logfile)), false)
  250. .await
  251. .unwrap_or_default();
  252. anyhow::bail!(
  253. "Build on '{}' failed after {}.\n{}",
  254. node.name,
  255. format_elapsed(elapsed),
  256. exit_log
  257. );
  258. }
  259. break;
  260. }
  261. info!(" Build running for {}...", format_elapsed(elapsed));
  262. }
  263. // Stamp checksum label on the built image.
  264. let stamp_cmd = format!(
  265. "printf 'FROM %s\n' {} | docker build --label sm121.checksum={} -t {} -",
  266. shq(image_tag),
  267. checksum,
  268. shq(image_tag)
  269. );
  270. let _stamp = run_ssh(config, node, stamp_cmd, false).await?;
  271. info!("Stamped checksum label '{}' on '{}'", checksum, image_tag);
  272. Ok(())
  273. }
  274. /// Decide whether to build and dispatch the build for the serve flow.
  275. pub async fn handle_build(
  276. config: &Config,
  277. cluster: &Cluster,
  278. recipe_name: &str,
  279. recipe: &Recipe,
  280. opts: &ServeOpts,
  281. build_node_name: &str,
  282. ) -> Result<()> {
  283. if opts.dry_run {
  284. let dir = recipe.docker_image_dir.as_deref().unwrap_or(recipe_name);
  285. let overrides = if recipe.build.versions.is_empty() {
  286. String::new()
  287. } else {
  288. format!(
  289. "\n[dry-run] recipe [build.versions] overrides {} pin(s) \u{2014} merged into versions.env on the node",
  290. recipe.build.versions.len()
  291. )
  292. };
  293. println!(
  294. "[dry-run] would build '{}' on '{}' from docker-images/{}/build.sh{}",
  295. recipe.docker_image, build_node_name, dir, overrides
  296. );
  297. return Ok(());
  298. }
  299. let image_dir = recipe
  300. .docker_image_dir
  301. .clone()
  302. .unwrap_or_else(|| recipe_name.to_string());
  303. let local_dir = std::path::Path::new("docker-images").join(&image_dir);
  304. if !local_dir.join("build.sh").exists() {
  305. anyhow::bail!(
  306. "No build system at '{}' (missing build.sh)",
  307. local_dir.display()
  308. );
  309. }
  310. let build_node = cluster
  311. .nodes
  312. .iter()
  313. .find(|n| n.name == build_node_name)
  314. .ok_or_else(|| anyhow::anyhow!("build-node '{}' not in cluster", build_node_name))?;
  315. let (merged_env, checksum) = build_checksum(local_dir.as_path(), &recipe.build.versions)?;
  316. let should_build = if opts.force_build {
  317. true
  318. } else {
  319. let img_id = run_ssh(
  320. config,
  321. build_node,
  322. format!(
  323. "docker image ls --format '{{{{.ID}}}}' {} | head -1",
  324. shq(&recipe.docker_image)
  325. ),
  326. false,
  327. )
  328. .await?;
  329. if img_id.trim().is_empty() {
  330. true
  331. } else {
  332. let label = run_ssh(
  333. config,
  334. build_node,
  335. format!(
  336. "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {} 2>/dev/null || echo MISSING",
  337. shq(&recipe.docker_image)
  338. ),
  339. false,
  340. )
  341. .await?;
  342. let label = label.trim();
  343. if label == checksum {
  344. info!(
  345. "Image '{}' checksum matches on '{}' — skipping build",
  346. recipe.docker_image, build_node.name
  347. );
  348. false
  349. } else {
  350. info!(
  351. "Image '{}' checksum mismatch on '{}' (got {}, want {}) — rebuilding",
  352. recipe.docker_image, build_node.name, label, checksum
  353. );
  354. true
  355. }
  356. }
  357. };
  358. if should_build {
  359. build_image_on_node(
  360. config,
  361. build_node,
  362. local_dir.as_path(),
  363. recipe_name,
  364. &recipe.docker_image,
  365. &checksum,
  366. merged_env.as_deref(),
  367. )
  368. .await?;
  369. }
  370. Ok(())
  371. }
  372. /// Format a `Duration` as a compact human string: `45s`, `1m 30s`, `12m 04s`.
  373. fn format_elapsed(d: std::time::Duration) -> String {
  374. let total_secs = d.as_secs();
  375. let mins = total_secs / 60;
  376. let secs = total_secs % 60;
  377. if mins == 0 {
  378. format!("{}s", secs)
  379. } else {
  380. format!("{}m {:02}s", mins, secs)
  381. }
  382. }
  383. #[cfg(test)]
  384. mod tests {
  385. use super::{build_checksum, format_elapsed, parse_versions_env, serialize_versions_env};
  386. use std::collections::HashMap;
  387. use std::fs;
  388. use std::path::PathBuf;
  389. use std::time::Duration;
  390. fn temp_test_dir(name: &str) -> PathBuf {
  391. let p =
  392. std::env::temp_dir().join(format!("sm121-build-test-{}-{}", std::process::id(), name));
  393. fs::remove_dir_all(&p).ok();
  394. fs::create_dir_all(&p).unwrap();
  395. p
  396. }
  397. #[test]
  398. fn format_elapsed_seconds_and_minutes() {
  399. assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
  400. assert_eq!(format_elapsed(Duration::from_secs(30)), "30s");
  401. assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
  402. assert_eq!(format_elapsed(Duration::from_secs(60)), "1m 00s");
  403. assert_eq!(format_elapsed(Duration::from_secs(90)), "1m 30s");
  404. assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
  405. }
  406. #[test]
  407. fn parse_versions_env_skips_comments_and_blanks() {
  408. let raw = "# comment\n\nKEY=val\nOTHER=a=b\n SPACED = x \nnoeq\n";
  409. let pins = parse_versions_env(raw);
  410. assert_eq!(pins.get("KEY"), Some(&"val".to_string()));
  411. assert_eq!(pins.get("OTHER"), Some(&"a=b".to_string()));
  412. assert_eq!(pins.get("SPACED"), Some(&"x".to_string()));
  413. assert!(!pins.contains_key("noeq"));
  414. assert_eq!(pins.len(), 3);
  415. }
  416. #[test]
  417. fn serialize_versions_env_sorted_one_per_line() {
  418. let mut pins = std::collections::BTreeMap::new();
  419. pins.insert("B".into(), "2".into());
  420. pins.insert("A".into(), "1".into());
  421. let s = serialize_versions_env(&pins);
  422. assert_eq!(
  423. s,
  424. "# Merged versions.env \u{2014} generated by sm121 (recipe [build.versions] overrides applied)\nA=1\nB=2\n"
  425. );
  426. }
  427. #[test]
  428. fn build_checksum_no_overrides_returns_none_and_dir_checksum() {
  429. let dir = temp_test_dir("no_overrides");
  430. fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
  431. let (merged, ck) = build_checksum(&dir, &HashMap::new()).unwrap();
  432. assert!(merged.is_none());
  433. let (_, ck2) = build_checksum(&dir, &HashMap::new()).unwrap();
  434. assert_eq!(ck, ck2);
  435. fs::remove_dir_all(&dir).ok();
  436. }
  437. #[test]
  438. fn build_checksum_overrides_change_checksum_and_produce_merged() {
  439. let dir = temp_test_dir("overrides");
  440. fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
  441. let no_over = build_checksum(&dir, &HashMap::new()).unwrap().1;
  442. let mut over = HashMap::new();
  443. over.insert("A".to_string(), "99".to_string());
  444. over.insert("C".to_string(), "3".to_string());
  445. let (merged, with_over) = build_checksum(&dir, &over).unwrap();
  446. assert_ne!(no_over, with_over);
  447. let m = merged.unwrap();
  448. assert!(m.contains("A=99"));
  449. assert!(m.contains("C=3"));
  450. assert!(m.contains("B=2"));
  451. assert!(!m.contains("\nA=1\n"));
  452. over.insert("A".to_string(), "100".to_string());
  453. let (_, ck_changed) = build_checksum(&dir, &over).unwrap();
  454. assert_ne!(with_over, ck_changed);
  455. fs::remove_dir_all(&dir).ok();
  456. }
  457. }