فهرست منبع

refactor: split ends (HA/SSH) from control logic with traits and orchestrator

- Define NodeProber, PowerController, NodePinger traits in new ends.rs
- Real implementations: SshProber, HaPowerController, SshNodePinger
- Orchestrator prepare_cluster() composes traits into tested flow:
  probe → if unreachable & auto_power_on → power_on_if_off → wait
  → re-probe → select_cluster → filter occupied nodes
- Remove old occupied_nodes() and cluster_power.rs (power_on_cluster_if_off)
- Use cluster_state::select_cluster() instead of config.rs occupied-set path
- 4 new unit tests with mock ends:
  - auto-power-on powers off cluster and selects
  - no auto-power-on on off cluster fails cleanly
  - already-on cluster skips power and selects directly
  - occupied nodes are excluded from result
- Cluster, Node, NcclConfig derive Debug for test assertions
Alexander Korolev 3 ماه پیش
والد
کامیت
31aaf141e5
8فایلهای تغییر یافته به همراه683 افزوده شده و 247 حذف شده
  1. 0 136
      src/cluster_power.rs
  2. 0 21
      src/cluster_state.rs
  3. 3 3
      src/config.rs
  4. 20 86
      src/docker/mod.rs
  5. 99 0
      src/ends.rs
  6. 2 1
      src/main.rs
  7. 225 0
      src/orchestrate/mod.rs
  8. 334 0
      src/orchestrate/tests.rs

+ 0 - 136
src/cluster_power.rs

@@ -1,136 +0,0 @@
-//! Cluster power operations - power on cluster if switch is off.
-
-use anyhow::{Context, Result};
-use tracing::info;
-
-use crate::config::{Cluster, Config, NcclConfig, Recipe, resolve_cluster};
-use crate::home_assistant::Client as HaClient;
-use crate::ssh::run_ssh_with_retry;
-
-/// Power on cluster if the smart plug(s) are off.
-///
-/// Mirrors `power::power_on` but checks entity states first so it's a
-/// no-op when everything is already on.
-pub async fn power_on_cluster_if_off(
-    config: &Config,
-    recipe: &Recipe,
-    cluster_name: &str,
-    node_name: Option<&str>,
-    dry_run: bool,
-) -> Result<()> {
-    let (cluster_to_check, _nccl_opt) = if let Some(nn) = node_name {
-        let (solo_c, _solo_n) = config
-            .clusters
-            .iter()
-            .find_map(|c| {
-                c.nodes.iter().find(|n| n.name == nn).map(|node| {
-                    let solo = Cluster {
-                        name: format!("solo/{}", node.name),
-                        master_ib_ip: node.ib_ip.clone(),
-                        nodes: vec![node.clone()],
-                        extra_entities: vec![],
-                    };
-                    (solo, None::<NcclConfig>)
-                })
-            })
-            .context(format!("Node '{}' not found in any cluster", nn))?;
-        (solo_c, None)
-    } else {
-        let (c, n) = resolve_cluster(
-            config,
-            recipe,
-            cluster_name,
-            &std::collections::HashSet::new(),
-        )?;
-        (c, Some(n.clone()))
-    };
-
-    let Some(ha_cfg) = &config.home_assistant else {
-        // No HA → can't power on via smart plugs; assume node is already
-        // reachable or the user handles power separately.
-        return Ok(());
-    };
-    let ha = HaClient::new(ha_cfg)?;
-    crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
-
-    // Collect all entity IDs for this cluster (per-node + cluster-level extras).
-    let mut all_entities: Vec<String> = Vec::new();
-    for node in &cluster_to_check.nodes {
-        if let Some(entity) = &node.entity {
-            all_entities.push(entity.clone());
-        }
-    }
-    all_entities.extend(cluster_to_check.extra_entities.iter().cloned());
-
-    if all_entities.is_empty() {
-        // No entities configured → nothing to power on.
-        return Ok(());
-    }
-
-    // Check if any entity is NOT "on".
-    let any_off = {
-        let mut off = false;
-        for e in &all_entities {
-            match ha.get_state(e).await {
-                Some(ref s) if s == "on" => {}
-                _ => {
-                    off = true;
-                }
-            }
-        }
-        off
-    };
-
-    if !any_off {
-        // All entities already on; nodes should be reachable.
-        return Ok(());
-    }
-
-    info!("{}", fluent_i18n::t!("cluster-switch-off-detected"));
-    if dry_run {
-        info!("{}", fluent_i18n::t!("power-on-dryrun"));
-        return Ok(());
-    }
-
-    // Power on each entity (per-node first, then cluster extras).
-    for e in &all_entities {
-        ha.turn_on(e).await?;
-    }
-    info!("{}", fluent_i18n::t!("cluster-switch-powered-on"));
-
-    // Wait for nodes to become available via SSH.
-    info!("{}", fluent_i18n::t!("cluster-waiting-for-nodes"));
-
-    // Initial boot delay — nodes need time for POST → bootloader → OS → SSH.
-    let boot_wait_secs = 60;
-    info!("⏳ Waiting {}s for nodes to boot...", boot_wait_secs);
-    tokio::time::sleep(std::time::Duration::from_secs(boot_wait_secs)).await;
-
-    let max_poll_secs = 120;
-    let poll_interval_secs = 10;
-    let mut waited_secs = 0;
-    while waited_secs < max_poll_secs {
-        let mut all_online = true;
-        for node in &cluster_to_check.nodes {
-            let cmd = "echo ok".to_string();
-            if run_ssh_with_retry(config, node, cmd, false, 2)
-                .await
-                .is_err()
-            {
-                all_online = false;
-                break;
-            }
-        }
-        if all_online {
-            info!("{}", fluent_i18n::t!("cluster-nodes-available"));
-            break;
-        }
-        tokio::time::sleep(std::time::Duration::from_secs(poll_interval_secs)).await;
-        waited_secs += poll_interval_secs;
-    }
-    if waited_secs >= max_poll_secs {
-        anyhow::bail!("{}", fluent_i18n::t!("cluster-nodes-not-available-timeout"));
-    }
-
-    Ok(())
-}

