cluster_state.rs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. //! Cluster state — tracks which nodes are reachable and occupied,
  2. //! separate from the SSH probing that collects it. This split lets
  3. //! the cluster-selection logic be tested without real hardware.
  4. //!
  5. //! State collection (the "control" side) lives in [`ClusterState::probe`].
  6. //! Selection logic (the "state" side) lives on [`ClusterState`] methods.
  7. use anyhow::Result;
  8. use std::collections::{HashMap, HashSet};
  9. use tracing::{info, warn};
  10. use crate::config::{Cluster, Config, NcclConfig};
  11. use crate::ssh::run_ssh;
  12. /// Per-node state gathered during a probe.
  13. #[derive(Clone, Debug, Default)]
  14. pub struct NodeState {
  15. /// SSH connection succeeded (node is at least booted and reachable).
  16. pub reachable: bool,
  17. /// Node has at least one running sm121 container.
  18. pub occupied: bool,
  19. }
  20. /// Aggregate view of the cluster fleet — built by [`probe`].
  21. #[derive(Clone, Debug, Default)]
  22. pub struct ClusterState {
  23. /// Node name → state. Absence means the node was not probed.
  24. pub nodes: HashMap<String, NodeState>,
  25. }
  26. /// Cluster-level health verdict.
  27. ///
  28. /// A cluster is **UP** only when *all* of its nodes are reachable AND its
  29. /// HA switch entities (`extra_entities`, when HA is configured) report
  30. /// "on". One offline node — or a switch off — takes the whole cluster
  31. /// down: containers on surviving nodes are ignored, `free` is 0.
  32. #[derive(Clone, Debug, PartialEq, serde::Serialize)]
  33. pub struct ClusterVerdict {
  34. pub up: bool,
  35. /// HA extra entities report anything other than "on".
  36. pub switch_off: bool,
  37. /// Free nodes; 0 when the cluster is down.
  38. pub free: u32,
  39. }
  40. /// Derive the verdict from per-node `(reachable, occupied)` pairs plus the
  41. /// HA switch state. The single source of truth for cluster health — both
  42. /// the `health` command and the web dashboard route through this.
  43. pub fn verdict(reachables: &[(bool, bool)], switch_off: bool) -> ClusterVerdict {
  44. let all_reachable = reachables.iter().all(|(r, _)| *r);
  45. let up = all_reachable && !switch_off;
  46. let free = if up {
  47. reachables.iter().filter(|(_, o)| !o).count() as u32
  48. } else {
  49. 0
  50. };
  51. ClusterVerdict {
  52. up,
  53. switch_off,
  54. free,
  55. }
  56. }
  57. impl ClusterState {
  58. /// Probe all unique nodes across the config and collect their state.
  59. /// This is the only function that touches SSH (the "control" side).
  60. pub async fn probe(config: &Config) -> Self {
  61. let mut seen = HashSet::new();
  62. let mut handles = Vec::new();
  63. for cluster in &config.clusters {
  64. for node in &cluster.nodes {
  65. if seen.contains(&node.ssh_ip) {
  66. continue;
  67. }
  68. seen.insert(node.ssh_ip.clone());
  69. let config = config.clone();
  70. let node = node.clone();
  71. let name = node.name.clone();
  72. let cmd = "docker ps --filter label=sm121.cluster --format '{{.Names}}' | head -1"
  73. .to_string();
  74. handles.push(crate::locale::spawn_with_locale(async move {
  75. (name, run_ssh(&config, &node, cmd, false).await)
  76. }));
  77. }
  78. }
  79. let mut nodes: HashMap<String, NodeState> = HashMap::new();
  80. for h in futures::future::join_all(handles).await {
  81. match h {
  82. Ok((name, Ok(output))) => {
  83. let occupied = !output.trim().is_empty();
  84. nodes.insert(
  85. name,
  86. NodeState {
  87. reachable: true,
  88. occupied,
  89. },
  90. );
  91. }
  92. Ok((name, Err(e))) => {
  93. // SSH failure → unreachable, cannot be deployed on
  94. nodes.insert(
  95. name.clone(),
  96. NodeState {
  97. reachable: false,
  98. occupied: false,
  99. },
  100. );
  101. warn!("node probe failed: {name}: {e:#}");
  102. }
  103. Err(e) => {
  104. // A probe task panicked (JoinError) — log it so a node
  105. // silently dropping out of the state map is diagnosable.
  106. warn!("Cluster probe task panicked: {}", e);
  107. }
  108. }
  109. }
  110. Self { nodes }
  111. }
  112. /// Nodes in `cluster` that are reachable AND not occupied.
  113. pub fn free_nodes<'c>(&self, cluster: &'c Cluster) -> Vec<&'c crate::config::Node> {
  114. cluster.nodes.iter().filter(|n| self.free(n)).collect()
  115. }
  116. /// Number of free nodes in `cluster`.
  117. pub fn free_count(&self, cluster: &Cluster) -> u32 {
  118. self.free_nodes(cluster).len() as u32
  119. }
  120. /// Whether a single node is free (reachable + not occupied).
  121. fn free(&self, node: &crate::config::Node) -> bool {
  122. self.nodes
  123. .get(&node.name)
  124. .is_some_and(|s| s.reachable && !s.occupied)
  125. }
  126. }
  127. /// Select a cluster given the current cluster state.
  128. ///
  129. /// NCCL config is only required for multi-node recipes
  130. /// (`required_nodes > 1`). For single-node recipes, clusters without
  131. /// NCCL config are allowed — the config is returned as `None`.
  132. pub fn select_cluster<'c>(
  133. config: &'c Config,
  134. cluster_name: &str,
  135. required_nodes: Option<u32>,
  136. state: &ClusterState,
  137. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  138. if cluster_name != "auto" {
  139. let cluster = crate::config::resolve_cluster_name(&config.clusters, cluster_name)?;
  140. let nccl = config.nccl.get(&cluster.name);
  141. if required_nodes.is_some_and(|n| n > 1) && nccl.is_none() {
  142. anyhow::bail!("no NCCL config for cluster '{}'", cluster.name);
  143. }
  144. return Ok((cluster.clone(), nccl));
  145. }
  146. // For single-node recipes, NCCL config is not required — allow clusters
  147. // without it. For multi-node, require it.
  148. let candidates: Vec<&Cluster> = if required_nodes.is_some_and(|n| n > 1) {
  149. config
  150. .clusters
  151. .iter()
  152. .filter_map(|c| {
  153. let _nccl = config.nccl.get(&c.name)?;
  154. Some(c)
  155. })
  156. .filter(|c| required_nodes.is_none_or(|r| state.free_count(c) >= r))
  157. .collect()
  158. } else {
  159. config
  160. .clusters
  161. .iter()
  162. .filter(|c| required_nodes.is_none_or(|r| state.free_count(c) >= r))
  163. .collect()
  164. };
  165. let mut candidates = candidates;
  166. candidates.sort_by_key(|c| c.nodes.len());
  167. if let Some(cluster) = candidates.into_iter().next() {
  168. let avail = state.free_count(cluster);
  169. let nccl = config.nccl.get(&cluster.name);
  170. info!(
  171. "{}",
  172. fluent_i18n::t!("cluster-selected", {
  173. "name" => cluster.name.to_string(),
  174. "nodes" => avail,
  175. })
  176. );
  177. return Ok((cluster.clone(), nccl));
  178. }
  179. anyhow::bail!(fluent_i18n::t!("cluster-auto-fail"));
  180. }
  181. #[cfg(test)]
  182. mod tests;