Bläddra i källkod

feat(recipe): extra_mounts with config alias resolution + tilde + env injection

Recipes can now declare structured bind-mounts for docker run that stay
portable across installs:

  [[extra_mounts]]
  alias = "jit_cache"        # resolved via [mount_aliases] in config.toml; wins over host
  host = "~/.cache/jit"      # fallback default if alias absent; ~ expanded to ssh_user home
  container = "/root/.cache/jit"
  read_only = false          # optional, adds :ro
  env = "XDG_CACHE_HOME"     # optional: injects -e <name>=<container path>

A new top-level [mount_aliases] table in config.toml maps names to host
paths, so an operator can repoint a mount site-wide without editing recipes
(the alias overrides the recipe's host default when present). If the alias
is absent from config, the recipe's host default is used (with ~ expanded
to /home/<ssh_user> or /root), so recipes ship with good defaults and work
out of the box. At least one of alias/host is required (serve aborts with a
clear error otherwise).

The optional env field injects an env var pointing at the *container* path,
before recipe [env], so a recipe no longer hardcodes the mount path in two
places (mount + env) — the path has one source of truth.

Replaces the prior undocumented volumes recipe field (HashMap<host, container>),
which was less capable (no alias, no tilde, no :ro, no env).

Applied to glm52-hybrid-fp8-mxfp4: added a persistent JIT/torch-inductor
cache mount (alias=jit_cache, container=/root/.cache/jit, env=XDG_CACHE_HOME)
and removed the hardcoded XDG_CACHE_HOME from [env]. Previously the JIT
cache was lost on every cold boot (~20 min recompilation): the comment
claimed persistence via the HF cache mount, but that mount only covers
/root/.cache/huggingface, not all of /root/.cache.

docker command assembly moved into a new src/docker/command.rs submodule
(reusable build_docker_command + env/mount/flag helpers) so src/docker/mod.rs
stays under the 500-line gate. 7 new tests cover alias-override, host
fallback + tilde, :ro, env injection, and resolution edge cases.
Alexander Korolev 3 månader sedan
förälder
incheckning
2b0deeaffa

+ 11 - 0
AGENTS.md

@@ -38,6 +38,13 @@ entrypoint = ["vllm", "serve"]      # optional, after image
 before_serve = [{ command = "echo", on_failure = "abort" }]
 args = ["--kv-cache-dtype", "fp8"]
 
