cluster_state.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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::{Context, 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. impl ClusterState {
  27. /// Probe all unique nodes across the config and collect their state.
  28. /// This is the only function that touches SSH (the "control" side).
  29. pub async fn probe(config: &Config) -> Self {
  30. let mut seen = HashSet::new();
  31. let mut handles = Vec::new();
  32. for cluster in &config.clusters {
  33. for node in &cluster.nodes {
  34. if seen.contains(&node.ssh_ip) {
  35. continue;
  36. }
  37. seen.insert(node.ssh_ip.clone());
  38. let config = config.clone();
  39. let node = node.clone();
  40. let name = node.name.clone();
  41. let cmd =
  42. "docker ps -q --filter label=sm121.cluster --format '{{.Names}}' | head -1"
  43. .to_string();
  44. handles.push(tokio::spawn(async move {
  45. (name, run_ssh(&config, &node, cmd, false).await)
  46. }));
  47. }
  48. }
  49. let mut nodes: HashMap<String, NodeState> = HashMap::new();
  50. for h in futures::future::join_all(handles).await {
  51. match h {
  52. Ok((name, Ok(output))) => {
  53. let occupied = !output.trim().is_empty();
  54. nodes.insert(
  55. name,
  56. NodeState {
  57. reachable: true,
  58. occupied,
  59. },
  60. );
  61. }
  62. Ok((name, Err(_))) => {
  63. // SSH failure → unreachable, cannot be deployed on
  64. nodes.insert(
  65. name,
  66. NodeState {
  67. reachable: false,
  68. occupied: false,
  69. },
  70. );
  71. }
  72. Err(e) => {
  73. // A probe task panicked (JoinError) — log it so a node
  74. // silently dropping out of the state map is diagnosable.
  75. warn!("Cluster probe task panicked: {}", e);
  76. }
  77. }
  78. }
  79. Self { nodes }
  80. }
  81. /// Nodes in `cluster` that are reachable AND not occupied.
  82. pub fn free_nodes<'c>(&self, cluster: &'c Cluster) -> Vec<&'c crate::config::Node> {
  83. cluster.nodes.iter().filter(|n| self.free(n)).collect()
  84. }
  85. /// Number of free nodes in `cluster`.
  86. pub fn free_count(&self, cluster: &Cluster) -> u32 {
  87. self.free_nodes(cluster).len() as u32
  88. }
  89. /// Whether a single node is free (reachable + not occupied).
  90. fn free(&self, node: &crate::config::Node) -> bool {
  91. self.nodes
  92. .get(&node.name)
  93. .is_some_and(|s| s.reachable && !s.occupied)
  94. }
  95. }
  96. /// Select a cluster given the current cluster state.
  97. pub fn select_cluster<'c>(
  98. config: &'c Config,
  99. cluster_name: &str,
  100. required_nodes: Option<u32>,
  101. state: &ClusterState,
  102. ) -> Result<(Cluster, &'c NcclConfig)> {
  103. if cluster_name != "auto" {
  104. let cluster = crate::config::resolve_cluster_name(&config.clusters, cluster_name)?;
  105. let nccl = config
  106. .nccl
  107. .get(&cluster.name)
  108. .context("no NCCL config for cluster")?;
  109. return Ok((cluster.clone(), nccl));
  110. }
  111. let mut candidates: Vec<(&Cluster, &NcclConfig)> = config
  112. .clusters
  113. .iter()
  114. .filter_map(|c| Some((c, config.nccl.get(&c.name)?)))
  115. .filter(|(c, _)| required_nodes.is_none_or(|r| state.free_count(c) >= r))
  116. .collect();
  117. candidates.sort_by_key(|(c, _)| c.nodes.len());
  118. if let Some((cluster, nccl)) = candidates.into_iter().next() {
  119. let avail = state.free_count(cluster);
  120. info!(
  121. "{}",
  122. fluent_i18n::t!("cluster-selected", {
  123. "name" => cluster.name.to_string(),
  124. "nodes" => avail,
  125. })
  126. );
  127. return Ok((cluster.clone(), nccl));
  128. }
  129. anyhow::bail!(fluent_i18n::t!("cluster-auto-fail"));
  130. }
  131. #[cfg(test)]
  132. mod tests {
  133. use super::*;
  134. use crate::config::{Config, NcclConfig, Node};
  135. fn test_node(name: &str) -> Node {
  136. Node {
  137. name: name.to_string(),
  138. ssh_ip: format!("10.0.0.{}", name.chars().last().unwrap_or('0')),
  139. ib_ip: "10.0.1.1".to_string(),
  140. rank: 0,
  141. host_key: None,
  142. entity: None,
  143. }
  144. }
  145. fn test_cluster(name: &str, node_names: &[&str]) -> Cluster {
  146. Cluster {
  147. name: name.to_string(),
  148. master_ib_ip: "10.0.1.1".to_string(),
  149. nodes: node_names.iter().map(|n| test_node(n)).collect(),
  150. extra_entities: vec![],
  151. }
  152. }
  153. fn test_nccl() -> NcclConfig {
  154. NcclConfig {
  155. ib_hca: "r".to_string(),
  156. socket_ifname: "e".to_string(),
  157. ib_gid_index: 3,
  158. ib_disable: 0,
  159. gloo_socket_ifname: None,
  160. ucx_net_devices: None,
  161. nccl_cross_nic: None,
  162. nccl_proto: None,
  163. nccl_p2p_level: None,
  164. }
  165. }
  166. #[test]
  167. fn free_count_all_reachable() {
  168. let mut state = ClusterState::default();
  169. state.nodes.insert(
  170. "n1".into(),
  171. NodeState {
  172. reachable: true,
  173. occupied: false,
  174. },
  175. );
  176. state.nodes.insert(
  177. "n2".into(),
  178. NodeState {
  179. reachable: true,
  180. occupied: false,
  181. },
  182. );
  183. let c = test_cluster("c", &["n1", "n2"]);
  184. assert_eq!(state.free_count(&c), 2);
  185. }
  186. #[test]
  187. fn free_count_occupied_excluded() {
  188. let mut state = ClusterState::default();
  189. state.nodes.insert(
  190. "n1".into(),
  191. NodeState {
  192. reachable: true,
  193. occupied: true,
  194. },
  195. );
  196. state.nodes.insert(
  197. "n2".into(),
  198. NodeState {
  199. reachable: true,
  200. occupied: false,
  201. },
  202. );
  203. let c = test_cluster("c", &["n1", "n2"]);
  204. assert_eq!(state.free_count(&c), 1);
  205. }
  206. #[test]
  207. fn free_count_unreachable_excluded() {
  208. let mut state = ClusterState::default();
  209. state.nodes.insert(
  210. "n1".into(),
  211. NodeState {
  212. reachable: false,
  213. occupied: false,
  214. },
  215. );
  216. state.nodes.insert(
  217. "n2".into(),
  218. NodeState {
  219. reachable: true,
  220. occupied: false,
  221. },
  222. );
  223. let c = test_cluster("c", &["n1", "n2"]);
  224. assert_eq!(state.free_count(&c), 1);
  225. }
  226. #[test]
  227. fn free_count_missing_node_not_free() {
  228. let mut state = ClusterState::default();
  229. state.nodes.insert(
  230. "n1".into(),
  231. NodeState {
  232. reachable: true,
  233. occupied: false,
  234. },
  235. );
  236. // n2 not probed — absent is treated as not free
  237. let c = test_cluster("c", &["n1", "n2"]);
  238. assert_eq!(state.free_count(&c), 1);
  239. }
  240. #[test]
  241. fn select_smallest_with_enough_free() {
  242. let mut state = ClusterState::default();
  243. // big cluster: 4 nodes, 2 free
  244. state.nodes.insert(
  245. "n1".into(),
  246. NodeState {
  247. reachable: true,
  248. occupied: true,
  249. },
  250. );
  251. state.nodes.insert(
  252. "n2".into(),
  253. NodeState {
  254. reachable: true,
  255. occupied: true,
  256. },
  257. );
  258. state.nodes.insert(
  259. "n3".into(),
  260. NodeState {
  261. reachable: true,
  262. occupied: false,
  263. },
  264. );
  265. state.nodes.insert(
  266. "n4".into(),
  267. NodeState {
  268. reachable: true,
  269. occupied: false,
  270. },
  271. );
  272. // small cluster: 2 nodes, 1 free
  273. state.nodes.insert(
  274. "n5".into(),
  275. NodeState {
  276. reachable: true,
  277. occupied: true,
  278. },
  279. );
  280. state.nodes.insert(
  281. "n6".into(),
  282. NodeState {
  283. reachable: true,
  284. occupied: false,
  285. },
  286. );
  287. let cfg = Config {
  288. ssh_user: "u".to_string(),
  289. ssh_key_path: "/k".into(),
  290. clusters: vec![
  291. test_cluster("big", &["n1", "n2", "n3", "n4"]),
  292. test_cluster("small", &["n5", "n6"]),
  293. ],
  294. nccl: {
  295. let mut m = HashMap::new();
  296. m.insert("big".into(), test_nccl());
  297. m.insert("small".into(), test_nccl());
  298. m
  299. },
  300. hf_cache_dir: None,
  301. init_port: 25000,
  302. before_serve: HashMap::new(),
  303. exec_aliases: HashMap::new(),
  304. home_assistant: None,
  305. drop_caches: true,
  306. mount_aliases: HashMap::new(),
  307. global_mods_dir: None,
  308. };
  309. // Request 1 node — small cluster is smallest with free >= 1
  310. let (c, _) = select_cluster(&cfg, "auto", Some(1), &state).unwrap();
  311. assert_eq!(c.name, "small");
  312. // Request 2 nodes — only big cluster has 2 free
  313. let (c, _) = select_cluster(&cfg, "auto", Some(2), &state).unwrap();
  314. assert_eq!(c.name, "big");
  315. }
  316. #[test]
  317. fn select_cluster_not_found_errors() {
  318. let state = ClusterState::default();
  319. let cfg = Config {
  320. ssh_user: "u".to_string(),
  321. ssh_key_path: "/k".into(),
  322. clusters: vec![],
  323. nccl: HashMap::new(),
  324. hf_cache_dir: None,
  325. init_port: 25000,
  326. before_serve: HashMap::new(),
  327. exec_aliases: HashMap::new(),
  328. home_assistant: None,
  329. drop_caches: true,
  330. mount_aliases: HashMap::new(),
  331. global_mods_dir: None,
  332. };
  333. assert!(select_cluster(&cfg, "nonexistent", None, &state).is_err());
  334. }
  335. #[test]
  336. fn no_cluster_has_enough_free_errors() {
  337. let mut state = ClusterState::default();
  338. state.nodes.insert(
  339. "n1".into(),
  340. NodeState {
  341. reachable: true,
  342. occupied: true,
  343. },
  344. );
  345. let cfg = Config {
  346. ssh_user: "u".to_string(),
  347. ssh_key_path: "/k".into(),
  348. clusters: vec![test_cluster("c", &["n1"])],
  349. nccl: {
  350. let mut m = HashMap::new();
  351. m.insert("c".into(), test_nccl());
  352. m
  353. },
  354. hf_cache_dir: None,
  355. init_port: 25000,
  356. before_serve: HashMap::new(),
  357. exec_aliases: HashMap::new(),
  358. home_assistant: None,
  359. drop_caches: true,
  360. mount_aliases: HashMap::new(),
  361. global_mods_dir: None,
  362. };
  363. assert!(select_cluster(&cfg, "auto", Some(1), &state).is_err());
  364. }
  365. }