publish.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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 anyhow::{Context, Result, bail};
  10. use std::collections::BTreeMap;
  11. use std::path::{Path, PathBuf};
  12. use tracing::info;
  13. use crate::config::{Config, Recipe, recipe_dirs};
  14. use crate::docker::build_dir::{build_checksum, flatten_overlay};
  15. use crate::ssh::shq;
  16. /// Publish `recipe_name` into `out` (default `./publish/<name>`), optionally
  17. /// creating a gist. `cluster` (optional) supplies fabric env + node count in
  18. /// the generated run script.
  19. pub struct PushOpts {
  20. pub push: bool,
  21. pub node: Option<String>,
  22. pub force: bool,
  23. }
  24. /// The (separate) recipe-bundle registry: where to upload the bundle and with
  25. /// which token. Not to be confused with the image push target.
  26. pub struct RecipeRegistryOpts {
  27. pub url: Option<String>,
  28. pub token: Option<String>,
  29. }
  30. pub async fn publish(
  31. config: &Config,
  32. recipe_name: &str,
  33. cluster: Option<&str>,
  34. out: Option<PathBuf>,
  35. gist: bool,
  36. recipe_registry: &RecipeRegistryOpts,
  37. push_opts: PushOpts,
  38. ) -> Result<()> {
  39. let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  40. if recipe.deploy != crate::config::DeployTarget::Docker {
  41. bail!("publish supports docker recipes only (deploy = tmux has no image bundle)");
  42. }
  43. let bundle = out.unwrap_or_else(|| Path::new("publish").join(recipe_name));
  44. if bundle.exists() {
  45. std::fs::remove_dir_all(&bundle)
  46. .with_context(|| format!("clearing {}", bundle.display()))?;
  47. }
  48. std::fs::create_dir_all(&bundle)?;
  49. let recipe_src = recipe_dirs(config)
  50. .into_iter()
  51. .map(|d| d.join(recipe_name).join("recipe.toml"))
  52. .find(|p| p.exists())
  53. .with_context(|| format!("recipe.toml for '{recipe_name}' not found in recipe dirs"))?;
  54. std::fs::copy(&recipe_src, bundle.join("recipe.toml"))?;
  55. let mut versions: BTreeMap<String, String> = BTreeMap::new();
  56. let profile_used = recipe.build_profile.clone().is_some_and(|p| !p.is_empty())
  57. || !recipe.build.versions.is_empty();
  58. let image_dir =
  59. Path::new("docker-images").join(recipe.docker_image_dir.as_deref().unwrap_or(recipe_name));
  60. if image_dir.join("Dockerfile").exists() {
  61. let df = std::fs::read_to_string(image_dir.join("Dockerfile"))?;
  62. let (df_flat, nested) = flatten_overlay(&image_dir, &bundle, &df)?;
  63. std::fs::write(bundle.join("Dockerfile"), &df_flat)?;
  64. if gist && nested {
  65. bail!(
  66. "publish --gist needs a flat overlay ({image_dir:?} nests non-flattenable dirs) — use --out/--registry instead"
  67. );
  68. }
  69. // Merge the profile + recipe [build.versions] pins the same way the
  70. // build path does, so the bundle builds the image that was served.
  71. let (merged_env, _) = build_checksum(
  72. &image_dir,
  73. recipe.build_profile.as_deref(),
  74. &recipe.build.versions,
  75. )?;
  76. if let Some(merged) = merged_env {
  77. std::fs::write(bundle.join("versions.env"), &merged)?;
  78. versions = versions_build_args(&merged);
  79. info!(
  80. "📦 bundled image overlay {} with merged pin set (profile: {})",
  81. image_dir.display(),
  82. recipe
  83. .build_profile
  84. .as_deref()
  85. .unwrap_or("— recipe [build.versions] only")
  86. );
  87. } else if let Ok(v) = std::fs::read_to_string(image_dir.join("versions.env")) {
  88. std::fs::write(bundle.join("versions.env"), &v)?;
  89. versions = versions_build_args(&v);
  90. info!("📦 bundled image overlay {}", image_dir.display());
  91. }
  92. // Bundle the workspace's generic build driver (forwards every pin as
  93. // --build-arg, prints the `Done: <tag>` marker the launcher labels
  94. // with) when one exists; otherwise a minimal driver.
  95. let ws_build = image_dir.join("build.sh");
  96. if let Ok(bs) = std::fs::read_to_string(&ws_build) {
  97. std::fs::write(bundle.join("build.sh"), bs)?;
  98. } else {
  99. std::fs::write(
  100. bundle.join("build.sh"),
  101. "#!/usr/bin/env bash\nset -euo pipefail; cd \"$(dirname \"$0\")\"\n\
  102. TAG=\"${TAG:?set TAG=<recipe docker_image>}\"\nDOCKER_BUILDKIT=1 docker build -t \"$TAG\" -f Dockerfile . 2>&1\n\
  103. echo \"=== Done: ${TAG} ===\"\n",
  104. )?;
  105. }
  106. } else {
  107. info!(
  108. "ℹ️ no docker-images/{} overlay — bundling recipe + scripts only",
  109. image_dir.display()
  110. );
  111. std::fs::write(
  112. bundle.join("build.sh"),
  113. "#!/usr/bin/env bash\nset -euo pipefail; cd \"$(dirname \"$0\")\"\n\
  114. TAG=\"${TAG:?set TAG=<recipe docker_image>}\"\nDOCKER_BUILDKIT=1 docker build -t \"$TAG\" -f Dockerfile . 2>&1\n\
  115. echo \"=== Done: ${TAG} ===\"\n",
  116. )?;
  117. }
  118. let cluster = match cluster {
  119. Some(want) => Some(
  120. config
  121. .clusters
  122. .iter()
  123. .find(|c| c.name == want)
  124. .with_context(|| format!("cluster '{want}' not in config"))?,
  125. ),
  126. None => None,
  127. };
  128. let nccl = cluster.map(|c| config.nccl.get(&c.name));
  129. let revision = crate::publish_render::hf_revision(&recipe.model).await;
  130. let hf_cache = config
  131. .mount_aliases
  132. .get("hf_cache")
  133. .cloned()
  134. .or_else(|| config.hf_cache_dir.clone())
  135. .unwrap_or_else(|| "~/.cache/huggingface".into());
  136. let run = crate::publish_render::render_run_script(
  137. &recipe,
  138. cluster,
  139. nccl.flatten(),
  140. &hf_cache,
  141. config.init_port,
  142. revision.as_deref(),
  143. )?;
  144. std::fs::write(bundle.join("run.sh"), run)?;
  145. let readme = crate::publish_render::render_readme(
  146. recipe_name,
  147. &recipe,
  148. &versions,
  149. revision.as_deref(),
  150. image_dir.exists(),
  151. profile_used,
  152. );
  153. std::fs::write(bundle.join("README.md"), readme)?;
  154. info!("📝 bundle written to {}", bundle.display());
  155. // install.sh is part of the bundle even for the gist flow — the gist
  156. // branch only patches the returned id into it afterwards.
  157. std::fs::write(
  158. bundle.join("install.sh"),
  159. crate::publish_render::render_install_sh(
  160. recipe_name,
  161. &recipe.docker_image,
  162. &versions,
  163. None,
  164. ),
  165. )?;
  166. if let Some(url) = &recipe_registry.url {
  167. crate::registry::upload(&bundle, recipe_name, url, recipe_registry.token.as_deref())
  168. .await?;
  169. }
  170. if gist {
  171. let id = crate::publish_render::publish_gist(&bundle, &recipe.name)?;
  172. // The gist knows its own id now — bake it into install.sh and push the
  173. // update, so `curl …/raw/install.sh | bash` self-bootstraps from anywhere.
  174. let raw = std::fs::read_to_string(bundle.join("install.sh"))?.replacen(
  175. "GIST_ID=' '",
  176. &format!("GIST_ID={}", shq(&id)),
  177. 1,
  178. );
  179. std::fs::write(bundle.join("install.sh"), &raw)?;
  180. // Push the updated install.sh back to the gist (gh gist edit is
  181. // interactive-only — use the API):
  182. let body = serde_json::json!({ "files": { "install.sh": { "content": raw } } }).to_string();
  183. let pushed = std::process::Command::new("gh")
  184. .args(["api", "-X", "POST", &format!("gists/{id}"), "--input", "-"])
  185. .stdin(std::process::Stdio::piped())
  186. .stdout(std::process::Stdio::null())
  187. .stderr(std::process::Stdio::null())
  188. .spawn()
  189. .and_then(|mut child| {
  190. if let Some(mut stdin) = child.stdin.take() {
  191. use std::io::Write;
  192. stdin.write_all(body.as_bytes())?;
  193. }
  194. child.wait()
  195. });
  196. match pushed {
  197. Ok(s) if s.success() => info!("🔗 install.sh updated with gist id"),
  198. _ => info!("ℹ️ could not auto-update install.sh — set GIST_ID={id} manually"),
  199. }
  200. }
  201. if push_opts.push {
  202. push_recipe_image(config, &recipe, &push_opts).await?;
  203. }
  204. Ok(())
  205. }
  206. /// `publish --push`: push the recipe's image to the `[registry]` registry
  207. /// under its version tag (the `sm121.checksum` label, or the image ID when
  208. /// unlabeled) + `latest`. The image is found on `--node` or by probing all
  209. /// cluster nodes; the push runs from that node.
  210. async fn push_recipe_image(config: &Config, recipe: &Recipe, opts: &PushOpts) -> Result<()> {
  211. if config.registry.is_none() {
  212. anyhow::bail!(
  213. "publish --push requires a [registry] section in config.toml (base = ghcr.io/<you>)"
  214. );
  215. }
  216. let image = recipe.docker_image.trim();
  217. if image.is_empty() {
  218. anyhow::bail!(
  219. "recipe '{}' has no docker_image — nothing to push",
  220. recipe.name
  221. );
  222. }
  223. // 1. Find a node that has the image.
  224. let image = image.to_string();
  225. let found: Option<crate::config::Node> = if let Some(want) = &opts.node {
  226. let node = config
  227. .clusters
  228. .iter()
  229. .flat_map(|c| c.nodes.iter())
  230. .find(|n| n.name.as_str() == want.as_str())
  231. .with_context(|| format!("node '{want}' not in any cluster"))?
  232. .clone();
  233. let id = crate::ssh::run_ssh(
  234. config,
  235. &node,
  236. format!(
  237. "docker image ls --format '{{{{.ID}}}}' {img} | head -1",
  238. img = shq(&image)
  239. ),
  240. false,
  241. )
  242. .await
  243. .unwrap_or_default();
  244. if id.trim().is_empty() {
  245. None
  246. } else {
  247. Some(node)
  248. }
  249. } else {
  250. let mut seen = std::collections::HashSet::new();
  251. let all: Vec<crate::config::Node> = config
  252. .clusters
  253. .iter()
  254. .flat_map(|c| c.nodes.iter().cloned())
  255. .filter(|n| seen.insert(n.ssh_ip.clone()))
  256. .collect();
  257. let handles = all.iter().map(|node| {
  258. let config = config.clone();
  259. let node = node.clone();
  260. let image = image.clone();
  261. crate::locale::spawn_with_locale(async move {
  262. let id = crate::ssh::run_ssh(
  263. &config,
  264. &node,
  265. format!(
  266. "docker image ls --format '{{{{.ID}}}}' {img} | head -1",
  267. img = shq(&image)
  268. ),
  269. false,
  270. )
  271. .await
  272. .unwrap_or_default();
  273. (node.name.clone(), id.trim().to_string())
  274. })
  275. });
  276. let results = futures::future::join_all(handles).await;
  277. all.into_iter()
  278. .zip(results)
  279. .find_map(|(node, h)| h.ok().filter(|(_, id)| !id.is_empty()).map(|_| node))
  280. };
  281. let Some(node) = found else {
  282. anyhow::bail!(
  283. "image '{image}' not found on {} — build it first (sm121 build --recipe {} --build-node <n>)",
  284. opts.node
  285. .as_deref()
  286. .map(|n| format!("'{n}'"))
  287. .unwrap_or_else(|| "any node".to_string()),
  288. recipe.name
  289. );
  290. };
  291. // 2. Version = the sm121.checksum label (build checksum), else the image ID.
  292. let label = crate::ssh::run_ssh(
  293. config,
  294. &node,
  295. format!(
  296. "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {img} 2>/dev/null || true",
  297. img = shq(&image)
  298. ),
  299. false,
  300. )
  301. .await
  302. .unwrap_or_default();
  303. let version = if label.trim().len() >= 12 {
  304. label.trim()[..12].to_string()
  305. } else {
  306. let id = crate::ssh::run_ssh(
  307. config,
  308. &node,
  309. format!(
  310. "docker image inspect --format '{{{{.Id}}}}' {img}",
  311. img = shq(&image)
  312. ),
  313. false,
  314. )
  315. .await
  316. .unwrap_or_default();
  317. let id = id.trim().strip_prefix("sha256:").unwrap_or(id.trim());
  318. id.get(..12).unwrap_or(id).to_string()
  319. };
  320. info!(
  321. "publish --push: pushing '{image}' v{version} from '{}'",
  322. node.name
  323. );
  324. crate::docker::push::push_image(config, &node, &image, &version, opts.force, "publish").await
  325. }
  326. /// Parse `versions.env` (`KEY=VAL` lines, `#` comments, optional quotes) into
  327. /// `--build-arg` pairs.
  328. pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {
  329. let mut out = BTreeMap::new();
  330. for line in text.lines() {
  331. let line = line.split('#').next().unwrap_or("").trim();
  332. if let Some((k, v)) = line.split_once('=') {
  333. let v = v.trim().trim_matches('"').trim_matches('\'');
  334. if !k.trim().is_empty() && !v.is_empty() {
  335. out.insert(k.trim().to_string(), v.to_string());
  336. }
  337. }
  338. }
  339. out
  340. }
  341. #[cfg(test)]
  342. mod tests {
  343. use super::*;
  344. use crate::config::Recipe;
  345. #[test]
  346. fn flatten_overlay_rewrites_copy_lines_and_collision_renames() {
  347. let tmp = std::env::temp_dir().join("sm121_flatten_overlay_test");
  348. let _ = std::fs::remove_dir_all(&tmp);
  349. let src = tmp.join("src");
  350. std::fs::create_dir_all(src.join("locks")).unwrap();
  351. std::fs::create_dir_all(src.join("patches")).unwrap();
  352. std::fs::create_dir_all(src.join("profile")).unwrap();
  353. std::fs::write(src.join("apt-sources.list"), "root").unwrap();
  354. std::fs::write(src.join("locks/apt-sources.list"), "lock").unwrap(); // collision
  355. std::fs::write(src.join("locks/apt-packages.txt"), "pkg").unwrap();
  356. std::fs::write(src.join("locks/python-bootstrap.txt"), "boot").unwrap(); // unreferenced -> not shipped
  357. std::fs::write(src.join("patches/p.patch"), "z").unwrap();
  358. std::fs::write(src.join("profile/x.env"), "y").unwrap();
  359. std::fs::create_dir_all(src.join("extra")).unwrap();
  360. std::fs::write(src.join("extra/y.env"), "z2").unwrap(); // unreferenced dir -> not shipped
  361. std::fs::write(src.join("Dockerfile"), "f").unwrap();
  362. let df = "FROM x\n# see patches/UPSTREAM.md\nCOPY apt-sources.list /tmp/root-a.list\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";
  363. let dst = tmp.join("dst");
  364. std::fs::create_dir_all(&dst).unwrap();
  365. let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();
  366. assert!(
  367. !nested,
  368. "nothing nests anymore — unreferenced files are dropped"
  369. );
  370. // collision rename for the referenced locks file
  371. assert!(
  372. dst.join("locks-apt-sources.list").exists(),
  373. "collision rename"
  374. );
  375. assert!(dst.join("apt-sources.list").exists(), "root file shipped");
  376. assert_eq!(
  377. std::fs::read_to_string(dst.join("apt-packages.txt")).unwrap(),
  378. "pkg"
  379. );
  380. assert_eq!(std::fs::read_to_string(dst.join("p.patch")).unwrap(), "z");
  381. assert!(dst.join("x.env").exists(), "profile dir flattened");
  382. // unreferenced files / dirs are not shipped
  383. assert!(!dst.join("python-bootstrap.txt").exists());
  384. assert!(!dst.join("extra").exists());
  385. // Dockerfile rewritten: renamed file, prefix drops (root + continuation),
  386. // comments + non-COPY lines untouched
  387. assert!(flat.contains("COPY locks-apt-sources.list /tmp/a.list"));
  388. assert!(flat.contains("COPY apt-packages.txt /tmp/b.txt \\\n p.patch"));
  389. assert!(flat.contains("COPY x.env /tmp/x"));
  390. assert!(flat.contains("COPY apt-sources.list /tmp/root-a.list"));
  391. assert!(flat.contains("# see patches/UPSTREAM.md"));
  392. assert!(flat.contains("RUN grep patches/ /tmp/x"));
  393. assert!(!dst.join("Dockerfile").exists());
  394. }
  395. #[test]
  396. fn flatten_overlay_glob_collision_keeps_dir_nested() {
  397. let tmp = std::env::temp_dir().join("sm121_flatten_glob_test");
  398. let _ = std::fs::remove_dir_all(&tmp);
  399. let src = tmp.join("src");
  400. std::fs::create_dir_all(src.join("patches")).unwrap();
  401. std::fs::write(src.join("a.py"), "root").unwrap(); // referenced + collides with patches/a.py
  402. std::fs::write(src.join("patches/a.py"), "patched").unwrap();
  403. let df = "FROM x\nCOPY a.py /tmp/a0.py\nCOPY patches/*.py /tmp/\n";
  404. let dst = tmp.join("dst");
  405. std::fs::create_dir_all(&dst).unwrap();
  406. let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();
  407. assert!(nested, "glob collision must not flatten");
  408. assert!(dst.join("patches/a.py").exists());
  409. assert!(
  410. flat.contains("COPY patches/*.py /tmp/"),
  411. "line left untouched"
  412. );
  413. }
  414. #[test]
  415. fn versions_env_parses_quoted_and_commented() {
  416. let v = versions_build_args("# c\nBASE_IMAGE=\"img@sha256:1\"\nX = 'y' \nNOVAL=\n");
  417. assert_eq!(v.get("BASE_IMAGE").unwrap(), "img@sha256:1");
  418. assert_eq!(v.get("X").unwrap(), "y");
  419. assert!(!v.contains_key("NOVAL"));
  420. }
  421. fn rec(engine: &str) -> Recipe {
  422. toml::from_str(&format!(
  423. "engine = \"{engine}\"\nname = \"T\"\ndocker_image = \"i:t\"\nmodel = \"o/m\"\n\
  424. port = 30000\nrequired_nodes = 2\ntensor_parallel_size = 2\n\
  425. args = [\"--speculative-config\", \"'{{\\\"method\\\":\\\"mtp\\\"}}'\"]\n"
  426. ))
  427. .unwrap()
  428. }
  429. #[test]
  430. fn run_script_vllm_has_dist_flags_and_json_arg() {
  431. let s = crate::publish_render::render_run_script(
  432. &rec("vllm"),
  433. None,
  434. None,
  435. "/hf",
  436. 29500,
  437. Some("abc"),
  438. )
  439. .unwrap();
  440. assert!(s.contains("--tensor-parallel-size 2 --nnodes 2 --node-rank \"$NODE_RANK\""));
  441. assert!(s.contains("--revision \"$MODEL_REVISION\""));
  442. assert!(s.contains("'--speculative-config'"));
  443. assert!(s.contains(&shq("'{\"method\":\"mtp\"}'"))); // json arg survives shq quoting intact
  444. }
  445. #[test]
  446. fn run_script_sglang_solo_omits_dist() {
  447. let mut r = rec("sglang");
  448. r.solo = Some(true);
  449. let s =
  450. crate::publish_render::render_run_script(&r, None, None, "/hf", 29500, None).unwrap();
  451. assert!(!s.contains("--nnodes") && !s.contains("MASTER_ADDR"));
  452. assert!(s.contains("# MODEL_REVISION="));
  453. }
  454. }