cluster_state.rs 15 KB

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