publish.rs 18 KB

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