Browse Source

feat(recipe): add built-in script aliases and after_stop hook

Recipes can now reference host scripts baked into the binary by alias,
and run a paired teardown hook after `sm121 stop` — the two DGX
service-management scripts that were previously scp'd and run by hand
become first-class recipe entries.

Built-in script registry (src/builtin_scripts.rs): scripts under
host-scripts/ are embedded at compile time via include_str! and exposed
by alias. The launcher uploads + runs them via SSH per node, so no file
copy from the operator's machine is needed — deploys are atomic and
version-pinned to the binary. Scripts stay as real .sh files (diffable,
shellcheck-able).

PreServeCommand gains a Script { script, on_failure } variant: a bare
string stays a raw shell command (unchanged), and { script = "alias" }
references a built-in. Unknown aliases error at resolution time with the
list of known ones. before_serve and mods (which share the type) get
alias support for free.

New after_stop recipe field + runner (src/after_stop.rs): the mirror of
before_serve for the teardown side. Runs per-node in parallel after
stop_cluster succeeds, same on_failure semantics. The natural home for
restore_dgx_services paired with a before_serve stop_dgx_services.

Rename + relocate: scripts/stop-for-glmnf3.sh -> host-scripts/
stop-dgx-services.sh (alias stop_dgx_services) and scripts/
start-after-glmnf3.sh -> host-scripts/restore-dgx-services.sh (alias
restore_dgx_services). The glmnf3 model name is dropped — these are
machine-specific (DGX), not model-specific. scripts/check.sh is left
alone (dev tooling). History preserved via git mv.

ssh::upload_script: a leaner variant of upload_dir for a single
in-memory script body (base64 + pipe + chmod +x); remote_path is
shell-quoted to keep the C-1 escaping discipline. Used by both the
before_serve and after_stop runners.

Tests: +8 (108 -> 116) covering alias resolution (known/unknown), the
rename regression (no glmnf3), after_stop no-op/dry-run/deserialization,
and before+after coexistence. AGENTS.md documents the new field, the
entry shape, and the alias registry.
Alexander Korolev 2 tháng trước cách đây
mục cha
commit
65198e60fc

+ 34 - 0
AGENTS.md

@@ -43,6 +43,7 @@ distributed_backend = "mp"          # or "ray"
 shm_size = "64gb"                   # optional, only set when non-empty
 entrypoint = ["vllm", "serve"]      # optional, after image
 before_serve = [{ command = "echo", on_failure = "abort" }]
+after_stop = [{ script = "restore_dgx_services" }]   # runs after `sm121 stop`
 args = ["--kv-cache-dtype", "fp8"]
 
 [[extra_mounts]]                    # extra bind-mounts for `docker run -v`
@@ -96,6 +97,39 @@ wrapping.  When `[mods]` is present, the recipe **must** also set `entrypoint`
 
 Implementation: `--entrypoint sh image -c 'mods && exec <original entrypoint>'`
 