+ 0 - 21
src/cluster_state.rs

@@ -28,7 +28,6 @@ pub struct ClusterState {
     pub nodes: HashMap<String, NodeState>,
 }
 
-#[allow(dead_code)]
 impl ClusterState {
     /// Probe all unique nodes across the config and collect their state.
     /// This is the only function that touches SSH (the "control" side).
@@ -98,29 +97,9 @@ impl ClusterState {
             .get(&node.name)
             .is_some_and(|s| s.reachable && !s.occupied)
     }
-
-    /// Set of occupied node names (reachable + has containers).
-    /// Used to filter occupied nodes from deployment targets.
-    pub fn occupied_names(&self) -> HashSet<&str> {
-        self.nodes
-            .iter()
-            .filter(|(_, s)| s.occupied)
-            .map(|(n, _)| n.as_str())
-            .collect()
-    }
-
-    /// Set of unreachable node names (SSH failure).
-    pub fn unreachable_names(&self) -> HashSet<&str> {
-        self.nodes
-            .iter()
-            .filter(|(_, s)| !s.reachable)
-            .map(|(n, _)| n.as_str())
-            .collect()
-    }
 }
 
 /// Select a cluster given the current cluster state.
-#[allow(dead_code)]
 pub fn select_cluster<'c>(
     config: &'c Config,
     cluster_name: &str,

+ 3 - 3
src/config.rs

@@ -5,7 +5,7 @@ use anyhow::{Context, Result};
 use serde::Deserialize;
 use std::collections::{HashMap, HashSet};
 use tracing::info;
