Selaa lähdekoodia

recipe [env] refs: values can resolve from recipe fields ({ from = "port" })

Operator: a hardcoded PORT in [env] next to port = N is two sources for
one value; the launcher-wide injection was rejected as implicit. [env]
values are now an untagged EnvValue enum — a plain string (unchanged) or
{ from = "<field>" } resolved at serve time (Recipe::resolved_env, used
by docker -e, tmux export, and the publish run.sh script). Unknown 'from'
targets fail fast listing the supported ones (currently: port). No string
parsing — the TOML table carries the intent. Both sparkring recipes use
PORT = { from = "port" }; stock recipes are untouched.

Refactor to keep types.rs under the 500-line gate: Recipe + EnvValue move
to config/recipe.rs; the pre_serve facade re-exports move to config.rs
(trimmed to what non-test code imports through crate::config::, test
modules import RecipeArgs/VenvSetup directly). 279 tests green.
Alexander Korolev 1 kuukausi sitten
vanhempi
sitoutus
f4abb53cbf

+ 3 - 0
AGENTS.md

@@ -155,6 +155,9 @@ commands = [                         # run *inside* container before engine
 
 [env]
 KEY = "val"                         # overrides launcher defaults
+PORT = { from = "port" }            # or: value resolves from a recipe field at
+                                     # serve time (supported target: port) —
+                                     # the value is declared once, can't drift
 
 build_profile = "glm53-r28"        # optional; named pin set from docker-images/<dir>/profiles/<name>.env
 

+ 3 - 1
DEVELOPMENT.md

@@ -12,7 +12,9 @@ recipes/                   # Recipe directories (<name>/recipe.toml)
 src/
 ├── main.rs                # CLI entrypoint, command dispatch, i18n init
 ├── cli.rs                 # clap derive CLI definitions
-├── config.rs              # Config/Recipe deserialization, cluster resolution
+├── config.rs              # Config deserialization, cluster resolution;
+│                          #   config/types.rs (Node/Cluster/Config),
+│                          #   config/recipe.rs (Recipe + [env] EnvValue refs)
 ├── ssh.rs                 # SSH client, key loading, host key verification
 ├── docker/
 │   ├── mod.rs             # Docker command builder: serve, stop, health, logs

+ 20 - 0
history/night-shifts/2026-09-08.md

@@ -413,3 +413,23 @@ The recipe [env] table already is the explicit mechanism (renders
 [env] (their serve-with-warmup wrapper polls 127.0.0.1:$PORT; must match
 the recipe `port`). Stock recipes get no PORT env. Gates green (275 tests),
 dry-run verified: sparkring -e PORT=8000, r28-spark 0.
+
+## 18:10 UTC+2 — [env] values can resolve from recipe fields (`{ from = "port" }`)
+
+Operator: a hardcoded `PORT = "8000"` in [env] next to `port = 8000` is
+fragile (two places for one value); the launcher-wide PORT injection was
+rejected as implicit. Solution: [env] values are now an untagged
+`EnvValue` enum — plain string (unchanged) or a reference into a recipe
+field: `PORT = { from = "port" }`. `Recipe::resolved_env()` resolves at
+serve time (docker -e, tmux export, publish run.sh); unknown `from`
+targets fail fast listing the supported ones (currently: port). No string
+parsing — the TOML structure carries the intent. Both sparkring recipes
+use it. 279 tests green (4 new: resolve, error, mixed TOML parse, docker
+render).
+
+Refactor follow-up (same commit batch): `Recipe` + `EnvValue` moved from
+`config/types.rs` to `config/recipe.rs` (types.rs hit the 500-line gate at
+554; recipe.rs is 183 lines now). The `pre_serve` facade re-exports moved
+from types.rs to config.rs, trimmed to the four names non-test code imports
+through `crate::config::` (test modules now import `RecipeArgs`/`VenvSetup`
+directly from `crate::pre_serve`). Gates: 279 tests, lines ok.

+ 3 - 3
recipes/glm53-flash-sparkring-2x/recipe.toml

@@ -102,9 +102,9 @@ VLLM_WORKER_MULTIPROC_METHOD = "spawn"
 VLLM_DISABLED_KERNELS = "MarlinFP8ScaledMMLinearKernel"
 PYTHONDONTWRITEBYTECODE = "1"
 LD_PRELOAD = "/opt/sparkring/nccl-pci/libnccl.so.2.30.7"
-# The serve-with-warmup wrapper polls the API at 127.0.0.1:$PORT — must
-# match `port` above (the launcher only passes --port as a CLI flag).
-PORT = "8000"
+# The serve-with-warmup wrapper polls the API at 127.0.0.1:$PORT — the value
+# resolves from the recipe's `port` field, so the two can't drift apart.
+PORT = { from = "port" }
 SERVED_MODEL_NAME = "GLM-5.3-Flash-NVFP4-Spark"
 DFLASH_WARMUP = "1"
 DFLASH_WARMUP_CONCURRENCIES = "1"

+ 3 - 3
recipes/glm53-flash-sparkring-4x/recipe.toml

@@ -116,9 +116,9 @@ VLLM_WORKER_MULTIPROC_METHOD = "spawn"
 VLLM_NO_USAGE_STATS = "1"
 HF_HUB_OFFLINE = "1"
 TRANSFORMERS_OFFLINE = "1"
-# The serve-with-warmup wrapper polls the API at 127.0.0.1:$PORT — must
-# match `port` above (the launcher only passes --port as a CLI flag).
-PORT = "8000"
+# The serve-with-warmup wrapper polls the API at 127.0.0.1:$PORT — the value
+# resolves from the recipe's `port` field, so the two can't drift apart.
+PORT = { from = "port" }
 SERVED_MODEL_NAME = "glm-5.3-flash-spark"
 DFLASH_WARMUP = "1"
 DFLASH_WARMUP_CONCURRENCIES = "1"

+ 3 - 2
src/after_stop.rs

@@ -145,7 +145,8 @@ fn truncate(s: &str, max: usize) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::{Node, PreServeCommand, VenvSetup};
+    use crate::config::{Node, PreServeCommand};
+    use crate::pre_serve::VenvSetup;
 
     fn test_cluster() -> Cluster {
         Cluster {
@@ -194,7 +195,7 @@ mod tests {
             required_nodes: None,
             pipeline_parallel_size: None,
             tensor_parallel_size: 1,
-            args: crate::config::RecipeArgs::default(),
+            args: crate::pre_serve::RecipeArgs::default(),
             env: std::collections::HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 3 - 2
src/before_serve.rs

@@ -184,7 +184,8 @@ fn truncate(s: &str, max: usize) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::{Node, PreServeCommand, VenvSetup};
+    use crate::config::{Node, PreServeCommand};
+    use crate::pre_serve::VenvSetup;
     use std::collections::HashMap;
 
     fn test_cluster() -> Cluster {
@@ -362,7 +363,7 @@ mod tests {
             required_nodes: None,
             pipeline_parallel_size: None,
             tensor_parallel_size: 1,
-            args: crate::config::RecipeArgs::default(),
+            args: crate::pre_serve::RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 3 - 0
src/config.rs

@@ -48,6 +48,9 @@ impl ExtraEntity {
     }
 }
 
+mod recipe;
+pub use crate::pre_serve::{BeforeServeTable, DistributedBackend, OnFailure, PreServeCommand};
+pub use recipe::*;
 mod types;
 pub use types::*;
 

+ 183 - 0
src/config/recipe.rs

@@ -0,0 +1,183 @@
+//! The `Recipe` type (parsed from `recipes/<name>/recipe.toml`) and
+//! [`EnvValue`] — the `[env]` value type: a literal string, or a reference
+//! into a recipe field (`{ from = "port" }`) resolved at serve time.
+
+use crate::config::types::{
+    ComposedModel, DeployTarget, ExtraMount, RecipeBuild, default_engine, default_port,
+    default_shm_size, default_sync_image, default_tensor_parallel_size,
+};
+use crate::pre_serve::{DistributedBackend, ModsTable, PreServeCommand, RecipeArgs, VenvSetup};
+use anyhow::{Result, bail};
+use serde::Deserialize;
+use std::collections::HashMap;
+
+/// An `[env]` entry value: either a literal string, or a reference into a
+/// recipe field resolved at serve time (`{ from = "port" }`). Untagged so
+/// both forms mix freely in one `[env]` table.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(untagged)]
+pub enum EnvValue {
+    Plain(String),
+    Ref { from: String },
+}
+
+impl From<String> for EnvValue {
+    fn from(s: String) -> Self {
+        EnvValue::Plain(s)
+    }
+}
+
+impl From<&str> for EnvValue {
+    fn from(s: &str) -> Self {
+        EnvValue::Plain(s.to_string())
+    }
+}
+
+impl EnvValue {
+    /// Resolve to a literal value. A `Ref` entry pulls its value from a
+    /// recipe field (`from = "port"` → the recipe's `port`), so the value is
+    /// declared once instead of being duplicated between `port = ...` and
+    /// `[env]`. Unknown targets are an error listing the supported ones.
+    pub fn resolve(&self, key: &str, recipe: &Recipe) -> Result<String> {
+        match self {
+            EnvValue::Plain(s) => Ok(s.clone()),
+            EnvValue::Ref { from } => match from.as_str() {
+                "port" => Ok(recipe.port.to_string()),
+                _ => {
+                    bail!("recipe [env] '{key}': unknown 'from' target '{from}' (supported: port)")
+                }
+            },
+        }
+    }
+}
+
+#[derive(Clone, Deserialize)]
+pub struct Recipe {
+    #[serde(default = "default_engine")]
+    pub engine: String,
+    pub name: String,
+    #[serde(default)]
+    pub docker_image: String,
+    #[serde(default)]
+    pub model: String,
+    #[serde(default = "default_port")]
+    pub port: u16,
+    #[serde(default)]
+    pub required_nodes: Option<u32>,
+    #[serde(default)]
+    pub pipeline_parallel_size: Option<u32>,
+    #[serde(default = "default_tensor_parallel_size")]
+    pub tensor_parallel_size: u32,
+    #[serde(default)]
+    pub args: RecipeArgs,
+    #[serde(default)]
+    pub env: HashMap<String, EnvValue>,
+    #[serde(default)]
+    pub cluster: Option<String>,
+    #[serde(default)]
+    pub entrypoint: Vec<String>,
+    pub distributed_backend: Option<DistributedBackend>,
+    #[serde(default = "default_shm_size")]
+    pub shm_size: String,
+    #[serde(default)]
+    pub before_serve: Vec<PreServeCommand>,
+    /// Commands run per node **after** stopping containers (`sm121 stop`);
+    /// same shape as [`before_serve`] (raw shell or `Script` aliases),
+    /// parallel across nodes. E.g. `after_stop = [{ script =
+    /// "restore_dgx_services" }]`.
+    #[serde(default)]
+    pub after_stop: Vec<PreServeCommand>,
+    /// Whether this recipe should always be served single-node on the first
+    /// free node found (solo mode). When set, `sm121 serve` ignores
+    /// `required_nodes` / multi-node cluster selection and launches exactly
+    /// one container with no distributed flags. The CLI `--solo`/`--no-solo`
+    /// flag overrides this per-invocation.
+    #[serde(default)]
+    pub solo: Option<bool>,
+    /// Deployment target — `docker` (default) or `tmux`. When `tmux`, the
+    /// `serve` command launches via detached `tmux` sessions instead of
+    /// `docker run`. Docker-only features (mods, composed_model, image
+    /// sync) are rejected in tmux mode.
+    #[serde(default)]
+    pub deploy: DeployTarget,
+    /// Python virtualenv path for tmux deployment. When set, the tmux
+    /// launch command prepends `source <venv>/bin/activate &&` before
+    /// the entrypoint, so the engine runs from the specified venv instead
+    /// of the system Python. Docker-only (ignored in Docker mode — the
+    /// container has its own Python).
+    ///
+    /// Example: `venv = "~/venvs/vllm025"`
+    #[serde(default)]
+    pub venv: Option<String>,
+    /// Shell commands to create and initialize the venv at `venv` if it
+    /// does not already exist on a node. Either an inline multi-line string
+    /// (one command per line; blank lines and `#` comments are skipped) or
+    /// a `Vec<String>` list. Only used in tmux deployment mode.
+    ///
+    /// The launcher checks `test -f <venv>/bin/activate` on each node
+    /// before serve; if absent, it runs these commands in order, then
+    /// re-checks. A failure aborts the serve.
+    ///
+    /// Example (inline): `uv venv ~/venvs/vllm025 -p 3.12 && uv pip install ...`
+    /// Example (list): `["uv venv ~/venvs/vllm025 -p 3.12", "..."]`
+    #[serde(default)]
+    pub venv_setup: VenvSetup,
+    /// Path to a shell script (relative to the recipe directory) that creates
+    /// and initializes the venv. When set, the launcher reads the file from
+    /// `recipes/<recipe_name>/<venv_setup_script>` and runs it on each node
+    /// if the venv doesn't exist. Mutually exclusive with `venv_setup` — if
+    /// both are set, `venv_setup_script` takes precedence.
+    ///
+    /// Example: `venv_setup_script = "venv_setup.sh"`
+    #[serde(default)]
+    pub venv_setup_script: Option<String>,
+    #[serde(default)]
+    pub mods: ModsTable,
+    #[serde(default = "default_sync_image")]
+    pub sync_image: bool,
+    /// Build directory for docker-images/ (default: derived from recipe name).
+    #[serde(default)]
+    pub docker_image_dir: Option<String>,
+    /// Recipe-driven `versions.env` overrides merged at build time (recipe
+    /// wins, after the profile) and folded into the build checksum, so a pin
+    /// change here triggers a rebuild without editing the image tree. Keys
+    /// must match the `versions.env` names the `Dockerfile` declares as ARG.
+    #[serde(default)]
+    pub build: RecipeBuild,
+    /// Named version profile: `docker-images/<dir>/profiles/<name>.env` (same
+    /// `KEY=VAL` format as `versions.env`). Merged over the image's pins
+    /// (profile wins) then recipe `[build.versions]` (recipe wins) before
+    /// build, folded into the build checksum. Lets a recipe reference a full
+    /// pin set (an engine generation: vLLM + b12x + LMCache cut together) by
+    /// name instead of copy-pasting every pin. Docker-only.
+    #[serde(default)]
+    pub build_profile: Option<String>,
+    /// When set, the launcher builds a /tmp composed-model dir per-node
+    /// (symlinks into the HF cache + generated files), bind-mounts it at
+    /// `mount`, and rewrites `model` to that path. Lets a recipe combine
+    /// multiple HF repos (e.g. a base checkpoint + an expert overlay)
+    /// without mutating the HF cache. See [`ComposedModel`].
+    ///
+    /// Docker-only: rejected when `deploy = "tmux"`.
+    #[serde(default)]
+    pub composed_model: Option<ComposedModel>,
+    /// Extra bind-mounts for `docker run` (e.g. persistent JIT cache). Host
+    /// path resolves from `Config.mount_aliases` when `alias` is set (alias
+    /// wins); otherwise falls back to `host` (`~` expanded). One of the two
+    /// is required.
+    #[serde(default)]
+    pub extra_mounts: Vec<ExtraMount>,
+}
+
+impl Recipe {
+    /// All `[env]` entries resolved to literal values — `Ref` entries pull
+    /// their value from the recipe's own fields (fail-fast on unknown
+    /// targets). Docker `-e` and tmux `export` use this.
+    pub fn resolved_env(&self) -> Result<HashMap<String, String>> {
+        let mut out = HashMap::new();
+        for (k, v) in &self.env {
+            out.insert(k.clone(), v.resolve(k, self)?);
+        }
+        Ok(out)
+    }
+}

+ 6 - 128
src/config/types.rs

@@ -1,12 +1,8 @@
 use super::ExtraEntity;
+use crate::pre_serve::BeforeServeTable;
 use serde::Deserialize;
 use std::collections::HashMap;
 
-pub use crate::pre_serve::{
-    BeforeServeTable, DistributedBackend, ModsTable, OnFailure, PreServeCommand, RecipeArgs,
-    VenvSetup,
-};
-
 #[derive(Clone, Debug, Deserialize)]
 pub struct Node {
     pub name: String,
@@ -109,143 +105,25 @@ fn default_shutdown_delay() -> u64 {
 fn default_init_port() -> u16 {
     25000
 }
-fn default_port() -> u16 {
+pub(crate) fn default_port() -> u16 {
     30000
 }
-fn default_engine() -> String {
+pub(crate) fn default_engine() -> String {
     "vllm".to_string()
 }
-fn default_shm_size() -> String {
+pub(crate) fn default_shm_size() -> String {
     String::new()
 }
-fn default_tensor_parallel_size() -> u32 {
+pub(crate) fn default_tensor_parallel_size() -> u32 {
     1
 }
-fn default_sync_image() -> bool {
+pub(crate) fn default_sync_image() -> bool {
     true
 }
 fn default_drop_caches() -> bool {
     true
 }
 
-#[derive(Clone, Deserialize)]
-pub struct Recipe {
-    #[serde(default = "default_engine")]
-    pub engine: String,
-    pub name: String,
-    #[serde(default)]
-    pub docker_image: String,
-    #[serde(default)]
-    pub model: String,
-    #[serde(default = "default_port")]
-    pub port: u16,
-    #[serde(default)]
-    pub required_nodes: Option<u32>,
-    #[serde(default)]
-    pub pipeline_parallel_size: Option<u32>,
-    #[serde(default = "default_tensor_parallel_size")]
-    pub tensor_parallel_size: u32,
-    #[serde(default)]
-    pub args: RecipeArgs,
-    #[serde(default)]
-    pub env: HashMap<String, String>,
-    #[serde(default)]
-    pub cluster: Option<String>,
-    #[serde(default)]
-    pub entrypoint: Vec<String>,
-    pub distributed_backend: Option<DistributedBackend>,
-    #[serde(default = "default_shm_size")]
-    pub shm_size: String,
-    #[serde(default)]
-    pub before_serve: Vec<PreServeCommand>,
-    /// Commands run per node **after** stopping containers (`sm121 stop`);
-    /// same shape as [`before_serve`] (raw shell or `Script` aliases),
-    /// parallel across nodes. E.g. `after_stop = [{ script =
-    /// "restore_dgx_services" }]`.
-    #[serde(default)]
-    pub after_stop: Vec<PreServeCommand>,
-    /// Whether this recipe should always be served single-node on the first
-    /// free node found (solo mode). When set, `sm121 serve` ignores
-    /// `required_nodes` / multi-node cluster selection and launches exactly
-    /// one container with no distributed flags. The CLI `--solo`/`--no-solo`
-    /// flag overrides this per-invocation.
-    #[serde(default)]
-    pub solo: Option<bool>,
-    /// Deployment target — `docker` (default) or `tmux`. When `tmux`, the
-    /// `serve` command launches via detached `tmux` sessions instead of
-    /// `docker run`. Docker-only features (mods, composed_model, image
-    /// sync) are rejected in tmux mode.
-    #[serde(default)]
-    pub deploy: DeployTarget,
-    /// Python virtualenv path for tmux deployment. When set, the tmux
-    /// launch command prepends `source <venv>/bin/activate &&` before
-    /// the entrypoint, so the engine runs from the specified venv instead
-    /// of the system Python. Docker-only (ignored in Docker mode — the
-    /// container has its own Python).
-    ///
-    /// Example: `venv = "~/venvs/vllm025"`
-    #[serde(default)]
-    pub venv: Option<String>,
-    /// Shell commands to create and initialize the venv at `venv` if it
-    /// does not already exist on a node. Either an inline multi-line string
-    /// (one command per line; blank lines and `#` comments are skipped) or
-    /// a `Vec<String>` list. Only used in tmux deployment mode.
-    ///
-    /// The launcher checks `test -f <venv>/bin/activate` on each node
-    /// before serve; if absent, it runs these commands in order, then
-    /// re-checks. A failure aborts the serve.
-    ///
-    /// Example (inline): `uv venv ~/venvs/vllm025 -p 3.12 && uv pip install ...`
-    /// Example (list): `["uv venv ~/venvs/vllm025 -p 3.12", "..."]`
-    #[serde(default)]
-    pub venv_setup: VenvSetup,
-    /// Path to a shell script (relative to the recipe directory) that creates
-    /// and initializes the venv. When set, the launcher reads the file from
-    /// `recipes/<recipe_name>/<venv_setup_script>` and runs it on each node
-    /// if the venv doesn't exist. Mutually exclusive with `venv_setup` — if
-    /// both are set, `venv_setup_script` takes precedence.
-    ///
-    /// Example: `venv_setup_script = "venv_setup.sh"`
-    #[serde(default)]
-    pub venv_setup_script: Option<String>,
-    #[serde(default)]
-    pub mods: ModsTable,
-    #[serde(default = "default_sync_image")]
-    pub sync_image: bool,
-    /// Build directory for docker-images/ (default: derived from recipe name).
-    #[serde(default)]
-    pub docker_image_dir: Option<String>,
-    /// Recipe-driven `versions.env` overrides merged at build time (recipe
-    /// wins, after the profile) and folded into the build checksum, so a pin
-    /// change here triggers a rebuild without editing the image tree. Keys
-    /// must match the `versions.env` names the `Dockerfile` declares as ARG.
-    #[serde(default)]
-    pub build: RecipeBuild,
-    /// Named version profile: `docker-images/<dir>/profiles/<name>.env` (same
-    /// `KEY=VAL` format as `versions.env`). Merged over the image's pins
-    /// (profile wins) then recipe `[build.versions]` (recipe wins) before
-    /// build, folded into the build checksum. Lets a recipe reference a full
-    /// pin set (an engine generation: vLLM + b12x + LMCache cut together) by
-    /// name instead of copy-pasting every pin. Docker-only.
-    #[serde(default)]
-    pub build_profile: Option<String>,
-    /// When set, the launcher builds a /tmp composed-model dir per-node
-    /// (symlinks into the HF cache + generated files), bind-mounts it at
-    /// `mount`, and rewrites `model` to that path. Lets a recipe combine
-    /// multiple HF repos (e.g. a base checkpoint + an expert overlay)
-    /// without mutating the HF cache. See [`ComposedModel`].
-    ///
-    /// Docker-only: rejected when `deploy = "tmux"`.
-    #[serde(default)]
-    pub composed_model: Option<ComposedModel>,
-    /// Extra bind-mounts for `docker run` (e.g. persistent JIT cache). Host
-    /// path resolves from `Config.mount_aliases` when `alias` is set (alias
-    /// wins); otherwise falls back to `host` (`~` expanded). One of the two
-    /// is required.
-    #[serde(default)]
-    pub extra_mounts: Vec<ExtraMount>,
-}
-
 /// Recipe-level build config: `versions` — a map of `versions.env` pin
 /// overrides merged over the image's pins before build (after the profile).
 #[derive(Clone, Debug, Default, Deserialize)]

+ 13 - 8
src/docker/command.rs

@@ -168,7 +168,11 @@ pub(crate) fn substitute_path_refs(recipe: &Recipe, path_refs: &[(String, String
         let placeholder = format!("{{{{{}}}}}", name);
         r.args = r.args.substitute(&placeholder, path);
         for v in r.env.values_mut() {
-            *v = v.replace(&placeholder, path);
+            // Ref entries reference recipe fields — nothing to interpolate
+            // (they resolve at serve time via `resolved_env`).
+            if let crate::config::EnvValue::Plain(s) = v {
+                *s = s.replace(&placeholder, path);
+            }
         }
     }
     r
@@ -246,7 +250,7 @@ fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a s
 }
 
 /// 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> {
+fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Result<Vec<String>> {
     let mut pairs: Vec<(&str, Cow<str>)> =
         vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
     if ctx.nnodes > 1 {
@@ -275,15 +279,16 @@ fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec
             pairs.push((name.as_str(), m.container.as_str().into()));
         }
     }
-    for (k, v) in &ctx.recipe.env {
+    let recipe_env = ctx.recipe.resolved_env()?;
+    for (k, v) in &recipe_env {
         pairs.push((k.as_str(), v.as_str().into()));
     }
     // Quote the whole `K=V` so a recipe `[env]` value or name containing
     // shell metacharacters cannot inject into the remote `docker run` shell.
-    pairs
+    Ok(pairs
         .into_iter()
         .map(|(k, v)| format!("-e {}", shq(&format!("{k}={v}"))))
-        .collect()
+        .collect())
 }
 
 /// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
@@ -312,7 +317,7 @@ pub(crate) fn build_docker_command(
     ctx: &DockerCommandCtx,
     node: &Node,
     is_head: bool,
-) -> Vec<String> {
+) -> Result<Vec<String>> {
     let flags = build_docker_flags(ctx);
     let mut cmd = vec![
         format!("docker run -d {}", flags.join(" ")),
@@ -330,7 +335,7 @@ pub(crate) fn build_docker_command(
         }
         cmd.push(spec);
     }
-    cmd.extend(collect_docker_env(ctx, node, is_head));
+    cmd.extend(collect_docker_env(ctx, node, is_head)?);
     cmd.push(format!(
         "--name {}",
         shq(&format!("sm121_{}_{}", ctx.recipe_name, node.rank))
@@ -376,7 +381,7 @@ pub(crate) fn build_docker_command(
         // shell-quoted so they survive the remote shell as one argv each.
         cmd.extend(["--entrypoint sh".into(), img, "-c".into(), script]);
     }
-    cmd
+    Ok(cmd)
 }
 
 #[cfg(test)]

+ 78 - 7
src/docker/command_tests.rs

@@ -1,8 +1,8 @@
 use super::*;
 use crate::config::{
-    Cluster, DeployTarget, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
-    VenvSetup,
+    Cluster, DeployTarget, DistributedBackend, EnvValue, ExtraMount, NcclConfig, Node, Recipe,
 };
+use crate::pre_serve::{RecipeArgs, VenvSetup};
 use std::collections::HashMap;
 
 fn tcls() -> Cluster {
@@ -71,7 +71,9 @@ fn run_with(recipe: &Recipe) -> String {
         mods_remote_path: String::new(),
         extra_mounts: resolved.static_mounts,
     };
-    build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
+    build_docker_command(&ctx, &tcls().nodes[0], true)
+        .unwrap()
+        .join(" ")
 }
 
 fn base_recipe() -> Recipe {
@@ -107,6 +109,74 @@ fn base_recipe() -> Recipe {
     }
 }
 
+#[test]
+fn env_ref_resolves_to_recipe_port() {
+    let mut r = base_recipe();
+    r.port = 8123;
+    r.env = HashMap::from([
+        (
+            "PORT".into(),
+            crate::config::EnvValue::Ref {
+                from: "port".into(),
+            },
+        ),
+        ("PLAIN".into(), crate::config::EnvValue::Plain("x".into())),
+    ]);
+    let env = r.resolved_env().unwrap();
+    assert_eq!(env["PORT"], "8123");
+    assert_eq!(env["PLAIN"], "x");
+}
+
+#[test]
+fn env_ref_unknown_target_errors() {
+    let mut r = base_recipe();
+    r.env = HashMap::from([(
+        "K".into(),
+        crate::config::EnvValue::Ref {
+            from: "bogus".into(),
+        },
+    )]);
+    let err = r.resolved_env().unwrap_err().to_string();
+    assert!(err.contains("bogus"), "err: {err}");
+    assert!(err.contains("K"), "err: {err}");
+}
+
+#[test]
+fn env_toml_parses_mixed_plain_and_ref() {
+    let r: Recipe = toml::from_str(
+        r#"
+engine = "vllm"
+name = "T"
+docker_image = "img"
+model = "mdl"
+port = 31337
+
+[env]
+A = "1"
+PORT = { from = "port" }
+"#,
+    )
+    .unwrap();
+    assert_eq!(r.env.len(), 2);
+    let env = r.resolved_env().unwrap();
+    assert_eq!(env["PORT"], "31337");
+    assert_eq!(env["A"], "1");
+}
+
+#[test]
+fn env_ref_renders_in_docker_env() {
+    let mut r = base_recipe();
+    r.port = 39999;
+    r.env = HashMap::from([(
+        "PORT".into(),
+        crate::config::EnvValue::Ref {
+            from: "port".into(),
+        },
+    )]);
+    let cmd = run_with(&r);
+    assert!(cmd.contains("-e 'PORT=39999'"), "cmd: {cmd}");
+}
+
 #[test]
 fn extra_mounts_are_mounted() {
     let mut r = base_recipe();
@@ -153,7 +223,7 @@ fn malicious_env_value_cannot_inject() {
     // quoted argument — never break out of the remote `docker run`.
     let mut r = base_recipe();
     r.env
-        .insert("EVIL".to_string(), "x; touch /tmp/pwned".to_string());
+        .insert("EVIL".to_string(), "x; touch /tmp/pwned".into());
     let cmd = run_with(&r);
     assert!(cmd.contains("-e 'EVIL=x; touch /tmp/pwned'"));
     // No raw `; touch` escape: the whole K=V is inside quotes.
@@ -230,8 +300,7 @@ fn substitute_path_refs_replaces_placeholders() {
 #[test]
 fn substitute_path_refs_in_env() {
     let mut r = base_recipe();
-    r.env
-        .insert("MTP_PATH".to_string(), "{{mtp_draft}}".to_string());
+    r.env.insert("MTP_PATH".to_string(), "{{mtp_draft}}".into());
     let path_refs = vec![(
         "mtp_draft".to_string(),
         "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
@@ -239,7 +308,9 @@ fn substitute_path_refs_in_env() {
     let substituted = substitute_path_refs(&r, &path_refs);
     assert_eq!(
         substituted.env["MTP_PATH"],
-        "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft"
+        EnvValue::Plain(
+            "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string()
+        )
     );
 }
 

+ 4 - 1
src/docker/launch_node.rs

@@ -104,7 +104,10 @@ pub(crate) async fn launch_node(
         nccl,
         ..dm_ctx.clone()
     };
-    let cmd = command::build_docker_command(&ctx, node, is_head).join(" ");
+    let cmd = match command::build_docker_command(&ctx, node, is_head) {
+        Ok(c) => c.join(" "),
+        Err(e) => return fail(e),
+    };
     let result = crate::ssh::run_ssh_with_retry(config, node, cmd, dry, 2).await;
     (node.name.clone(), node.rank, engine.to_string(), result)
 }

+ 2 - 1
src/docker/mirror.rs

@@ -144,7 +144,8 @@ pub async fn ensure_mirrors(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::{DeployTarget, DistributedBackend, RecipeArgs, VenvSetup};
+    use crate::config::{DeployTarget, DistributedBackend};
+    use crate::pre_serve::{RecipeArgs, VenvSetup};
     use std::collections::HashMap;
 
     fn cfg() -> Config {

+ 1 - 1
src/docker/sglang_tests.rs

@@ -45,7 +45,7 @@ fn srun(recipe: &Recipe, node: &Node, head: bool) -> String {
     };
     let mut ctx = make_dm_ctx(recipe, 4, 4, tp, false);
     ctx.recipe_name = "sg".to_string();
-    squash(&build_docker_command(&ctx, node, head).join(" "))
+    squash(&build_docker_command(&ctx, node, head).unwrap().join(" "))
 }
 
 #[test]

+ 14 - 4
src/docker/solo_tests.rs

@@ -17,7 +17,9 @@ fn single_node_run_omits_distributed_flags() {
     r.pipeline_parallel_size = Some(1);
     // nnodes=1 is what start_cluster computes after trimming to required_nodes.
     let ctx = make_dm_ctx(&r, 1, 1, 1, false);
-    let cmd = build_docker_command(&ctx, &tcls().nodes[0], true).join(" ");
+    let cmd = build_docker_command(&ctx, &tcls().nodes[0], true)
+        .unwrap()
+        .join(" ");
     assert!(
         !cmd.contains("--nnodes"),
         "single-node must not pass --nnodes: {cmd}"
@@ -56,7 +58,11 @@ fn multi_node_run_still_emits_distributed_flags() {
     // Regression guard: the single-node fix must not break the multi-node path.
     let r = trec(); // required_nodes=2, tp collapses to nnodes=2
     let ctx = make_dm_ctx(&r, 2, 2, 2, false);
-    let cmd = squash(&build_docker_command(&ctx, &tcls().nodes[0], true).join(" "));
+    let cmd = squash(
+        &build_docker_command(&ctx, &tcls().nodes[0], true)
+            .unwrap()
+            .join(" "),
+    );
     assert!(cmd.contains("--nnodes 2"));
     assert!(cmd.contains("--tensor-parallel-size 2"));
     assert!(cmd.contains("--pipeline-parallel-size 2"));
@@ -70,7 +76,9 @@ fn solo_worker_node_emits_headless_no_rendezvous() {
     // Multi-node: a worker (rank>0) gets --headless; single-node never does.
     let r = trec();
     let ctx = make_dm_ctx(&r, 2, 2, 2, false);
-    let worker_cmd = build_docker_command(&ctx, &tcls().nodes[1], false).join(" ");
+    let worker_cmd = build_docker_command(&ctx, &tcls().nodes[1], false)
+        .unwrap()
+        .join(" ");
     assert!(
         worker_cmd.contains("--headless"),
         "multi-node worker needs --headless: {worker_cmd}"
@@ -79,7 +87,9 @@ fn solo_worker_node_emits_headless_no_rendezvous() {
     let mut r1 = r.clone();
     r1.required_nodes = Some(1);
     let ctx1 = make_dm_ctx(&r1, 1, 1, 1, false);
-    let solo_cmd = build_docker_command(&ctx1, &tcls().nodes[1], false).join(" ");
+    let solo_cmd = build_docker_command(&ctx1, &tcls().nodes[1], false)
+        .unwrap()
+        .join(" ");
     assert!(!solo_cmd.contains("--headless"));
 }
 

+ 25 - 7
src/docker/tests.rs

@@ -1,6 +1,7 @@
 use super::command::{DockerCommandCtx, build_docker_command};
 use super::*;
-use crate::config::{DeployTarget, DistributedBackend, RecipeArgs, VenvSetup};
+use crate::config::{DeployTarget, DistributedBackend};
+use crate::pre_serve::{RecipeArgs, VenvSetup};
 use std::collections::HashMap;
 
 fn tcls() -> Cluster {
@@ -106,7 +107,11 @@ fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
     } else {
         recipe.tensor_parallel_size
     };
-    squash(&build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, false), node, head).join(" "))
+    squash(
+        &build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, false), node, head)
+            .unwrap()
+            .join(" "),
+    )
 }
 
 #[test]
@@ -119,7 +124,11 @@ fn nccl_gid_unresolved_renders_auto() {
     nccl.ib_gid_index = None;
     let mut ctx = make_dm_ctx(&trec(), 2, 2, 2, false);
     ctx.nccl = Some(nccl);
-    let cmd = squash(&build_docker_command(&ctx, &tcls().nodes[0], true).join(" "));
+    let cmd = squash(
+        &build_docker_command(&ctx, &tcls().nodes[0], true)
+            .unwrap()
+            .join(" "),
+    );
     assert!(
         cmd.contains("-e NCCL_IB_GID_INDEX=auto"),
         "unresolved GID should render 'auto' placeholder: {cmd}"
@@ -132,7 +141,11 @@ fn run_keep(recipe: &Recipe, node: &Node, head: bool) -> String {
     } else {
         recipe.tensor_parallel_size
     };
-    squash(&build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, true), node, head).join(" "))
+    squash(
+        &build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, true), node, head)
+            .unwrap()
+            .join(" "),
+    )
 }
 
 #[test]
@@ -328,7 +341,11 @@ fn custom_init_port() {
     };
     let mut ctx = make_dm_ctx(&r, 2, 2, tp, false);
     ctx.init_port = 29500;
-    let cmd = squash(&build_docker_command(&ctx, &tcls().nodes[0], true).join(" "));
+    let cmd = squash(
+        &build_docker_command(&ctx, &tcls().nodes[0], true)
+            .unwrap()
+            .join(" "),
+    );
     assert!(cmd.contains("--master-port 29500"));
     assert!(cmd.contains("--master-addr 1"));
 }
@@ -393,8 +410,9 @@ fn entrypoint_script_is_shell_quoted() {
     // Every tail word — incl. the `sh -c` script — is shell-quoted.
     let mut r = trec();
     r.entrypoint = vec!["sh".into(), "-c".into(), "echo $i; sleep 1".into()];
-    let cmd =
-        build_docker_command(&make_dm_ctx(&r, 2, 2, 2, false), &tcls().nodes[0], true).join(" ");
+    let cmd = build_docker_command(&make_dm_ctx(&r, 2, 2, 2, false), &tcls().nodes[0], true)
+        .unwrap()
+        .join(" ");
     assert!(
         cmd.contains("'echo $i; sleep 1'"),
         "must be shell-quoted: {cmd}"

+ 2 - 2
src/doctor.rs

@@ -348,7 +348,7 @@ pub async fn try_repaired_image(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::VenvSetup;
+    use crate::pre_serve::VenvSetup;
     use std::collections::HashMap;
 
     fn test_recipe() -> Recipe {
@@ -362,7 +362,7 @@ mod tests {
             required_nodes: Some(2),
             pipeline_parallel_size: Some(2),
             tensor_parallel_size: 1,
-            args: crate::config::RecipeArgs::List(vec![
+            args: crate::pre_serve::RecipeArgs::List(vec![
                 "--max-model-len".to_string(),
                 "4096".to_string(),
             ]),

+ 2 - 1
src/engine_flags.rs

@@ -104,7 +104,8 @@ pub fn build_engine_args(ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::{DeployTarget, RecipeArgs, VenvSetup};
+    use crate::config::DeployTarget;
+    use crate::pre_serve::{RecipeArgs, VenvSetup};
     use std::collections::HashMap;
 
     fn base_recipe() -> Recipe {

+ 7 - 6
src/publish.rs

@@ -98,7 +98,7 @@ pub async fn publish(
         &hf_cache,
         config.init_port,
         revision.as_deref(),
-    );
+    )?;
     std::fs::write(bundle.join("run.sh"), run)?;
     // Bundle build driver: launcher stamps the label, needs `Done: <tag>`.
     std::fs::write(
@@ -192,7 +192,7 @@ fn render_run_script(
     hf_cache: &str,
     init_port: u16,
     revision: Option<&str>,
-) -> String {
+) -> Result<String> {
     let nodes = cluster.map_or_else(
         || recipe.required_nodes.unwrap_or(2) as usize,
         |c| c.nodes.len(),
@@ -250,7 +250,8 @@ fn render_run_script(
         if recipe.shm_size.is_empty() { "32g".into() } else { recipe.shm_size.clone() },
         hf_cache, // unquoted: `~` must expand in bash
     ));
-    for (k, v) in recipe.env.iter().collect::<BTreeMap<_, _>>() {
+    let env = recipe.resolved_env()?;
+    for (k, v) in env.iter().collect::<BTreeMap<_, _>>() {
         s.push_str(&format!("  -e {} \\\n", shq(&format!("{k}={v}"))));
     }
     s.push_str("  \"$IMAGE\" \\\n");
@@ -285,7 +286,7 @@ fn render_run_script(
         s.push_str(" \\\n  ${HEADLESS:+$HEADLESS}");
     }
     s.push_str(&format!(" \\\n  --host 0.0.0.0 --port {}\n", recipe.port));
-    s
+    Ok(s)
 }
 
 fn render_install_sh(
@@ -481,7 +482,7 @@ mod tests {
 
     #[test]
     fn run_script_vllm_has_dist_flags_and_json_arg() {
-        let s = render_run_script(&rec("vllm"), None, None, "/hf", 29500, Some("abc"));
+        let s = render_run_script(&rec("vllm"), None, None, "/hf", 29500, Some("abc")).unwrap();
         assert!(s.contains("--tensor-parallel-size 2 --nnodes 2 --node-rank \"$NODE_RANK\""));
         assert!(s.contains("--revision \"$MODEL_REVISION\""));
         assert!(s.contains("'--speculative-config'"));
@@ -492,7 +493,7 @@ mod tests {
     fn run_script_sglang_solo_omits_dist() {
         let mut r = rec("sglang");
         r.solo = Some(true);
-        let s = render_run_script(&r, None, None, "/hf", 29500, None);
+        let s = render_run_script(&r, None, None, "/hf", 29500, None).unwrap();
         assert!(!s.contains("--nnodes") && !s.contains("MASTER_ADDR"));
         assert!(s.contains("# MODEL_REVISION="));
     }

+ 7 - 4
src/tmux/commands.rs

@@ -2,6 +2,7 @@ use crate::config::{Cluster, Node, Recipe};
 use crate::engine_flags::{EngineFlagsCtx, build_engine_args};
 use crate::ssh::shq;
 use crate::tmux::TmuxManager;
+use anyhow::Result;
 
 impl TmuxManager {
     /// Build the engine launch command for a node.
@@ -74,7 +75,7 @@ impl TmuxManager {
         recipe: &Recipe,
         nccl: Option<&crate::config::NcclConfig>,
         cluster: &Cluster,
-    ) -> Vec<String> {
+    ) -> Result<Vec<String>> {
         let sess = self.session_name(recipe, node.rank);
         let mut cmds: Vec<String> = Vec::new();
 
@@ -100,7 +101,8 @@ impl TmuxManager {
                 cluster.effective_master_ip()
             ));
         }
-        for (k, v) in &recipe.env {
+        let env = recipe.resolved_env()?;
+        for (k, v) in &env {
             cmds.push(format!(
                 "tmux send-keys -t {}:0 'export {}=\"{}\"' Enter",
                 sess, k, v
@@ -129,14 +131,15 @@ impl TmuxManager {
         ));
         cmds.push(format!("tmux select-window -t {}:0", sess));
 
-        cmds
+        Ok(cmds)
     }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::{DeployTarget, DistributedBackend, RecipeArgs, VenvSetup};
+    use crate::config::{DeployTarget, DistributedBackend};
+    use crate::pre_serve::{RecipeArgs, VenvSetup};
     use std::collections::HashMap;
 
     fn trecipe() -> Recipe {

+ 3 - 3
src/tmux/serve.rs

@@ -118,7 +118,7 @@ pub async fn tmux_serve(
                 .await
                 .context("resolving head node NCCL config")?;
         let head_cmds =
-            manager.build_session_commands(head_node, &recipe, head_nccl.as_ref(), &cluster);
+            manager.build_session_commands(head_node, &recipe, head_nccl.as_ref(), &cluster)?;
         manager
             .create_session(config, head_node, &head_cmds, dry_run)
             .await?;
@@ -141,7 +141,7 @@ pub async fn tmux_serve(
             let node_nccl =
                 crate::detect::resolve_nccl_for_node(config, node, nccl.as_ref(), dry_run).await?;
             let worker_cmds =
-                manager.build_session_commands(node, &recipe, node_nccl.as_ref(), &cluster);
+                manager.build_session_commands(node, &recipe, node_nccl.as_ref(), &cluster)?;
             manager
                 .create_session(config, node, &worker_cmds, dry_run)
                 .await?;
@@ -168,7 +168,7 @@ pub async fn tmux_serve(
                         Err(e) => return Err(anyhow::anyhow!(e)),
                     };
                 let cmds =
-                    manager.build_session_commands(&node, &recipe, node_nccl.as_ref(), &cluster);
+                    manager.build_session_commands(&node, &recipe, node_nccl.as_ref(), &cluster)?;
                 manager
                     .create_session(&config, &node, &cmds, dry)
                     .await