registry.rs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. //! Client for the public recipe registry (sm121recipes).
  2. //!
  3. //! Publish: tar.gz the flat bundle produced by `sm121 publish` and POST it.
  4. //! Install: fetch the recipe archive and rebuild the launcher layout under
  5. //! the current directory — `recipe.toml` (+docs) go to `./recipes/<name>/`,
  6. //! image-overlay files (Dockerfile, patches, versions.env, …) to
  7. //! `./docker-images/<name>/`. The registry itself stores the flat bundle.
  8. use std::path::Path;
  9. use anyhow::{Context, Result, bail};
  10. use tracing::info;
  11. pub const DEFAULT_URL: &str = "https://sm121recipes.onlinedb.org";
  12. /// Files that belong in the launcher's `recipes/<name>/` dir: the recipe
  13. /// itself plus the human-facing docs the bundle generates. **Everything else
  14. /// in the bundle is image build context** (Dockerfile, `*.patch`, patcher
  15. /// `*.py`, `versions.env`, `UPSTREAM.md`, `patches/**`, …) and goes to
  16. /// `./docker-images/<name>/`. An allowlist keeps future build-context files
  17. /// from silently mislanding in the recipe dir.
  18. pub(crate) fn is_image_file(entry: &str) -> bool {
  19. !matches!(entry, "recipe.toml" | "README.md" | "run.sh" | "install.sh")
  20. }
  21. fn client() -> Result<reqwest::Client> {
  22. reqwest::Client::builder()
  23. .timeout(std::time::Duration::from_secs(120))
  24. .build()
  25. .context("http client")
  26. }
  27. /// Tar.gz the (flat) bundle dir; entry paths are relative to it.
  28. pub(crate) fn tar_gz_dir(dir: &Path) -> Result<Vec<u8>> {
  29. let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
  30. {
  31. let mut ar = tar::Builder::new(&mut enc);
  32. for entry in walkdir::WalkDir::new(dir) {
  33. let e = entry?;
  34. if e.file_type().is_file() {
  35. let rel = e.path().strip_prefix(dir)?;
  36. ar.append_path_with_name(e.path(), rel)?;
  37. }
  38. }
  39. ar.finish()?;
  40. }
  41. Ok(enc.finish()?)
  42. }
  43. pub async fn upload(bundle: &Path, name: &str, registry: &str, token: Option<&str>) -> Result<()> {
  44. let token = token
  45. .map(str::to_string)
  46. .or_else(|| std::env::var("SM121RECIPES_TOKEN").ok())
  47. .with_context(|| "publishing to a registry needs --token <t> or $SM121RECIPES_TOKEN")?;
  48. let url = format!("{}/api/recipes/{}", registry.trim_end_matches('/'), name);
  49. let body = tar_gz_dir(bundle)?;
  50. let resp = client()?
  51. .post(&url)
  52. .bearer_auth(token)
  53. .header(reqwest::header::CONTENT_TYPE, "application/gzip")
  54. .body(body)
  55. .send()
  56. .await
  57. .context("registry unreachable")?;
  58. let status = resp.status();
  59. let text = resp.text().await.unwrap_or_default();
  60. if !status.is_success() {
  61. bail!("registry rejected {name}: {status} {text}");
  62. }
  63. info!("🌐 published {name} → {url}");
  64. Ok(())
  65. }
  66. pub async fn handle(cmd: crate::cli::RegistryCommands) -> Result<()> {
  67. use crate::cli::RegistryCommands;
  68. match cmd {
  69. RegistryCommands::List { registry } => list(registry.as_deref()).await,
  70. RegistryCommands::Show { name, registry } => show(&name, registry.as_deref()).await,
  71. RegistryCommands::Install {
  72. name,
  73. registry,
  74. force,
  75. } => install(&name, registry.as_deref(), force).await,
  76. }
  77. }
  78. pub async fn list(registry: Option<&str>) -> Result<()> {
  79. let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
  80. let items: Vec<serde_json::Value> = client()?
  81. .get(format!("{base}/api/recipes"))
  82. .send()
  83. .await?
  84. .json()
  85. .await?;
  86. if items.is_empty() {
  87. println!("registry is empty");
  88. return Ok(());
  89. }
  90. for r in items {
  91. println!(
  92. "{:<32} {:<8} {:<40} {}",
  93. r["name"].as_str().unwrap_or("?"),
  94. r["engine"].as_str().unwrap_or("?"),
  95. r["model"].as_str().unwrap_or("?"),
  96. r["title"].as_str().unwrap_or(""),
  97. );
  98. }
  99. Ok(())
  100. }
  101. pub async fn show(name: &str, registry: Option<&str>) -> Result<()> {
  102. let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
  103. let v: serde_json::Value = client()?
  104. .get(format!("{base}/api/recipes/{name}"))
  105. .send()
  106. .await?
  107. .json()
  108. .await?;
  109. print!(
  110. "{}",
  111. v["recipe_toml"].as_str().unwrap_or("(no recipe_toml)")
  112. );
  113. if let Some(files) = v["files"].as_array() {
  114. info!(
  115. "files: {}",
  116. files
  117. .iter()
  118. .filter_map(|f| f.as_str())
  119. .collect::<Vec<_>>()
  120. .join(", ")
  121. );
  122. }
  123. Ok(())
  124. }
  125. pub async fn install(name: &str, registry: Option<&str>, force: bool) -> Result<()> {
  126. validate_name(name)?;
  127. let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
  128. let resp = client()?
  129. .get(format!("{base}/recipes/{name}/archive.tar.gz"))
  130. .send()
  131. .await
  132. .context("registry unreachable")?;
  133. if !resp.status().is_success() {
  134. bail!(
  135. "recipe '{name}' not found at {base} (HTTP {})",
  136. resp.status()
  137. );
  138. }
  139. let bytes = resp.bytes().await?;
  140. let rdir = Path::new("recipes").join(name);
  141. let idir = Path::new("docker-images").join(name);
  142. if rdir.exists() && !force {
  143. bail!("{} exists — overwrite with --force", rdir.display());
  144. }
  145. std::fs::create_dir_all(&rdir)?;
  146. let dec = flate2::read::GzDecoder::new(&bytes[..]);
  147. let mut ar = tar::Archive::new(dec);
  148. let mut n = 0usize;
  149. for entry in ar.entries()? {
  150. let mut entry = entry?;
  151. let path = entry.path()?.to_path_buf();
  152. let Some(rel) = path.to_str().map(str::to_string).filter(|s| !s.is_empty()) else {
  153. continue;
  154. };
  155. if !entry.header().entry_type().is_file() {
  156. continue;
  157. }
  158. if rel.contains("..") || rel.starts_with('/') {
  159. bail!("unsafe path in registry archive: {rel}");
  160. }
  161. let dst = if is_image_file(&rel) { &idir } else { &rdir };
  162. let out = dst.join(&rel);
  163. if let Some(p) = out.parent() {
  164. std::fs::create_dir_all(p)?;
  165. }
  166. let mut f = std::fs::File::create(&out)?;
  167. std::io::copy(&mut entry, &mut f)?;
  168. n += 1;
  169. }
  170. if n == 0 {
  171. bail!("archive for '{name}' contained no files");
  172. }
  173. if idir.exists() {
  174. info!(
  175. "📦 {name}: recipe → {}, image overlay → {}",
  176. rdir.display(),
  177. idir.display()
  178. );
  179. } else {
  180. info!("📦 {name}: recipe → {}", rdir.display());
  181. }
  182. Ok(())
  183. }
  184. fn validate_name(name: &str) -> Result<()> {
  185. if name.is_empty()
  186. || !name
  187. .chars()
  188. .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
  189. {
  190. bail!("invalid recipe name '{name}'");
  191. }
  192. Ok(())
  193. }
  194. #[cfg(test)]
  195. mod tests {
  196. use super::*;
  197. #[test]
  198. fn flat_bundle_splits_into_launcher_layout() {
  199. for img in [
  200. "Dockerfile",
  201. "modelopt.patch",
  202. "patches/a.patch",
  203. "patch_mla.py",
  204. "versions.env",
  205. "UPSTREAM.md",
  206. ] {
  207. assert!(is_image_file(img), "{img} should be an image file");
  208. }
  209. for rec in ["recipe.toml", "README.md", "run.sh", "install.sh"] {
  210. assert!(!is_image_file(rec), "{rec} should be a recipe file");
  211. }
  212. }
  213. #[test]
  214. fn names_are_slugs() {
  215. assert!(validate_name("glm53-flash-nvfp4-lab").is_ok());
  216. assert!(validate_name("../x").is_err());
  217. assert!(validate_name("Upper").is_err());
  218. assert!(validate_name("").is_err());
  219. }
  220. #[test]
  221. fn tar_gz_roundtrips_bundle() {
  222. let dir = std::env::temp_dir().join(format!("sm121-cli-{}", std::process::id()));
  223. std::fs::create_dir_all(dir.join("patches")).unwrap();
  224. std::fs::write(dir.join("recipe.toml"), b"engine = \"vllm\"").unwrap();
  225. std::fs::write(dir.join("patches/a.patch"), b"diff").unwrap();
  226. let bytes = tar_gz_dir(&dir).unwrap();
  227. let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
  228. let mut names: Vec<String> = ar
  229. .entries()
  230. .unwrap()
  231. .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
  232. .collect();
  233. names.sort();
  234. assert_eq!(names, ["patches/a.patch", "recipe.toml"]);
  235. let _ = std::fs::remove_dir_all(&dir);
  236. }
  237. }