publish.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. //! `sm121 publish` — turn a recipe + its `docker-images/<dir>` overlay into a
  2. //! standalone, reproducible recipe-card bundle (README, flat Dockerfile +
  3. //! patches, run/install scripts, the recipe itself). `--gist` additionally
  4. //! publishes the bundle as a public GitHub gist via `gh`.
  5. //!
  6. //! `patches/` files are flattened into the bundle root (gists cannot carry
  7. //! directories); other sub-directories are preserved and `--gist` is refused
  8. //! for such overlays.
  9. use std::collections::BTreeMap;
  10. use std::path::{Path, PathBuf};
  11. use std::process::Command;
  12. use anyhow::{Context, Result, bail};
  13. use tracing::info;
  14. use crate::config::{Cluster, Config, NcclConfig, Recipe, recipe_dirs};
  15. use crate::docker::build_dir::copy_flat;
  16. use crate::ssh::shq;
  17. /// Publish `recipe_name` into `out` (default `./publish/<name>`), optionally
  18. /// creating a gist. `cluster` (optional) supplies fabric env + node count in
  19. /// the generated run script.
  20. pub async fn publish(
  21. config: &Config,
  22. recipe_name: &str,
  23. cluster: Option<&str>,
  24. out: Option<PathBuf>,
  25. gist: bool,
  26. registry: Option<&str>,
  27. token: Option<&str>,
  28. ) -> Result<()> {
  29. let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  30. if recipe.deploy != crate::config::DeployTarget::Docker {
  31. bail!("publish supports docker recipes only (deploy = tmux has no image bundle)");
  32. }
  33. let bundle = out.unwrap_or_else(|| Path::new("publish").join(recipe_name));
  34. if bundle.exists() {
  35. std::fs::remove_dir_all(&bundle)
  36. .with_context(|| format!("clearing {}", bundle.display()))?;
  37. }
  38. std::fs::create_dir_all(&bundle)?;
  39. let recipe_src = recipe_dirs(config)
  40. .into_iter()
  41. .map(|d| d.join(recipe_name).join("recipe.toml"))
  42. .find(|p| p.exists())
  43. .with_context(|| format!("recipe.toml for '{recipe_name}' not found in recipe dirs"))?;
  44. std::fs::copy(&recipe_src, bundle.join("recipe.toml"))?;
  45. let mut versions: BTreeMap<String, String> = BTreeMap::new();
  46. let image_dir =
  47. Path::new("docker-images").join(recipe.docker_image_dir.as_deref().unwrap_or(recipe_name));
  48. if image_dir.join("Dockerfile").exists() {
  49. let df = std::fs::read_to_string(image_dir.join("Dockerfile"))?;
  50. std::fs::write(bundle.join("Dockerfile"), flatten_dockerfile(&df))?;
  51. let nested = copy_flat(&image_dir, &bundle)?;
  52. if gist && nested {
  53. bail!(
  54. "publish --gist needs a flat overlay ({image_dir:?} nests non-patches dirs) — use --out/--registry instead"
  55. );
  56. }
  57. if let Ok(v) = std::fs::read_to_string(image_dir.join("versions.env")) {
  58. std::fs::write(bundle.join("versions.env"), &v)?;
  59. versions = versions_build_args(&v);
  60. }
  61. info!("📦 bundled image overlay {}", image_dir.display());
  62. } else {
  63. info!(
  64. "ℹ️ no docker-images/{} overlay — bundling recipe + scripts only",
  65. image_dir.display()
  66. );
  67. }
  68. let cluster = match cluster {
  69. Some(want) => Some(
  70. config
  71. .clusters
  72. .iter()
  73. .find(|c| c.name == want)
  74. .with_context(|| format!("cluster '{want}' not in config"))?,
  75. ),
  76. None => None,
  77. };
  78. let nccl = cluster.map(|c| config.nccl.get(&c.name));
  79. let revision = hf_revision(&recipe.model).await;
  80. let hf_cache = config
  81. .mount_aliases
  82. .get("hf_cache")
  83. .cloned()
  84. .or_else(|| config.hf_cache_dir.clone())
  85. .unwrap_or_else(|| "~/.cache/huggingface".into());
  86. let run = render_run_script(
  87. &recipe,
  88. cluster,
  89. nccl.flatten(),
  90. &hf_cache,
  91. config.init_port,
  92. revision.as_deref(),
  93. )?;
  94. std::fs::write(bundle.join("run.sh"), run)?;
  95. // Bundle build driver: launcher stamps the label, needs `Done: <tag>`.
  96. std::fs::write(
  97. bundle.join("build.sh"),
  98. "#!/usr/bin/env bash\nset -euo pipefail; cd \"$(dirname \"$0\")\"\n\
  99. TAG=\"${TAG:?set TAG=<recipe docker_image>}\"\nDOCKER_BUILDKIT=1 docker build -t \"$TAG\" -f Dockerfile . 2>&1\n\
  100. echo \"=== Done: ${TAG} ===\"\n",
  101. )?;
  102. let readme = render_readme(
  103. recipe_name,
  104. &recipe,
  105. &versions,
  106. revision.as_deref(),
  107. image_dir.exists(),
  108. );
  109. std::fs::write(bundle.join("README.md"), readme)?;
  110. info!("📝 bundle written to {}", bundle.display());
  111. // install.sh is part of the bundle even for the gist flow — the gist
  112. // branch only patches the returned id into it afterwards.
  113. std::fs::write(
  114. bundle.join("install.sh"),
  115. render_install_sh(recipe_name, &recipe.docker_image, &versions, None),
  116. )?;
  117. if let Some(url) = registry {
  118. crate::registry::upload(&bundle, recipe_name, url, token).await?;
  119. }
  120. if gist {
  121. let id = publish_gist(&bundle, &recipe.name)?;
  122. // The gist knows its own id now — bake it into install.sh and push the
  123. // update, so `curl …/raw/install.sh | bash` self-bootstraps from anywhere.
  124. let raw = std::fs::read_to_string(bundle.join("install.sh"))?.replacen(
  125. "GIST_ID=' '",
  126. &format!("GIST_ID={}", shq(&id)),
  127. 1,
  128. );
  129. std::fs::write(bundle.join("install.sh"), &raw)?;
  130. match Command::new("gh")
  131. .args(["gist", "edit", &id])
  132. .arg(bundle.join("install.sh"))
  133. .output()
  134. {
  135. Ok(o) if o.status.success() => info!("🔗 install.sh updated with gist id"),
  136. _ => info!("ℹ️ could not auto-update install.sh — set GIST_ID={id} manually"),
  137. }
  138. }
  139. Ok(())
  140. }
  141. /// Rewrite `patches/…` references to the bundle root on COPY/ADD lines
  142. /// (gists cannot carry directories). Comments keep their original text.
  143. pub(crate) fn flatten_dockerfile(text: &str) -> String {
  144. let mut in_copy = false;
  145. text.lines()
  146. .map(|line| {
  147. let t = line.trim_start();
  148. let is_copy = in_copy || t.starts_with("COPY ") || t.starts_with("ADD ");
  149. in_copy = is_copy && t.ends_with('\\');
  150. if is_copy {
  151. line.replace("patches/", "")
  152. } else {
  153. line.to_string()
  154. }
  155. })
  156. .collect::<Vec<_>>()
  157. .join("\n")
  158. + if text.ends_with('\n') { "\n" } else { "" }
  159. }
  160. /// Parse `versions.env` (`KEY=VAL` lines, `#` comments, optional quotes) into
  161. /// `--build-arg` pairs.
  162. pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {
  163. let mut out = BTreeMap::new();
  164. for line in text.lines() {
  165. let line = line.split('#').next().unwrap_or("").trim();
  166. if let Some((k, v)) = line.split_once('=') {
  167. let v = v.trim().trim_matches('"').trim_matches('\'');
  168. if !k.trim().is_empty() && !v.is_empty() {
  169. out.insert(k.trim().to_string(), v.to_string());
  170. }
  171. }
  172. }
  173. out
  174. }
  175. fn render_run_script(
  176. recipe: &Recipe,
  177. cluster: Option<&Cluster>,
  178. nccl: Option<&NcclConfig>,
  179. hf_cache: &str,
  180. init_port: u16,
  181. revision: Option<&str>,
  182. ) -> Result<String> {
  183. let nodes = cluster.map_or_else(
  184. || recipe.required_nodes.unwrap_or(2) as usize,
  185. |c| c.nodes.len(),
  186. );
  187. let solo = recipe.solo == Some(true) || nodes <= 1;
  188. let master = cluster
  189. .and_then(|c| c.master_ib_ip.clone())
  190. .or_else(|| cluster.and_then(|c| c.nodes.iter().find(|n| n.rank == 0)?.ib_ip.clone()))
  191. .unwrap_or_else(|| "<rank0-IB-ip>".into());
  192. let mut s = String::new();
  193. s.push_str(&format!(
  194. "#!/usr/bin/env bash\n\
  195. # {} — generated by `sm121 publish`. Run on rank 0; on each worker set\n\
  196. # NODE_RANK=<rank> and HEADLESS=\"--headless\" (drop --host/--port).\n\
  197. set -euo pipefail\n\n\
  198. MODEL={}\n\
  199. IMAGE={}\n",
  200. recipe.name,
  201. shq(&recipe.model),
  202. shq(&recipe.docker_image),
  203. ));
  204. if let Some(r) = revision {
  205. s.push_str(&format!(
  206. "MODEL_REVISION={} # pin: download + serve THIS revision (repo branches move)\n",
  207. shq(r)
  208. ));
  209. } else if recipe.model.contains('/') {
  210. s.push_str(
  211. "# MODEL_REVISION= # consider pinning the HF revision (the repo branch moves)\n",
  212. );
  213. }
  214. if !solo {
  215. s.push_str(&format!(
  216. "MASTER_ADDR={} # rank-0 IB ip\nNODE_RANK=0\nHEADLESS=\"\"\n",
  217. shq(&master)
  218. ));
  219. }
  220. s.push_str("\n# --- fabric env (adjust per cluster) ------------------------------------\n");
  221. match nccl {
  222. Some(n) => {
  223. s.push_str(&format!(
  224. "FABRIC_ENV=(\n -e NCCL_IB_HCA={}\n -e NCCL_SOCKET_IFNAME={}\n -e GLOO_SOCKET_IFNAME={}\n -e NCCL_IB_DISABLE={}\n",
  225. shq(&n.ib_hca), shq(&n.socket_ifname),
  226. shq(n.gloo_socket_ifname.as_deref().unwrap_or(&n.socket_ifname)), n.ib_disable));
  227. match n.ib_gid_index {
  228. Some(g) => s.push_str(&format!(" -e NCCL_IB_GID_INDEX={g}\n)\n")),
  229. 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"),
  230. }
  231. }
  232. None => s.push_str("FABRIC_ENV=(\n # -e NCCL_IB_HCA=... -e NCCL_SOCKET_IFNAME=... -e NCCL_IB_GID_INDEX=...\n)\n"),
  233. }
  234. s.push_str(&format!(
  235. "\nexec docker run -d --name pub-{} --gpus all --ipc=host --network host \\\n --shm-size {} \\\n -v {}:/root/.cache/huggingface \\\n \"${{FABRIC_ENV[@]}}\" \\\n",
  236. recipe.model.replace('/', "-"),
  237. if recipe.shm_size.is_empty() { "32g".into() } else { recipe.shm_size.clone() },
  238. hf_cache, // unquoted: `~` must expand in bash
  239. ));
  240. let env = recipe.resolved_env()?;
  241. for (k, v) in env.iter().collect::<BTreeMap<_, _>>() {
  242. s.push_str(&format!(" -e {} \\\n", shq(&format!("{k}={v}"))));
  243. }
  244. s.push_str(" \"$IMAGE\" \\\n");
  245. s.push_str(&format!(
  246. " --model \"$MODEL\"{}",
  247. if revision.is_some() {
  248. " --revision \"$MODEL_REVISION\""
  249. } else {
  250. ""
  251. }
  252. ));
  253. if !solo {
  254. if recipe.engine == "sglang" {
  255. s.push_str(&format!(
  256. " \\\n --tp {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --dist-init-addr \"$MASTER_ADDR:{init_port}\"",
  257. recipe.tensor_parallel_size
  258. ));
  259. } else {
  260. s.push_str(&format!(
  261. " \\\n --tensor-parallel-size {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --master-addr \"$MASTER_ADDR\" --master-port {init_port}",
  262. recipe.tensor_parallel_size
  263. ));
  264. if let Some(pp) = recipe.pipeline_parallel_size.filter(|p| *p > 1) {
  265. s.push_str(&format!(" --pipeline-parallel-size {pp}"));
  266. }
  267. }
  268. }
  269. for a in recipe.args.to_vec() {
  270. s.push_str(&format!(" \\\n {}", shq(&a)));
  271. }
  272. if !solo {
  273. s.push_str(" \\\n ${HEADLESS:+$HEADLESS}");
  274. }
  275. s.push_str(&format!(" \\\n --host 0.0.0.0 --port {}\n", recipe.port));
  276. Ok(s)
  277. }
  278. fn render_install_sh(
  279. recipe_name: &str,
  280. image: &str,
  281. versions: &BTreeMap<String, String>,
  282. gist_id: Option<&str>,
  283. ) -> String {
  284. let args = versions
  285. .iter()
  286. .map(|(k, v)| format!(" --build-arg {}={} \\\n", shq(k), shq(v)))
  287. .collect::<String>();
  288. let gid = gist_id
  289. .map(shq)
  290. .unwrap_or_else(|| "' '".to_string());
  291. let image_q = shq(image);
  292. format!(
  293. "#!/usr/bin/env bash\n\
  294. # Build {image}. Run from this folder, or set GIST_ID and run from\n\
  295. # anywhere to clone the published gist into /tmp first.\n\
  296. # Usage: curl -fsSL <raw-url>/install.sh | bash (after publishing as a gist)\n\
  297. set -euo pipefail\n\n\
  298. GIST_ID={gid} # filled in by `sm121 publish --gist`\n\
  299. DIR=/tmp/{recipe_name}-build\n\
  300. IMAGE={image_q}\n\n\
  301. HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
  302. if [ -f \"$HERE/Dockerfile\" ]; then\n SRC=\"$HERE\"\n\
  303. else\n rm -rf \"$DIR\"; git clone -q \"https://gist.github.com/${{GIST_ID}}.git\" \"$DIR\"; SRC=\"$DIR\"\n\
  304. fi\n\n\
  305. docker build -t \"$IMAGE\" \\\n{args}\"$SRC\"\n\
  306. echo \"built: $IMAGE — see run.sh\"\n",
  307. )
  308. }
  309. fn render_readme(
  310. recipe_name: &str,
  311. recipe: &Recipe,
  312. versions: &BTreeMap<String, String>,
  313. revision: Option<&str>,
  314. has_image: bool,
  315. ) -> String {
  316. let mut s = format!("# {} — reproducible recipe card\n\n", recipe.name);
  317. s.push_str(&format!(
  318. "Generated by `sm121 publish` from the [sm121-launcher](https://git.kilork.org/sm121/sm121-launcher) \
  319. recipe `{recipe_name}` (engine: {}, model: `{}`).\n\n",
  320. recipe.engine, recipe.model
  321. ));
  322. s.push_str(
  323. "**`recipe.toml` is the source of truth — its header comments document every \
  324. non-obvious flag and the measured boot markers. Read it first.**\n\n",
  325. );
  326. s.push_str("## Files\n\n| file | what |\n|---|---|\n| `recipe.toml` | the launcher recipe (rationale in comments) |\n");
  327. if has_image {
  328. s.push_str(
  329. "| `Dockerfile`, patches, `versions.env` | image overlay (flat — build here) |\n",
  330. );
  331. }
  332. s.push_str("| `install.sh` | one-shot build (gist-aware) |\n| `run.sh` | docker run, rank 0 (worker deltas in comments) |\n\n");
  333. if has_image {
  334. s.push_str("## Build\n\n```bash\nbash install.sh\n```\n\n");
  335. s.push_str("## Pinned coordinates\n\n| thing | ref |\n|---|---|\n");
  336. if let Some(b) = versions.get("BASE_IMAGE") {
  337. let note = if b.contains('@') {
  338. "digest-pinned"
  339. } else {
  340. "⚠ mutable tag — re-pin if reproducibility matters"
  341. };
  342. s.push_str(&format!("| base image | `{b}` ({note}) |\n"));
  343. }
  344. match revision {
  345. Some(r) => s.push_str(&format!("| model | `{}` @ `{r}` |\n", recipe.model)),
  346. None if recipe.model.contains('/') => s.push_str(&format!(
  347. "| model | `{}` @ main — ⚠ unpinned branch |\n",
  348. recipe.model
  349. )),
  350. _ => {}
  351. }
  352. s.push('\n');
  353. }
  354. s.push_str(
  355. "## Run\n\nCache the model on every node (see `recipe.toml` PREREQUISITES), edit \
  356. `MASTER_ADDR`/fabric env in `run.sh`, run on rank 0, then on each worker with \
  357. `NODE_RANK`/`HEADLESS` set. `recipe.toml` `[[extra_mounts]]` and built-in \
  358. `before_serve` host scripts are launcher-side — translate manually if needed.\n",
  359. );
  360. s
  361. }
  362. async fn hf_revision(model: &str) -> Option<String> {
  363. if !model.contains('/') || model.starts_with('.') || model.starts_with('/') {
  364. return None;
  365. }
  366. let url = format!("https://huggingface.co/api/models/{model}");
  367. let resp = reqwest::Client::builder()
  368. .timeout(std::time::Duration::from_secs(8))
  369. .build()
  370. .ok()?
  371. .get(url)
  372. .send()
  373. .await
  374. .ok()?
  375. .json::<serde_json::Value>()
  376. .await
  377. .ok()?;
  378. resp.get("sha")?.as_str().map(str::to_string)
  379. }
  380. fn publish_gist(bundle: &Path, desc: &str) -> Result<String> {
  381. let mut cmd = Command::new("gh");
  382. cmd.arg("gist")
  383. .arg("create")
  384. .arg("--public")
  385. .arg("--desc")
  386. .arg(desc);
  387. let mut files = Vec::new();
  388. for e in std::fs::read_dir(bundle)? {
  389. let p = e?.path();
  390. if p.is_file() {
  391. files.push(p);
  392. }
  393. }
  394. files.sort();
  395. cmd.args(&files);
  396. let out = cmd
  397. .output()
  398. .context("`gh` not found — install GitHub CLI or copy the bundle manually")?;
  399. if !out.status.success() {
  400. bail!(
  401. "gh gist create failed: {}",
  402. String::from_utf8_lossy(&out.stderr)
  403. );
  404. }
  405. let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
  406. info!("🌐 gist: {url}");
  407. // gh prints the full html url; the gist *id* is the last path segment.
  408. Ok(url.rsplit('/').next().unwrap_or(&url).to_string())
  409. }
  410. #[cfg(test)]
  411. mod tests {
  412. use super::*;
  413. #[test]
  414. fn flatten_rewrites_copy_lines_only() {
  415. 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";
  416. let f = flatten_dockerfile(df);
  417. assert!(f.contains("# see patches/UPSTREAM.md"));
  418. assert!(f.contains("COPY a.py /tmp/a.py"));
  419. assert!(f.contains(" c.patch\nRUN grep patches/"));
  420. }
  421. #[test]
  422. fn copy_flat_preserves_nested_dirs_reports_nested() {
  423. let tmp = std::env::temp_dir().join("sm121_copy_flat_test");
  424. let _ = std::fs::remove_dir_all(&tmp);
  425. let src = tmp.join("src");
  426. std::fs::create_dir_all(src.join("plugin/pkg")).unwrap();
  427. std::fs::create_dir_all(src.join("patches")).unwrap();
  428. std::fs::write(src.join("plugin/pyproject.toml"), "x").unwrap();
  429. std::fs::write(src.join("plugin/pkg/m.py"), "y").unwrap();
  430. std::fs::write(src.join("patches/p.patch"), "z").unwrap();
  431. std::fs::write(src.join("Dockerfile"), "f").unwrap();
  432. let dst = tmp.join("dst");
  433. std::fs::create_dir_all(&dst).unwrap();
  434. let nested = copy_flat(&src, &dst).unwrap();
  435. assert!(nested);
  436. let m = std::fs::read_to_string(dst.join("plugin/pkg/m.py")).unwrap();
  437. assert_eq!(m, "y");
  438. assert_eq!(std::fs::read_to_string(dst.join("p.patch")).unwrap(), "z");
  439. assert!(!dst.join("Dockerfile").exists());
  440. }
  441. #[test]
  442. fn versions_env_parses_quoted_and_commented() {
  443. let v = versions_build_args("# c\nBASE_IMAGE=\"img@sha256:1\"\nX = 'y' \nNOVAL=\n");
  444. assert_eq!(v.get("BASE_IMAGE").unwrap(), "img@sha256:1");
  445. assert_eq!(v.get("X").unwrap(), "y");
  446. assert!(!v.contains_key("NOVAL"));
  447. }
  448. fn rec(engine: &str) -> Recipe {
  449. toml::from_str(&format!(
  450. "engine = \"{engine}\"\nname = \"T\"\ndocker_image = \"i:t\"\nmodel = \"o/m\"\n\
  451. port = 30000\nrequired_nodes = 2\ntensor_parallel_size = 2\n\
  452. args = [\"--speculative-config\", \"'{{\\\"method\\\":\\\"mtp\\\"}}'\"]\n"
  453. ))
  454. .unwrap()
  455. }
  456. #[test]
  457. fn run_script_vllm_has_dist_flags_and_json_arg() {
  458. let s = render_run_script(&rec("vllm"), None, None, "/hf", 29500, Some("abc")).unwrap();
  459. assert!(s.contains("--tensor-parallel-size 2 --nnodes 2 --node-rank \"$NODE_RANK\""));
  460. assert!(s.contains("--revision \"$MODEL_REVISION\""));
  461. assert!(s.contains("'--speculative-config'"));
  462. assert!(s.contains(&shq("'{\"method\":\"mtp\"}'"))); // json arg survives shq quoting intact
  463. }
  464. #[test]
  465. fn run_script_sglang_solo_omits_dist() {
  466. let mut r = rec("sglang");
  467. r.solo = Some(true);
  468. let s = render_run_script(&r, None, None, "/hf", 29500, None).unwrap();
  469. assert!(!s.contains("--nnodes") && !s.contains("MASTER_ADDR"));
  470. assert!(s.contains("# MODEL_REVISION="));
  471. }
  472. }