Pārlūkot izejas kodu

build works outside the launcher repo; bundles gain a build.sh

- versions.env resolution is quote-agnostic now (parse_versiones_env strips
  one level of matching quotes; quoted and unquoted pins checksum the same,
  so editors switching style do not trigger spurious rebuilds). Lab
  BASE_IMAGE re-pinned to its digest after an accidental unpin.
- docker_image_sources in [global]: extra dirs searched after
  ./docker-images (repo roots containing docker-images/ or dirs of image
  dirs), mirroring recipe_sources. sm121 build from any cwd resolves the
  image dir via resolve_build_dir: workspace paths, sources, then
  published-bundle fallback (recipes/<name>/ or ./<name>/ with bundled
  build.sh+Dockerfile). Errors list every path tried.
- sm121 publish now writes a minimal build.sh into every bundle (pins are
  flattened into the Dockerfile; launcher only needs the Done marker to
  stamp the checksum label) — bundles are self-buildable.
- load_recipe also resolves ./<name>/recipe.toml (flat bundle clones) and
  the not-found error names all searched paths; recipe_dirs includes ".".
- checksum/version-parsing machinery moved to docker/build_dir.rs so
  build.rs stays under the line gate.

This is the launcher half of making git.kilork.org/sm121/recipes usable
from outside the workspace; data repo skips the recipes/ nesting next.
kilork 1 mēnesi atpakaļ
vecāks
revīzija
267b9daae7

+ 3 - 1
docker-images/glm53-flash-nvfp4-lab/versions.env

@@ -1,4 +1,6 @@
 # Authoritative build inputs for the GLM-5.3-Flash NVFP4 (local-inference-lab)
 # SM121 image: day-0 vLLM image + the NoPE-MLA GB10 mod + the lab-checkpoint
 # patches (see Dockerfile and patches/UPSTREAM.md).
-BASE_IMAGE="vllm/vllm-openai:glm53-flash-arm64-cu130@sha256:905c02933be6021301db2dc284e24e3727467aa3a0f63b41d609885778a07bce"  # digest-pinned: tested base; tag is mutable
+# digest-pinned: tested base; tag is mutable (uncomment to float)
+#BASE_IMAGE="vllm/vllm-openai:glm53-flash-arm64-cu130"
+BASE_IMAGE="vllm/vllm-openai:glm53-flash-arm64-cu130@sha256:905c02933be6021301db2dc284e24e3727467aa3a0f63b41d609885778a07bce"

+ 1 - 0
src/after_stop.rs

@@ -177,6 +177,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             global_mods_dir: None,
         }

+ 1 - 0
src/before_serve.rs

@@ -228,6 +228,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
         }
     }

+ 5 - 0
src/cluster_state/tests.rs

@@ -210,6 +210,7 @@ fn select_smallest_with_enough_free() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     };
@@ -239,6 +240,7 @@ fn select_cluster_not_found_errors() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     };
@@ -272,6 +274,7 @@ fn no_cluster_has_enough_free_errors() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     };
@@ -301,6 +304,7 @@ fn single_node_allows_cluster_without_nccl() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     };
@@ -340,6 +344,7 @@ fn multi_node_requires_nccl() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     };

+ 29 - 16
src/config.rs

