|
@@ -7,15 +7,13 @@
|
|
|
//! directories); other sub-directories are preserved and `--gist` is refused
|
|
//! directories); other sub-directories are preserved and `--gist` is refused
|
|
|
//! for such overlays.
|
|
//! for such overlays.
|
|
|
|
|
|
|
|
|
|
+use anyhow::{Context, Result, bail};
|
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeMap;
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
|
-use std::process::Command;
|
|
|
|
|
-
|
|
|
|
|
-use anyhow::{Context, Result, bail};
|
|
|
|
|
use tracing::info;
|
|
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;
|
|
use crate::ssh::shq;
|
|
|
|
|
|
|
|
/// Publish `recipe_name` into `out` (default `./publish/<name>`), optionally
|
|
/// 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"))?;
|
|
std::fs::copy(&recipe_src, bundle.join("recipe.toml"))?;
|
|
|
|
|
|
|
|
let mut versions: BTreeMap<String, String> = BTreeMap::new();
|
|
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 =
|
|
let image_dir =
|
|
|
Path::new("docker-images").join(recipe.docker_image_dir.as_deref().unwrap_or(recipe_name));
|
|
Path::new("docker-images").join(recipe.docker_image_dir.as_deref().unwrap_or(recipe_name));
|
|
|
if image_dir.join("Dockerfile").exists() {
|
|
if image_dir.join("Dockerfile").exists() {
|
|
|
let df = std::fs::read_to_string(image_dir.join("Dockerfile"))?;
|
|
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 {
|
|
if gist && nested {
|
|
|
bail!(
|
|
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)?;
|
|
std::fs::write(bundle.join("versions.env"), &v)?;
|
|
|
versions = versions_build_args(&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 {
|
|
} else {
|
|
|
info!(
|
|
info!(
|
|
|
"ℹ️ no docker-images/{} overlay — bundling recipe + scripts only",
|
|
"ℹ️ no docker-images/{} overlay — bundling recipe + scripts only",
|
|
|
image_dir.display()
|
|
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 {
|
|
let cluster = match cluster {
|
|
@@ -83,7 +121,7 @@ pub async fn publish(
|
|
|
None => None,
|
|
None => None,
|
|
|
};
|
|
};
|
|
|
let nccl = cluster.map(|c| config.nccl.get(&c.name));
|
|
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
|
|
let hf_cache = config
|
|
|
.mount_aliases
|
|
.mount_aliases
|
|
|
.get("hf_cache")
|
|
.get("hf_cache")
|
|
@@ -91,7 +129,7 @@ pub async fn publish(
|
|
|
.or_else(|| config.hf_cache_dir.clone())
|
|
.or_else(|| config.hf_cache_dir.clone())
|
|
|
.unwrap_or_else(|| "~/.cache/huggingface".into());
|
|
.unwrap_or_else(|| "~/.cache/huggingface".into());
|
|
|
|
|
|
|
|
- let run = render_run_script(
|
|
|
|
|
|
|
+ let run = crate::publish_render::render_run_script(
|
|
|
&recipe,
|
|
&recipe,
|
|
|
cluster,
|
|
cluster,
|
|
|
nccl.flatten(),
|
|
nccl.flatten(),
|
|
@@ -100,19 +138,13 @@ pub async fn publish(
|
|
|
revision.as_deref(),
|
|
revision.as_deref(),
|
|
|
)?;
|
|
)?;
|
|
|
std::fs::write(bundle.join("run.sh"), run)?;
|
|
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_name,
|
|
|
&recipe,
|
|
&recipe,
|
|
|
&versions,
|
|
&versions,
|
|
|
revision.as_deref(),
|
|
revision.as_deref(),
|
|
|
image_dir.exists(),
|
|
image_dir.exists(),
|
|
|
|
|
+ profile_used,
|
|
|
);
|
|
);
|
|
|
std::fs::write(bundle.join("README.md"), readme)?;
|
|
std::fs::write(bundle.join("README.md"), readme)?;
|
|
|
info!("📝 bundle written to {}", bundle.display());
|
|
info!("📝 bundle written to {}", bundle.display());
|
|
@@ -121,7 +153,12 @@ pub async fn publish(
|
|
|
// branch only patches the returned id into it afterwards.
|
|
// branch only patches the returned id into it afterwards.
|
|
|
std::fs::write(
|
|
std::fs::write(
|
|
|
bundle.join("install.sh"),
|
|
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 {
|
|
if let Some(url) = registry {
|
|
@@ -129,7 +166,7 @@ pub async fn publish(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if gist {
|
|
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
|
|
// 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.
|
|
// update, so `curl …/raw/install.sh | bash` self-bootstraps from anywhere.
|
|
|
let raw = std::fs::read_to_string(bundle.join("install.sh"))?.replacen(
|
|
let raw = std::fs::read_to_string(bundle.join("install.sh"))?.replacen(
|
|
@@ -138,37 +175,27 @@ pub async fn publish(
|
|
|
1,
|
|
1,
|
|
|
);
|
|
);
|
|
|
std::fs::write(bundle.join("install.sh"), &raw)?;
|
|
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"),
|
|
_ => info!("ℹ️ could not auto-update install.sh — set GIST_ID={id} manually"),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
Ok(())
|
|
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
|
|
/// Parse `versions.env` (`KEY=VAL` lines, `#` comments, optional quotes) into
|
|
|
/// `--build-arg` pairs.
|
|
/// `--build-arg` pairs.
|
|
|
pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {
|
|
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
|
|
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)]
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
mod tests {
|
|
|
use super::*;
|
|
use super::*;
|
|
|
|
|
+ use crate::config::Recipe;
|
|
|
|
|
|
|
|
#[test]
|
|
#[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 _ = std::fs::remove_dir_all(&tmp);
|
|
|
let src = tmp.join("src");
|
|
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::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("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();
|
|
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");
|
|
let dst = tmp.join("dst");
|
|
|
std::fs::create_dir_all(&dst).unwrap();
|
|
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_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());
|
|
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]
|
|
#[test]
|
|
|
fn versions_env_parses_quoted_and_commented() {
|
|
fn versions_env_parses_quoted_and_commented() {
|
|
|
let v = versions_build_args("# c\nBASE_IMAGE=\"img@sha256:1\"\nX = 'y' \nNOVAL=\n");
|
|
let v = versions_build_args("# c\nBASE_IMAGE=\"img@sha256:1\"\nX = 'y' \nNOVAL=\n");
|
|
@@ -478,7 +300,15 @@ mod tests {
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn run_script_vllm_has_dist_flags_and_json_arg() {
|
|
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("--tensor-parallel-size 2 --nnodes 2 --node-rank \"$NODE_RANK\""));
|
|
|
assert!(s.contains("--revision \"$MODEL_REVISION\""));
|
|
assert!(s.contains("--revision \"$MODEL_REVISION\""));
|
|
|
assert!(s.contains("'--speculative-config'"));
|
|
assert!(s.contains("'--speculative-config'"));
|
|
@@ -489,7 +319,8 @@ mod tests {
|
|
|
fn run_script_sglang_solo_omits_dist() {
|
|
fn run_script_sglang_solo_omits_dist() {
|
|
|
let mut r = rec("sglang");
|
|
let mut r = rec("sglang");
|
|
|
r.solo = Some(true);
|
|
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("--nnodes") && !s.contains("MASTER_ADDR"));
|
|
|
assert!(s.contains("# MODEL_REVISION="));
|
|
assert!(s.contains("# MODEL_REVISION="));
|
|
|
}
|
|
}
|