tests.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. use super::*;
  2. #[path = "retry_tests.rs"]
  3. mod retry_tests;
  4. use crate::cluster_state::NodeState;
  5. use crate::config::{NcclConfig, Node};
  6. use std::collections::HashMap;
  7. use std::sync::atomic::{AtomicUsize, Ordering};
  8. // --- Mock ends ---
  9. struct MockProber {
  10. state: ClusterState,
  11. }
  12. impl NodeProber for MockProber {
  13. async fn probe(&self, _config: &Config) -> ClusterState {
  14. self.state.clone()
  15. }
  16. }
  17. struct MockPower {
  18. results: HashMap<String, bool>,
  19. turned_on: std::sync::Mutex<Vec<String>>,
  20. }
  21. impl PowerController for MockPower {
  22. async fn entity_is_on(&self, entity: &str) -> Option<bool> {
  23. self.results.get(entity).copied()
  24. }
  25. async fn turn_on(&self, entity: &str) -> Result<()> {
  26. self.turned_on.lock().unwrap().push(entity.to_string());
  27. Ok(())
  28. }
  29. }
  30. struct MockPinger {
  31. reachable: bool,
  32. }
  33. impl NodePinger for MockPinger {
  34. async fn is_reachable(&self, _config: &Config, _node: &Node) -> bool {
  35. self.reachable
  36. }
  37. }
  38. fn test_node(name: &str) -> Node {
  39. Node {
  40. name: name.to_string(),
  41. ssh_ip: format!("10.0.0.{}", name.chars().last().unwrap_or('0')),
  42. ib_ip: Some("10.0.1.1".to_string()),
  43. rank: 0,
  44. host_key: None,
  45. entity: Some(format!("switch.{}", name)),
  46. }
  47. }
  48. fn test_cluster(name: &str, node_names: &[&str]) -> Cluster {
  49. Cluster {
  50. name: name.to_string(),
  51. master_ib_ip: Some("10.0.1.1".to_string()),
  52. nodes: node_names.iter().map(|n| test_node(n)).collect(),
  53. extra_entities: vec![],
  54. }
  55. }
  56. fn test_nccl() -> NcclConfig {
  57. NcclConfig {
  58. ib_hca: "r".to_string(),
  59. socket_ifname: "e".to_string(),
  60. ib_gid_index: Some(3),
  61. ib_disable: 0,
  62. gloo_socket_ifname: None,
  63. ucx_net_devices: None,
  64. nccl_cross_nic: None,
  65. nccl_proto: None,
  66. nccl_p2p_level: None,
  67. }
  68. }
  69. fn test_config(clusters: Vec<Cluster>) -> Config {
  70. let mut nccl = HashMap::new();
  71. for c in &clusters {
  72. nccl.insert(c.name.clone(), test_nccl());
  73. }
  74. Config {
  75. ssh_user: "u".to_string(),
  76. ssh_key_path: "/k".into(),
  77. clusters,
  78. nccl,
  79. hf_cache_dir: None,
  80. init_port: 25000,
  81. before_serve: HashMap::new(),
  82. exec_aliases: HashMap::new(),
  83. home_assistant: None,
  84. mikrotik: None,
  85. drop_caches: true,
  86. mount_aliases: HashMap::new(),
  87. recipe_sources: Vec::new(),
  88. docker_image_sources: Vec::new(),
  89. cluster_sources: Vec::new(),
  90. registry: None,
  91. global_mods_dir: None,
  92. }
  93. }
  94. fn test_recipe(required_nodes: Option<u32>) -> Recipe {
  95. toml::from_str(&format!(
  96. r#"
  97. name = "test"
  98. model = "test/model"
  99. required_nodes = {}
  100. "#,
  101. required_nodes.map_or("".into(), |n| n.to_string())
  102. ))
  103. .unwrap()
  104. }
  105. #[tokio::test]
  106. async fn cluster_off_auto_power_on_powers_and_selects() {
  107. let c = test_cluster("c", &["n1", "n2"]);
  108. let config = test_config(vec![c.clone()]);
  109. let recipe = test_recipe(Some(2));
  110. let mut state = ClusterState::default();
  111. state.nodes.insert(
  112. "n1".into(),
  113. NodeState {
  114. reachable: false,
  115. occupied: false,
  116. },
  117. );
  118. state.nodes.insert(
  119. "n2".into(),
  120. NodeState {
  121. reachable: false,
  122. occupied: false,
  123. },
  124. );
  125. let mut after = ClusterState::default();
  126. after.nodes.insert(
  127. "n1".into(),
  128. NodeState {
  129. reachable: true,
  130. occupied: false,
  131. },
  132. );
  133. after.nodes.insert(
  134. "n2".into(),
  135. NodeState {
  136. reachable: true,
  137. occupied: false,
  138. },
  139. );
  140. let power = MockPower {
  141. results: [("switch.n1".into(), false), ("switch.n2".into(), false)]
  142. .into_iter()
  143. .collect(),
  144. turned_on: std::sync::Mutex::new(Vec::new()),
  145. };
  146. let call_count = AtomicUsize::new(0);
  147. struct CountProber {
  148. first: ClusterState,
  149. second: ClusterState,
  150. count: AtomicUsize,
  151. }
  152. impl NodeProber for CountProber {
  153. async fn probe(&self, _config: &Config) -> ClusterState {
  154. let n = self.count.fetch_add(1, Ordering::SeqCst);
  155. if n == 0 {
  156. self.first.clone()
  157. } else {
  158. self.second.clone()
  159. }
  160. }
  161. }
  162. let prober = CountProber {
  163. first: state,
  164. second: after,
  165. count: call_count,
  166. };
  167. let pinger = MockPinger { reachable: true };
  168. let (cluster, _nccl) = prepare_cluster(
  169. &config,
  170. &recipe,
  171. "auto",
  172. None,
  173. &PrepareRequest {
  174. prober: &prober,
  175. power: Some(&power),
  176. pinger: &pinger,
  177. auto_power_on: true,
  178. boot_timing: BootTiming {
  179. boot_wait_secs: 0,
  180. poll_interval_secs: 0,
  181. max_poll_secs: 10,
  182. retry_grace_secs: 0,
  183. },
  184. force_occupied: false,
  185. },
  186. )
  187. .await
  188. .unwrap();
  189. assert_eq!(cluster.name, "c");
  190. assert_eq!(cluster.nodes.len(), 2);
  191. let turned_on = power.turned_on.lock().unwrap();
  192. assert!(turned_on.contains(&"switch.n1".to_string()));
  193. assert!(turned_on.contains(&"switch.n2".to_string()));
  194. }
  195. #[tokio::test]
  196. async fn cluster_off_no_auto_power_fails() {
  197. let c = test_cluster("c", &["n1", "n2"]);
  198. let config = test_config(vec![c.clone()]);
  199. let recipe = test_recipe(Some(2));
  200. let mut state = ClusterState::default();
  201. state.nodes.insert(
  202. "n1".into(),
  203. NodeState {
  204. reachable: false,
  205. occupied: false,
  206. },
  207. );
  208. state.nodes.insert(
  209. "n2".into(),
  210. NodeState {
  211. reachable: false,
  212. occupied: false,
  213. },
  214. );
  215. let prober = MockProber { state };
  216. let pinger = MockPinger { reachable: false };
  217. let power = MockPower {
  218. results: HashMap::new(),
  219. turned_on: std::sync::Mutex::new(Vec::new()),
  220. };
  221. let result = prepare_cluster(
  222. &config,
  223. &recipe,
  224. "auto",
  225. None,
  226. &PrepareRequest {
  227. prober: &prober,
  228. power: Some(&power),
  229. pinger: &pinger,
  230. auto_power_on: false,
  231. boot_timing: BootTiming {
  232. boot_wait_secs: 0,
  233. poll_interval_secs: 0,
  234. max_poll_secs: 10,
  235. retry_grace_secs: 0,
  236. },
  237. force_occupied: false,
  238. },
  239. )
  240. .await;
  241. assert!(result.is_err());
  242. let err = result.unwrap_err().to_string();
  243. assert!(err.contains("No suitable cluster found") || err.contains("requires"));
  244. }
  245. #[tokio::test]
  246. async fn cluster_already_on_selects_directly() {
  247. let c = test_cluster("c", &["n1", "n2"]);
  248. let config = test_config(vec![c.clone()]);
  249. let recipe = test_recipe(Some(2));
  250. let mut state = ClusterState::default();
  251. state.nodes.insert(
  252. "n1".into(),
  253. NodeState {
  254. reachable: true,
  255. occupied: false,
  256. },
  257. );
  258. state.nodes.insert(
  259. "n2".into(),
  260. NodeState {
  261. reachable: true,
  262. occupied: false,
  263. },
  264. );
  265. let prober = MockProber { state };
  266. let pinger = MockPinger { reachable: true };
  267. let power = MockPower {
  268. results: [("switch.n1".into(), true), ("switch.n2".into(), true)]
  269. .into_iter()
  270. .collect(),
  271. turned_on: std::sync::Mutex::new(Vec::new()),
  272. };
  273. let (cluster, _nccl) = prepare_cluster(
  274. &config,
  275. &recipe,
  276. "auto",
  277. None,
  278. &PrepareRequest {
  279. prober: &prober,
  280. power: Some(&power),
  281. pinger: &pinger,
  282. auto_power_on: true,
  283. boot_timing: BootTiming {
  284. boot_wait_secs: 0,
  285. poll_interval_secs: 0,
  286. max_poll_secs: 10,
  287. retry_grace_secs: 0,
  288. },
  289. force_occupied: false,
  290. },
  291. )
  292. .await
  293. .unwrap();
  294. assert_eq!(cluster.name, "c");
  295. assert_eq!(cluster.nodes.len(), 2);
  296. assert!(power.turned_on.lock().unwrap().is_empty());
  297. }
  298. #[tokio::test]
  299. async fn occupied_nodes_excluded() {
  300. let c = test_cluster("c", &["n1", "n2"]);
  301. let config = test_config(vec![c.clone()]);
  302. let recipe = test_recipe(Some(1));
  303. let mut state = ClusterState::default();
  304. state.nodes.insert(
  305. "n1".into(),
  306. NodeState {
  307. reachable: true,
  308. occupied: true,
  309. },
  310. );
  311. state.nodes.insert(
  312. "n2".into(),
  313. NodeState {
  314. reachable: true,
  315. occupied: false,
  316. },
  317. );
  318. let prober = MockProber { state };
  319. let pinger = MockPinger { reachable: true };
  320. let power = MockPower {
  321. results: HashMap::new(),
  322. turned_on: std::sync::Mutex::new(Vec::new()),
  323. };
  324. let (cluster, _nccl) = prepare_cluster(
  325. &config,
  326. &recipe,
  327. "auto",
  328. None,
  329. &PrepareRequest {
  330. prober: &prober,
  331. power: Some(&power),
  332. pinger: &pinger,
  333. auto_power_on: false,
  334. boot_timing: BootTiming {
  335. boot_wait_secs: 0,
  336. poll_interval_secs: 0,
  337. max_poll_secs: 10,
  338. retry_grace_secs: 0,
  339. },
  340. force_occupied: false,
  341. },
  342. )
  343. .await
  344. .unwrap();
  345. assert_eq!(cluster.nodes.len(), 1);
  346. assert_eq!(cluster.nodes[0].name, "n2");
  347. }
  348. #[tokio::test]
  349. async fn force_occupied_keeps_occupied_nodes() {
  350. // `--force` skips the occupancy filter: an occupied node is kept in the
  351. // available set (reachable is still required). Mirror of
  352. // `occupied_nodes_excluded` with force_occupied: true.
  353. let c = test_cluster("c", &["n1", "n2"]);
  354. let config = test_config(vec![c.clone()]);
  355. let recipe = test_recipe(Some(2));
  356. let mut state = ClusterState::default();
  357. state.nodes.insert(
  358. "n1".into(),
  359. NodeState {
  360. reachable: true,
  361. occupied: true,
  362. },
  363. );
  364. state.nodes.insert(
  365. "n2".into(),
  366. NodeState {
  367. reachable: true,
  368. occupied: false,
  369. },
  370. );
  371. let prober = MockProber { state };
  372. let pinger = MockPinger { reachable: true };
  373. let power = MockPower {
  374. results: HashMap::new(),
  375. turned_on: std::sync::Mutex::new(Vec::new()),
  376. };
  377. let (cluster, _nccl) = prepare_cluster(
  378. &config,
  379. &recipe,
  380. "auto",
  381. None,
  382. &PrepareRequest {
  383. prober: &prober,
  384. power: Some(&power),
  385. pinger: &pinger,
  386. auto_power_on: false,
  387. boot_timing: BootTiming {
  388. boot_wait_secs: 0,
  389. poll_interval_secs: 0,
  390. max_poll_secs: 10,
  391. retry_grace_secs: 0,
  392. },
  393. force_occupied: true,
  394. },
  395. )
  396. .await
  397. .unwrap();
  398. // Both nodes kept (n1 occupied but force_occupied=true).
  399. assert_eq!(cluster.nodes.len(), 2);
  400. }
  401. #[test]
  402. fn dry_run_resolves_without_probing_when_all_nodes_unreachable() {
  403. // Dry-run must resolve a cluster even when no node is reachable — the
  404. // whole point is to preview the command without touching hardware.
  405. let c = test_cluster("c", &["n1", "n2"]);
  406. let config = test_config(vec![c.clone()]);
  407. let recipe = test_recipe(Some(2));
  408. let (cluster, _nccl) =
  409. crate::orchestrate::prepare_cluster_dry_run(&config, &recipe, "auto", None).unwrap();
  410. assert_eq!(cluster.name, "c");
  411. // Dry-run keeps every configured node (no free-node filtering).
  412. assert_eq!(cluster.nodes.len(), 2);
  413. }
  414. #[test]
  415. fn dry_run_solo_node_skips_probing() {
  416. let c = test_cluster("c", &["n1", "n2"]);
  417. let config = test_config(vec![c.clone()]);
  418. let recipe = test_recipe(Some(1));
  419. let (cluster, _nccl) =
  420. crate::orchestrate::prepare_cluster_dry_run(&config, &recipe, "auto", Some("n2")).unwrap();
  421. assert_eq!(cluster.nodes.len(), 1);
  422. assert_eq!(cluster.nodes[0].name, "n2");
  423. }