+## Built-in scripts (baked into the binary)
+
+Host scripts under `host-scripts/` are embedded at compile time
+(`include_str!` in `src/builtin_scripts.rs`) and can be referenced by
+**alias** from any `before_serve` / `after_stop` list. The launcher uploads
++ runs them via SSH per node — no `scp` from the operator's machine.
+
+Entry shape (untagged enum, three forms):
+
+```toml
+before_serve = [
+  "sudo nvidia-smi -lgc",                       # bare string = raw shell command
+  { command = "echo hi", on_failure = "warn" },  # raw command + on_failure
+  { script = "stop_dgx_services" },              # built-in alias
+  { script = "stop_dgx_services", on_failure = "warn" },
+]
+```
+
+A bare string is always a raw command (today's behavior, unchanged); a
+built-in is referenced only via `{ script = "<alias>" }`. Unknown aliases
+error at resolution time with the list of known ones.
+
+Built-in aliases:
+
+| Alias | File | Purpose |
+|-------|------|---------|
+| `stop_dgx_services` | `host-scripts/stop-dgx-services.sh` | Stop non-essential services on a DGX node (frees unified memory) |
+| `restore_dgx_services` | `host-scripts/restore-dgx-services.sh` | Restore services stopped by `stop_dgx_services` |
+
+To add a built-in: drop the `.sh` into `host-scripts/`, add an
+`include_str!` + `BuiltinScript` entry in `src/builtin_scripts.rs`, document
+the alias here. Scripts stay diffable / shellcheck-able as real files.
+
 ## Flags auto-generated
 
 - vLLM: `--tensor-parallel-size`, `--pipeline-parallel-size`, `--nnodes`, `--node-rank`, `--master-addr`, `--master-port`, `--host`, `--port`, `--headless` (workers)

+ 6 - 4
scripts/start-after-glmnf3.sh → host-scripts/restore-dgx-services.sh

@@ -1,9 +1,11 @@
 #!/usr/bin/env bash
-# start-after-glmnf3.sh — Restore non-essential services on this node.
-# Run after inference to bring back services stopped by stop-for-glmnf3.sh.
+# restore-dgx-services.sh — Restore non-essential services on a DGX node.
+# Brings back the services stopped by stop-dgx-services.sh. Machine-specific
+# (DGX), not model-specific.
 #
-# Usage:
-#   ssh dgxN.lan 'sudo bash ~/start-after-glmnf3.sh'
+# Baked into the sm121 binary at compile time (see src/builtin_scripts.rs);
+# recipes reference it by alias `restore_dgx_services` in an `after_stop` list.
+# The launcher uploads and runs it via SSH on each node.
 
 set -euo pipefail
 

+ 7 - 6
scripts/stop-for-glmnf3.sh → host-scripts/stop-dgx-services.sh

@@ -1,12 +1,13 @@
 #!/usr/bin/env bash
-# stop-for-glmnf3.sh — Stop non-essential services on this node.
-# Frees unified memory for GLM-5.2 NF3-Hybrid inference.
+# stop-dgx-services.sh — Stop non-essential services on a DGX node.
+# Frees unified memory for inference. Machine-specific (DGX), not model-
+# specific — safe to run before any large-model serve.
 #
-# Usage:
-#   scp scripts/stop-for-glmnf3.sh dgxN.lan:~/
-#   ssh dgxN.lan 'sudo bash ~/stop-for-glmnf3.sh'
+# Baked into the sm121 binary at compile time (see src/builtin_scripts.rs);
+# recipes reference it by alias `stop_dgx_services` in a `before_serve` /
+# `after_stop` list. The launcher uploads and runs it via SSH on each node.
 #
-# Reboot to restore services, or run start-after-glmnf3.sh.
+# To restore services afterwards, use `restore_dgx_services` (or reboot).
 
 set -euo pipefail
 

+ 4 - 0
locales/en/main.ftl

@@ -92,6 +92,10 @@ mods-uploaded = ✅ Mods uploaded to { $node }
 mods-entrypoint-required = Recipe '{ $name }' has mods but no entrypoint; entrypoint is required when mods are present
 power-off-container-stop-failed = ⚠️ Failed to stop containers on node { $name }: { $error}
 
+after-stop-failed = after_stop: node { $name } failed: { $error }
+after-stop-run = { $node } ({ $tag }) :: { $cmd }
+after-stop-ignored = ⚠ { $cmd } ignored: { $error }
+
 # --- Strings that previously bypassed the i18n system (hardcoded) ---
 boot-wait = ⏳ Waiting { $secs }s for nodes to boot...
 doctor-found-repaired = 🩺 Found repaired image: { $tag }

+ 263 - 0
src/after_stop.rs

@@ -0,0 +1,263 @@
+//! `after_stop` — recipe commands that run on each node **after** containers
+//! are stopped (`sm121 stop`). The mirror of [`crate::before_serve`], for the
+//! teardown side of a serve: e.g. `restore_dgx_services` to bring back the
+//! services that a `before_serve` `stop_dgx_services` stopped.
+//!
+//! Same entry shape ([`PreServeCommand`]: `Bare` / `Configured` / `Script`)
+//! and the same `on_failure` semantics as `before_serve`. Recipe-only (no
+//! cluster-level `[after_stop]` config); runs per-node in parallel, commands
+//! sequentially within a node.
+
+use anyhow::Result;
+use tracing::{info, warn};
+
+use crate::config::{Cluster, Config, Node, OnFailure, PreServeCommand, Recipe};
+use crate::ssh::shq;
+
+/// Run a recipe's `after_stop` commands on every node of the cluster after
+/// containers are stopped. See module docs for semantics.
+pub async fn run(config: &Config, cluster: &Cluster, recipe: &Recipe, dry_run: bool) -> Result<()> {
+    if recipe.after_stop.is_empty() {
+        return Ok(());
+    }
+
+    info!(
+        "🔧 after_stop hooks: {} command(s) on {} node(s)",
+        recipe.after_stop.len(),
+        cluster.nodes.len()
+    );
+
+    let cmds: Vec<(&'static str, PreServeCommand)> = recipe
+        .after_stop
+        .iter()
+        .map(|c| ("recipe", c.clone()))
+        .collect();
+
+    let mut handles = Vec::with_capacity(cluster.nodes.len());
+    for node in &cluster.nodes {
+        let config = config.clone();
+        let node = node.clone();
+        let cmds = cmds.clone();
+        let dry = dry_run;
+        handles.push(tokio::spawn(async move {
+            (
+                node.name.clone(),
+                run_for_node(&config, &node, &cmds, dry).await,
+            )
+        }));
+    }
+
+    let mut any_failed = false;
+    for h in handles {
+        let (name, result) = h.await?;
+        if let Err(e) = result {
+            any_failed = true;
+            warn!(
+                "{}",
+                fluent_i18n::t!("after-stop-failed", { "name" => &name, "error" => e.to_string() })
+            );
+        }
+    }
+
+    if any_failed {
+        anyhow::bail!("after_stop hooks failed on at least one node (see warnings above)");
+    }
+    Ok(())
+}
+
+/// Run a fixed list of commands on a single node, sequentially. Mirrors
+/// `before_serve::run_for_node`; `Script` entries are uploaded + invoked via
+/// `bash`, raw entries run as-is.
+async fn run_for_node(
+    config: &Config,
+    node: &Node,
+    cmds: &[(&'static str, PreServeCommand)],
+    dry_run: bool,
+) -> Result<()> {
+    for (origin, cmd) in cmds {
+        let on_failure = cmd.on_failure();
+        let (label_cmd, ssh_cmd) = match cmd {
+            PreServeCommand::Script { script, .. } => {
+                let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
+                    anyhow::anyhow!(
+                        "Unknown built-in script alias '{}' on node {}. Known: {}",
+                        script,
+                        node.name,
+                        crate::builtin_scripts::known_aliases()
+                    )
+                })?;
+                let remote = format!("/tmp/sm121-builtin-{script}.sh");
+                crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
+                (
+                    format!("script:{script}"),
+                    format!("sudo bash {}", shq(&remote)),
+                )
+            }
+            _ => {
+                let c = cmd.command();
+                (c.to_string(), c.to_string())
+            }
+        };
+        if dry_run {
+            info!(
+                "{}",
+                fluent_i18n::t!("after-stop-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
+            );
+            continue;
+        }
+        let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
+        let status = match &result {
+            Ok(_) => "ok",
+            Err(_) => "fail",
+        };
+        let label = format!("[{}|{}|{}]", node.name, origin, status);
+        info!("  {} $ {}", label, label_cmd);
+        if let Err(ref e) = result {
+            match on_failure {
+                OnFailure::Abort => {
+                    return Err(anyhow::anyhow!(
+                        "after_stop command failed on {}: {}",
+                        node.name,
+                        e
+                    ));
+                }
+                OnFailure::Warn => warn!(
+                    "{}",
+                    fluent_i18n::t!("after-stop-ignored", { "cmd" => &node.name, "error" => e.to_string() })
+                ),
+                OnFailure::Continue => {}
+            }
+        }
+    }
+    Ok(())
+}
+
+fn truncate(s: &str, max: usize) -> String {
+    // Same char-safe truncate as before_serve (multibyte-safe).
+    let head: String = s.chars().take(max).collect();
+    if s.chars().count() <= max {
+        s.to_string()
+    } else {
+        format!("{head}…")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{Node, PreServeCommand};
+
+    fn test_cluster() -> Cluster {
+        Cluster {
+            name: "c1".to_string(),
+            master_ib_ip: "1.2.3.4".to_string(),
+            nodes: vec![Node {
+                name: "n0".to_string(),
+                ssh_ip: "10.0.0.1".to_string(),
+                ib_ip: "1.2.3.4".to_string(),
+                rank: 0,
+                host_key: None,
+                entity: None,
+            }],
+            extra_entities: vec![],
+        }
+    }
+
+    fn test_config() -> Config {
+        Config {
+            ssh_user: "u".to_string(),
+            ssh_key_path: "/k".into(),
+            clusters: vec![test_cluster()],
+            nccl: std::collections::HashMap::new(),
+            hf_cache_dir: None,
+            init_port: 25000,
+            before_serve: std::collections::HashMap::new(),
+            exec_aliases: std::collections::HashMap::new(),
+            home_assistant: None,
+            drop_caches: true,
+            mount_aliases: std::collections::HashMap::new(),
+            global_mods_dir: None,
+        }
+    }
+
+    fn test_recipe(after_stop: Vec<PreServeCommand>) -> Recipe {
+        Recipe {
+            engine: "vllm".to_string(),
+            name: "T".to_string(),
+            docker_image: String::new(),
+            model: "x".to_string(),
+            port: 30000,
+            required_nodes: None,
+            pipeline_parallel_size: None,
+            tensor_parallel_size: 1,
+            args: crate::config::RecipeArgs::default(),
+            env: std::collections::HashMap::new(),
+            cluster: None,
+            entrypoint: vec![],
+            distributed_backend: Some(crate::config::DistributedBackend::Mp),
+            before_serve: vec![],
+            after_stop,
+            mods: Default::default(),
+            sync_image: true,
+            docker_image_dir: None,
+            shm_size: String::new(),
+            extra_mounts: vec![],
+        }
+    }
+
+    #[tokio::test]
+    async fn empty_after_stop_does_nothing() {
+        let cfg = test_config();
+        let c = cfg.clusters[0].clone();
+        let r = test_recipe(vec![]);
+        // No hooks → immediate Ok, no SSH.
+        super::run(&cfg, &c, &r, true).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn dry_run_emits_for_each_command() {
+        let cfg = test_config();
+        let c = cfg.clusters[0].clone();
+        let r = test_recipe(vec![
+            PreServeCommand::Bare("echo done".into()),
+            PreServeCommand::Script {
+                script: "restore_dgx_services".into(),
+                on_failure: OnFailure::Warn,
+            },
+        ]);
+        // Dry-run must not error on the known alias.
+        super::run(&cfg, &c, &r, true).await.unwrap();
+    }
+
+    #[test]
+    fn deserialize_after_stop_script_entry() {
+        let toml_str = r#"
+name = "Demo"
+model = "x"
+after_stop = [
+    "echo cleaned up",
+    { script = "restore_dgx_services" },
+    { script = "restore_dgx_services", on_failure = "warn" },
+]
+"#;
+        let r: Recipe = toml::from_str(toml_str).unwrap();
+        assert_eq!(r.after_stop.len(), 3);
+        assert!(matches!(r.after_stop[0], PreServeCommand::Bare(_)));
+        assert!(matches!(r.after_stop[1], PreServeCommand::Script { .. }));
+        // Known alias resolves.
+        assert!(crate::builtin_scripts::resolve("restore_dgx_services").is_some());
+    }
+
+    #[test]
+    fn deserialize_before_and_after_stop_coexist() {
+        let toml_str = r#"
+name = "Demo"
+model = "x"
+before_serve = [{ script = "stop_dgx_services" }]
+after_stop   = [{ script = "restore_dgx_services" }]
+"#;
+        let r: Recipe = toml::from_str(toml_str).unwrap();
+        assert_eq!(r.before_serve.len(), 1);
+        assert_eq!(r.after_stop.len(), 1);
+    }
+}

+ 27 - 4
src/before_serve.rs

@@ -101,22 +101,43 @@ async fn run_for_node(
     dry_run: bool,
 ) -> Result<()> {
     for (origin, cmd) in cmds {
-        let command = cmd.command();
         let on_failure = cmd.on_failure();
+        // Resolve the entry to the SSH command string to run. A `Script`
+        // alias is uploaded to the node and invoked via `bash`; a raw
+        // command (`Bare`/`Configured`) runs as-is.
+        let (label_cmd, ssh_cmd) = match cmd {
+            PreServeCommand::Script { script, .. } => {
+                let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
+                    anyhow::anyhow!(
+                        "Unknown built-in script alias '{}' on node {}. Known: {}",
+                        script,
+                        node.name,
+                        crate::builtin_scripts::known_aliases()
+                    )
+                })?;
+                let remote = format!("/tmp/sm121-builtin-{}.sh", script);
+                crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
+                (format!("script:{script}"), format!("sudo bash {remote}"))
+            }
+            _ => {
+                let c = cmd.command();
+                (c.to_string(), c.to_string())
+            }
+        };
         if dry_run {
             info!(
                 "{}",
-                fluent_i18n::t!("pre-serve-run", { "node" => &node.name, "tag" => &origin, "cmd" => truncate(command, 120) })
+                fluent_i18n::t!("pre-serve-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
             );
             continue;
         }
-        let result = crate::ssh::run_ssh(config, node, command.to_string(), dry_run).await;
+        let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
         let status = match &result {
             Ok(_) => "ok",
             Err(_) => "fail",
         };
         let label = format!("[{}|{}|{}]", node.name, origin, status);
-        info!("  {} $ {}", label, command);
+        info!("  {} $ {}", label, label_cmd);
         if let Err(ref e) = result {
             match on_failure {
                 OnFailure::Abort => {
@@ -262,6 +283,7 @@ mod tests {
         let cfg = test_config(HashMap::new());
         let recipe = Recipe {
             before_serve: vec![],
+            after_stop: vec![],
             ..test_recipe()
         };
         assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
@@ -283,6 +305,7 @@ mod tests {
             entrypoint: vec![],
             distributed_backend: Some(crate::config::DistributedBackend::Mp),
             before_serve: vec![],
+            after_stop: vec![],
             mods: Default::default(),
             sync_image: true,
             docker_image_dir: None,

+ 89 - 0
src/builtin_scripts.rs

@@ -0,0 +1,89 @@
+//! Built-in host scripts baked into the binary at compile time.
+//!
+//! Each script lives as a real `.sh` file under `host-scripts/` (so it stays
+//! diffable, shellcheck-able, and editor-friendly) and is embedded here via
+//! `include_str!`. Recipes reference a script by **alias** in a
+//! `before_serve` / `after_stop` list (e.g. `{ script = "stop_dgx_services" }`);
+//! the launcher resolves the alias to the script body and runs it over SSH on
+//! each node. No file copy from the operator's machine is needed.
+//!
+//! To add a new built-in:
+//! 1. Drop the `.sh` file into `host-scripts/`.
+//! 2. Add an `include_str!` line + a `(alias, body)` entry below.
+//! 3. Document the alias in AGENTS.md.
+
+/// A built-in script: its alias and the embedded body.
+pub struct BuiltinScript {
+    pub alias: &'static str,
+    pub body: &'static str,
+}
+
+/// The registry of all baked-in scripts. Order is the display order for
+/// error hints.
+pub static BUILTIN_SCRIPTS: &[BuiltinScript] = &[
+    BuiltinScript {
+        alias: "stop_dgx_services",
+        body: include_str!("../host-scripts/stop-dgx-services.sh"),
+    },
+    BuiltinScript {
+        alias: "restore_dgx_services",
+        body: include_str!("../host-scripts/restore-dgx-services.sh"),
+    },
+];
+
+/// Resolve an alias to its script body. Returns `None` (and the caller
+/// formats the list of known aliases) when the alias is unknown.
+pub fn resolve(alias: &str) -> Option<&'static str> {
+    BUILTIN_SCRIPTS
+        .iter()
+        .find(|s| s.alias == alias)
+        .map(|s| s.body)
+}
+
+/// Comma-separated list of known aliases, for error messages.
+pub fn known_aliases() -> String {
+    BUILTIN_SCRIPTS
+        .iter()
+        .map(|s| s.alias)
+        .collect::<Vec<_>>()
+        .join(", ")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn resolves_known_aliases() {
+        assert!(resolve("stop_dgx_services").is_some());
+        assert!(resolve("restore_dgx_services").is_some());
+        let body = resolve("stop_dgx_services").unwrap();
+        // Sanity: the embedded script is non-empty and starts with a shebang.
+        assert!(body.starts_with("#!/usr/bin/env bash"));
+        assert!(body.contains("systemctl"));
+    }
+
+    #[test]
+    fn unknown_alias_returns_none() {
+        assert!(resolve("nope").is_none());
+    }
+
+    #[test]
+    fn known_aliases_lists_all() {
+        let list = known_aliases();
+        assert!(list.contains("stop_dgx_services"));
+        assert!(list.contains("restore_dgx_services"));
+    }
+
+    #[test]
+    fn scripts_reference_each_other_correctly() {
+        // The restore script's docstring mentions the stop script by its
+        // filename (`stop-dgx-services.sh`, not the old `glmnf3` name) —
+        // regression guard for the rename.
+        let restore = resolve("restore_dgx_services").unwrap();
+        assert!(restore.contains("stop-dgx-services.sh"));
+        assert!(!restore.contains("glmnf3"));
+        let stop = resolve("stop_dgx_services").unwrap();
+        assert!(!stop.contains("glmnf3"));
+    }
+}

+ 8 - 0
src/config/types.rs

@@ -138,6 +138,14 @@ pub struct Recipe {
     pub shm_size: String,
     #[serde(default)]
     pub before_serve: Vec<PreServeCommand>,
+    /// Commands to run on each node **after** stopping containers
+    /// (`sm121 stop`). Same shape as [`before_serve`]: raw shell commands
+    /// (`Bare`/`Configured`) or built-in script aliases (`Script`). Runs
+    /// per-node in parallel, sequentially within a node. Typical use:
+    /// `after_stop = [{ script = "restore_dgx_services" }]` to bring back
+    /// services that `before_serve` stopped.
+    #[serde(default)]
+    pub after_stop: Vec<PreServeCommand>,
     #[serde(default)]
     pub mods: ModsTable,
     #[serde(default = "default_sync_image")]

+ 1 - 0
src/docker/command.rs

@@ -383,6 +383,7 @@ mod tests {
             entrypoint: vec![],
             distributed_backend: Some(DistributedBackend::Mp),
             before_serve: vec![],
+            after_stop: vec![],
             mods: Default::default(),
             shm_size: String::new(),
             sync_image: true,

+ 2 - 0
src/docker/tests.rs

@@ -59,6 +59,7 @@ fn trec() -> Recipe {
         entrypoint: vec![],
         distributed_backend: Some(DistributedBackend::Mp),
         before_serve: vec![],
+        after_stop: vec![],
         mods: Default::default(),
         shm_size: String::new(),
         sync_image: true,
@@ -244,6 +245,7 @@ fn sglang_recipe() -> Recipe {
         entrypoint: vec!["sglang".into(), "serve".into()],
         distributed_backend: Some(DistributedBackend::Mp),
         before_serve: vec![],
+        after_stop: vec![],
         mods: Default::default(),
         shm_size: String::new(),
         sync_image: true,

+ 1 - 0
src/doctor.rs

@@ -369,6 +369,7 @@ mod tests {
             entrypoint: vec![],
             distributed_backend: Some(crate::config::DistributedBackend::Mp),
             before_serve: vec![],
+            after_stop: vec![],
             mods: Default::default(),
             sync_image: true,
             docker_image_dir: None,

+ 18 - 5
src/main.rs

@@ -5,7 +5,9 @@ use clap::Parser;
 
 fluent_i18n::i18n!("locales", fallback = "en");
 
+mod after_stop;
 mod before_serve;
+mod builtin_scripts;
 mod cli;
 mod cluster_state;
 mod config;
@@ -265,9 +267,11 @@ async fn handle_stop(
     power_off: bool,
     dry_run: bool,
 ) -> Result<()> {
-    if !node.is_empty() {
+    let (stopped_cluster, stopped_nodes): (Cluster, Vec<String>) = if !node.is_empty() {
         let cluster = resolve_cluster_for_op(config, recipe_name, cluster).await?;
-        docker::stop_cluster(config, &cluster, recipe_name, node, dry_run).await?;
+        let nodes = node.to_vec();
+        docker::stop_cluster(config, &cluster, recipe_name, &nodes, dry_run).await?;
+        (cluster, nodes)
     } else {
         let variants = docker::deploy::discover_recipe_deployments(config, recipe_name).await?;
         if variants.is_empty() {
@@ -276,7 +280,9 @@ async fn handle_stop(
         if variants.len() == 1 {
             let v = &variants[0];
             let cluster = select_cluster_by_name(config, &v.cluster)?;
-            docker::stop_cluster(config, &cluster, recipe_name, &v.nodes, dry_run).await?;
+            let nodes = v.nodes.clone();
+            docker::stop_cluster(config, &cluster, recipe_name, &nodes, dry_run).await?;
+            (cluster, nodes)
         } else {
             let mut msg = format!(
                 "Multiple deployments of '{}' found — use --node to select:\n",
@@ -298,10 +304,17 @@ async fn handle_stop(
             }
             anyhow::bail!(msg);
         }
+    };
+
+    // Run recipe `after_stop` hooks (e.g. restore_dgx_services) on the
+    // nodes that were actually stopped. Best-effort against the loaded
+    // recipe; if the recipe file is unavailable, skip silently.
+    if let Ok(recipe) = crate::config::load_recipe(recipe_name).await {
+        crate::after_stop::run(config, &stopped_cluster, &recipe, dry_run).await?;
     }
+
     if power_off {
-        let cluster = resolve_cluster_for_op(config, recipe_name, cluster).await?;
-        crate::power::power_off(config, &cluster, node, dry_run).await?;
+        crate::power::power_off(config, &stopped_cluster, &stopped_nodes, dry_run).await?;
     }
     Ok(())
 }

+ 21 - 1
src/pre_serve.rs

@@ -15,7 +15,18 @@ pub enum OnFailure {
     Continue,
 }
 
-/// One entry in a `before_serve` block.
+/// One entry in a `before_serve` / `after_stop` / `mods` block.
+///
+/// Three mutually exclusive forms (untagged, so TOML picks by shape):
+/// - `Bare(String)` — a raw shell command, run as-is over SSH.
+/// - `Configured { command, on_failure }` — a raw shell command with an
+///   explicit `on_failure` policy.
+/// - `Script { script, on_failure }` — a built-in script referenced by
+///   alias (e.g. `stop_dgx_services`). The script body is baked into the
+///   binary at compile time (see [`crate::builtin_scripts`]) and uploaded
+///   + run via SSH on each node — no file copy from the operator's machine.
+///
+///   Unknown aliases error at resolution time with the list of known ones.
 #[derive(Clone, Debug, Deserialize)]
 #[serde(untagged)]
 pub enum PreServeCommand {
@@ -25,13 +36,21 @@ pub enum PreServeCommand {
         #[serde(default)]
         on_failure: OnFailure,
     },
+    Script {
+        script: String,
+        #[serde(default)]
+        on_failure: OnFailure,
+    },
 }
 
 impl PreServeCommand {
+    /// For `Bare`/`Configured`, the raw shell command. For `Script`, the
+    /// alias (NOT the script body) — use [`resolve`] to get the body.
     pub fn command(&self) -> &str {
         match self {
             PreServeCommand::Bare(c) => c,
             PreServeCommand::Configured { command, .. } => command,
+            PreServeCommand::Script { script, .. } => script,
         }
     }
 
@@ -39,6 +58,7 @@ impl PreServeCommand {
         match self {
             PreServeCommand::Bare(_) => OnFailure::default(),
             PreServeCommand::Configured { on_failure, .. } => *on_failure,
+            PreServeCommand::Script { on_failure, .. } => *on_failure,
         }
     }
 }

+ 36 - 0
src/ssh.rs

@@ -292,6 +292,42 @@ pub(crate) fn shq(s: &str) -> String {
     out
 }
 
+/// Upload an in-memory script body to a node and make it executable.
+///
+/// Cheaper than [`upload_dir`]: no tar, just base64-encode the body and
+/// pipe through SSH for decoding into `remote_path`, then `chmod +x`. Used
+/// by the built-in script runner (`before_serve` / `after_stop`) to deploy
+/// a baked-in script to each node before invoking it via `bash`.
+pub async fn upload_script(
+    config: &Config,
+    node: &Node,
+    body: &str,
+    remote_path: &str,
+    dry_run: bool,
+) -> Result<()> {
+    if dry_run {
+        println!(
+            "  ssh {}@{}: upload script ({} bytes) -> {}",
+            config.ssh_user,
+            node.ssh_ip,
+            body.len(),
+            remote_path
+        );
+        return Ok(());
+    }
+    let b64 = STANDARD.encode(body.as_bytes());
+    // `remote_path` is shell-quoted; the base64 body is safe inside single
+    // quotes (charset A-Za-z0-9+/=).
+    let cmd = format!(
+        "printf '%s' '{}' | base64 -d > {} && chmod +x {}",
+        b64,
+        shq(remote_path),
+        shq(remote_path)
+    );
+    run_ssh(config, node, cmd, false).await?;
+    Ok(())
+}
+
 /// Upload a local directory to a remote node by tarring, base64-encoding,
 /// and piping the result through SSH for decoding and extraction.
 ///