Explorar el Código

feat(registry): publish --registry upload + sm121 registry list/show/install

Round-trips recipe bundles against the public registry
(https://sm121recipes.onlinedb.org, repo sm121recipes):

- publish --registry URL [--token T | $SM121RECIPES_TOKEN]: tars the
  flat bundle and POSTs it (one git commit server-side);
- registry list / show <name> / install <name> [--force]: install
  splits the flat archive back into the launcher layout — recipe.toml +
  docs to ./recipes/<name>/, ALL build context (Dockerfile, *.patch,
  patcher *.py, versions.env, ...) to ./docker-images/<name>/;
- fixed the --gist path (install.sh was only written on the non-gist
  branch, so gist publishes crashed reading it back);
- Publish args flattened into a clap Args struct (main.rs back <500).

E2E-tested against a locally-run registry: real glm53-flash-nvfp4-lab
bundle published -> listed -> installed into a clean dir -> doctor ok.
3 new tests (split allowlist, slug guard, tar roundtrip); 222 total.
Alexander Korolev hace 1 mes
padre
commit
51912523c5
Se han modificado 6 ficheros con 390 adiciones y 29 borrados
  1. 52 0
      Cargo.lock
  2. 2 0
      Cargo.toml
  3. 55 16
      src/cli.rs
  4. 13 7
      src/main.rs
  5. 13 6
      src/publish.rs
  6. 255 0
      src/registry.rs

+ 52 - 0
Cargo.lock

@@ -811,6 +811,16 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
 
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
 [[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
@@ -1594,6 +1604,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
 [[package]]
 name = "litemap"
 version = "0.8.2"
@@ -2374,6 +2390,19 @@ dependencies = [
  "semver",
 ]
 
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "rustls"
 version = "0.23.40"
@@ -2745,6 +2774,7 @@ dependencies = [
  "base64",
  "clap",
  "dirs",
+ "flate2",
  "fluent-i18n",
  "futures",
  "reqwest",
@@ -2752,6 +2782,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sha2",
+ "tar",
  "tokio",
  "toml",
  "toml_edit",
@@ -2914,6 +2945,17 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
 [[package]]
 name = "thiserror"
 version = "2.0.18"
@@ -3726,6 +3768,16 @@ version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
 
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
 [[package]]
 name = "yoke"
 version = "0.8.3"

+ 2 - 0
Cargo.toml

@@ -20,5 +20,7 @@ futures = "0.3"
 walkdir = "2"
 dirs = "6"
 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
+flate2 = "1"
+tar = "0.4"
 fluent-i18n = "0.1.0"
 axum = "0.8"

+ 55 - 16
src/cli.rs

@@ -1,4 +1,4 @@
-use clap::{Parser, Subcommand};
+use clap::{Args, Parser, Subcommand};
 
 #[derive(Parser)]
 #[command(author, version, about = "DGX Spark Launcher for vLLM and SGLang")]
@@ -156,22 +156,11 @@ pub enum Commands {
     },
 
     /// Publish a recipe (+ image overlay) as a standalone reproducible bundle
-    Publish {
-        #[arg(long, required = true)]
-        recipe: String,
-
-        /// Cluster whose fabric env + node count fill the generated run script
-        #[arg(long)]
-        cluster: Option<String>,
-
-        /// Output dir (default: ./publish/<recipe>)
-        #[arg(long)]
-        out: Option<std::path::PathBuf>,
+    Publish(PublishArgs),
 
-        /// Also create a public GitHub gist (requires `gh` CLI, authenticated)
-        #[arg(long)]
-        gist: bool,
-    },
+    /// Browse and install recipes from the public registry
+    #[command(subcommand)]
+    Registry(RegistryCommands),
 
     /// Manage tmux sessions (Docker alternative)
     #[command(subcommand)]
@@ -291,6 +280,56 @@ pub enum ConfigCommands {
     InitGlobal,
 }
 
+#[derive(Args)]
+pub struct PublishArgs {
+    #[arg(long, required = true)]
+    pub recipe: String,
+
+    /// Cluster whose fabric env + node count fill the generated run script
+    #[arg(long)]
+    pub cluster: Option<String>,
+
+    /// Output dir (default: ./publish/<recipe>)
+    #[arg(long)]
+    pub out: Option<std::path::PathBuf>,
+
+    /// Also create a public GitHub gist (requires `gh` CLI, authenticated)
+    #[arg(long)]
+    pub gist: bool,
+
+    /// Upload the bundle to a recipe registry (see `sm121 registry --help`)
+    #[arg(long, value_name = "URL")]
+    pub registry: Option<String>,
+
+    /// Registry publish token (or $SM121RECIPES_TOKEN)
+    #[arg(long)]
+    pub token: Option<String>,
+}
+
+#[derive(Subcommand)]
+pub enum RegistryCommands {
+    /// List recipes on the registry
+    List {
+        #[arg(long, value_name = "URL")]
+        registry: Option<String>,
+    },
+    /// Print a registry recipe (recipe.toml)
+    Show {
+        name: String,
+        #[arg(long, value_name = "URL")]
+        registry: Option<String>,
+    },
+    /// Download a recipe into ./recipes/<name>/ (+ ./docker-images/<name>/ if it has an image)
+    Install {
+        name: String,
+        #[arg(long, value_name = "URL")]
+        registry: Option<String>,
+        /// Overwrite an existing ./recipes/<name>/
+        #[arg(long)]
+        force: bool,
+    },
+}
+
 #[derive(Subcommand)]
 pub enum TmuxCommands {
     /// Launch cluster via tmux

+ 13 - 7
src/main.rs

@@ -26,6 +26,7 @@ mod orchestrate;
 mod power;
 mod pre_serve;
 mod publish;
+mod registry;
 mod ssh;
 mod tmux;
 mod web;
@@ -151,14 +152,19 @@ async fn main() -> Result<()> {
         } => {
             doctor::doctor(&config, &recipe, &cluster, repair, dry_run).await?;
         }
-        Commands::Publish {
-            recipe,
-            cluster,
-            out,
-            gist,
-        } => {
-            publish::publish(&config, &recipe, cluster.as_deref(), out, gist).await?;
+        Commands::Publish(a) => {
+            publish::publish(
+                &config,
+                &a.recipe,
+                a.cluster.as_deref(),
+                a.out,
+                a.gist,
+                a.registry.as_deref(),
+                a.token.as_deref(),
+            )
+            .await?;
         }
+        Commands::Registry(cmd) => registry::handle(cmd).await?,
         Commands::Tmux(tmux_cmd) => {
             crate::tmux::handle_tmux(&config, tmux_cmd, dry_run).await?;
         }

+ 13 - 6
src/publish.rs

@@ -25,6 +25,8 @@ pub async fn publish(
     cluster: Option<&str>,
     out: Option<PathBuf>,
     gist: bool,
+    registry: Option<&str>,
+    token: Option<&str>,
 ) -> Result<()> {
     let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
     if recipe.deploy != crate::config::DeployTarget::Docker {
@@ -101,6 +103,17 @@ pub async fn publish(
     std::fs::write(bundle.join("README.md"), readme)?;
     info!("📝 bundle written to {}", bundle.display());
 
+    // install.sh is part of the bundle even for the gist flow — the gist
+    // branch only patches the returned id into it afterwards.
+    std::fs::write(
+        bundle.join("install.sh"),
+        render_install_sh(recipe_name, &recipe.docker_image, &versions, None),
+    )?;
+
+    if let Some(url) = registry {
+        crate::registry::upload(&bundle, recipe_name, url, token).await?;
+    }
+
     if gist {
         let id = publish_gist(&bundle, &recipe.name)?;
         // The gist knows its own id now — bake it into install.sh and push the
@@ -119,15 +132,9 @@ pub async fn publish(
             Ok(o) if o.status.success() => info!("🔗 install.sh updated with gist id"),
             _ => info!("ℹ️  could not auto-update install.sh — set GIST_ID={id} manually"),
         }
-    } else {
-        std::fs::write(
-            bundle.join("install.sh"),
-            render_install_sh(recipe_name, &recipe.docker_image, &versions, None),
-        )?;
     }
     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 {

+ 255 - 0
src/registry.rs

@@ -0,0 +1,255 @@
+//! Client for the public recipe registry (sm121recipes).
+//!
+//! Publish: tar.gz the flat bundle produced by `sm121 publish` and POST it.
+//! Install: fetch the recipe archive and rebuild the launcher layout under
+//! the current directory — `recipe.toml` (+docs) go to `./recipes/<name>/`,
+//! image-overlay files (Dockerfile, patches, versions.env, …) to
+//! `./docker-images/<name>/`. The registry itself stores the flat bundle.
+
+use std::path::Path;
+
+use anyhow::{Context, Result, bail};
+use tracing::info;
+
+pub const DEFAULT_URL: &str = "https://sm121recipes.onlinedb.org";
+
+/// Files that belong in the launcher's `recipes/<name>/` dir: the recipe
+/// itself plus the human-facing docs the bundle generates. **Everything else
+/// in the bundle is image build context** (Dockerfile, `*.patch`, patcher
+/// `*.py`, `versions.env`, `UPSTREAM.md`, `patches/**`, …) and goes to
+/// `./docker-images/<name>/`. An allowlist keeps future build-context files
+/// from silently mislanding in the recipe dir.
+pub(crate) fn is_image_file(entry: &str) -> bool {
+    !matches!(entry, "recipe.toml" | "README.md" | "run.sh" | "install.sh")
+}
+
+fn client() -> Result<reqwest::Client> {
+    reqwest::Client::builder()
+        .timeout(std::time::Duration::from_secs(120))
+        .build()
+        .context("http client")
+}
+
+/// Tar.gz the (flat) bundle dir; entry paths are relative to it.
+pub(crate) fn tar_gz_dir(dir: &Path) -> Result<Vec<u8>> {
+    let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
+    {
+        let mut ar = tar::Builder::new(&mut enc);
+        for entry in walkdir::WalkDir::new(dir) {
+            let e = entry?;
+            if e.file_type().is_file() {
+                let rel = e.path().strip_prefix(dir)?;
+                ar.append_path_with_name(e.path(), rel)?;
+            }
+        }
+        ar.finish()?;
+    }
+    Ok(enc.finish()?)
+}
+
+pub async fn upload(bundle: &Path, name: &str, registry: &str, token: Option<&str>) -> Result<()> {
+    let token = token
+        .map(str::to_string)
+        .or_else(|| std::env::var("SM121RECIPES_TOKEN").ok())
+        .with_context(|| "publishing to a registry needs --token <t> or $SM121RECIPES_TOKEN")?;
+    let url = format!("{}/api/recipes/{}", registry.trim_end_matches('/'), name);
+    let body = tar_gz_dir(bundle)?;
+    let resp = client()?
+        .post(&url)
+        .bearer_auth(token)
+        .header(reqwest::header::CONTENT_TYPE, "application/gzip")
+        .body(body)
+        .send()
+        .await
+        .context("registry unreachable")?;
+    let status = resp.status();
+    let text = resp.text().await.unwrap_or_default();
+    if !status.is_success() {
+        bail!("registry rejected {name}: {status} {text}");
+    }
+    info!("🌐 published {name} → {url}");
+    Ok(())
+}
+
+pub async fn handle(cmd: crate::cli::RegistryCommands) -> Result<()> {
+    use crate::cli::RegistryCommands;
+    match cmd {
+        RegistryCommands::List { registry } => list(registry.as_deref()).await,
+        RegistryCommands::Show { name, registry } => show(&name, registry.as_deref()).await,
+        RegistryCommands::Install {
+            name,
+            registry,
+            force,
+        } => install(&name, registry.as_deref(), force).await,
+    }
+}
+
+pub async fn list(registry: Option<&str>) -> Result<()> {
+    let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
+    let items: Vec<serde_json::Value> = client()?
+        .get(format!("{base}/api/recipes"))
+        .send()
+        .await?
+        .json()
+        .await?;
+    if items.is_empty() {
+        println!("registry is empty");
+        return Ok(());
+    }
+    for r in items {
+        println!(
+            "{:<32} {:<8} {:<40} {}",
+            r["name"].as_str().unwrap_or("?"),
+            r["engine"].as_str().unwrap_or("?"),
+            r["model"].as_str().unwrap_or("?"),
+            r["title"].as_str().unwrap_or(""),
+        );
+    }
+    Ok(())
+}
+
+pub async fn show(name: &str, registry: Option<&str>) -> Result<()> {
+    let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
+    let v: serde_json::Value = client()?
+        .get(format!("{base}/api/recipes/{name}"))
+        .send()
+        .await?
+        .json()
+        .await?;
+    print!(
+        "{}",
+        v["recipe_toml"].as_str().unwrap_or("(no recipe_toml)")
+    );
+    if let Some(files) = v["files"].as_array() {
+        info!(
+            "files: {}",
+            files
+                .iter()
+                .filter_map(|f| f.as_str())
+                .collect::<Vec<_>>()
+                .join(", ")
+        );
+    }
+    Ok(())
+}
+
+pub async fn install(name: &str, registry: Option<&str>, force: bool) -> Result<()> {
+    validate_name(name)?;
+    let base = registry.unwrap_or(DEFAULT_URL).trim_end_matches('/');
+    let resp = client()?
+        .get(format!("{base}/recipes/{name}/archive.tar.gz"))
+        .send()
+        .await
+        .context("registry unreachable")?;
+    if !resp.status().is_success() {
+        bail!(
+            "recipe '{name}' not found at {base} (HTTP {})",
+            resp.status()
+        );
+    }
+    let bytes = resp.bytes().await?;
+
+    let rdir = Path::new("recipes").join(name);
+    let idir = Path::new("docker-images").join(name);
+    if rdir.exists() && !force {
+        bail!("{} exists — overwrite with --force", rdir.display());
+    }
+    std::fs::create_dir_all(&rdir)?;
+
+    let dec = flate2::read::GzDecoder::new(&bytes[..]);
+    let mut ar = tar::Archive::new(dec);
+    let mut n = 0usize;
+    for entry in ar.entries()? {
+        let mut entry = entry?;
+        let path = entry.path()?.to_path_buf();
+        let Some(rel) = path.to_str().map(str::to_string).filter(|s| !s.is_empty()) else {
+            continue;
+        };
+        if !entry.header().entry_type().is_file() {
+            continue;
+        }
+        if rel.contains("..") || rel.starts_with('/') {
+            bail!("unsafe path in registry archive: {rel}");
+        }
+        let dst = if is_image_file(&rel) { &idir } else { &rdir };
+        let out = dst.join(&rel);
+        if let Some(p) = out.parent() {
+            std::fs::create_dir_all(p)?;
+        }
+        let mut f = std::fs::File::create(&out)?;
+        std::io::copy(&mut entry, &mut f)?;
+        n += 1;
+    }
+    if n == 0 {
+        bail!("archive for '{name}' contained no files");
+    }
+    if idir.exists() {
+        info!(
+            "📦 {name}: recipe → {}, image overlay → {}",
+            rdir.display(),
+            idir.display()
+        );
+    } else {
+        info!("📦 {name}: recipe → {}", rdir.display());
+    }
+    Ok(())
+}
+
+fn validate_name(name: &str) -> Result<()> {
+    if name.is_empty()
+        || !name
+            .chars()
+            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
+    {
+        bail!("invalid recipe name '{name}'");
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn flat_bundle_splits_into_launcher_layout() {
+        for img in [
+            "Dockerfile",
+            "modelopt.patch",
+            "patches/a.patch",
+            "patch_mla.py",
+            "versions.env",
+            "UPSTREAM.md",
+        ] {
+            assert!(is_image_file(img), "{img} should be an image file");
+        }
+        for rec in ["recipe.toml", "README.md", "run.sh", "install.sh"] {
+            assert!(!is_image_file(rec), "{rec} should be a recipe file");
+        }
+    }
+
+    #[test]
+    fn names_are_slugs() {
+        assert!(validate_name("glm53-flash-nvfp4-lab").is_ok());
+        assert!(validate_name("../x").is_err());
+        assert!(validate_name("Upper").is_err());
+        assert!(validate_name("").is_err());
+    }
+
+    #[test]
+    fn tar_gz_roundtrips_bundle() {
+        let dir = std::env::temp_dir().join(format!("sm121-cli-{}", std::process::id()));
+        std::fs::create_dir_all(dir.join("patches")).unwrap();
+        std::fs::write(dir.join("recipe.toml"), b"engine = \"vllm\"").unwrap();
+        std::fs::write(dir.join("patches/a.patch"), b"diff").unwrap();
+        let bytes = tar_gz_dir(&dir).unwrap();
+        let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
+        let mut names: Vec<String> = ar
+            .entries()
+            .unwrap()
+            .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
+            .collect();
+        names.sort();
+        assert_eq!(names, ["patches/a.patch", "recipe.toml"]);
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}