Explorar el Código

fix: publish — merge build profile pins, flatten all COPY-referenced dirs, gist via gh api

Alexander Korolev hace 1 mes
padre
commit
40f9e96bef
Se han modificado 4 ficheros con 570 adiciones y 335 borrados
  1. 167 26
      src/docker/build_dir.rs
  2. 1 1
      src/main.rs
  3. 139 308
      src/publish.rs
  4. 263 0
      src/publish_render.rs

+ 167 - 26
src/docker/build_dir.rs

@@ -10,41 +10,182 @@ use anyhow::{Context, Result};
 use sha2::{Digest, Sha256};
 use std::collections::{BTreeMap, HashMap};
 use std::io::Read;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 
 /// Compute a SHA-256 checksum over all files in `dir` (recursive).
 /// Sorted by relative path to ensure reproducibility.
-/// Copy every file under `src_dir` into `dst`: `patches/*` and `patches/`-like
-/// subtrees are flattened to `dst` root (gists cannot carry directories), other
-/// sub-directories are preserved with their relative path. Returns `true` if
-/// any non-`patches` sub-directory was preserved (callers refuse the gist flow
-/// for such overlays).
-pub fn copy_flat(src_dir: &Path, dst: &Path) -> Result<bool> {
-    let mut nested = false;
+/// Flatten an image overlay into `dst` and rewrite its Dockerfile:
+/// the `patches/` subtree plus every sub-directory the Dockerfile
+/// COPY/ADD-references is flattened to the bundle root (gists cannot
+/// carry directories).  Name collisions rename the flattened file to
+/// `{dir}-{name}` and the COPY line is rewritten to the new name; a
+/// collision under a glob source aborts the flatten for that directory
+/// (it stays nested, `nested = true` — the gist flow refuses it).
+/// Unreferenced sub-directories are always preserved nested.
+pub fn flatten_overlay(src_dir: &Path, dst: &Path, dockerfile: &str) -> Result<(String, bool)> {
+    use std::collections::BTreeSet;
+
+    // 1) Sub-directories referenced by COPY/ADD sources (incl. continuations).
+    let mut referenced: BTreeSet<String> = BTreeSet::new();
+    let mut in_copy = false;
+    for line in dockerfile.lines() {
+        let t = line.trim_start();
+        let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
+        in_copy = is_copy && t.ends_with('\\');
+        if !is_copy {
+            continue;
+        }
+        let body = t.trim_end_matches('\\');
+        let toks: Vec<&str> = body.split_whitespace().skip(1).collect();
+        for tok in &toks[..toks.len().saturating_sub(1)] {
+            if tok.starts_with('-') {
+                continue; // --from=… etc.
+            }
+            let parts: Vec<&str> = tok.split('/').collect();
+            // parts[0].is_empty() => absolute path (/tmp/…) — not a build-dir ref
+            if parts.len() >= 2 && !parts[0].is_empty() && src_dir.join(parts[0]).is_dir() {
+                referenced.insert(parts[0].to_string());
+            }
+        }
+    }
+    let flatten_dirs: BTreeSet<String> = referenced
+        .iter()
+        .cloned()
+        .chain(std::iter::once("patches".to_string()))
+        .collect();
+
+    // 2) Root files first (they define the collision set), except the driver.
     for entry in std::fs::read_dir(src_dir)? {
         let entry = entry?;
-        let ft = entry.file_type()?;
-        let name = entry.file_name().to_string_lossy().to_string();
-        if ft.is_dir() {
-            if name == "patches" {
-                for f in std::fs::read_dir(entry.path())? {
-                    let f = f?;
-                    if f.file_type()?.is_file() {
-                        std::fs::copy(f.path(), dst.join(f.file_name()))
-                            .with_context(|| "copying patch")?;
-                    }
+        if entry.file_type()?.is_file() {
+            let name = entry.file_name().to_string_lossy().to_string();
+            if name != "build.sh" && name != "Dockerfile" {
+                std::fs::copy(entry.path(), dst.join(&name))?;
+            }
+        }
+    }
+
+    // 3) Flatten the referenced dirs (+ patches/); everything else nests.
+    let mut nested = false;
+    let mut flattened: BTreeSet<String> = BTreeSet::new(); // dirs actually flattened
+    let mut renames: BTreeMap<String, String> = BTreeMap::new(); // "dir/file" -> new name
+    let mut globs: BTreeSet<String> = BTreeSet::new(); // dirs whose COPY source is a glob
+    for line in dockerfile.lines() {
+        let t = line.trim_start();
+        if t.starts_with("COPY ") || t.starts_with("ADD ") {
+            for tok in t.split_whitespace().skip(1) {
+                if tok.starts_with('-') {
+                    continue;
+                }
+                let parts: Vec<&str> = tok.split('/').collect();
+                if parts.len() >= 2 && flatten_dirs.contains(parts[0]) && tok.contains('*') {
+                    globs.insert(parts[0].to_string());
                 }
-            } else {
-                nested = true;
-                let sub = dst.join(&name);
-                std::fs::create_dir_all(&sub)?;
-                copy_tree(&entry.path(), &sub)?;
             }
-        } else if ft.is_file() && name != "build.sh" && name != "Dockerfile" {
-            std::fs::copy(entry.path(), dst.join(&name))?;
         }
     }
-    Ok(nested)
+    for dir in flatten_dirs.iter() {
+        let dir_path = src_dir.join(dir);
+        if !dir_path.is_dir() {
+            continue; // referenced but absent — COPY will fail in docker, not here
+        }
+        let files: Vec<PathBuf> = std::fs::read_dir(&dir_path)?
+            .filter_map(|e| e.ok())
+            .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
+            .map(|e| e.path())
+            .collect();
+        let collides = files
+            .iter()
+            .any(|f| dst.join(f.file_name().unwrap()).exists());
+        if collides && globs.contains(dir) {
+            nested = true;
+            std::fs::create_dir_all(dst.join(dir))?;
+            copy_tree(&dir_path, &dst.join(dir))?; // rename under a glob is impossible
+            continue;
+        }
+        for f in files {
+            let name = f.file_name().unwrap().to_string_lossy().to_string();
+            let key = format!("{dir}/{name}");
+            let new = if dst.join(&name).exists() {
+                let renamed = format!("{dir}-{name}");
+                renames.insert(key, renamed.clone());
+                renamed
+            } else {
+                name
+            };
+            std::fs::copy(f, dst.join(&new))?;
+        }
+        flattened.insert(dir.clone());
+    }
+    for entry in std::fs::read_dir(src_dir)? {
+        let entry = entry?;
+        let name = entry.file_name().to_string_lossy().to_string();
+        if !entry.file_type()?.is_dir() || flatten_dirs.contains(&name) {
+            continue;
+        }
+        // profiles/ is launcher metadata — its pins are already merged into
+        // the bundle's versions.env, so it is not re-shipped.
+        if name == "profiles" {
+            continue;
+        }
+        nested = true;
+        std::fs::create_dir_all(dst.join(&name))?;
+        copy_tree(&entry.path(), &dst.join(&name))?;
+    }
+
+    // 4) Rewrite COPY/ADD sources for the flattened dirs (incl. continuations).
+    let mut in_copy = false;
+    let mut dst_done = false; // destination token already seen on an earlier line
+    let rewritten = dockerfile
+        .lines()
+        .map(|line| {
+            let t = line.trim_start();
+            let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
+            in_copy = is_copy && t.ends_with('\\');
+            if !is_copy {
+                return line.to_string();
+            }
+            let head = &line[..line.len() - t.len()]; // preserve indentation
+            let is_first = t.starts_with("COPY ") || t.starts_with("ADD ");
+            let toks: Vec<&str> = t.split_whitespace().collect();
+            let n = toks.len();
+            if is_first {
+                // command + at least one src + a dest on this line -> all
+                // continuation tokens are sources
+                dst_done = toks[1..]
+                    .iter()
+                    .filter(|t| **t != "\\" && !t.starts_with('-'))
+                    .count()
+                    >= 2;
+            }
+            let rewritten_toks: Vec<String> = toks
+                .iter()
+                .enumerate()
+                .map(|(i, tok)| {
+                    let is_dest = !is_first && !dst_done && i == n.saturating_sub(1);
+                    if (is_first && i == 0) || is_dest || tok.starts_with('-') {
+                        return tok.to_string();
+                    }
+                    let parts: Vec<&str> = tok.split('/').collect();
+                    if parts.len() < 2 || !flattened.contains(parts[0]) {
+                        return tok.to_string();
+                    }
+                    if tok.contains('*') {
+                        return parts[1..].join("/"); // glob: drop the dir prefix
+                    }
+                    let key = (*tok).to_string();
+                    renames
+                        .get(&key)
+                        .cloned()
+                        .unwrap_or_else(|| parts[1].to_string())
+                })
+                .collect();
+            format!("{}{}", head, rewritten_toks.join(" "))
+        })
+        .collect::<Vec<_>>()
+        .join("\n")
+        + if dockerfile.ends_with('\n') { "\n" } else { "" };
+    Ok((rewritten, nested))
 }
 
 fn copy_tree(src_dir: &Path, dst_dir: &Path) -> Result<()> {

+ 1 - 1
src/main.rs

@@ -30,6 +30,7 @@ mod orchestrate;
 mod power;
 mod pre_serve;
 mod publish;
+mod publish_render;
 mod readiness;
 mod registry;
 mod report;
@@ -38,7 +39,6 @@ mod ssh;
 mod stamp;
 mod tmux;
 mod web;
-
 use crate::cli::{Cli, Commands, OutputFormat, PowerAction};
 use crate::config::{
     Cluster, Config, DeployTarget, load_recipe, resolve_cluster, select_cluster_by_name,

+ 139 - 308
src/publish.rs

@@ -7,15 +7,13 @@
 //! directories); other sub-directories are preserved and `--gist` is refused
 //! for such overlays.
 
+use anyhow::{Context, Result, bail};
 use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
-use std::process::Command;
-
-use anyhow::{Context, Result, bail};
 use tracing::info;
 
-use crate::config::{Cluster, Config, NcclConfig, Recipe, recipe_dirs};
-use crate::docker::build_dir::copy_flat;
+use crate::config::{Config, recipe_dirs};
+use crate::docker::build_dir::{build_checksum, flatten_overlay};
 use crate::ssh::shq;
 
 /// Publish `recipe_name` into `out` (default `./publish/<name>`), optionally
@@ -49,27 +47,67 @@ pub async fn publish(
     std::fs::copy(&recipe_src, bundle.join("recipe.toml"))?;
 
     let mut versions: BTreeMap<String, String> = BTreeMap::new();
+    let profile_used = recipe.build_profile.clone().is_some_and(|p| !p.is_empty())
+        || !recipe.build.versions.is_empty();
     let image_dir =
         Path::new("docker-images").join(recipe.docker_image_dir.as_deref().unwrap_or(recipe_name));
     if image_dir.join("Dockerfile").exists() {
         let df = std::fs::read_to_string(image_dir.join("Dockerfile"))?;
-        std::fs::write(bundle.join("Dockerfile"), flatten_dockerfile(&df))?;
-        let nested = copy_flat(&image_dir, &bundle)?;
+        let (df_flat, nested) = flatten_overlay(&image_dir, &bundle, &df)?;
+        std::fs::write(bundle.join("Dockerfile"), &df_flat)?;
         if gist && nested {
             bail!(
-                "publish --gist needs a flat overlay ({image_dir:?} nests non-patches dirs) — use --out/--registry instead"
+                "publish --gist needs a flat overlay ({image_dir:?} nests non-flattenable dirs) — use --out/--registry instead"
             );
         }
-        if let Ok(v) = std::fs::read_to_string(image_dir.join("versions.env")) {
+        // Merge the profile + recipe [build.versions] pins the same way the
+        // build path does, so the bundle builds the image that was served.
+        let (merged_env, _) = build_checksum(
+            &image_dir,
+            recipe.build_profile.as_deref(),
+            &recipe.build.versions,
+        )?;
+        if let Some(merged) = merged_env {
+            std::fs::write(bundle.join("versions.env"), &merged)?;
+            versions = versions_build_args(&merged);
+            info!(
+                "📦 bundled image overlay {} with merged pin set (profile: {})",
+                image_dir.display(),
+                recipe
+                    .build_profile
+                    .as_deref()
+                    .unwrap_or("— recipe [build.versions] only")
+            );
+        } else if let Ok(v) = std::fs::read_to_string(image_dir.join("versions.env")) {
             std::fs::write(bundle.join("versions.env"), &v)?;
             versions = versions_build_args(&v);
+            info!("📦 bundled image overlay {}", image_dir.display());
+        }
+        // Bundle the workspace's generic build driver (forwards every pin as
+        // --build-arg, prints the `Done: <tag>` marker the launcher labels
+        // with) when one exists; otherwise a minimal driver.
+        let ws_build = image_dir.join("build.sh");
+        if let Ok(bs) = std::fs::read_to_string(&ws_build) {
+            std::fs::write(bundle.join("build.sh"), bs)?;
+        } else {
+            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",
+            )?;
         }
-        info!("📦 bundled image overlay {}", image_dir.display());
     } else {
         info!(
             "ℹ️  no docker-images/{} overlay — bundling recipe + scripts only",
             image_dir.display()
         );
+        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 cluster = match cluster {
@@ -83,7 +121,7 @@ pub async fn publish(
         None => None,
     };
     let nccl = cluster.map(|c| config.nccl.get(&c.name));
-    let revision = hf_revision(&recipe.model).await;
+    let revision = crate::publish_render::hf_revision(&recipe.model).await;
     let hf_cache = config
         .mount_aliases
         .get("hf_cache")
@@ -91,7 +129,7 @@ pub async fn publish(
         .or_else(|| config.hf_cache_dir.clone())
         .unwrap_or_else(|| "~/.cache/huggingface".into());
 
-    let run = render_run_script(
+    let run = crate::publish_render::render_run_script(
         &recipe,
         cluster,
         nccl.flatten(),
@@ -100,19 +138,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(
+    let readme = crate::publish_render::render_readme(
         recipe_name,
         &recipe,
         &versions,
         revision.as_deref(),
         image_dir.exists(),
+        profile_used,
     );
     std::fs::write(bundle.join("README.md"), readme)?;
     info!("📝 bundle written to {}", bundle.display());
@@ -121,7 +153,12 @@ pub async fn publish(
     // branch only patches the returned id into it afterwards.
     std::fs::write(
         bundle.join("install.sh"),
-        render_install_sh(recipe_name, &recipe.docker_image, &versions, None),
+        crate::publish_render::render_install_sh(
+            recipe_name,
+            &recipe.docker_image,
+            &versions,
+            None,
+        ),
     )?;
 
     if let Some(url) = registry {
@@ -129,7 +166,7 @@ pub async fn publish(
     }
 
     if gist {
-        let id = publish_gist(&bundle, &recipe.name)?;
+        let id = crate::publish_render::publish_gist(&bundle, &recipe.name)?;
         // The gist knows its own id now — bake it into install.sh and push the
         // update, so `curl …/raw/install.sh | bash` self-bootstraps from anywhere.
         let raw = std::fs::read_to_string(bundle.join("install.sh"))?.replacen(
@@ -138,37 +175,27 @@ pub async fn publish(
             1,
         );
         std::fs::write(bundle.join("install.sh"), &raw)?;
-        match Command::new("gh")
-            .args(["gist", "edit", &id])
-            .arg(bundle.join("install.sh"))
-            .output()
-        {
-            Ok(o) if o.status.success() => info!("🔗 install.sh updated with gist id"),
+        // Push the updated install.sh back to the gist (gh gist edit is
+        // interactive-only — use the API):
+        let body = serde_json::json!({ "files": { "install.sh": { "content": raw } } }).to_string();
+        let pushed = std::process::Command::new("gh")
+            .args(["api", "-X", "POST", &format!("gists/{id}"), "--input", "-"])
+            .stdin(std::process::Stdio::piped())
+            .spawn()
+            .and_then(|mut child| {
+                if let Some(mut stdin) = child.stdin.take() {
+                    use std::io::Write;
+                    stdin.write_all(body.as_bytes())?;
+                }
+                child.wait()
+            });
+        match pushed {
+            Ok(s) if s.success() => info!("🔗 install.sh updated with gist id"),
             _ => info!("ℹ️  could not auto-update install.sh — set GIST_ID={id} manually"),
         }
     }
     Ok(())
 }
-/// Rewrite `patches/…` references to the bundle root on COPY/ADD lines
-/// (gists cannot carry directories). Comments keep their original text.
-pub(crate) fn flatten_dockerfile(text: &str) -> String {
-    let mut in_copy = false;
-    text.lines()
-        .map(|line| {
-            let t = line.trim_start();
-            let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
-            in_copy = is_copy && t.ends_with('\\');
-            if is_copy {
-                line.replace("patches/", "")
-            } else {
-                line.to_string()
-            }
-        })
-        .collect::<Vec<_>>()
-        .join("\n")
-        + if text.ends_with('\n') { "\n" } else { "" }
-}
-
 /// Parse `versions.env` (`KEY=VAL` lines, `#` comments, optional quotes) into
 /// `--build-arg` pairs.
 pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {
@@ -185,280 +212,75 @@ pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {
     out
 }
 
-fn render_run_script(
-    recipe: &Recipe,
-    cluster: Option<&Cluster>,
-    nccl: Option<&NcclConfig>,
-    hf_cache: &str,
-    init_port: u16,
-    revision: Option<&str>,
-) -> Result<String> {
-    let nodes = cluster.map_or_else(
-        || recipe.required_nodes.unwrap_or(2) as usize,
-        |c| c.nodes.len(),
-    );
-    let solo = recipe.solo == Some(true) || nodes <= 1;
-    let master = cluster
-        .and_then(|c| c.master_ib_ip.clone())
-        .or_else(|| cluster.and_then(|c| c.nodes.iter().find(|n| n.rank == 0)?.ib_ip.clone()))
-        .unwrap_or_else(|| "<rank0-IB-ip>".into());
-    let mut s = String::new();
-    s.push_str(&format!(
-        "#!/usr/bin/env bash\n\
-         # {} — generated by `sm121 publish`. Run on rank 0; on each worker set\n\
-         # NODE_RANK=<rank> and HEADLESS=\"--headless\" (drop --host/--port).\n\
-         set -euo pipefail\n\n\
-         MODEL={}\n\
-         IMAGE={}\n",
-        recipe.name,
-        shq(&recipe.model),
-        shq(&recipe.docker_image),
-    ));
-    if let Some(r) = revision {
-        s.push_str(&format!(
-            "MODEL_REVISION={}   # pin: download + serve THIS revision (repo branches move)\n",
-            shq(r)
-        ));
-    } else if recipe.model.contains('/') {
-        s.push_str(
-            "# MODEL_REVISION=   # consider pinning the HF revision (the repo branch moves)\n",
-        );
-    }
-    if !solo {
-        s.push_str(&format!(
-            "MASTER_ADDR={}   # rank-0 IB ip\nNODE_RANK=0\nHEADLESS=\"\"\n",
-            shq(&master)
-        ));
-    }
-    s.push_str("\n# --- fabric env (adjust per cluster) ------------------------------------\n");
-    match nccl {
-        Some(n) => {
-            s.push_str(&format!(
-                "FABRIC_ENV=(\n  -e NCCL_IB_HCA={}\n  -e NCCL_SOCKET_IFNAME={}\n  -e GLOO_SOCKET_IFNAME={}\n  -e NCCL_IB_DISABLE={}\n",
-                shq(&n.ib_hca), shq(&n.socket_ifname),
-                shq(n.gloo_socket_ifname.as_deref().unwrap_or(&n.socket_ifname)), n.ib_disable));
-            match n.ib_gid_index {
-                Some(g) => s.push_str(&format!("  -e NCCL_IB_GID_INDEX={g}\n)\n")),
-                None => s.push_str("  # NCCL_IB_GID_INDEX renumbers across reboots on DGX Spark —\n  # detect the RoCE-v2 IPv4 GID per node, don't copy a stale number:\n  # for i in $(seq 0 15); do grep -l RoCE* /sys/class/infiniband/$HCA/ports/1/gid_attrs/types/$i 2>/dev/null && echo $i; done\n)\n"),
-            }
-        }
-        None => s.push_str("FABRIC_ENV=(\n  # -e NCCL_IB_HCA=... -e NCCL_SOCKET_IFNAME=... -e NCCL_IB_GID_INDEX=...\n)\n"),
-    }
-    s.push_str(&format!(
-        "\nexec docker run -d --name pub-{} --gpus all --ipc=host --network host \\\n  --shm-size {} \\\n  -v {}:/root/.cache/huggingface \\\n  \"${{FABRIC_ENV[@]}}\" \\\n",
-        recipe.model.replace('/', "-"),
-        if recipe.shm_size.is_empty() { "32g".into() } else { recipe.shm_size.clone() },
-        hf_cache, // unquoted: `~` must expand in bash
-    ));
-    let env = recipe.resolved_env()?;
-    for (k, v) in env.iter().collect::<BTreeMap<_, _>>() {
-        s.push_str(&format!("  -e {} \\\n", shq(&format!("{k}={v}"))));
-    }
-    s.push_str("  \"$IMAGE\" \\\n");
-    s.push_str(&format!(
-        "  --model \"$MODEL\"{}",
-        if revision.is_some() {
-            " --revision \"$MODEL_REVISION\""
-        } else {
-            ""
-        }
-    ));
-    if !solo {
-        if recipe.engine == "sglang" {
-            s.push_str(&format!(
-                " \\\n  --tp {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --dist-init-addr \"$MASTER_ADDR:{init_port}\"",
-                recipe.tensor_parallel_size
-            ));
-        } else {
-            s.push_str(&format!(
-                " \\\n  --tensor-parallel-size {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --master-addr \"$MASTER_ADDR\" --master-port {init_port}",
-                recipe.tensor_parallel_size
-            ));
-            if let Some(pp) = recipe.pipeline_parallel_size.filter(|p| *p > 1) {
-                s.push_str(&format!(" --pipeline-parallel-size {pp}"));
-            }
-        }
-    }
-    for a in recipe.args.to_vec() {
-        s.push_str(&format!(" \\\n  {}", shq(&a)));
-    }
-    if !solo {
-        s.push_str(" \\\n  ${HEADLESS:+$HEADLESS}");
-    }
-    s.push_str(&format!(" \\\n  --host 0.0.0.0 --port {}\n", recipe.port));
-    Ok(s)
-}
-
-fn render_install_sh(
-    recipe_name: &str,
-    image: &str,
-    versions: &BTreeMap<String, String>,
-    gist_id: Option<&str>,
-) -> String {
-    let args = versions
-        .iter()
-        .map(|(k, v)| format!("  --build-arg {}={} \\\n", shq(k), shq(v)))
-        .collect::<String>();
-    let gid = gist_id
-        .map(shq)
-        .unwrap_or_else(|| "'          '".to_string());
-    let image_q = shq(image);
-    format!(
-        "#!/usr/bin/env bash\n\
-         # Build {image}. Run from this folder, or set GIST_ID and run from\n\
-         # anywhere to clone the published gist into /tmp first.\n\
-         # Usage: curl -fsSL <raw-url>/install.sh | bash   (after publishing as a gist)\n\
-         set -euo pipefail\n\n\
-         GIST_ID={gid}  # filled in by `sm121 publish --gist`\n\
-         DIR=/tmp/{recipe_name}-build\n\
-         IMAGE={image_q}\n\n\
-         HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
-         if [ -f \"$HERE/Dockerfile\" ]; then\n  SRC=\"$HERE\"\n\
-         else\n  rm -rf \"$DIR\"; git clone -q \"https://gist.github.com/${{GIST_ID}}.git\" \"$DIR\"; SRC=\"$DIR\"\n\
-         fi\n\n\
-         docker build -t \"$IMAGE\" \\\n{args}\"$SRC\"\n\
-         echo \"built: $IMAGE — see run.sh\"\n",
-    )
-}
-
-fn render_readme(
-    recipe_name: &str,
-    recipe: &Recipe,
-    versions: &BTreeMap<String, String>,
-    revision: Option<&str>,
-    has_image: bool,
-) -> String {
-    let mut s = format!("# {} — reproducible recipe card\n\n", recipe.name);
-    s.push_str(&format!(
-        "Generated by `sm121 publish` from the [sm121-launcher](https://git.kilork.org/sm121/sm121-launcher) \
-         recipe `{recipe_name}` (engine: {}, model: `{}`).\n\n",
-        recipe.engine, recipe.model
-    ));
-    s.push_str(
-        "**`recipe.toml` is the source of truth — its header comments document every \
-                non-obvious flag and the measured boot markers. Read it first.**\n\n",
-    );
-    s.push_str("## Files\n\n| file | what |\n|---|---|\n| `recipe.toml` | the launcher recipe (rationale in comments) |\n");
-    if has_image {
-        s.push_str(
-            "| `Dockerfile`, patches, `versions.env` | image overlay (flat — build here) |\n",
-        );
-    }
-    s.push_str("| `install.sh` | one-shot build (gist-aware) |\n| `run.sh` | docker run, rank 0 (worker deltas in comments) |\n\n");
-    if has_image {
-        s.push_str("## Build\n\n```bash\nbash install.sh\n```\n\n");
-        s.push_str("## Pinned coordinates\n\n| thing | ref |\n|---|---|\n");
-        if let Some(b) = versions.get("BASE_IMAGE") {
-            let note = if b.contains('@') {
-                "digest-pinned"
-            } else {
-                "⚠ mutable tag — re-pin if reproducibility matters"
-            };
-            s.push_str(&format!("| base image | `{b}` ({note}) |\n"));
-        }
-        match revision {
-            Some(r) => s.push_str(&format!("| model | `{}` @ `{r}` |\n", recipe.model)),
-            None if recipe.model.contains('/') => s.push_str(&format!(
-                "| model | `{}` @ main — ⚠ unpinned branch |\n",
-                recipe.model
-            )),
-            _ => {}
-        }
-        s.push('\n');
-    }
-    s.push_str(
-        "## Run\n\nCache the model on every node (see `recipe.toml` PREREQUISITES), edit \
-                `MASTER_ADDR`/fabric env in `run.sh`, run on rank 0, then on each worker with \
-                `NODE_RANK`/`HEADLESS` set. `recipe.toml` `[[extra_mounts]]` and built-in \
-                `before_serve` host scripts are launcher-side — translate manually if needed.\n",
-    );
-    s
-}
-
-async fn hf_revision(model: &str) -> Option<String> {
-    if !model.contains('/') || model.starts_with('.') || model.starts_with('/') {
-        return None;
-    }
-    let url = format!("https://huggingface.co/api/models/{model}");
-    let resp = reqwest::Client::builder()
-        .timeout(std::time::Duration::from_secs(8))
-        .build()
-        .ok()?
-        .get(url)
-        .send()
-        .await
-        .ok()?
-        .json::<serde_json::Value>()
-        .await
-        .ok()?;
-    resp.get("sha")?.as_str().map(str::to_string)
-}
-
-fn publish_gist(bundle: &Path, desc: &str) -> Result<String> {
-    let mut cmd = Command::new("gh");
-    cmd.arg("gist")
-        .arg("create")
-        .arg("--public")
-        .arg("--desc")
-        .arg(desc);
-    let mut files = Vec::new();
-    for e in std::fs::read_dir(bundle)? {
-        let p = e?.path();
-        if p.is_file() {
-            files.push(p);
-        }
-    }
-    files.sort();
-    cmd.args(&files);
-    let out = cmd
-        .output()
-        .context("`gh` not found — install GitHub CLI or copy the bundle manually")?;
-    if !out.status.success() {
-        bail!(
-            "gh gist create failed: {}",
-            String::from_utf8_lossy(&out.stderr)
-        );
-    }
-    let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
-    info!("🌐 gist: {url}");
-    // gh prints the full html url; the gist *id* is the last path segment.
-    Ok(url.rsplit('/').next().unwrap_or(&url).to_string())
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::config::Recipe;
 
     #[test]
-    fn flatten_rewrites_copy_lines_only() {
-        let df = "FROM x\n# see patches/UPSTREAM.md\nCOPY patches/a.py /tmp/a.py\nCOPY b /tmp/ \\\n  patches/c.patch\nRUN grep patches/ /tmp/x\n";
-        let f = flatten_dockerfile(df);
-        assert!(f.contains("# see patches/UPSTREAM.md"));
-        assert!(f.contains("COPY a.py /tmp/a.py"));
-        assert!(f.contains("  c.patch\nRUN grep patches/"));
-    }
-
-    #[test]
-    fn copy_flat_preserves_nested_dirs_reports_nested() {
-        let tmp = std::env::temp_dir().join("sm121_copy_flat_test");
+    fn flatten_overlay_rewrites_copy_lines_and_collision_renames() {
+        let tmp = std::env::temp_dir().join("sm121_flatten_overlay_test");
         let _ = std::fs::remove_dir_all(&tmp);
         let src = tmp.join("src");
-        std::fs::create_dir_all(src.join("plugin/pkg")).unwrap();
+        std::fs::create_dir_all(src.join("locks")).unwrap();
         std::fs::create_dir_all(src.join("patches")).unwrap();
-        std::fs::write(src.join("plugin/pyproject.toml"), "x").unwrap();
-        std::fs::write(src.join("plugin/pkg/m.py"), "y").unwrap();
+        std::fs::create_dir_all(src.join("profile")).unwrap();
+        std::fs::write(src.join("apt-sources.list"), "root").unwrap();
+        std::fs::write(src.join("locks/apt-sources.list"), "lock").unwrap(); // collision
+        std::fs::write(src.join("locks/apt-packages.txt"), "pkg").unwrap();
         std::fs::write(src.join("patches/p.patch"), "z").unwrap();
+        std::fs::write(src.join("profile/x.env"), "y").unwrap();
+        std::fs::create_dir_all(src.join("extra")).unwrap();
+        std::fs::write(src.join("extra/y.env"), "z2").unwrap(); // unreferenced -> nested
         std::fs::write(src.join("Dockerfile"), "f").unwrap();
+        let df = "FROM x\n# see patches/UPSTREAM.md\nCOPY locks/apt-sources.list /tmp/a.list\nCOPY locks/apt-packages.txt /tmp/b.txt \\\n  patches/p.patch\nCOPY profile/x.env /tmp/x\nRUN grep patches/ /tmp/x\n";
         let dst = tmp.join("dst");
         std::fs::create_dir_all(&dst).unwrap();
-        let nested = copy_flat(&src, &dst).unwrap();
-        assert!(nested);
-        let m = std::fs::read_to_string(dst.join("plugin/pkg/m.py")).unwrap();
-        assert_eq!(m, "y");
+        let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();
+        // referenced dirs flatten, unreferenced stays nested
+        assert!(nested, "unreferenced dir must stay nested");
+        assert!(dst.join("extra/y.env").exists());
+        assert!(
+            dst.join("locks-apt-sources.list").exists(),
+            "collision rename"
+        );
+        assert!(dst.join("apt-sources.list").exists());
+        assert_eq!(
+            std::fs::read_to_string(dst.join("apt-packages.txt")).unwrap(),
+            "pkg"
+        );
         assert_eq!(std::fs::read_to_string(dst.join("p.patch")).unwrap(), "z");
+        assert!(dst.join("x.env").exists(), "profile dir flattened");
+        // Dockerfile rewritten: renamed file, prefix drops (root + continuation),
+        // comments + non-COPY lines untouched
+        assert!(flat.contains("COPY locks-apt-sources.list /tmp/a.list"));
+        assert!(flat.contains("COPY apt-packages.txt /tmp/b.txt \\\n  p.patch"));
+        assert!(flat.contains("COPY x.env /tmp/x"));
+        assert!(flat.contains("# see patches/UPSTREAM.md"));
+        assert!(flat.contains("RUN grep patches/ /tmp/x"));
         assert!(!dst.join("Dockerfile").exists());
     }
 
+    #[test]
+    fn flatten_overlay_glob_collision_keeps_dir_nested() {
+        let tmp = std::env::temp_dir().join("sm121_flatten_glob_test");
+        let _ = std::fs::remove_dir_all(&tmp);
+        let src = tmp.join("src");
+        std::fs::create_dir_all(src.join("patches")).unwrap();
+        std::fs::write(src.join("a.py"), "root").unwrap(); // collides with patches/a.py
+        std::fs::write(src.join("patches/a.py"), "patched").unwrap();
+        let df = "FROM x\nCOPY patches/*.py /tmp/\n";
+        let dst = tmp.join("dst");
+        std::fs::create_dir_all(&dst).unwrap();
+        let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();
+        assert!(nested, "glob collision must not flatten");
+        assert!(dst.join("patches/a.py").exists());
+        assert!(
+            flat.contains("COPY patches/*.py /tmp/"),
+            "line left untouched"
+        );
+    }
+
     #[test]
     fn versions_env_parses_quoted_and_commented() {
         let v = versions_build_args("# c\nBASE_IMAGE=\"img@sha256:1\"\nX = 'y' \nNOVAL=\n");
@@ -478,7 +300,15 @@ mod tests {
 
     #[test]
     fn run_script_vllm_has_dist_flags_and_json_arg() {
-        let s = render_run_script(&rec("vllm"), None, None, "/hf", 29500, Some("abc")).unwrap();
+        let s = crate::publish_render::render_run_script(
+            &rec("vllm"),
+            None,
+            None,
+            "/hf",
+            29500,
+            Some("abc"),
+        )
+        .unwrap();
         assert!(s.contains("--tensor-parallel-size 2 --nnodes 2 --node-rank \"$NODE_RANK\""));
         assert!(s.contains("--revision \"$MODEL_REVISION\""));
         assert!(s.contains("'--speculative-config'"));
@@ -489,7 +319,8 @@ mod tests {
     fn run_script_sglang_solo_omits_dist() {
         let mut r = rec("sglang");
         r.solo = Some(true);
-        let s = render_run_script(&r, None, None, "/hf", 29500, None).unwrap();
+        let s =
+            crate::publish_render::render_run_script(&r, None, None, "/hf", 29500, None).unwrap();
         assert!(!s.contains("--nnodes") && !s.contains("MASTER_ADDR"));
         assert!(s.contains("# MODEL_REVISION="));
     }

+ 263 - 0
src/publish_render.rs

@@ -0,0 +1,263 @@
+//! Bundle rendering for `sm121 publish`: run script, install script, README,
+//! HF revision lookup, and the `gh gist` flow.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result, bail};
+use tracing::info;
+
+use crate::config::{Cluster, NcclConfig, Recipe};
+use crate::ssh::shq;
+
+pub(crate) fn render_run_script(
+    recipe: &Recipe,
+    cluster: Option<&Cluster>,
+    nccl: Option<&NcclConfig>,
+    hf_cache: &str,
+    init_port: u16,
+    revision: Option<&str>,
+) -> Result<String> {
+    let nodes = cluster.map_or_else(
+        || recipe.required_nodes.unwrap_or(2) as usize,
+        |c| c.nodes.len(),
+    );
+    let solo = recipe.solo == Some(true) || nodes <= 1;
+    let master = cluster
+        .and_then(|c| c.master_ib_ip.clone())
+        .or_else(|| cluster.and_then(|c| c.nodes.iter().find(|n| n.rank == 0)?.ib_ip.clone()))
+        .unwrap_or_else(|| "<rank0-IB-ip>".into());
+    let mut s = String::new();
+    s.push_str(&format!(
+        "#!/usr/bin/env bash\n\
+         # {} — generated by `sm121 publish`. Run on rank 0; on each worker set\n\
+         # NODE_RANK=<rank> and HEADLESS=\"--headless\" (drop --host/--port).\n\
+         set -euo pipefail\n\n\
+         MODEL={}\n\
+         IMAGE={}\n",
+        recipe.name,
+        shq(&recipe.model),
+        shq(&recipe.docker_image),
+    ));
+    if let Some(r) = revision {
+        s.push_str(&format!(
+            "MODEL_REVISION={}   # pin: download + serve THIS revision (repo branches move)\n",
+            shq(r)
+        ));
+    } else if recipe.model.contains('/') {
+        s.push_str(
+            "# MODEL_REVISION=   # consider pinning the HF revision (the repo branch moves)\n",
+        );
+    }
+    if !solo {
+        s.push_str(&format!(
+            "MASTER_ADDR={}   # rank-0 IB ip\nNODE_RANK=0\nHEADLESS=\"\"\n",
+            shq(&master)
+        ));
+    }
+    s.push_str("\n# --- fabric env (adjust per cluster) ------------------------------------\n");
+    match nccl {
+        Some(n) => {
+            s.push_str(&format!(
+                "FABRIC_ENV=(\n  -e NCCL_IB_HCA={}\n  -e NCCL_SOCKET_IFNAME={}\n  -e GLOO_SOCKET_IFNAME={}\n  -e NCCL_IB_DISABLE={}\n",
+                shq(&n.ib_hca), shq(&n.socket_ifname),
+                shq(n.gloo_socket_ifname.as_deref().unwrap_or(&n.socket_ifname)), n.ib_disable));
+            match n.ib_gid_index {
+                Some(g) => s.push_str(&format!("  -e NCCL_IB_GID_INDEX={g}\n)\n")),
+                None => s.push_str("  # NCCL_IB_GID_INDEX renumbers across reboots on DGX Spark —\n  # detect the RoCE-v2 IPv4 GID per node, don't copy a stale number:\n  # for i in $(seq 0 15); do grep -l RoCE* /sys/class/infiniband/$HCA/ports/1/gid_attrs/types/$i 2>/dev/null && echo $i; done\n)\n"),
+            }
+        }
+        None => s.push_str("FABRIC_ENV=(\n  # -e NCCL_IB_HCA=... -e NCCL_SOCKET_IFNAME=... -e NCCL_IB_GID_INDEX=...\n)\n"),
+    }
+    s.push_str(&format!(
+        "\nexec docker run -d --name pub-{} --gpus all --ipc=host --network host \\\n  --shm-size {} \\\n  -v {}:/root/.cache/huggingface \\\n  \"${{FABRIC_ENV[@]}}\" \\\n",
+        recipe.model.replace('/', "-"),
+        if recipe.shm_size.is_empty() { "32g".into() } else { recipe.shm_size.clone() },
+        hf_cache, // unquoted: `~` must expand in bash
+    ));
+    let env = recipe.resolved_env()?;
+    for (k, v) in env.iter().collect::<BTreeMap<_, _>>() {
+        s.push_str(&format!("  -e {} \\\n", shq(&format!("{k}={v}"))));
+    }
+    s.push_str("  \"$IMAGE\" \\\n");
+    s.push_str(&format!(
+        "  --model \"$MODEL\"{}",
+        if revision.is_some() {
+            " --revision \"$MODEL_REVISION\""
+        } else {
+            ""
+        }
+    ));
+    if !solo {
+        if recipe.engine == "sglang" {
+            s.push_str(&format!(
+                " \\\n  --tp {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --dist-init-addr \"$MASTER_ADDR:{init_port}\"",
+                recipe.tensor_parallel_size
+            ));
+        } else {
+            s.push_str(&format!(
+                " \\\n  --tensor-parallel-size {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --master-addr \"$MASTER_ADDR\" --master-port {init_port}",
+                recipe.tensor_parallel_size
+            ));
+            if let Some(pp) = recipe.pipeline_parallel_size.filter(|p| *p > 1) {
+                s.push_str(&format!(" --pipeline-parallel-size {pp}"));
+            }
+        }
+    }
+    for a in recipe.args.to_vec() {
+        s.push_str(&format!(" \\\n  {}", shq(&a)));
+    }
+    if !solo {
+        s.push_str(" \\\n  ${HEADLESS:+$HEADLESS}");
+    }
+    s.push_str(&format!(" \\\n  --host 0.0.0.0 --port {}\n", recipe.port));
+    Ok(s)
+}
+
+pub(crate) fn render_install_sh(
+    recipe_name: &str,
+    image: &str,
+    versions: &BTreeMap<String, String>,
+    gist_id: Option<&str>,
+) -> String {
+    let args = versions
+        .iter()
+        .map(|(k, v)| format!("  --build-arg {}={} \\\n", shq(k), shq(v)))
+        .collect::<String>();
+    let gid = gist_id
+        .map(shq)
+        .unwrap_or_else(|| "'          '".to_string());
+    let image_q = shq(image);
+    format!(
+        "#!/usr/bin/env bash\n\
+         # Build {image}. Run from this folder, or set GIST_ID and run from\n\
+         # anywhere to clone the published gist into /tmp first.\n\
+         # Usage: curl -fsSL <raw-url>/install.sh | bash   (after publishing as a gist)\n\
+         set -euo pipefail\n\n\
+         GIST_ID={gid}  # filled in by `sm121 publish --gist`\n\
+         DIR=/tmp/{recipe_name}-build\n\
+         IMAGE={image_q}\n\n\
+         HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
+         if [ -f \"$HERE/Dockerfile\" ]; then\n  SRC=\"$HERE\"\n\
+         else\n  rm -rf \"$DIR\"; git clone -q \"https://gist.github.com/${{GIST_ID}}.git\" \"$DIR\"; SRC=\"$DIR\"\n\
+         fi\n\n\
+         docker build -t \"$IMAGE\" \\\n{args}\"$SRC\"\n\
+         echo \"built: $IMAGE — see run.sh\"\n",
+    )
+}
+
+pub(crate) fn render_readme(
+    recipe_name: &str,
+    recipe: &Recipe,
+    versions: &BTreeMap<String, String>,
+    revision: Option<&str>,
+    has_image: bool,
+    profile_used: bool,
+) -> String {
+    let mut s = format!("# {} — reproducible recipe card\n\n", recipe.name);
+    s.push_str(&format!(
+        "Generated by `sm121 publish` from the [sm121-launcher](https://git.kilork.org/sm121/sm121-launcher) \
+         recipe `{recipe_name}` (engine: {}, model: `{}`).\n\n",
+        recipe.engine, recipe.model
+    ));
+    s.push_str(
+        "**`recipe.toml` is the source of truth — its header comments document every \
+                non-obvious flag and the measured boot markers. Read it first.**\n\n",
+    );
+    s.push_str("## Files\n\n| file | what |\n|---|---|\n| `recipe.toml` | the launcher recipe (rationale in comments) |\n");
+    if has_image {
+        s.push_str(
+            "| `Dockerfile`, patches, `versions.env` | image overlay (flat — build here) |\n",
+        );
+    }
+    s.push_str("| `install.sh` | one-shot build (gist-aware) |\n| `run.sh` | docker run, rank 0 (worker deltas in comments) |\n\n");
+    if has_image {
+        s.push_str("## Build\n\n```bash\nbash install.sh\n```\n\n");
+        s.push_str("## Pinned coordinates\n\n| thing | ref |\n|---|---|\n");
+        if profile_used {
+            s.push_str(&format!(
+                "| image pins | `versions.env` with {} merged in (precedence: image base < profile < recipe [build.versions]) |\n",
+                recipe
+                    .build_profile
+                    .as_deref()
+                    .map(|p| format!("profile `{p}`"))
+                    .unwrap_or_else(|| "`[build.versions]` overrides".into())
+            ));
+        }
+        if let Some(b) = versions.get("BASE_IMAGE") {
+            let note = if b.contains('@') {
+                "digest-pinned"
+            } else {
+                "⚠ mutable tag — re-pin if reproducibility matters"
+            };
+            s.push_str(&format!("| base image | `{b}` ({note}) |\n"));
+        }
+        match revision {
+            Some(r) => s.push_str(&format!("| model | `{}` @ `{r}` |\n", recipe.model)),
+            None if recipe.model.contains('/') => s.push_str(&format!(
+                "| model | `{}` @ main — ⚠ unpinned branch |\n",
+                recipe.model
+            )),
+            _ => {}
+        }
+        s.push('\n');
+    }
+    s.push_str(
+        "## Run\n\nCache the model on every node (see `recipe.toml` PREREQUISITES), edit \
+                `MASTER_ADDR`/fabric env in `run.sh`, run on rank 0, then on each worker with \
+                `NODE_RANK`/`HEADLESS` set. `recipe.toml` `[[extra_mounts]]` and built-in \
+                `before_serve` host scripts are launcher-side — translate manually if needed.\n",
+    );
+    s
+}
+
+pub(crate) async fn hf_revision(model: &str) -> Option<String> {
+    if !model.contains('/') || model.starts_with('.') || model.starts_with('/') {
+        return None;
+    }
+    let url = format!("https://huggingface.co/api/models/{model}");
+    let resp = reqwest::Client::builder()
+        .timeout(std::time::Duration::from_secs(8))
+        .build()
+        .ok()?
+        .get(url)
+        .send()
+        .await
+        .ok()?
+        .json::<serde_json::Value>()
+        .await
+        .ok()?;
+    resp.get("sha")?.as_str().map(str::to_string)
+}
+
+pub(crate) fn publish_gist(bundle: &Path, desc: &str) -> Result<String> {
+    let mut cmd = Command::new("gh");
+    cmd.arg("gist")
+        .arg("create")
+        .arg("--public")
+        .arg("--desc")
+        .arg(desc);
+    let mut files = Vec::new();
+    for e in std::fs::read_dir(bundle)? {
+        let p = e?.path();
+        if p.is_file() {
+            files.push(p);
+        }
+    }
+    files.sort();
+    cmd.args(&files);
+    let out = cmd
+        .output()
+        .context("`gh` not found — install GitHub CLI or copy the bundle manually")?;
+    if !out.status.success() {
+        bail!(
+            "gh gist create failed: {}",
+            String::from_utf8_lossy(&out.stderr)
+        );
+    }
+    let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
+    info!("🌐 gist: {url}");
+    // gh prints the full html url; the gist *id* is the last path segment.
+    Ok(url.rsplit('/').next().unwrap_or(&url).to_string())
+}