Преглед на файлове

feat(config): global [before_serve."*"] hooks + optional master_ib_ip

- before_serve: literal "*" key runs for every cluster (first), per-cluster
  table runs after (wins), recipe last. No glob machinery — YAGNI.
- master_ib_ip: optional — Cluster::effective_master_ip() already fell back
  to rank-0 node ip (ib->ssh); fixed the one raw-field read in the
  cluster-started message and added fallback tests.
Alexander Korolev преди 1 месец
родител
ревизия
08d005ffb5
променени са 4 файла, в които са добавени 101 реда и са изтрити 8 реда
  1. 5 2
      AGENTS.md
  2. 63 5
      src/before_serve.rs
  3. 32 0
      src/config/tests.rs
  4. 1 1
      src/docker/mod.rs

+ 5 - 2
AGENTS.md

@@ -454,7 +454,7 @@ cluster_sources = ["/path/clusters.toml"]   # extra cluster def files: `ssh_user
 
 [[clusters]]
 name = "c"
-master_ib_ip = "10.0.0.1"
+master_ib_ip = "10.0.0.1"            # optional; omitted → rank-0 node's ib_ip (else ssh_ip)
 nodes = [{ name = "n", ssh_ip = "...", ib_ip = "...", rank = 0, entity = "switch.n" }]
 extra_entities = [                  # cluster-level HA entities
     "switch.network",                # bare string = blocking (off → cluster down)
@@ -479,7 +479,10 @@ name = "command"
 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"]
+[before_serve."*"]                  # global: runs on every cluster; "*" is a literal key, not a glob
+commands = ["sudo nvidia-smi -lgc 0,2000"]
+
+[before_serve."c"]                  # per-cluster: runs after the global list, can supplement/override
 commands = ["cmd", { command = "cmd", on_failure = "warn" }]
 ```
 

+ 63 - 5
src/before_serve.rs

@@ -4,11 +4,12 @@ use tracing::{info, warn};
 use crate::config::{Cluster, Config, OnFailure, PreServeCommand, Recipe};
 
 /// Run pre-serve commands on every node of the cluster before launching
-/// containers or tmux sessions.  Combines cluster-level config
-/// (`[before_serve."<cluster>"]`) with recipe-level overrides
-/// (`before_serve = [...]` in recipe.toml).  Cluster-level commands run
-/// first, then recipe-level — each in declared order — so the resulting
-/// state is predictable.
+/// containers or tmux sessions.  Combines config-level hooks with
+/// recipe-level overrides (`before_serve = [...]` in recipe.toml).  Config
+/// hooks come from `[before_serve."*"]` (global — runs for every cluster)
+/// followed by `[before_serve."<cluster>"]` (per-cluster), then recipe —
+/// each in declared order — so per-cluster can supplement/override the
+/// global list and recipe commands run last.
 ///
 /// Per-command behaviour on a non-zero exit is controlled by
 /// [`PreServeCommand::on_failure`]:
@@ -81,6 +82,12 @@ fn combined_commands(
     recipe: &Recipe,
 ) -> Vec<(&'static str, PreServeCommand)> {
     let mut out: Vec<(&'static str, PreServeCommand)> = Vec::new();
+    // Global hooks (literal "*" key — not a glob): run for every cluster.
+    if let Some(table) = config.before_serve.get("*") {
+        for c in &table.commands {
+            out.push(("global", c.clone()));
+        }
+    }
     if let Some(table) = config.before_serve.get(&cluster.name) {
         for c in &table.commands {
             out.push(("cluster", c.clone()));
@@ -255,6 +262,57 @@ mod tests {
         assert_eq!(truncate(s, 2), "你好…");
     }
 
+    #[test]
+    fn combined_runs_global_then_cluster_then_recipe() {
+        let mut bs = HashMap::new();
+        bs.insert(
+            "c1".to_string(),
+            crate::config::BeforeServeTable {
+                commands: vec![PreServeCommand::Bare("cluster_cmd".into())],
+            },
+        );
+        bs.insert(
+            "*".to_string(),
+            crate::config::BeforeServeTable {
+                commands: vec![PreServeCommand::Bare("global_cmd".into())],
+            },
+        );
+        let cfg = test_config(bs);
+        let recipe = Recipe {
+            before_serve: vec![PreServeCommand::Bare("recipe_cmd".into())],
+            ..test_recipe()
+        };
+
+        let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
+        let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
+        let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
+        assert_eq!(names, vec!["global_cmd", "cluster_cmd", "recipe_cmd"]);
+        assert_eq!(origins, vec!["global", "cluster", "recipe"]);
+    }
+
+    #[test]
+    fn combined_global_applies_to_unlisted_cluster() {
+        let mut bs = HashMap::new();
+        bs.insert(
+            "*".to_string(),
+            crate::config::BeforeServeTable {
+                commands: vec![PreServeCommand::Bare("global_cmd".into())],
+            },
+        );
+        // Cluster "c1" has no specific entry — only the global hook runs.
+        let cfg = test_config(bs);
+        let recipe = Recipe {
+            before_serve: vec![],
+            after_stop: vec![],
+            solo: None,
+            ..test_recipe()
+        };
+
+        let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
+        let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
+        assert_eq!(names, vec!["global_cmd"]);
+    }
+
     #[test]
     fn combined_orders_cluster_then_recipe() {
         let mut bs = HashMap::new();

+ 32 - 0
src/config/tests.rs

@@ -425,3 +425,35 @@ async fn test_load_recipe_from_recipe_sources() {
     .unwrap();
     assert_eq!(recipe.name, "remote");
 }
+
+#[test]
+fn effective_master_ip_falls_back_to_rank0_ib_ip() {
+    let mut c = tcls();
+    c.master_ib_ip = None;
+    assert_eq!(c.effective_master_ip(), "192.168.1.1"); // rank-0 ib_ip
+}
+
+#[test]
+fn effective_master_ip_falls_back_to_ssh_ip_when_ib_missing() {
+    let mut c = tcls();
+    c.master_ib_ip = None;
+    for n in &mut c.nodes {
+        n.ib_ip = None;
+    }
+    assert_eq!(c.effective_master_ip(), "10.0.0.1"); // rank-0 ssh_ip
+}
+
+#[test]
+fn effective_master_ip_uses_first_node_without_rank0() {
+    let mut c = tcls();
+    c.master_ib_ip = None;
+    for n in &mut c.nodes {
+        n.rank = 3; // no rank 0 → first node wins
+    }
+    assert_eq!(c.effective_master_ip(), "192.168.1.1");
+}
+
+#[test]
+fn effective_master_ip_prefers_explicit_config() {
+    assert_eq!(tcls().effective_master_ip(), "192.168.1.1");
+}

+ 1 - 1
src/docker/mod.rs

@@ -490,7 +490,7 @@ async fn start_cluster(
         sleep(Duration::from_secs(10)).await;
         info!(
             "{}",
-            fluent_i18n::t!("cluster-started", { "ip" => &cluster.master_ib_ip, "port" => recipe.port })
+            fluent_i18n::t!("cluster-started", { "ip" => cluster.effective_master_ip(), "port" => recipe.port })
         );
     }
     Ok(())