+[[extra_mounts]]                    # extra bind-mounts for `docker run -v`
+alias = "jit_cache"                # resolves via [mount_aliases] in config.toml; wins over `host`
+host = "~/.cache/jit"              # fallback default if alias absent; `~` expanded to ssh_user home
+container = "/root/.cache/jit"     # container-side target (required)
+read_only = false                 # optional, default false (adds :ro)
+env = "XDG_CACHE_HOME"            # optional: injects -e <name>=<container path>
+
 [mods]
 commands = [                         # run *inside* container before engine
     "pip install flash-attn",
@@ -99,6 +106,10 @@ shutdown_delay_seconds = 30
 [exec_aliases]
 name = "command"
 
+[mount_aliases]                     # host paths referenced by name from recipe [[extra_mounts]]
+jit_cache = "/mnt/nas/ai/jit"      # alias wins over recipe `host`; lets operators repoint mounts site-wide
+hf_cache = "/mnt/nas/ai/huggingface"
+
 [before_serve."c"]
 commands = ["cmd", { command = "cmd", on_failure = "warn" }]
 ```

+ 12 - 3
recipes/glm52-hybrid-fp8-mxfp4/recipe.toml

@@ -35,6 +35,16 @@ distributed_backend = "mp"
 shm_size = "10gb"
 docker_image_dir = "glm52-int4-int8"
 
+# Persistent JIT/torch-inductor cache (avoids ~20 min recompilation on cold boot).
+# `alias = "jit_cache"` resolves via [mount_aliases] in config.toml; if absent,
+# falls back to host "~/.cache/jit" (expanded to /home/<ssh_user>/.cache/jit).
+# `env` injects -e XDG_CACHE_HOME=/root/.cache/jit so vLLM/torch write there.
+[[extra_mounts]]
+alias = "jit_cache"
+host = "~/.cache/jit"
+container = "/root/.cache/jit"
+env = "XDG_CACHE_HOME"
+
 entrypoint = ["vllm", "serve"]
 
 args = [
@@ -109,9 +119,8 @@ CUTE_DSL_ARCH = "sm_121a"
 TORCH_CUDA_ARCH_LIST = "12.1a"
 
 # ── JIT cache ──────────────────────────────────────────────────────────────
-# Under ~/.cache so it persists inside the HF cache mount (-v ~/.cache/.../hf)
-# Avoids ~20 min recompilation on each cold boot.
-XDG_CACHE_HOME = "/root/.cache"
+# Persisted via the [[extra_mounts]] jit_cache entry above: XDG_CACHE_HOME is
+# injected from that mount, pointing at /root/.cache/jit (no hardcoded dupe).
 
 # ── NCCL overrides ─────────────────────────────────────────────────────────
 # Cluster config provides NCCL_IB_HCA, NCCL_IB_GID_INDEX, NCCL_SOCKET_IFNAME

+ 2 - 1
src/before_serve.rs

@@ -194,6 +194,7 @@ mod tests {
             home_assistant: None,
             global_mods_dir: None,
             drop_caches: true,
+            mount_aliases: HashMap::new(),
         }
     }
 
@@ -252,7 +253,7 @@ mod tests {
             sync_image: true,
             docker_image_dir: None,
             shm_size: String::new(),
-            volumes: HashMap::new(),
+            extra_mounts: vec![],
         }
     }
 

+ 3 - 0
src/cluster_state.rs

@@ -319,6 +319,7 @@ mod tests {
             exec_aliases: HashMap::new(),
             home_assistant: None,
             drop_caches: true,
+            mount_aliases: HashMap::new(),
             global_mods_dir: None,
         };
 
@@ -345,6 +346,7 @@ mod tests {
             exec_aliases: HashMap::new(),
             home_assistant: None,
             drop_caches: true,
+            mount_aliases: HashMap::new(),
             global_mods_dir: None,
         };
         assert!(select_cluster(&cfg, "nonexistent", None, &state).is_err());
@@ -375,6 +377,7 @@ mod tests {
             exec_aliases: HashMap::new(),
             home_assistant: None,
             drop_caches: true,
+            mount_aliases: HashMap::new(),
             global_mods_dir: None,
         };
         assert!(select_cluster(&cfg, "auto", Some(1), &state).is_err());

+ 1 - 0
src/config/tests.rs

@@ -41,6 +41,7 @@ fn tcfg() -> Config {
         home_assistant: None,
         global_mods_dir: None,
         drop_caches: true,
+        mount_aliases: HashMap::new(),
     }
 }
 

+ 134 - 2
src/config/types.rs

@@ -66,6 +66,13 @@ pub struct Config {
     /// Drop page cache before starting containers.
     #[serde(default = "default_drop_caches")]
     pub drop_caches: bool,
+    /// Named mount-alias registry — host paths referenced by name from recipe
+    /// `[[extra_mounts]]` entries. Lets recipes stay portable (reference an
+    /// alias like `jit_cache`) while the operator sets the real path here.
+    /// A recipe may also carry a `host` default; the alias, when present,
+    /// overrides it.
+    #[serde(default)]
+    pub mount_aliases: HashMap<String, String>,
 }
 
 #[derive(Clone, Deserialize)]
@@ -138,9 +145,12 @@ pub struct Recipe {
     /// Build directory for docker-images/ (default: derived from recipe name).
     #[serde(default)]
     pub docker_image_dir: Option<String>,
-    /// Docker volume mounts: host_path -> container_path
+    /// Extra bind-mounts to add to `docker run` (e.g. persistent JIT cache).
+    /// Each entry resolves its host path from `Config.mount_aliases` when
+    /// `alias` is set and present there (alias wins); otherwise falls back to
+    /// `host` (with `~` expansion). At least one of `alias`/`host` is required.
     #[serde(default)]
-    pub volumes: HashMap<String, String>,
+    pub extra_mounts: Vec<ExtraMount>,
 }
 
 impl Config {
@@ -156,3 +166,125 @@ impl Config {
         std::path::PathBuf::from(format!("{}/.cache/huggingface", home))
     }
 }
+
+/// A recipe-level extra bind-mount for `docker run`.
+///
+/// `alias` resolves against `Config.mount_aliases` and, when present there,
+/// overrides `host` (so an operator can repoint a mount site-wide without
+/// editing recipes). If the alias is absent from config, `host` is used as the
+/// default. `~` (and `~/`) at the start of the resolved path is expanded to the
+/// remote SSH user's home. At least one of `alias` or `host` must be set.
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct ExtraMount {
+    /// Name resolved via `Config.mount_aliases`; overrides `host` when present.
+    #[serde(default)]
+    pub alias: Option<String>,
+    /// Fallback host path (used when the alias is not in `mount_aliases`).
+    #[serde(default)]
+    pub host: Option<String>,
+    /// Container-side mount target.
+    pub container: String,
+    /// Mount read-only (`:ro`).
+    #[serde(default)]
+    pub read_only: bool,
+    /// If set, inject `-e <env>=<container path>` so recipes don't hardcode
+    /// the path in two places (mount + env).
+    #[serde(default)]
+    pub env: Option<String>,
+}
+
+/// Expand a leading `~` / `~/` to the remote SSH user's home directory.
+/// Paths without a leading `~` are returned unchanged. Used for host paths
+/// before they are sent to `docker run -v`.
+pub fn expand_tilde(path: &str, ssh_user: &str) -> String {
+    let home = if ssh_user == "root" {
+        "/root".to_string()
+    } else {
+        format!("/home/{}", ssh_user)
+    };
+    if let Some(rest) = path.strip_prefix("~/") {
+        format!("{}/{}", home, rest)
+    } else if path == "~" {
+        home
+    } else {
+        path.to_string()
+    }
+}
+
+/// Resolve a recipe `ExtraMount`'s host path: the config alias wins over the
+/// recipe `host` default; if neither is set, error. `~` is expanded.
+pub fn resolve_mount_host(
+    alias: Option<&str>,
+    host: Option<&str>,
+    mount_aliases: &HashMap<String, String>,
+    ssh_user: &str,
+    container: &str,
+) -> anyhow::Result<String> {
+    let raw = alias
+        .and_then(|a| {
+            if a.trim().is_empty() {
+                None
+            } else {
+                mount_aliases.get(a).map(String::as_str)
+            }
+        })
+        .or_else(|| host.filter(|h| !h.trim().is_empty()))
+        .ok_or_else(|| {
+            anyhow::anyhow!(
+                "extra_mount '{}' has neither a resolvable alias nor a host path",
+                container
+            )
+        })?;
+    Ok(expand_tilde(raw, ssh_user))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{expand_tilde, resolve_mount_host};
+    use std::collections::HashMap;
+
+    #[test]
+    fn expand_tilde_user_and_root() {
+        assert_eq!(expand_tilde("~/.cache", "kilork"), "/home/kilork/.cache");
+        assert_eq!(expand_tilde("~/", "kilork"), "/home/kilork/");
+        assert_eq!(expand_tilde("~", "root"), "/root");
+        assert_eq!(expand_tilde("/abs/path", "kilork"), "/abs/path");
+        assert_eq!(expand_tilde("rel/path", "kilork"), "rel/path");
+    }
+
+    #[test]
+    fn resolve_mount_alias_wins_over_host() {
+        let aliases = HashMap::from([("jit_cache".to_string(), "/mnt/nas/jit".to_string())]);
+        // alias present in config → alias wins, host ignored
+        let r = resolve_mount_host(Some("jit_cache"), Some("~/.cache/jit"), &aliases, "u", "/c");
+        assert_eq!(r.unwrap(), "/mnt/nas/jit");
+    }
+
+    #[test]
+    fn resolve_mount_falls_back_to_host_with_tilde() {
+        let aliases = HashMap::new();
+        // alias not in config → host default used, with ~ expansion
+        let r = resolve_mount_host(
+            Some("jit_cache"),
+            Some("~/.cache/jit"),
+            &aliases,
+            "kilork",
+            "/c",
+        );
+        assert_eq!(r.unwrap(), "/home/kilork/.cache/jit");
+    }
+
+    #[test]
+    fn resolve_mount_host_only() {
+        let aliases = HashMap::new();
+        let r = resolve_mount_host(None, Some("/var/cache"), &aliases, "u", "/c");
+        assert_eq!(r.unwrap(), "/var/cache");
+    }
+
+    #[test]
+    fn resolve_mount_neither_errors() {
+        let aliases = HashMap::new();
+        let r = resolve_mount_host(None, None, &aliases, "u", "/c");
+        assert!(r.is_err());
+    }
+}

+ 351 - 0
src/docker/command.rs

@@ -0,0 +1,351 @@
+//! Assembly of the `docker run` command per node — flags, env vars, mounts,
+//! and the vLLM/SGLang tail (engine flags + distributed rendezvous).
+//!
+//! [`build_docker_command`] is the single entry point; everything else here is
+//! private to this module.
+
+use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
+use anyhow::Result;
+use std::borrow::Cow;
+
+use super::{mods, tail};
+
+/// Shared context for [`build_docker_command`] — constructed once, cloned per node.
+#[derive(Clone)]
+pub(crate) struct DockerCommandCtx {
+    pub cluster: Cluster,
+    pub nccl: NcclConfig,
+    pub recipe: Recipe,
+    pub recipe_name: String,
+    pub pp_size: u32,
+    pub tp_size: u32,
+    pub nnodes: u32,
+    pub cache_dir: String,
+    pub keep: bool,
+    pub init_port: u16,
+    pub mods_remote_path: String,
+    /// Recipe `extra_mounts` with host paths already resolved (alias lookup +
+    /// `~` expansion) against `Config.mount_aliases` / `ssh_user`.
+    pub extra_mounts: Vec<ResolvedMount>,
+}
+
+/// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
+/// turn into a `-v` flag / `-e` env pair.
+#[derive(Clone)]
+pub(crate) struct ResolvedMount {
+    pub host: String,
+    pub container: String,
+    pub read_only: bool,
+    pub env: Option<String>,
+}
+
+/// Resolve every `extra_mount` in a recipe to a concrete host path using the
+/// config alias registry and the SSH user (for `~` expansion). Done once per
+/// serve; the resulting `Vec<ResolvedMount>` is cloned into each node's
+/// [`DockerCommandCtx`].
+pub(crate) fn resolve_extra_mounts(config: &Config, recipe: &Recipe) -> Result<Vec<ResolvedMount>> {
+    recipe
+        .extra_mounts
+        .iter()
+        .map(|m| {
+            let host = crate::config::resolve_mount_host(
+                m.alias.as_deref(),
+                m.host.as_deref(),
+                &config.mount_aliases,
+                &config.ssh_user,
+                &m.container,
+            )?;
+            Ok(ResolvedMount {
+                host,
+                container: m.container.clone(),
+                read_only: m.read_only,
+                env: m.env.clone(),
+            })
+        })
+        .collect()
+}
+
+/// Collect NCCL cluster env vars for multi-node deployments.
+fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
+    let is_sglang = ctx.recipe.engine == "sglang";
+    let sock = ctx.nccl.socket_ifname.as_str();
+    let gloo_if = ctx.nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
+    let ucx_dev = ctx.nccl.ucx_net_devices.as_deref().unwrap_or(sock);
+    let mut env: Vec<(&str, Cow<str>)> = vec![
+        ("NCCL_IB_DISABLE", format!("{}", ctx.nccl.ib_disable).into()),
+        ("NCCL_IB_HCA", ctx.nccl.ib_hca.as_str().into()),
+        ("NCCL_SOCKET_IFNAME", sock.into()),
+        ("GLOO_SOCKET_IFNAME", gloo_if.into()),
+        ("TP_SOCKET_IFNAME", gloo_if.into()),
+        (
+            "NCCL_IB_GID_INDEX",
+            ctx.nccl.ib_gid_index.to_string().into(),
+        ),
+        ("MN_IF_NAME", gloo_if.into()),
+        ("UCX_NET_DEVICES", ucx_dev.into()),
+        ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
+        ("OMP_NUM_THREADS", "4".into()),
+        ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
+        ("NCCL_NET", "IB".into()),
+        ("NCCL_DEBUG", "WARN".into()),
+    ];
+    if let Some(v) = ctx.nccl.nccl_cross_nic {
+        env.push(("NCCL_CROSS_NIC", v.to_string().into()));
+    }
+    if let Some(ref v) = ctx.nccl.nccl_proto {
+        env.push(("NCCL_PROTO", v.as_str().into()));
+    }
+    if let Some(ref v) = ctx.nccl.nccl_p2p_level {
+        env.push(("NCCL_P2P_LEVEL", v.as_str().into()));
+    }
+    let worker_ip = Cow::Borrowed(node.ib_ip.as_str());
+    if is_sglang {
+        env.push(("SGLANG_WORKER_IP", worker_ip));
+    } else {
+        env.push(("VLLM_HOST_IP", worker_ip));
+    }
+    env
+}
+
+/// Build the list of `-e KEY=VALUE` Docker env var pairs for a node.
+fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
+    let mut pairs: Vec<(&str, Cow<str>)> =
+        vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
+    if ctx.nnodes > 1 {
+        pairs.extend(collect_nccl_env(ctx, node));
+    }
+    if is_head {
+        pairs.push(("VLLM_LOGGING_COLOR", "1".into()));
+        pairs.push(("TERM", "xterm-256color".into()));
+    }
+    if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
+        pairs.push((
+            "RAY_ADDRESS",
+            format!("{}:6379", ctx.cluster.master_ib_ip).into(),
+        ));
+        pairs.push(("RAY_PORT", "6379".into()));
+        pairs.push(("RAY_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
+        pairs.push(("RAY_OVERRIDE_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
+    }
+    // extra_mount `env` defaults (point at the container path) are injected
+    // before recipe [env] so the recipe can still override them (last wins).
+    for m in &ctx.extra_mounts {
+        if let Some(name) = &m.env {
+            pairs.push((name.as_str(), m.container.as_str().into()));
+        }
+    }
+    for (k, v) in &ctx.recipe.env {
+        pairs.push((k.as_str(), v.as_str().into()));
+    }
+    pairs
+        .into_iter()
+        .map(|(k, v)| format!("-e {k}={v}"))
+        .collect()
+}
+
+/// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
+fn build_docker_flags(ctx: &DockerCommandCtx) -> Vec<String> {
+    let mut flags: Vec<String> = vec![
+        "--gpus all".to_string(),
+        "--ipc=host".to_string(),
+        "--privileged".to_string(),
+        "--network=host".to_string(),
+        "--init".to_string(),
+        "--ulimit memlock=-1".to_string(),
+        "--ulimit stack=67108864".to_string(),
+        "--ulimit nofile=1048576".to_string(),
+    ];
+    if !ctx.recipe.shm_size.is_empty() {
+        flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
+    }
+    if !ctx.keep {
+        flags.push("--rm".to_string());
+    }
+    flags
+}
+
+/// Build the full Docker command line for a node.
+pub(crate) fn build_docker_command(
+    ctx: &DockerCommandCtx,
+    node: &Node,
+    is_head: bool,
+) -> Vec<String> {
+    let flags = build_docker_flags(ctx);
+    let mut cmd = vec![
+        format!("docker run -d {}", flags.join(" ")),
+        format!("-v {}:/root/.cache/huggingface", ctx.cache_dir),
+    ];
+    if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
+        cmd.push(format!("-v {}:/mods:ro", ctx.mods_remote_path));
+    }
+    for m in &ctx.extra_mounts {
+        let mut spec = format!("-v {}:{}", m.host, m.container);
+        if m.read_only {
+            spec.push_str(":ro");
+        }
+        cmd.push(spec);
+    }
+    cmd.extend(collect_docker_env(ctx, node, is_head));
+    cmd.push(format!("--name sm121_{}_{}", ctx.recipe_name, node.rank));
+    cmd.push(format!("--label sm121.recipe={}", ctx.recipe_name));
+    cmd.push(format!("--label sm121.rank={}", node.rank));
+    cmd.push(format!("--label sm121.cluster={}", ctx.cluster.name));
+    // Add solo-node label for single-node deployments
+    if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
+        cmd.push(format!("--label sm121.solo-node={}", node.name));
+    }
+    let tctx = tail::TailCtx {
+        recipe: &ctx.recipe,
+        tp_size: ctx.tp_size,
+        pp_size: ctx.pp_size,
+        nnodes: ctx.nnodes,
+        init_port: ctx.init_port,
+        master_ib_ip: &ctx.cluster.master_ib_ip,
+    };
+    let tail = tail::build_tail(&tctx, node);
+    if ctx.recipe.mods.commands.is_empty() {
+        cmd.push(ctx.recipe.docker_image.clone());
+        cmd.extend(tail);
+    } else {
+        let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
+        cmd.extend([
+            "--entrypoint sh".into(),
+            ctx.recipe.docker_image.clone(),
+            "-c".into(),
+            script,
+        ]);
+    }
+    cmd
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{
+        Cluster, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
+    };
+    use std::collections::HashMap;
+
+    fn tcls() -> Cluster {
+        Cluster {
+            name: "t".into(),
+            master_ib_ip: "1".into(),
+            nodes: vec![Node {
+                name: "n0".into(),
+                ssh_ip: "10.0.0.1".into(),
+                ib_ip: "1".into(),
+                rank: 0,
+                host_key: None,
+                entity: None,
+            }],
+            extra_entities: vec![],
+        }
+    }
+
+    fn tnccl() -> NcclConfig {
+        NcclConfig {
+            ib_hca: "r".into(),
+            socket_ifname: "e".into(),
+            ib_gid_index: 3,
+            ib_disable: 0,
+            gloo_socket_ifname: None,
+            ucx_net_devices: None,
+            nccl_cross_nic: None,
+            nccl_proto: None,
+            nccl_p2p_level: None,
+        }
+    }
+
+    fn minimal_config() -> Config {
+        Config {
+            ssh_user: "u".into(),
+            ssh_key_path: "/tmp/key".into(),
+            clusters: vec![],
+            nccl: HashMap::new(),
+            hf_cache_dir: None,
+            init_port: 25000,
+            before_serve: HashMap::new(),
+            exec_aliases: HashMap::new(),
+            home_assistant: None,
+            global_mods_dir: None,
+            drop_caches: true,
+            mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),
+        }
+    }
+
+    fn run_with(recipe: &Recipe) -> String {
+        let ctx = DockerCommandCtx {
+            cluster: tcls(),
+            nccl: tnccl(),
+            recipe: recipe.clone(),
+            recipe_name: "r".to_string(),
+            pp_size: 2,
+            tp_size: 1,
+            nnodes: 2,
+            cache_dir: "/home/user/.cache/huggingface".to_string(),
+            keep: false,
+            init_port: 25000,
+            mods_remote_path: String::new(),
+            extra_mounts: resolve_extra_mounts(&minimal_config(), recipe).unwrap_or_default(),
+        };
+        build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
+    }
+
+    fn base_recipe() -> Recipe {
+        Recipe {
+            engine: "vllm".into(),
+            name: "T".into(),
+            docker_image: "img".into(),
+            model: "mdl".into(),
+            port: 8000,
+            required_nodes: Some(2),
+            pipeline_parallel_size: Some(2),
+            tensor_parallel_size: 1,
+            args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            env: HashMap::new(),
+            cluster: None,
+            entrypoint: vec![],
+            distributed_backend: Some(DistributedBackend::Mp),
+            before_serve: vec![],
+            mods: Default::default(),
+            shm_size: String::new(),
+            sync_image: true,
+            docker_image_dir: None,
+            extra_mounts: vec![],
+        }
+    }
+
+    #[test]
+    fn extra_mounts_are_mounted() {
+        let mut r = base_recipe();
+        r.extra_mounts.push(ExtraMount {
+            alias: Some("models".into()),
+            host: Some("~/.cache/models".into()),
+            container: "/models:ro".into(),
+            read_only: true,
+            env: Some("MODEL_DIR".into()),
+        });
+        let cmd = run_with(&r);
+        // alias present in minimal_config() → alias host wins
+        assert!(cmd.contains("-v /mnt/nas/models:/models:ro:ro"));
+        assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
+        // env injected pointing at the container path, before recipe env
+        assert!(cmd.contains("-e MODEL_DIR=/models:ro"));
+    }
+
+    #[test]
+    fn extra_mounts_fallback_to_host_when_alias_absent() {
+        let mut r = base_recipe();
+        r.extra_mounts.push(ExtraMount {
+            alias: Some("no_such_alias".into()),
+            host: Some("~/.cache/jit".into()),
+            container: "/root/.cache/jit".into(),
+            read_only: false,
+            env: None,
+        });
+        let cmd = run_with(&r);
+        // alias not in config → host default used with ~ expansion (ssh_user=u)
+        assert!(cmd.contains("-v /home/u/.cache/jit:/root/.cache/jit"));
+        assert!(!cmd.contains(":ro"));
+    }
+}

+ 4 - 155
src/docker/mod.rs

@@ -1,4 +1,5 @@
 pub mod build;
+pub mod command;
 pub mod deploy;
 pub mod health;
 pub mod logs;
@@ -12,7 +13,6 @@ use crate::{
     ssh::{run_ssh, run_ssh_with_retry},
 };
 use anyhow::{Context, Result};
-use std::borrow::Cow;
 use tokio::time::{Duration, sleep};
 use tracing::{info, warn};
 /// Options for the serve operation.
@@ -242,21 +242,6 @@ struct StartClusterOpts {
     mods_remote_path: String,
     dry_run: bool,
 }
-/// Shared context for [`build_docker_command`] — constructed once, cloned per node.
-#[derive(Clone)]
-struct DockerCommandCtx {
-    cluster: Cluster,
-    nccl: NcclConfig,
-    recipe: Recipe,
-    recipe_name: String,
-    pp_size: u32,
-    tp_size: u32,
-    nnodes: u32,
-    cache_dir: String,
-    keep: bool,
-    init_port: u16,
-    mods_remote_path: String,
-}
 async fn start_cluster(
     config: &Config,
     cluster: &Cluster,
@@ -299,7 +284,7 @@ async fn start_cluster(
             "docker rm -f sm121_{}_{} 2>/dev/null; true",
             recipe_name, node.rank
         );
-        let dm_ctx = DockerCommandCtx {
+        let dm_ctx = command::DockerCommandCtx {
             cluster: cluster.clone(),
             nccl: nccl.clone(),
             recipe: recipe.clone(),
@@ -311,12 +296,13 @@ async fn start_cluster(
             keep: opts.keep,
             init_port: config.init_port,
             mods_remote_path: opts.mods_remote_path.clone(),
+            extra_mounts: command::resolve_extra_mounts(&config, recipe)?,
         };
         handles.push(tokio::spawn(async move {
             if !dry {
                 let _ = run_ssh(&config, &node, cleanup, dry).await;
             }
-            let cmd = build_docker_command(&dm_ctx, &node, is_head).join(" ");
+            let cmd = command::build_docker_command(&dm_ctx, &node, is_head).join(" ");
             let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
             (node.name, node.rank, engine, result)
         }));
@@ -343,143 +329,6 @@ async fn start_cluster(
     }
     Ok(())
 }
-/// Collect NCCL cluster env vars for multi-node deployments.
-fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
-    let is_sglang = ctx.recipe.engine == "sglang";
-    let sock = ctx.nccl.socket_ifname.as_str();
-    let gloo_if = ctx.nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
-    let ucx_dev = ctx.nccl.ucx_net_devices.as_deref().unwrap_or(sock);
-    let mut env: Vec<(&str, Cow<str>)> = vec![
-        ("NCCL_IB_DISABLE", format!("{}", ctx.nccl.ib_disable).into()),
-        ("NCCL_IB_HCA", ctx.nccl.ib_hca.as_str().into()),
-        ("NCCL_SOCKET_IFNAME", sock.into()),
-        ("GLOO_SOCKET_IFNAME", gloo_if.into()),
-        ("TP_SOCKET_IFNAME", gloo_if.into()),
-        (
-            "NCCL_IB_GID_INDEX",
-            ctx.nccl.ib_gid_index.to_string().into(),
-        ),
-        ("MN_IF_NAME", gloo_if.into()),
-        ("UCX_NET_DEVICES", ucx_dev.into()),
-        ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
-        ("OMP_NUM_THREADS", "4".into()),
-        ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
-        ("NCCL_NET", "IB".into()),
-        ("NCCL_DEBUG", "WARN".into()),
-    ];
-    if let Some(v) = ctx.nccl.nccl_cross_nic {
-        env.push(("NCCL_CROSS_NIC", v.to_string().into()));
-    }
-    if let Some(ref v) = ctx.nccl.nccl_proto {
-        env.push(("NCCL_PROTO", v.as_str().into()));
-    }
-    if let Some(ref v) = ctx.nccl.nccl_p2p_level {
-        env.push(("NCCL_P2P_LEVEL", v.as_str().into()));
-    }
-    let worker_ip = Cow::Borrowed(node.ib_ip.as_str());
-    if is_sglang {
-        env.push(("SGLANG_WORKER_IP", worker_ip));
-    } else {
-        env.push(("VLLM_HOST_IP", worker_ip));
-    }
-    env
-}
-
-/// Build the list of `-e KEY=VALUE` Docker env var pairs for a node.
-fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
-    let mut pairs: Vec<(&str, Cow<str>)> =
-        vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
-    if ctx.nnodes > 1 {
-        pairs.extend(collect_nccl_env(ctx, node));
-    }
-    if is_head {
-        pairs.push(("VLLM_LOGGING_COLOR", "1".into()));
-        pairs.push(("TERM", "xterm-256color".into()));
-    }
-    if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
-        pairs.push((
-            "RAY_ADDRESS",
-            format!("{}:6379", ctx.cluster.master_ib_ip).into(),
-        ));
-        pairs.push(("RAY_PORT", "6379".into()));
-        pairs.push(("RAY_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
-        pairs.push(("RAY_OVERRIDE_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
-    }
-    for (k, v) in &ctx.recipe.env {
-        pairs.push((k.as_str(), v.as_str().into()));
-    }
-    pairs
-        .into_iter()
-        .map(|(k, v)| format!("-e {k}={v}"))
-        .collect()
-}
-
-/// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
-fn build_docker_flags(ctx: &DockerCommandCtx) -> Vec<String> {
-    let mut flags: Vec<String> = vec![
-        "--gpus all".to_string(),
-        "--ipc=host".to_string(),
-        "--privileged".to_string(),
-        "--network=host".to_string(),
-        "--init".to_string(),
-        "--ulimit memlock=-1".to_string(),
-        "--ulimit stack=67108864".to_string(),
-        "--ulimit nofile=1048576".to_string(),
-    ];
-    if !ctx.recipe.shm_size.is_empty() {
-        flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
-    }
-    if !ctx.keep {
-        flags.push("--rm".to_string());
-    }
-    flags
-}
-
-/// Build the full Docker command line for a node.
-fn build_docker_command(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
-    let flags = build_docker_flags(ctx);
-    let mut cmd = vec![
-        format!("docker run -d {}", flags.join(" ")),
-        format!("-v {}:/root/.cache/huggingface", ctx.cache_dir),
-    ];
-    if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
-        cmd.push(format!("-v {}:/mods:ro", ctx.mods_remote_path));
-    }
-    for (host_path, container_path) in &ctx.recipe.volumes {
-        cmd.push(format!("-v {}:{}", host_path, container_path));
-    }
-    cmd.extend(collect_docker_env(ctx, node, is_head));
-    cmd.push(format!("--name sm121_{}_{}", ctx.recipe_name, node.rank));
-    cmd.push(format!("--label sm121.recipe={}", ctx.recipe_name));
-    cmd.push(format!("--label sm121.rank={}", node.rank));
-    cmd.push(format!("--label sm121.cluster={}", ctx.cluster.name));
-    // Add solo-node label for single-node deployments
-    if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
-        cmd.push(format!("--label sm121.solo-node={}", node.name));
-    }
-    let tctx = tail::TailCtx {
-        recipe: &ctx.recipe,
-        tp_size: ctx.tp_size,
-        pp_size: ctx.pp_size,
-        nnodes: ctx.nnodes,
-        init_port: ctx.init_port,
-        master_ib_ip: &ctx.cluster.master_ib_ip,
-    };
-    let tail = tail::build_tail(&tctx, node);
-    if ctx.recipe.mods.commands.is_empty() {
-        cmd.push(ctx.recipe.docker_image.clone());
-        cmd.extend(tail);
-    } else {
-        let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
-        cmd.extend([
-            "--entrypoint sh".into(),
-            ctx.recipe.docker_image.clone(),
-            "-c".into(),
-            script,
-        ]);
-    }
-    cmd
-}
 
 #[cfg(test)]
 mod tests;

+ 4 - 11
src/docker/tests.rs

@@ -1,3 +1,4 @@
+use super::command::{DockerCommandCtx, build_docker_command};
 use super::*;
 use crate::config::{DistributedBackend, RecipeArgs};
 use std::collections::HashMap;
@@ -62,7 +63,7 @@ fn trec() -> Recipe {
         shm_size: String::new(),
         sync_image: true,
         docker_image_dir: None,
-        volumes: HashMap::new(),
+        extra_mounts: vec![],
     }
 }
 
@@ -79,6 +80,7 @@ fn make_dm_ctx(recipe: &Recipe, nnodes: u32, pp: u32, tp: u32, keep: bool) -> Do
         keep,
         init_port: 25000,
         mods_remote_path: String::new(),
+        extra_mounts: vec![],
     }
 }
 
@@ -246,7 +248,7 @@ fn sglang_recipe() -> Recipe {
         shm_size: String::new(),
         sync_image: true,
         docker_image_dir: None,
-        volumes: HashMap::new(),
+        extra_mounts: vec![],
     }
 }
 
@@ -319,15 +321,6 @@ fn sglang_entrypoint_model_path_order() {
     assert!(tp < port, "--tp before --port");
 }
 
-#[test]
-fn volumes_are_mounted() {
-    let mut r = trec();
-    r.volumes = HashMap::from([("/models/glm52-hybrid".into(), "/hybridmodel:ro".into())]);
-    let cmd = run(&r, &tcls().nodes[0], true);
-    assert!(cmd.contains("-v /models/glm52-hybrid:/hybridmodel:ro"));
-    assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
-}
-
 #[test]
 fn keep_disables_rm() {
     let cmd = run_keep(&trec(), &tcls().nodes[0], true);

+ 1 - 1
src/doctor.rs

@@ -373,7 +373,7 @@ mod tests {
             sync_image: true,
             docker_image_dir: None,
             shm_size: String::new(),
-            volumes: HashMap::new(),
+            extra_mounts: vec![],
         }
     }
 

+ 1 - 0
src/exec.rs

@@ -169,6 +169,7 @@ mod tests {
             exec_aliases,
             home_assistant: None,
             drop_caches: true,
+            mount_aliases: HashMap::new(),
             global_mods_dir: None,
         }
     }

+ 1 - 0
src/orchestrate/tests.rs

@@ -88,6 +88,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         exec_aliases: HashMap::new(),
         home_assistant: None,
         drop_caches: true,
+        mount_aliases: HashMap::new(),
         global_mods_dir: None,
     }
 }