@@ -160,7 +160,7 @@ pub async fn merge_cluster_sources(config: &mut Config) -> Result<()> {
 /// Expand `~/...` against the local user's home (unlike `expand_tilde`,
 /// which targets the ssh user's home *on the node*). Absolute paths pass
 /// through.
-fn local_path(s: &str) -> std::path::PathBuf {
+pub fn local_path(s: &str) -> std::path::PathBuf {
     if let (Some(rest), Some(home)) = (s.strip_prefix("~/"), dirs::home_dir()) {
         return home.join(rest);
     }
@@ -286,10 +286,14 @@ pub async fn resolve_mods_dir(name: &str) -> Option<std::path::PathBuf> {
     cfg.is_dir().then_some(cfg)
 }
 
-/// All recipe dirs in search order: `./recipes`, each `recipe_sources`
-/// entry (`~` expanded), then the global config dir's `recipes/`.
+/// All recipe dirs in search order: `./recipes`, `.` (published-bundle
+/// clones are flat), each `recipe_sources` entry (`~` expanded), then the
+/// global config dir's `recipes/`.
 pub fn recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
-    let mut v = vec![std::path::PathBuf::from("recipes")];
+    let mut v = vec![
+        std::path::PathBuf::from("recipes"),
+        std::path::PathBuf::from("."),
+    ];
     for s in &config.recipe_sources {
         v.push(local_path(s));
     }
@@ -303,24 +307,33 @@ pub fn recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
 /// then each `sources` entry (a dir containing `<name>/recipe.toml`, `~`
 /// expanded), then `<default_config_dir>/recipes/<name>/recipe.toml`.
 pub async fn load_recipe(recipe_name: &str, sources: &[String]) -> Result<Recipe> {
-    let cwd_path = format!("recipes/{}/recipe.toml", recipe_name);
-    if let Ok(content) = tokio::fs::read_to_string(&cwd_path).await {
-        return Ok(toml::from_str(&content)?);
-    }
+    // Search order: `recipes/<name>` (launcher workspace), then `<name>`
+    // (published bundle layout — a clone of the recipe registry is flat),
+    // then each source, then the global config dir.
+    let mut paths: Vec<std::path::PathBuf> = vec![
+        std::path::PathBuf::from(format!("recipes/{}/recipe.toml", recipe_name)),
+        std::path::PathBuf::from(format!("{}/recipe.toml", recipe_name)),
+    ];
     for src in sources {
-        let p = local_path(src).join(recipe_name).join("recipe.toml");
-        if let Ok(content) = tokio::fs::read_to_string(&p).await {
-            return Ok(toml::from_str(&content)?);
-        }
+        paths.push(local_path(src).join(recipe_name).join("recipe.toml"));
     }
     if let Some(dir) = default_config_dir() {
-        let in_default = dir.join("recipes").join(recipe_name).join("recipe.toml");
-        if let Ok(content) = tokio::fs::read_to_string(&in_default).await {
+        paths.push(dir.join("recipes").join(recipe_name).join("recipe.toml"));
+    }
+    for p in &paths {
+        if let Ok(content) = tokio::fs::read_to_string(p).await {
             return Ok(toml::from_str(&content)?);
         }
     }
-    let content = tokio::fs::read_to_string(&cwd_path).await?;
-    Ok(toml::from_str(&content)?)
+    anyhow::bail!(
+        "recipe '{}' not found; searched: {}",
+        recipe_name,
+        paths
+            .iter()
+            .map(|p| p.to_string_lossy())
+            .collect::<Vec<_>>()
+            .join(", ")
+    )
 }
 
 pub fn default_config_dir() -> Option<std::path::PathBuf> {

+ 1 - 0
src/config/tests.rs

@@ -43,6 +43,7 @@ fn tcfg() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
     }
 }

+ 16 - 17
src/config/types.rs

@@ -25,9 +25,8 @@ pub struct Node {
 pub struct NcclConfig {
     pub ib_hca: String,
     pub socket_ifname: String,
-    /// RoCE GID index. `None` (absent in config) = auto-detect on each node
-    /// at serve time (`NCCL_IB_GID_INDEX` is resolved per node from the
-    /// RoCE-v2 IPv4 GID). `Some(n)` = pin explicitly (opt-out of detection).
+    /// RoCE GID index: absent = auto-detect per node (`NCCL_IB_GID_INDEX`
+    /// resolved from the RoCE-v2 IPv4 GID at serve time); Some(n) = pinned.
     #[serde(default)]
     pub ib_gid_index: Option<u32>,
     pub ib_disable: u32,
@@ -80,14 +79,18 @@ pub struct Config {
     /// Drop page cache before starting containers.
     #[serde(default = "default_drop_caches")]
     pub drop_caches: bool,
-    /// Mount-alias registry: recipe `[[extra_mounts]]` `alias` resolves here;
-    /// the alias wins over the recipe's `host` default. Keeps recipes portable.
+    /// Mount-alias registry: recipe `[[extra_mounts]]` `alias` resolves here; the
+    /// alias wins over the recipe's `host` default. Keeps recipes portable.
     #[serde(default)]
     pub mount_aliases: HashMap<String, String>,
-    /// Extra recipe dirs (each `<name>/recipe.toml`) searched after `./recipes`; `~` expanded.
+    /// Extra recipe dirs (each `<name>/recipe.toml`) searched after `./recipes` (`~` expanded).
     #[serde(default)]
     pub recipe_sources: Vec<String>,
-    /// Extra cluster def files (`[[clusters]]`, `[nccl.<name>]` tables); this file wins.
+    /// Extra image-build dirs searched after `./docker-images`: repo roots
+    /// (with `docker-images/`) or dirs of image dirs; `~` expanded.
+    #[serde(default)]
+    pub docker_image_sources: Vec<String>,
+    /// Extra cluster def files (`[[clusters]]`, `[nccl.<name>]`); this file wins.
     #[serde(default)]
     pub cluster_sources: Vec<String>,
 }
@@ -155,12 +158,10 @@ pub struct Recipe {
     pub shm_size: String,
     #[serde(default)]
     pub before_serve: Vec<PreServeCommand>,
-    /// Commands to run on each node **after** stopping containers
-    /// (`sm121 stop`). Same shape as [`before_serve`]: raw shell commands
-    /// (`Bare`/`Configured`) or built-in script aliases (`Script`). Runs
-    /// per-node in parallel, sequentially within a node. Typical use:
-    /// `after_stop = [{ script = "restore_dgx_services" }]` to bring back
-    /// services that `before_serve` stopped.
+    /// Commands run per node **after** stopping containers (`sm121 stop`);
+    /// same shape as [`before_serve`] (raw shell or `Script` aliases),
+    /// parallel across nodes. E.g. `after_stop = [{ script =
+    /// "restore_dgx_services" }]`.
     #[serde(default)]
     pub after_stop: Vec<PreServeCommand>,
     /// Whether this recipe should always be served single-node on the first
@@ -341,12 +342,10 @@ impl Config {
 ///   `huggingface-cli download <repo>` (the launcher never downloads).
 #[derive(Clone, Debug, Default, Deserialize)]
 pub struct ExtraMount {
-    /// Name resolved via `Config.mount_aliases`; overrides `host` when present.
-    /// Mutually exclusive with `repo`.
+    /// Name resolved via `Config.mount_aliases` (overrides `host`); else the
+    /// fallback `host` path; both mutually exclusive with `repo`.
     #[serde(default)]
     pub alias: Option<String>,
-    /// Fallback host path (used when the alias is not in `mount_aliases`).
-    /// Mutually exclusive with `repo`.
     #[serde(default)]
     pub host: Option<String>,
     /// HF Hub repo id, e.g. `aidendle94/GLM-5.2-Hybrid-FP8-MXFP4`. When set,

+ 1 - 0
src/detect.rs

@@ -156,6 +156,7 @@ mod tests {
             drop_caches: false,
             mount_aliases: Default::default(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
         };
         for dry in [false, true] {

+ 36 - 120
src/docker/build.rs

@@ -1,13 +1,11 @@
 use super::ServeOpts;
+use super::build_dir::{build_checksum, resolve_build_dir};
 use super::shq;
 use crate::config::{Cluster, Config, Node, Recipe};
 use crate::ssh::run_ssh;
-use anyhow::{Context, Result};
+use anyhow::Result;
 use base64::Engine;
 use base64::engine::general_purpose::STANDARD;
-use sha2::{Digest, Sha256};
-use std::collections::{BTreeMap, HashMap};
-use std::io::Read;
 use std::path::Path;
 use tracing::info;
 
@@ -16,112 +14,6 @@ use tracing::info;
 /// otherwise loop the 30s poll forever, hanging the launcher.
 pub const DEFAULT_MAX_BUILD_SECS: u64 = 90 * 60;
 
-/// Compute a SHA-256 checksum over all files in `dir` (recursive).
-/// Sorted by relative path to ensure reproducibility.
-pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
-    let mut hasher = Sha256::new();
-    let mut entries: Vec<_> = Vec::new();
-
-    for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
-        let entry = entry?;
-        if entry.file_type().is_dir() {
-            continue;
-        }
-        let rel = entry
-            .path()
-            .strip_prefix(dir)
-            .unwrap_or(entry.path())
-            .to_string_lossy()
-            .to_string();
-        entries.push((rel, entry.path().to_path_buf()));
-    }
-    entries.sort_by(|a, b| a.0.cmp(&b.0));
-
-    for (rel, path) in &entries {
-        let mut file = std::fs::File::open(path)?;
-        let mut buf = Vec::new();
-        file.read_to_end(&mut buf)?;
-        hasher.update(rel.as_bytes());
-        hasher.update(b"\0");
-        hasher.update(&buf);
-    }
-
-    let hash = hasher.finalize();
-    Ok(hash.iter().map(|b| format!("{:02x}", b)).collect())
-}
-
-/// Parse a `versions.env` file into a sorted map. Only `KEY=VAL` lines are
-/// captured; comments (`#`) and blank lines are dropped. The value is
-/// everything after the first `=` (so values may contain `=`).
-fn parse_versions_env(raw: &str) -> BTreeMap<String, String> {
-    let mut pins = BTreeMap::new();
-    for line in raw.lines() {
-        let line = line.trim();
-        if line.is_empty() || line.starts_with('#') {
-            continue;
-        }
-        if let Some((k, v)) = line.split_once('=') {
-            let k = k.trim();
-            if !k.is_empty() {
-                pins.insert(k.to_string(), v.trim().to_string());
-            }
-        }
-    }
-    pins
-}
-
-/// Serialize a pin map back to `versions.env` text (sorted by key; one
-/// `KEY=VAL` per line). Comments are not preserved — this is a generated
-/// artifact written to the remote build dir, not the source of truth.
-fn serialize_versions_env(pins: &BTreeMap<String, String>) -> String {
-    let mut out = String::from(
-        "# Merged versions.env \u{2014} generated by sm121 (recipe [build.versions] overrides applied)\n",
-    );
-    for (k, v) in pins {
-        out.push_str(k);
-        out.push('=');
-        out.push_str(v);
-        out.push('\n');
-    }
-    out
-}
-
-/// Compute the build checksum, folding in recipe `[build.versions]` overrides.
-///
-/// Returns `(merged_versions_env, checksum)`:
-/// - No overrides -> `(None, dir_checksum)`: the image's `versions.env` is
-///   uploaded as-is and the checksum is unchanged (existing images skip
-///   rebuild, backward-compatible).
-/// - Overrides present -> `(Some(merged), combined)`: `merged` is the image's
-///   `versions.env` with recipe pins applied (recipe wins) for the launcher
-///   to write to the remote build dir; `combined` hashes the dir checksum
-///   together with the merged env so any override change forces a rebuild.
-pub(crate) fn build_checksum(
-    local_dir: &Path,
-    overrides: &HashMap<String, String>,
-) -> Result<(Option<String>, String)> {
-    let dir_checksum = compute_dir_checksum(local_dir)?;
-    if overrides.is_empty() {
-        return Ok((None, dir_checksum));
-    }
-    let env_path = local_dir.join("versions.env");
-    let raw = std::fs::read_to_string(&env_path).context(format!(
-        "recipe carries [build.versions] overrides but '{}' has no versions.env",
-        env_path.display()
-    ))?;
-    let mut pins = parse_versions_env(&raw);
-    for (k, v) in overrides {
-        pins.insert(k.clone(), v.clone());
-    }
-    let merged = serialize_versions_env(&pins);
-    let mut h = Sha256::new();
-    h.update(dir_checksum.as_bytes());
-    h.update(b"\0");
-    h.update(merged.as_bytes());
-    let combined: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
-    Ok((Some(merged), combined))
-}
-
 /// Start a Docker image build on a remote node in a tmux session.
 /// After the build completes, stamps the checksum label on the image.
 pub async fn build_image_on_node(
@@ -297,6 +189,7 @@ pub async fn build_image_on_node(
 /// the build was skipped (checksum match) or it was a dry-run. Callers use the
 /// return value to treat the freshly built image as the authoritative sync
 /// source instead of re-running the "most recently created" heuristic.
+/// Where a recipe's image is built from.
 pub async fn handle_build(
     config: &Config,
     cluster: &Cluster,
@@ -307,6 +200,14 @@ pub async fn handle_build(
 ) -> Result<bool> {
     if opts.dry_run {
         let dir = recipe.docker_image_dir.as_deref().unwrap_or(recipe_name);
+        let review = resolve_build_dir(
+            Path::new("."),
+            recipe_name,
+            dir,
+            &config.docker_image_sources,
+        )
+        .map(|(p, s)| format!("{} [{:?}]", p.display(), s))
+        .unwrap_or_else(|e| e.to_string());
         let overrides = if recipe.build.versions.is_empty() {
             String::new()
         } else {
@@ -316,8 +217,8 @@ pub async fn handle_build(
             )
         };
         println!(
-            "[dry-run] would build '{}' on '{}' from docker-images/{}/build.sh{}",
-            recipe.docker_image, build_node_name, dir, overrides
+            "[dry-run] would build '{}' on '{}' from {}{}",
+            recipe.docker_image, build_node_name, review, overrides
         );
         return Ok(false);
     }
@@ -325,13 +226,12 @@ pub async fn handle_build(
         .docker_image_dir
         .clone()
         .unwrap_or_else(|| recipe_name.to_string());
-    let local_dir = std::path::Path::new("docker-images").join(&image_dir);
-    if !local_dir.join("build.sh").exists() {
-        anyhow::bail!(
-            "No build system at '{}' (missing build.sh)",
-            local_dir.display()
-        );
-    }
+    let (local_dir, _source) = resolve_build_dir(
+        Path::new("."),
+        recipe_name,
+        &image_dir,
+        &config.docker_image_sources,
+    )?;
     let build_node = cluster
         .nodes
         .iter()
@@ -412,7 +312,8 @@ fn format_elapsed(d: std::time::Duration) -> String {
 
 #[cfg(test)]
 mod tests {
-    use super::{build_checksum, format_elapsed, parse_versions_env, serialize_versions_env};
+    use super::format_elapsed;
+    use crate::docker::build_dir::{build_checksum, parse_versions_env, serialize_versions_env};
     use std::collections::HashMap;
     use std::fs;
     use std::path::PathBuf;
@@ -447,6 +348,21 @@ mod tests {
         assert_eq!(pins.len(), 3);
     }
 
+    #[test]
+    fn parse_versions_env_quotes_are_normalized() {
+        // quoted / unquoted / single-quoted forms of the same value must
+        // resolve identically (stable checksums across edits); inner quotes
+        // and non-matching outer quotes stay verbatim.
+        let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nD=\"m@n\"'\"op\"'\nE=\"half\nF='weird\"\n";
+        let pins = parse_versions_env(raw);
+        assert_eq!(pins.get("A"), Some(&"x:tag@sha256:abc".to_string()));
+        assert_eq!(pins.get("B"), Some(&"y".to_string()));
+        assert_eq!(pins.get("C"), Some(&"z".to_string()));
+        assert_eq!(pins.get("D"), Some(&"\"m@n\"'\"op\"'".to_string())); // mismatched outer quotes stay
+        assert_eq!(pins.get("E"), Some(&"\"half".to_string()));
+        assert_eq!(pins.get("F"), Some(&"'weird\"".to_string()));
+    }
+
     #[test]
     fn serialize_versions_env_sorted_one_per_line() {
         let mut pins = std::collections::BTreeMap::new();

+ 236 - 0
src/docker/build_dir.rs

@@ -0,0 +1,236 @@
+//! Image build-dir resolution and reproducible checksums.
+//!
+//! `sm121 build` has to work from anywhere: a launcher workspace
+//! (`docker-images/<dir>` + `docker_image_sources`), or a published
+//! bundle clone (git.kilork.org/sm121/recipes, flat `<name>/` layout).
+//! The checksum over the build dir is what gets stamped as the
+//! `sm121.checksum` label and compared by `docker/stale.rs`.
+
+use anyhow::{Context, Result};
+use sha2::{Digest, Sha256};
+use std::collections::{BTreeMap, HashMap};
+use std::io::Read;
+use std::path::Path;
+
+/// Compute a SHA-256 checksum over all files in `dir` (recursive).
+/// Sorted by relative path to ensure reproducibility.
+pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
+    let mut hasher = Sha256::new();
+    let mut entries: Vec<_> = Vec::new();
+
+    for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
+        let entry = entry?;
+        if entry.file_type().is_dir() {
+            continue;
+        }
+        let rel = entry
+            .path()
+            .strip_prefix(dir)
+            .unwrap_or(entry.path())
+            .to_string_lossy()
+            .to_string();
+        entries.push((rel, entry.path().to_path_buf()));
+    }
+    entries.sort_by(|a, b| a.0.cmp(&b.0));
+
+    for (rel, path) in &entries {
+        let mut file = std::fs::File::open(path)?;
+        let mut buf = Vec::new();
+        file.read_to_end(&mut buf)?;
+        hasher.update(rel.as_bytes());
+        hasher.update(b"\0");
+        hasher.update(&buf);
+    }
+
+    let hash = hasher.finalize();
+    Ok(hash.iter().map(|b| format!("{:02x}", b)).collect())
+}
+
+/// Parse a `versions.env` file into a sorted map. Only `KEY=VAL` lines are
+/// captured; comments (`#`) and blank lines are dropped. The value is
+/// everything after the first `=` (so values may contain `=`), with one
+/// level of matching `"` or `'` quotes stripped — quoted and unquoted forms
+/// of the same value must resolve (and checksum) identically.
+pub fn parse_versions_env(raw: &str) -> BTreeMap<String, String> {
+    let mut pins = BTreeMap::new();
+    for line in raw.lines() {
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+        if let Some((k, v)) = line.split_once('=') {
+            let k = k.trim();
+            if !k.is_empty() {
+                pins.insert(k.to_string(), unquote(v.trim()));
+            }
+        }
+    }
+    pins
+}
+
+/// Strip one level of matching single/double quotes (build.sh does the
+/// same; keeping the two parsers in agreement keeps checksums stable).
+fn unquote(v: &str) -> String {
+    let b = v.as_bytes();
+    let quoted = b.len() >= 2
+        && (b[0] == b'"' && *b.last().unwrap() == b'"'
+            || b[0] == b'\'' && *b.last().unwrap() == b'\'');
+    if quoted {
+        v[1..v.len() - 1].to_string()
+    } else {
+        v.to_string()
+    }
+}
+
+/// Serialize a pin map back to `versions.env` text (sorted by key; one
+/// `KEY=VAL` per line). Comments are not preserved — this is a generated
+/// artifact written to the remote build dir, not the source of truth.
+pub fn serialize_versions_env(pins: &BTreeMap<String, String>) -> String {
+    let mut out = String::from(
+        "# Merged versions.env — generated by sm121 (recipe [build.versions] overrides applied)\n",
+    );
+    for (k, v) in pins {
+        out.push_str(k);
+        out.push('=');
+        out.push_str(v);
+        out.push('\n');
+    }
+    out
+}
+
+/// Compute the build checksum, folding in recipe `[build.versions]` overrides.
+///
+/// Returns `(merged_versions_env, checksum)`:
+/// - No overrides -> `(None, dir_checksum)`: the image's `versions.env` is
+///   uploaded as-is and the checksum is unchanged (existing images skip
+///   rebuild, backward-compatible).
+/// - Overrides present -> `(Some(merged), combined)`: `merged` is the image's
+///   `versions.env` with recipe pins applied (recipe wins) for the launcher
+///   to write to the remote build dir; `combined` hashes the dir checksum
+///   together with the merged env so any override change forces a rebuild.
+pub(crate) fn build_checksum(
+    local_dir: &Path,
+    overrides: &HashMap<String, String>,
+) -> Result<(Option<String>, String)> {
+    let dir_checksum = compute_dir_checksum(local_dir)?;
+    if overrides.is_empty() {
+        return Ok((None, dir_checksum));
+    }
+    let env_path = local_dir.join("versions.env");
+    let raw = std::fs::read_to_string(&env_path).context(format!(
+        "recipe carries [build.versions] overrides but '{}' has no versions.env",
+        env_path.display()
+    ))?;
+    let mut pins = parse_versions_env(&raw);
+    for (k, v) in overrides {
+        pins.insert(k.clone(), v.clone());
+    }
+    let merged = serialize_versions_env(&pins);
+    let mut h = Sha256::new();
+    h.update(dir_checksum.as_bytes());
+    h.update(b"\0");
+    h.update(merged.as_bytes());
+    let combined: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
+    Ok((Some(merged), combined))
+}
+
+/// Where a recipe's image is built from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BuildSource {
+    /// Full build context: `build.sh` + `Dockerfile` + versions.env.
+    Workspace,
+    /// Published bundle (flattened; `build.sh`/`Dockerfile` at bundle root).
+    Bundle,
+}
+
+/// Resolve a recipe's image build dir. Search order:
+/// 1. `./docker-images/<dir>` (launcher workspace) — needs `build.sh`
+/// 2. each `docker_image_sources` entry `S`: `S/docker-images/<dir>`, then
+///    `S/<dir>` (a repo root containing `docker-images/`, or a dir of
+///    image dirs)
+/// 3. `./recipes/<name>/` then `./<name>/` carrying a bundled `build.sh`
+///    (published bundles from the recipe registry are self-contained)
+///
+/// Errors name every path tried.
+pub fn resolve_build_dir(
+    base: &Path,
+    recipe_name: &str,
+    image_dir: &str,
+    sources: &[String],
+) -> Result<(std::path::PathBuf, BuildSource)> {
+    let mut tried: Vec<String> = Vec::new();
+    let workspace = base.join("docker-images").join(image_dir);
+    if workspace.join("build.sh").is_file() {
+        return Ok((workspace, BuildSource::Workspace));
+    }
+    tried.push(workspace.to_string_lossy().into());
+    for src in sources {
+        let root = crate::config::local_path(src);
+        for cand in [
+            root.join("docker-images").join(image_dir),
+            root.join(image_dir),
+        ] {
+            if cand.join("build.sh").is_file() {
+                return Ok((cand, BuildSource::Workspace));
+            }
+            tried.push(cand.to_string_lossy().into());
+        }
+    }
+    for bun in [
+        base.join("recipes").join(recipe_name),
+        base.join(recipe_name),
+    ] {
+        if bun.join("build.sh").is_file() && bun.join("Dockerfile").is_file() {
+            return Ok((bun, BuildSource::Bundle));
+        }
+        tried.push(bun.to_string_lossy().into());
+    }
+    anyhow::bail!(
+        "no build context for image dir '{}' of recipe '{}'; searched (each needs build.sh; bundles also need a Dockerfile): {}",
+        image_dir,
+        recipe_name,
+        tried.join(", ")
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_versions_env_quotes_are_normalized() {
+        let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nE=\"half\nF='weird\"\n";
+        let pins = parse_versions_env(raw);
+        assert_eq!(pins.get("A"), Some(&"x:tag@sha256:abc".to_string()));
+        assert_eq!(pins.get("B"), Some(&"y".to_string()));
+        assert_eq!(pins.get("C"), Some(&"z".to_string()));
+        assert_eq!(pins.get("E"), Some(&"\"half".to_string()));
+        assert_eq!(pins.get("F"), Some(&"'weird\"".to_string()));
+    }
+
+    #[test]
+    fn parse_versions_env_skips_comments_and_blanks() {
+        let raw = "# comment\n\nKEY=val\nOTHER=a=b\n  SPACED  =  x  \nnoeq\n";
+        let pins = parse_versions_env(raw);
+        assert_eq!(pins.get("KEY"), Some(&"val".to_string()));
+        assert_eq!(pins.get("OTHER"), Some(&"a=b".to_string()));
+        assert_eq!(pins.get("SPACED"), Some(&"x".to_string()));
+        assert!(!pins.contains_key("noeq"));
+        assert_eq!(pins.len(), 3);
+    }
+
+    #[test]
+    fn resolve_falls_back_to_bundle_layout() {
+        let base = std::env::temp_dir().join(format!("sm121-resolve-test-{}", std::process::id()));
+        let bundle = base.join("recipes").join("test-resolve-recipe-x");
+        std::fs::create_dir_all(&bundle).unwrap();
+        std::fs::write(bundle.join("build.sh"), "").unwrap();
+        std::fs::write(bundle.join("Dockerfile"), "").unwrap();
+        let (dir, src) =
+            resolve_build_dir(&base, "test-resolve-recipe-x", "test-resolve-recipe-x", &[])
+                .unwrap();
+        assert_eq!(src, BuildSource::Bundle);
+        assert_eq!(dir, bundle);
+        let _ = std::fs::remove_dir_all(&base);
+    }
+}

+ 1 - 0
src/docker/command_tests.rs

@@ -50,6 +50,7 @@ fn minimal_config() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
     }
 }

+ 1 - 0
src/docker/mirror.rs

@@ -162,6 +162,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
         }
     }

+ 4 - 4
src/docker/mod.rs

@@ -9,6 +9,7 @@ pub mod mirror;
 pub mod mods;
 pub(crate) mod path_ref;
 pub use logs::show_logs;
+pub mod build_dir;
 pub mod stale;
 pub mod sync;
 pub mod tail;
@@ -46,10 +47,9 @@ pub struct ServeOpts {
     pub fit: Option<String>,
     pub clear: bool,
 }
-/// Shell-quote a string for safe interpolation into a remote `sh -c`
-/// command (single-quote wrapping, embedded quotes escaped). Re-exported
-/// here so docker submodules can `use super::shq;`; the impl lives in
-/// [`crate::ssh`] (the lower-level shell layer).
+/// Shell-quote for remote `sh -c` (single-quote wrapper, escapes quotes).
+/// Re-exported so docker submodules can `use super::shq;`; the impl lives
+/// in [`crate::ssh`].
 pub(crate) use crate::ssh::shq;
 pub async fn serve(
     config: &Config,

+ 9 - 5
src/docker/stale.rs

@@ -9,7 +9,7 @@
 //! mismatch (non-blocking — the operator may intentionally serve a known
 //! image). A stale image is still started; this only surfaces the drift.
 
-use super::build::build_checksum;
+use super::build_dir::build_checksum;
 use super::shq;
 use crate::config::{Cluster, Config, Recipe};
 use crate::ssh::run_ssh;
@@ -30,10 +30,14 @@ pub(crate) async fn warn_if_stale(
     let Some(dir) = &recipe.docker_image_dir else {
         return Ok(());
     };
-    let local = Path::new("docker-images").join(dir);
-    if !local.join("build.sh").exists() {
-        return Ok(());
-    }
+    let Ok((local, _source)) = super::build_dir::resolve_build_dir(
+        Path::new("."),
+        &recipe.name,
+        dir,
+        &config.docker_image_sources,
+    ) else {
+        return Ok(()); // no build context visible from here — nothing to compare
+    };
     let (_merged, expected) = build_checksum(&local, &recipe.build.versions)?;
     let head = cluster.nodes.first().context("No nodes in cluster")?;
     let label = run_ssh(

+ 1 - 0
src/exec.rs

@@ -171,6 +171,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             global_mods_dir: None,
         }

+ 1 - 0
src/orchestrate/tests.rs

@@ -90,6 +90,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         global_mods_dir: None,
     }

+ 7 - 0
src/publish.rs

@@ -93,6 +93,13 @@ pub async fn publish(
         revision.as_deref(),
     );
     std::fs::write(bundle.join("run.sh"), run)?;
+    // Bundle build driver: launcher stamps the label, needs `Done: <tag>`.
+    std::fs::write(
+        bundle.join("build.sh"),
+        "#!/usr/bin/env bash\nset -euo pipefail; cd \"$(dirname \"$0\")\"\n\
+         TAG=\"${TAG:?set TAG=<recipe docker_image>}\"\nDOCKER_BUILDKIT=1 docker build -t \"$TAG\" -f Dockerfile . 2>&1\n\
+         echo \"=== Done: ${TAG} ===\"\n",
+    )?;
     let readme = render_readme(
         recipe_name,
         &recipe,

+ 1 - 0
src/web/events.rs

@@ -213,6 +213,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),
             recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
         }
     }

+ 1 - 0
src/web/tests.rs

@@ -44,6 +44,7 @@ pub(crate) fn fake_config() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
     }
 }