-#[derive(Clone, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
 pub struct Node {
     pub name: String,
     pub ssh_ip: String,
@@ -16,7 +16,7 @@ pub struct Node {
     #[serde(default)]
     pub entity: Option<String>,
 }
-#[derive(Clone, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
 pub struct NcclConfig {
     pub ib_hca: String,
     pub socket_ifname: String,
@@ -33,7 +33,7 @@ pub struct NcclConfig {
     #[serde(default)]
     pub nccl_p2p_level: Option<String>,
 }
-#[derive(Clone, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
 pub struct Cluster {
     pub name: String,
     pub master_ib_ip: String,

+ 20 - 86
src/docker/mod.rs

@@ -5,13 +5,12 @@ pub mod mods;
 pub use logs::show_logs;
 pub mod tail;
 use crate::{
-    config::{Cluster, Config, NcclConfig, Node, Recipe, load_recipe, resolve_cluster},
+    config::{Cluster, Config, NcclConfig, Node, Recipe, load_recipe},
     doctor::try_repaired_image,
     ssh::{run_ssh, run_ssh_with_retry},
 };
 use anyhow::{Context, Result};
 use std::borrow::Cow;
-use std::collections::HashSet;
 use tokio::time::{Duration, sleep};
 use tracing::info;
 /// Options for the serve operation.
@@ -49,73 +48,26 @@ pub async fn serve(
         "{}",
         fluent_i18n::t!("recipe-starting", { "name" => rn, "engine" => re, "backend" => recipe.distributed_backend.map_or("none".to_string(), |b| b.to_string()) })
     );
-    // Check if cluster switch is off and power on if requested
-    if opts.auto_power_on {
-        crate::cluster_power::power_on_cluster_if_off(
-            config,
-            &recipe,
-            cluster_name,
-            node_name,
-            opts.dry_run,
-        )
-        .await?;
-    }
-    let (cluster, nccl): (Cluster, NcclConfig) = if let Some(nn) = node_name {
-        let (solo_c, solo_n) = config
-            .clusters
-            .iter()
-            .find_map(|c| {
-                c.nodes.iter().find(|n| n.name == nn).map(|node| {
-                    let solo = Cluster {
-                        name: c.name.clone(),
-                        master_ib_ip: node.ib_ip.clone(),
-                        nodes: vec![node.clone()],
-                        extra_entities: vec![],
-                    };
-                    let nc = config
-                        .nccl
-                        .get(&c.name)
-                        .cloned()
-                        .unwrap_or_else(|| NcclConfig {
-                            ib_hca: String::new(),
-                            socket_ifname: "lo".to_string(),
-                            ib_gid_index: 3,
-                            ib_disable: 1,
-                            gloo_socket_ifname: None,
-                            ucx_net_devices: None,
-                            nccl_cross_nic: None,
-                            nccl_proto: None,
-                            nccl_p2p_level: None,
-                        });
-                    (solo, nc)
-                })
-            })
-            .context(format!("Node '{}' not found in any cluster", nn))?;
-        (solo_c, solo_n)
+    // Prepare cluster: probe, auto-power-on if requested, select.
+    use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
+    use crate::orchestrate::prepare_cluster;
+
+    let power_ctrl = if opts.auto_power_on {
+        ha_controller_from_config(config)
     } else {
-        let occupied = occupied_nodes(config).await?;
-        let (c, n) = resolve_cluster(config, &recipe, cluster_name, &occupied)?;
-        // Use only available (non-occupied) nodes from the cluster.
-        let avail: Vec<Node> = c
-            .nodes
-            .into_iter()
-            .filter(|n| !occupied.contains(&n.name))
-            .collect();
-        let c = Cluster { nodes: avail, ..c };
-        (c, n.clone())
+        None
     };
-    if let Some(required) = recipe.required_nodes {
-        let actual = cluster.nodes.len() as u32;
-        if actual < required {
-            anyhow::bail!(
-                "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes",
-                recipe.name,
-                required,
-                cluster.name,
-                actual
-            );
-        }
-    }
+    let (cluster, nccl) = prepare_cluster(
+        config,
+        &recipe,
+        cluster_name,
+        node_name,
+        opts.auto_power_on,
+        &SshProber,
+        power_ctrl.as_ref(),
+        &SshNodePinger,
+    )
+    .await?;
     crate::before_serve::run(
         config,
         &cluster,
@@ -202,9 +154,6 @@ pub async fn stop_cluster(
     info!("{}", fluent_i18n::t!("cluster-all-stopped"));
     Ok(())
 }
-/// Find the cluster that has running containers for a recipe by reading
-/// the `sm121.cluster` label from live containers.  Used by stop/logs
-/// when `--cluster auto`.
 pub async fn resolve_cluster_by_container_label<'c>(
     config: &'c Config,
     recipe_name: &str,
@@ -233,22 +182,7 @@ pub async fn resolve_cluster_by_container_label<'c>(
     }
     anyhow::bail!("No running containers found for recipe '{}'", recipe_name);
 }
-/// Query running containers across all unique nodes and return the set of
-/// occupied node names.  A node is occupied if any sm121 container is
-/// running on it.
-pub async fn occupied_nodes(config: &Config) -> Result<HashSet<String>> {
-    let state = crate::cluster_state::ClusterState::probe(config).await;
-    let mut result: HashSet<String> = HashSet::new();
-    for (name, ns) in &state.nodes {
-        if ns.occupied {
-            result.insert(name.clone());
-        }
-    }
-    Ok(result)
-}
-/// Find the cluster that has running containers for a recipe by reading
-/// the `sm121.cluster` label from live containers.  Used by stop/logs
-/// when `--cluster auto`.
+/// Options for [start_cluster].
 struct StartClusterOpts {
     cache_dir: std::path::PathBuf,
     keep: bool,

+ 99 - 0
src/ends.rs

@@ -0,0 +1,99 @@
+//! End-effector traits — abstract operations that touch real hardware
+//! (SSH, Home Assistant, Docker).  Control logic in [`cluster_state`]
+//! and [`orchestrate`] works with these traits so it can be tested
+//! without live infrastructure.
+
+use anyhow::Result;
+
+use crate::cluster_state::ClusterState;
+use crate::config::{Cluster, Config, Node};
+
+/// Probe nodes and return their aggregate state.
+pub trait NodeProber {
+    /// Probe all unique nodes across the config.
+    fn probe(&self, config: &Config) -> impl std::future::Future<Output = ClusterState> + Send;
+}
+
+/// Power on/off smart plugs via Home Assistant.
+pub trait PowerController {
+    /// Check whether a HA entity is currently in state `"on"`.
+    fn entity_is_on(&self, entity: &str) -> impl std::future::Future<Output = Option<bool>> + Send;
+
+    /// Turn on a HA entity (power on the node/switch).
+    fn turn_on(&self, entity: &str) -> impl std::future::Future<Output = Result<()>> + Send;
+}
+
+/// Ping a node via SSH to test reachability.
+pub trait NodePinger {
+    /// Whether the node accepts SSH connections.
+    fn is_reachable(
+        &self,
+        config: &Config,
+        node: &Node,
+    ) -> impl std::future::Future<Output = bool> + Send;
+}
+
+// ---------------------------------------------------------------------------
+// Real implementations
+// ---------------------------------------------------------------------------
+
+use crate::home_assistant::Client as HaClient;
+use crate::home_assistant::warn_if_token_looks_short;
+use crate::ssh::run_ssh;
+
+pub struct SshProber;
+
+impl NodeProber for SshProber {
+    async fn probe(&self, config: &Config) -> ClusterState {
+        ClusterState::probe(config).await
+    }
+}
+
+pub struct HaPowerController {
+    ha: HaClient,
+}
+
+impl HaPowerController {
+    pub fn new(ha: HaClient) -> Self {
+        Self { ha }
+    }
+}
+
+impl PowerController for HaPowerController {
+    async fn entity_is_on(&self, entity: &str) -> Option<bool> {
+        self.ha.get_state(entity).await.map(|s| s == "on")
+    }
+
+    async fn turn_on(&self, entity: &str) -> Result<()> {
+        self.ha.turn_on(entity).await
+    }
+}
+
+pub struct SshNodePinger;
+
+impl NodePinger for SshNodePinger {
+    async fn is_reachable(&self, config: &Config, node: &Node) -> bool {
+        run_ssh(config, node, "echo ok".into(), false).await.is_ok()
+    }
+}
+
+/// Build a [`HaPowerController`] from config, returning `None` if HA is
+/// not configured.
+pub fn ha_controller_from_config(config: &Config) -> Option<HaPowerController> {
+    let ha_cfg = config.home_assistant.as_ref()?;
+    let ha = HaClient::new(ha_cfg).ok()?;
+    warn_if_token_looks_short(&ha_cfg.token);
+    Some(HaPowerController::new(ha))
+}
+
+/// Collect all entity IDs (per-node + cluster extras) for a cluster.
+pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
+    let mut entities: Vec<String> = Vec::new();
+    for node in &cluster.nodes {
+        if let Some(entity) = &node.entity {
+            entities.push(entity.clone());
+        }
+    }
+    entities.extend(cluster.extra_entities.iter().cloned());
+    entities
+}

+ 2 - 1
src/main.rs

@@ -7,15 +7,16 @@ fluent_i18n::i18n!("locales", fallback = "en");
 
 mod before_serve;
 mod cli;
-mod cluster_power;
 mod cluster_state;
 mod config;
 mod docker;
 mod doctor;
+mod ends;
 mod exec;
 mod health;
 mod home_assistant;
 mod host_keys;
+mod orchestrate;
 mod power;
 mod pre_serve;
 mod ssh;

+ 225 - 0
src/orchestrate/mod.rs

@@ -0,0 +1,225 @@
+//! Serve orchestrator — combines probing, power control, and node-pinging
+//! into a single `prepare_cluster` step that `serve` calls.
+//!
+//! Every external interaction goes through the trait bounds `P: NodeProber`,
+//! `C: PowerController`, `N: NodePinger`, making the control flow testable
+//! with mock implementations.
+
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+use tracing::info;
+
+use crate::cluster_state::{ClusterState, select_cluster};
+use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
+use crate::ends::{NodePinger, NodeProber, PowerController, cluster_entities};
+
+/// Prepare a cluster for serving: probe, optionally power on, wait, then
+/// select a cluster with its free nodes.
+///
+/// Returns a `Cluster` whose `nodes` field contains only free (reachable
+/// and not occupied) nodes, plus the matching NCCL config.
+#[allow(clippy::too_many_arguments)]
+pub async fn prepare_cluster<P, C, N>(
+    config: &Config,
+    recipe: &Recipe,
+    cluster_name: &str,
+    node_name: Option<&str>,
+    auto_power_on: bool,
+    prober: &P,
+    power: Option<&C>,
+    pinger: &N,
+) -> Result<(Cluster, NcclConfig)>
+where
+    P: NodeProber,
+    C: PowerController,
+    N: NodePinger,
+{
+    let state = prober.probe(config).await;
+
+    let resolved = if let Some(nn) = node_name {
+        resolve_solo_node(config, nn)?
+    } else {
+        // Auto-power-on: if any nodes are unreachable, try powering on.
+        let state = if auto_power_on && state_has_unreachable(&state, config) {
+            let (c, _nccl) = resolve_cluster_simple(config, recipe, cluster_name)?;
+            if let Some(pwr) = power {
+                power_on_if_off(&state, &c, pwr).await?;
+            }
+            // Wait for nodes to become reachable.
+            wait_for_all_nodes(config, &c.nodes, pinger).await?;
+            // Re-probe after power-on.
+            prober.probe(config).await
+        } else {
+            state
+        };
+
+        let (c, n) = select_cluster(config, cluster_name, recipe.required_nodes, &state)?;
+        let avail: Vec<Node> = c
+            .nodes
+            .into_iter()
+            .filter(|n| {
+                state
+                    .nodes
+                    .get(&n.name)
+                    .is_some_and(|ns| ns.reachable && !ns.occupied)
+            })
+            .collect();
+        let c = Cluster { nodes: avail, ..c };
+        (c, n.clone())
+    };
+
+    // Validate node count.
+    if let Some(required) = recipe.required_nodes {
+        let actual = resolved.0.nodes.len() as u32;
+        if actual < required {
+            anyhow::bail!(
+                "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes",
+                recipe.name,
+                required,
+                resolved.0.name,
+                actual
+            );
+        }
+    }
+
+    Ok(resolved)
+}
+
+/// Collect a single node from any cluster.
+fn resolve_solo_node(config: &Config, node_name: &str) -> Result<(Cluster, NcclConfig)> {
+    let (solo_c, solo_n) = config
+        .clusters
+        .iter()
+        .find_map(|c| {
+            c.nodes.iter().find(|n| n.name == node_name).map(|node| {
+                let solo = Cluster {
+                    name: c.name.clone(),
+                    master_ib_ip: node.ib_ip.clone(),
+                    nodes: vec![node.clone()],
+                    extra_entities: vec![],
+                };
+                let nc = config
+                    .nccl
+                    .get(&c.name)
+                    .cloned()
+                    .unwrap_or_else(|| NcclConfig {
+                        ib_hca: String::new(),
+                        socket_ifname: "lo".to_string(),
+                        ib_gid_index: 3,
+                        ib_disable: 1,
+                        gloo_socket_ifname: None,
+                        ucx_net_devices: None,
+                        nccl_cross_nic: None,
+                        nccl_proto: None,
+                        nccl_p2p_level: None,
+                    });
+                (solo, nc)
+            })
+        })
+        .context(format!("Node '{}' not found in any cluster", node_name))?;
+    Ok((solo_c, solo_n))
+}
+
+/// Resolve cluster by name or auto treating every config node as free
+/// (reachable, not occupied). Only used for the power-on pre-check
+/// before any probe has been made.
+fn resolve_cluster_simple<'c>(
+    config: &'c Config,
+    recipe: &Recipe,
+    cluster_name: &str,
+) -> Result<(Cluster, &'c NcclConfig)> {
+    use crate::cluster_state::NodeState;
+    let mut state = ClusterState::default();
+    for cluster in &config.clusters {
+        for node in &cluster.nodes {
+            state.nodes.entry(node.name.clone()).or_insert(NodeState {
+                reachable: true,
+                occupied: false,
+            });
+        }
+    }
+    select_cluster(config, cluster_name, recipe.required_nodes, &state)
+}
+
+/// Does the state contain any unreachable nodes?
+fn state_has_unreachable(state: &ClusterState, config: &Config) -> bool {
+    for cluster in &config.clusters {
+        for node in &cluster.nodes {
+            if state.nodes.get(&node.name).is_some_and(|ns| !ns.reachable) {
+                return true;
+            }
+        }
+    }
+    false
+}
+
+/// Power on HA entities that are not already on.
+async fn power_on_if_off(
+    _state: &ClusterState,
+    cluster: &Cluster,
+    power: &impl PowerController,
+) -> Result<bool> {
+    let entities = cluster_entities(cluster);
+    if entities.is_empty() {
+        return Ok(false);
+    }
+
+    let any_off = {
+        let mut off = false;
+        for e in &entities {
+            match power.entity_is_on(e).await {
+                Some(true) => {}
+                _ => off = true,
+            }
+        }
+        off
+    };
+
+    if !any_off {
+        return Ok(false);
+    }
+
+    info!("{}", fluent_i18n::t!("cluster-switch-off-detected"));
+    for e in &entities {
+        power.turn_on(e).await?;
+    }
+    info!("{}", fluent_i18n::t!("cluster-switch-powered-on"));
+    Ok(true)
+}
+
+/// Wait until every node in the list accepts an SSH ping, with a timeout.
+async fn wait_for_all_nodes(
+    config: &Config,
+    nodes: &[Node],
+    pinger: &impl NodePinger,
+) -> Result<()> {
+    info!("{}", fluent_i18n::t!("cluster-waiting-for-nodes"));
+
+    let boot_wait_secs = 60;
+    info!("⏳ Waiting {}s for nodes to boot...", boot_wait_secs);
+    tokio::time::sleep(Duration::from_secs(boot_wait_secs)).await;
+
+    let max_poll_secs = 120;
+    let poll_interval_secs = 10;
+    let mut waited = 0;
+    while waited < max_poll_secs {
+        let mut all_online = true;
+        for node in nodes {
+            if !pinger.is_reachable(config, node).await {
+                all_online = false;
+                break;
+            }
+        }
+        if all_online {
+            info!("{}", fluent_i18n::t!("cluster-nodes-available"));
+            return Ok(());
+        }
+        tokio::time::sleep(Duration::from_secs(poll_interval_secs)).await;
+        waited += poll_interval_secs;
+    }
+    anyhow::bail!("{}", fluent_i18n::t!("cluster-nodes-not-available-timeout"));
+}
+#[cfg(test)]
+#[path = "tests.rs"]
+mod tests;

+ 334 - 0
src/orchestrate/tests.rs

@@ -0,0 +1,334 @@
+use super::*;
+use crate::cluster_state::NodeState;
+use crate::config::{NcclConfig, Node};
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+// --- Mock ends ---
+
+struct MockProber {
+    state: ClusterState,
+}
+impl NodeProber for MockProber {
+    async fn probe(&self, _config: &Config) -> ClusterState {
+        self.state.clone()
+    }
+}
+
+struct MockPower {
+    results: HashMap<String, bool>,
+    turned_on: std::sync::Mutex<Vec<String>>,
+}
+impl PowerController for MockPower {
+    async fn entity_is_on(&self, entity: &str) -> Option<bool> {
+        self.results.get(entity).copied()
+    }
+    async fn turn_on(&self, entity: &str) -> Result<()> {
+        self.turned_on.lock().unwrap().push(entity.to_string());
+        Ok(())
+    }
+}
+
+struct MockPinger {
+    reachable: bool,
+}
+impl NodePinger for MockPinger {
+    async fn is_reachable(&self, _config: &Config, _node: &Node) -> bool {
+        self.reachable
+    }
+}
+
+fn test_node(name: &str) -> Node {
+    Node {
+        name: name.to_string(),
+        ssh_ip: format!("10.0.0.{}", name.chars().last().unwrap_or('0')),
+        ib_ip: "10.0.1.1".to_string(),
+        rank: 0,
+        host_key: None,
+        entity: Some(format!("switch.{}", name)),
+    }
+}
+
+fn test_cluster(name: &str, node_names: &[&str]) -> Cluster {
+    Cluster {
+        name: name.to_string(),
+        master_ib_ip: "10.0.1.1".to_string(),
+        nodes: node_names.iter().map(|n| test_node(n)).collect(),
+        extra_entities: vec![],
+    }
+}
+
+fn test_nccl() -> NcclConfig {
+    NcclConfig {
+        ib_hca: "r".to_string(),
+        socket_ifname: "e".to_string(),
+        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 test_config(clusters: Vec<Cluster>) -> Config {
+    let mut nccl = HashMap::new();
+    for c in &clusters {
+        nccl.insert(c.name.clone(), test_nccl());
+    }
+    Config {
+        ssh_user: "u".to_string(),
+        ssh_key_path: "/k".into(),
+        clusters,
+        nccl,
+        hf_cache_dir: None,
+        init_port: 25000,
+        before_serve: HashMap::new(),
+        exec_aliases: HashMap::new(),
+        home_assistant: None,
+        global_mods_dir: None,
+    }
+}
+
+fn test_recipe(required_nodes: Option<u32>) -> Recipe {
+    toml::from_str(&format!(
+        r#"
+name = "test"
+model = "test/model"
+required_nodes = {}
+"#,
+        required_nodes.map_or("".into(), |n| n.to_string())
+    ))
+    .unwrap()
+}
+
+#[tokio::test]
+async fn cluster_off_auto_power_on_powers_and_selects() {
+    let c = test_cluster("c", &["n1", "n2"]);
+    let config = test_config(vec![c.clone()]);
+    let recipe = test_recipe(Some(2));
+
+    let mut state = ClusterState::default();
+    state.nodes.insert(
+        "n1".into(),
+        NodeState {
+            reachable: false,
+            occupied: false,
+        },
+    );
+    state.nodes.insert(
+        "n2".into(),
+        NodeState {
+            reachable: false,
+            occupied: false,
+        },
+    );
+
+    let mut after = ClusterState::default();
+    after.nodes.insert(
+        "n1".into(),
+        NodeState {
+            reachable: true,
+            occupied: false,
+        },
+    );
+    after.nodes.insert(
+        "n2".into(),
+        NodeState {
+            reachable: true,
+            occupied: false,
+        },
+    );
+
+    let power = MockPower {
+        results: [("switch.n1".into(), false), ("switch.n2".into(), false)]
+            .into_iter()
+            .collect(),
+        turned_on: std::sync::Mutex::new(Vec::new()),
+    };
+
+    let call_count = AtomicUsize::new(0);
+    struct CountProber {
+        first: ClusterState,
+        second: ClusterState,
+        count: AtomicUsize,
+    }
+    impl NodeProber for CountProber {
+        async fn probe(&self, _config: &Config) -> ClusterState {
+            let n = self.count.fetch_add(1, Ordering::SeqCst);
+            if n == 0 {
+                self.first.clone()
+            } else {
+                self.second.clone()
+            }
+        }
+    }
+    let prober = CountProber {
+        first: state,
+        second: after,
+        count: call_count,
+    };
+
+    let pinger = MockPinger { reachable: true };
+
+    let (cluster, _nccl) = prepare_cluster(
+        &config,
+        &recipe,
+        "auto",
+        None,
+        true,
+        &prober,
+        Some(&power),
+        &pinger,
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(cluster.name, "c");
+    assert_eq!(cluster.nodes.len(), 2);
+    let turned_on = power.turned_on.lock().unwrap();
+    assert!(turned_on.contains(&"switch.n1".to_string()));
+    assert!(turned_on.contains(&"switch.n2".to_string()));
+}
+
+#[tokio::test]
+async fn cluster_off_no_auto_power_fails() {
+    let c = test_cluster("c", &["n1", "n2"]);
+    let config = test_config(vec![c.clone()]);
+    let recipe = test_recipe(Some(2));
+
+    let mut state = ClusterState::default();
+    state.nodes.insert(
+        "n1".into(),
+        NodeState {
+            reachable: false,
+            occupied: false,
+        },
+    );
+    state.nodes.insert(
+        "n2".into(),
+        NodeState {
+            reachable: false,
+            occupied: false,
+        },
+    );
+
+    let prober = MockProber { state };
+    let pinger = MockPinger { reachable: false };
+    let power = MockPower {
+        results: HashMap::new(),
+        turned_on: std::sync::Mutex::new(Vec::new()),
+    };
+
+    let result = prepare_cluster(
+        &config,
+        &recipe,
+        "auto",
+        None,
+        false,
+        &prober,
+        Some(&power),
+        &pinger,
+    )
+    .await;
+
+    assert!(result.is_err());
+    let err = result.unwrap_err().to_string();
+    assert!(err.contains("No suitable cluster found") || err.contains("requires"));
+}
+
+#[tokio::test]
+async fn cluster_already_on_selects_directly() {
+    let c = test_cluster("c", &["n1", "n2"]);
+    let config = test_config(vec![c.clone()]);
+    let recipe = test_recipe(Some(2));
+
+    let mut state = ClusterState::default();
+    state.nodes.insert(
+        "n1".into(),
+        NodeState {
+            reachable: true,
+            occupied: false,
+        },
+    );
+    state.nodes.insert(
+        "n2".into(),
+        NodeState {
+            reachable: true,
+            occupied: false,
+        },
+    );
+
+    let prober = MockProber { state };
+    let pinger = MockPinger { reachable: true };
+    let power = MockPower {
+        results: [("switch.n1".into(), true), ("switch.n2".into(), true)]
+            .into_iter()
+            .collect(),
+        turned_on: std::sync::Mutex::new(Vec::new()),
+    };
+
+    let (cluster, _nccl) = prepare_cluster(
+        &config,
+        &recipe,
+        "auto",
+        None,
+        true,
+        &prober,
+        Some(&power),
+        &pinger,
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(cluster.name, "c");
+    assert_eq!(cluster.nodes.len(), 2);
+    assert!(power.turned_on.lock().unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn occupied_nodes_excluded() {
+    let c = test_cluster("c", &["n1", "n2"]);
+    let config = test_config(vec![c.clone()]);
+    let recipe = test_recipe(Some(1));
+
+    let mut state = ClusterState::default();
+    state.nodes.insert(
+        "n1".into(),
+        NodeState {
+            reachable: true,
+            occupied: true,
+        },
+    );
+    state.nodes.insert(
+        "n2".into(),
+        NodeState {
+            reachable: true,
+            occupied: false,
+        },
+    );
+
+    let prober = MockProber { state };
+    let pinger = MockPinger { reachable: true };
+    let power = MockPower {
+        results: HashMap::new(),
+        turned_on: std::sync::Mutex::new(Vec::new()),
+    };
+
+    let (cluster, _nccl) = prepare_cluster(
+        &config,
+        &recipe,
+        "auto",
+        None,
+        false,
+        &prober,
+        Some(&power),
+        &pinger,
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(cluster.nodes.len(), 1);
+    assert_eq!(cluster.nodes[0].name, "n2");
+}