build_dir.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. //! Image build-dir resolution and reproducible checksums.
  2. //!
  3. //! `sm121 build` has to work from anywhere: a launcher workspace
  4. //! (`docker-images/<dir>` + `docker_image_sources`), or a published
  5. //! bundle clone (git.kilork.org/sm121/recipes, flat `<name>/` layout).
  6. //! The checksum over the build dir is what gets stamped as the
  7. //! `sm121.checksum` label and compared by `docker/stale.rs`.
  8. use anyhow::{Context, Result};
  9. use sha2::{Digest, Sha256};
  10. use std::collections::{BTreeMap, HashMap};
  11. use std::io::Read;
  12. use std::path::{Path, PathBuf};
  13. /// Compute a SHA-256 checksum over all files in `dir` (recursive).
  14. /// Sorted by relative path to ensure reproducibility.
  15. /// Flatten an image overlay into `dst` and rewrite its Dockerfile:
  16. /// the `patches/` subtree plus every sub-directory the Dockerfile
  17. /// COPY/ADD-references is flattened to the bundle root (gists cannot
  18. /// carry directories). Name collisions rename the flattened file to
  19. /// `{dir}-{name}` and the COPY line is rewritten to the new name; a
  20. /// collision under a glob source aborts the flatten for that directory
  21. /// (it stays nested, `nested = true` — the gist flow refuses it).
  22. /// Unreferenced sub-directories are always preserved nested.
  23. pub fn flatten_overlay(src_dir: &Path, dst: &Path, dockerfile: &str) -> Result<(String, bool)> {
  24. use std::collections::BTreeSet;
  25. // 1) Sub-directories referenced by COPY/ADD sources (incl. continuations).
  26. let mut referenced: BTreeSet<String> = BTreeSet::new();
  27. let mut in_copy = false;
  28. for line in dockerfile.lines() {
  29. let t = line.trim_start();
  30. let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
  31. in_copy = is_copy && t.ends_with('\\');
  32. if !is_copy {
  33. continue;
  34. }
  35. let body = t.trim_end_matches('\\');
  36. let toks: Vec<&str> = body.split_whitespace().skip(1).collect();
  37. for tok in &toks[..toks.len().saturating_sub(1)] {
  38. if tok.starts_with('-') {
  39. continue; // --from=… etc.
  40. }
  41. let parts: Vec<&str> = tok.split('/').collect();
  42. // parts[0].is_empty() => absolute path (/tmp/…) — not a build-dir ref
  43. if parts.len() >= 2 && !parts[0].is_empty() && src_dir.join(parts[0]).is_dir() {
  44. referenced.insert(parts[0].to_string());
  45. }
  46. }
  47. }
  48. let flatten_dirs: BTreeSet<String> = referenced
  49. .iter()
  50. .cloned()
  51. .chain(std::iter::once("patches".to_string()))
  52. .collect();
  53. // 2) Root files first (they define the collision set), except the driver.
  54. for entry in std::fs::read_dir(src_dir)? {
  55. let entry = entry?;
  56. if entry.file_type()?.is_file() {
  57. let name = entry.file_name().to_string_lossy().to_string();
  58. if name != "build.sh" && name != "Dockerfile" {
  59. std::fs::copy(entry.path(), dst.join(&name))?;
  60. }
  61. }
  62. }
  63. // 3) Flatten the referenced dirs (+ patches/); everything else nests.
  64. let mut nested = false;
  65. let mut flattened: BTreeSet<String> = BTreeSet::new(); // dirs actually flattened
  66. let mut renames: BTreeMap<String, String> = BTreeMap::new(); // "dir/file" -> new name
  67. let mut globs: BTreeSet<String> = BTreeSet::new(); // dirs whose COPY source is a glob
  68. for line in dockerfile.lines() {
  69. let t = line.trim_start();
  70. if t.starts_with("COPY ") || t.starts_with("ADD ") {
  71. for tok in t.split_whitespace().skip(1) {
  72. if tok.starts_with('-') {
  73. continue;
  74. }
  75. let parts: Vec<&str> = tok.split('/').collect();
  76. if parts.len() >= 2 && flatten_dirs.contains(parts[0]) && tok.contains('*') {
  77. globs.insert(parts[0].to_string());
  78. }
  79. }
  80. }
  81. }
  82. for dir in flatten_dirs.iter() {
  83. let dir_path = src_dir.join(dir);
  84. if !dir_path.is_dir() {
  85. continue; // referenced but absent — COPY will fail in docker, not here
  86. }
  87. let files: Vec<PathBuf> = std::fs::read_dir(&dir_path)?
  88. .filter_map(|e| e.ok())
  89. .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
  90. .map(|e| e.path())
  91. .collect();
  92. let collides = files
  93. .iter()
  94. .any(|f| dst.join(f.file_name().unwrap()).exists());
  95. if collides && globs.contains(dir) {
  96. nested = true;
  97. std::fs::create_dir_all(dst.join(dir))?;
  98. copy_tree(&dir_path, &dst.join(dir))?; // rename under a glob is impossible
  99. continue;
  100. }
  101. for f in files {
  102. let name = f.file_name().unwrap().to_string_lossy().to_string();
  103. let key = format!("{dir}/{name}");
  104. let new = if dst.join(&name).exists() {
  105. let renamed = format!("{dir}-{name}");
  106. renames.insert(key, renamed.clone());
  107. renamed
  108. } else {
  109. name
  110. };
  111. std::fs::copy(f, dst.join(&new))?;
  112. }
  113. flattened.insert(dir.clone());
  114. }
  115. for entry in std::fs::read_dir(src_dir)? {
  116. let entry = entry?;
  117. let name = entry.file_name().to_string_lossy().to_string();
  118. if !entry.file_type()?.is_dir() || flatten_dirs.contains(&name) {
  119. continue;
  120. }
  121. // profiles/ is launcher metadata — its pins are already merged into
  122. // the bundle's versions.env, so it is not re-shipped.
  123. if name == "profiles" {
  124. continue;
  125. }
  126. nested = true;
  127. std::fs::create_dir_all(dst.join(&name))?;
  128. copy_tree(&entry.path(), &dst.join(&name))?;
  129. }
  130. // 4) Rewrite COPY/ADD sources for the flattened dirs (incl. continuations).
  131. let mut in_copy = false;
  132. let mut dst_done = false; // destination token already seen on an earlier line
  133. let rewritten = dockerfile
  134. .lines()
  135. .map(|line| {
  136. let t = line.trim_start();
  137. let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
  138. in_copy = is_copy && t.ends_with('\\');
  139. if !is_copy {
  140. return line.to_string();
  141. }
  142. let head = &line[..line.len() - t.len()]; // preserve indentation
  143. let is_first = t.starts_with("COPY ") || t.starts_with("ADD ");
  144. let toks: Vec<&str> = t.split_whitespace().collect();
  145. let n = toks.len();
  146. if is_first {
  147. // command + at least one src + a dest on this line -> all
  148. // continuation tokens are sources
  149. dst_done = toks[1..]
  150. .iter()
  151. .filter(|t| **t != "\\" && !t.starts_with('-'))
  152. .count()
  153. >= 2;
  154. }
  155. let rewritten_toks: Vec<String> = toks
  156. .iter()
  157. .enumerate()
  158. .map(|(i, tok)| {
  159. let is_dest = !is_first && !dst_done && i == n.saturating_sub(1);
  160. if (is_first && i == 0) || is_dest || tok.starts_with('-') {
  161. return tok.to_string();
  162. }
  163. let parts: Vec<&str> = tok.split('/').collect();
  164. if parts.len() < 2 || !flattened.contains(parts[0]) {
  165. return tok.to_string();
  166. }
  167. if tok.contains('*') {
  168. return parts[1..].join("/"); // glob: drop the dir prefix
  169. }
  170. let key = (*tok).to_string();
  171. renames
  172. .get(&key)
  173. .cloned()
  174. .unwrap_or_else(|| parts[1].to_string())
  175. })
  176. .collect();
  177. format!("{}{}", head, rewritten_toks.join(" "))
  178. })
  179. .collect::<Vec<_>>()
  180. .join("\n")
  181. + if dockerfile.ends_with('\n') { "\n" } else { "" };
  182. Ok((rewritten, nested))
  183. }
  184. fn copy_tree(src_dir: &Path, dst_dir: &Path) -> Result<()> {
  185. for entry in std::fs::read_dir(src_dir)? {
  186. let entry = entry?;
  187. let ft = entry.file_type()?;
  188. let target = dst_dir.join(entry.file_name());
  189. if ft.is_dir() {
  190. std::fs::create_dir_all(&target)?;
  191. copy_tree(&entry.path(), &target)?;
  192. } else if ft.is_file() {
  193. std::fs::copy(entry.path(), &target)?;
  194. }
  195. }
  196. Ok(())
  197. }
  198. pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
  199. let mut hasher = Sha256::new();
  200. let mut entries: Vec<_> = Vec::new();
  201. for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
  202. let entry = entry?;
  203. if entry.file_type().is_dir() {
  204. continue;
  205. }
  206. let rel = entry
  207. .path()
  208. .strip_prefix(dir)
  209. .unwrap_or(entry.path())
  210. .to_string_lossy()
  211. .to_string();
  212. entries.push((rel, entry.path().to_path_buf()));
  213. }
  214. entries.sort_by(|a, b| a.0.cmp(&b.0));
  215. for (rel, path) in &entries {
  216. let mut file = std::fs::File::open(path)?;
  217. let mut buf = Vec::new();
  218. file.read_to_end(&mut buf)?;
  219. hasher.update(rel.as_bytes());
  220. hasher.update(b"\0");
  221. hasher.update(&buf);
  222. }
  223. let hash = hasher.finalize();
  224. Ok(hash.iter().map(|b| format!("{:02x}", b)).collect())
  225. }
  226. /// Parse a `versions.env` file into a sorted map. Only `KEY=VAL` lines are
  227. /// captured; comments (`#`) and blank lines are dropped. The value is
  228. /// everything after the first `=` (so values may contain `=`), with one
  229. /// level of matching `"` or `'` quotes stripped — quoted and unquoted forms
  230. /// of the same value must resolve (and checksum) identically.
  231. pub fn parse_versions_env(raw: &str) -> BTreeMap<String, String> {
  232. let mut pins = BTreeMap::new();
  233. for line in raw.lines() {
  234. let line = line.trim();
  235. if line.is_empty() || line.starts_with('#') {
  236. continue;
  237. }
  238. if let Some((k, v)) = line.split_once('=') {
  239. let k = k.trim();
  240. if !k.is_empty() {
  241. pins.insert(k.to_string(), unquote(v.trim()));
  242. }
  243. }
  244. }
  245. pins
  246. }
  247. /// Strip one level of matching single/double quotes (build.sh does the
  248. /// same; keeping the two parsers in agreement keeps checksums stable).
  249. fn unquote(v: &str) -> String {
  250. let b = v.as_bytes();
  251. let quoted = b.len() >= 2
  252. && (b[0] == b'"' && *b.last().unwrap() == b'"'
  253. || b[0] == b'\'' && *b.last().unwrap() == b'\'');
  254. if quoted {
  255. v[1..v.len() - 1].to_string()
  256. } else {
  257. v.to_string()
  258. }
  259. }
  260. /// Serialize a pin map back to `versions.env` text (sorted by key; one
  261. /// `KEY=VAL` per line). Comments are not preserved — this is a generated
  262. /// artifact written to the remote build dir, not the source of truth.
  263. pub fn serialize_versions_env(pins: &BTreeMap<String, String>) -> String {
  264. let mut out = String::from(
  265. "# Merged versions.env — generated by sm121 (profile + recipe [build.versions] applied)\n",
  266. );
  267. for (k, v) in pins {
  268. out.push_str(k);
  269. out.push('=');
  270. out.push_str(v);
  271. out.push('\n');
  272. }
  273. out
  274. }
  275. /// Compute the build checksum, folding in the version profile and recipe
  276. /// `[build.versions]` overrides.
  277. ///
  278. /// Precedence: image `versions.env` < profile file < recipe overrides.
  279. /// Returns `(merged_versions_env, checksum)`:
  280. /// - No profile and no overrides -> `(None, dir_checksum)`: the image's
  281. /// `versions.env` is uploaded as-is and the checksum is unchanged
  282. /// (existing images skip rebuild, backward-compatible).
  283. /// - Profile and/or overrides -> `(Some(merged), combined)`: `merged` is the
  284. /// image's `versions.env` with the profile pins and recipe pins applied
  285. /// (recipe wins) for the launcher to write to the remote build dir;
  286. /// `combined` hashes the dir checksum together with the merged env so any
  287. /// pin change forces a rebuild. Profile files live inside the build dir, so
  288. /// editing one changes the dir checksum too (double-protected).
  289. pub(crate) fn build_checksum(
  290. local_dir: &Path,
  291. profile: Option<&str>,
  292. overrides: &HashMap<String, String>,
  293. ) -> Result<(Option<String>, String)> {
  294. let dir_checksum = compute_dir_checksum(local_dir)?;
  295. if profile.is_none() && overrides.is_empty() {
  296. return Ok((None, dir_checksum));
  297. }
  298. let env_path = local_dir.join("versions.env");
  299. let raw = std::fs::read_to_string(&env_path).context(format!(
  300. "a version profile / [build.versions] override requires '{}' to exist",
  301. env_path.display()
  302. ))?;
  303. let mut pins = parse_versions_env(&raw);
  304. if let Some(name) = profile {
  305. let profile_path = local_dir.join("profiles").join(format!("{name}.env"));
  306. let raw = std::fs::read_to_string(&profile_path).with_context(|| {
  307. let available = std::fs::read_dir(local_dir.join("profiles"))
  308. .map(|d| {
  309. d.filter_map(|e| {
  310. e.ok()
  311. .filter(|e| e.path().extension().is_some_and(|x| x == "env"))
  312. .map(|e| e.file_name().to_string_lossy().into_owned())
  313. })
  314. .collect::<Vec<_>>()
  315. .join(", ")
  316. })
  317. .unwrap_or_default();
  318. format!(
  319. "build profile '{}' not found at {} (available: [{}])",
  320. name,
  321. profile_path.display(),
  322. available
  323. )
  324. })?;
  325. for (k, v) in parse_versions_env(&raw) {
  326. pins.insert(k, v);
  327. }
  328. }
  329. for (k, v) in overrides {
  330. pins.insert(k.clone(), v.clone());
  331. }
  332. let merged = serialize_versions_env(&pins);
  333. let mut h = Sha256::new();
  334. h.update(dir_checksum.as_bytes());
  335. h.update(b"\0");
  336. h.update(merged.as_bytes());
  337. let combined: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
  338. Ok((Some(merged), combined))
  339. }
  340. /// Where a recipe's image is built from.
  341. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  342. pub enum BuildSource {
  343. /// Full build context: `build.sh` + `Dockerfile` + versions.env.
  344. Workspace,
  345. /// Published bundle (flattened; `build.sh`/`Dockerfile` at bundle root).
  346. Bundle,
  347. }
  348. /// Resolve a recipe's image build dir. Search order:
  349. /// 1. `./docker-images/<dir>` (launcher workspace) — needs `build.sh`
  350. /// 2. each `docker_image_sources` entry `S`: `S/docker-images/<dir>`, then
  351. /// `S/<dir>` (a repo root containing `docker-images/`, or a dir of
  352. /// image dirs)
  353. /// 3. `./recipes/<name>/` then `./<name>/` carrying a bundled `build.sh`
  354. /// (published bundles from the recipe registry are self-contained)
  355. ///
  356. /// Errors name every path tried.
  357. pub fn resolve_build_dir(
  358. base: &Path,
  359. recipe_name: &str,
  360. image_dir: &str,
  361. sources: &[String],
  362. ) -> Result<(std::path::PathBuf, BuildSource)> {
  363. let mut tried: Vec<String> = Vec::new();
  364. let workspace = base.join("docker-images").join(image_dir);
  365. if workspace.join("build.sh").is_file() {
  366. return Ok((workspace, BuildSource::Workspace));
  367. }
  368. tried.push(workspace.to_string_lossy().into());
  369. for src in sources {
  370. let root = crate::config::local_path(src);
  371. for cand in [
  372. root.join("docker-images").join(image_dir),
  373. root.join(image_dir),
  374. ] {
  375. if cand.join("build.sh").is_file() {
  376. return Ok((cand, BuildSource::Workspace));
  377. }
  378. tried.push(cand.to_string_lossy().into());
  379. }
  380. }
  381. for bun in [
  382. base.join("recipes").join(recipe_name),
  383. base.join(recipe_name),
  384. ] {
  385. if bun.join("build.sh").is_file() && bun.join("Dockerfile").is_file() {
  386. return Ok((bun, BuildSource::Bundle));
  387. }
  388. tried.push(bun.to_string_lossy().into());
  389. }
  390. anyhow::bail!(
  391. "no build context for image dir '{}' of recipe '{}'; searched (each needs build.sh; bundles also need a Dockerfile): {}",
  392. image_dir,
  393. recipe_name,
  394. tried.join(", ")
  395. )
  396. }
  397. #[cfg(test)]
  398. mod tests {
  399. use super::*;
  400. #[test]
  401. fn parse_versions_env_quotes_are_normalized() {
  402. let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nE=\"half\nF='weird\"\n";
  403. let pins = parse_versions_env(raw);
  404. assert_eq!(pins.get("A"), Some(&"x:tag@sha256:abc".to_string()));
  405. assert_eq!(pins.get("B"), Some(&"y".to_string()));
  406. assert_eq!(pins.get("C"), Some(&"z".to_string()));
  407. assert_eq!(pins.get("E"), Some(&"\"half".to_string()));
  408. assert_eq!(pins.get("F"), Some(&"'weird\"".to_string()));
  409. }
  410. #[test]
  411. fn parse_versions_env_skips_comments_and_blanks() {
  412. let raw = "# comment\n\nKEY=val\nOTHER=a=b\n SPACED = x \nnoeq\n";
  413. let pins = parse_versions_env(raw);
  414. assert_eq!(pins.get("KEY"), Some(&"val".to_string()));
  415. assert_eq!(pins.get("OTHER"), Some(&"a=b".to_string()));
  416. assert_eq!(pins.get("SPACED"), Some(&"x".to_string()));
  417. assert!(!pins.contains_key("noeq"));
  418. assert_eq!(pins.len(), 3);
  419. }
  420. #[test]
  421. fn resolve_falls_back_to_bundle_layout() {
  422. let base = std::env::temp_dir().join(format!("sm121-resolve-test-{}", std::process::id()));
  423. let bundle = base.join("recipes").join("test-resolve-recipe-x");
  424. std::fs::create_dir_all(&bundle).unwrap();
  425. std::fs::write(bundle.join("build.sh"), "").unwrap();
  426. std::fs::write(bundle.join("Dockerfile"), "").unwrap();
  427. let (dir, src) =
  428. resolve_build_dir(&base, "test-resolve-recipe-x", "test-resolve-recipe-x", &[])
  429. .unwrap();
  430. assert_eq!(src, BuildSource::Bundle);
  431. assert_eq!(dir, bundle);
  432. let _ = std::fs::remove_dir_all(&base);
  433. }
  434. }