tests.rs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. //! Tests for [`crate::cluster_state`] (separate file — the main
  2. //! module sits at the 500-line cap).
  3. use crate::cluster_state::*;
  4. #[cfg(test)]
  5. use crate::config::{Config, NcclConfig, Node};
  6. use std::collections::HashMap;
  7. fn test_node(name: &str) -> Node {
  8. Node {
  9. name: name.to_string(),
  10. ssh_ip: format!("10.0.0.{}", name.chars().last().unwrap_or('0')),
  11. ib_ip: Some("10.0.1.1".to_string()),
  12. rank: 0,
  13. host_key: None,
  14. entity: None,
  15. }
  16. }
  17. fn test_cluster(name: &str, node_names: &[&str]) -> Cluster {
  18. Cluster {
  19. name: name.to_string(),
  20. master_ib_ip: Some("10.0.1.1".to_string()),
  21. nodes: node_names.iter().map(|n| test_node(n)).collect(),
  22. extra_entities: vec![],
  23. }
  24. }
  25. fn test_nccl() -> NcclConfig {
  26. NcclConfig {
  27. ib_hca: "r".to_string(),
  28. socket_ifname: "e".to_string(),
  29. ib_gid_index: Some(3),
  30. ib_disable: 0,
  31. gloo_socket_ifname: None,
  32. ucx_net_devices: None,
  33. nccl_cross_nic: None,
  34. nccl_proto: None,
  35. nccl_p2p_level: None,
  36. }
  37. }
  38. #[test]
  39. fn verdict_all_up() {
  40. let v = verdict(&[(true, false), (true, true)], false);
  41. assert!(v.up);
  42. assert!(!v.switch_off);
  43. assert_eq!(v.free, 1);
  44. }
  45. #[test]
  46. fn verdict_one_offline_node_takes_cluster_down() {
  47. let v = verdict(&[(true, false), (false, false)], false);
  48. assert!(!v.up);
  49. assert_eq!(v.free, 0);
  50. }
  51. #[test]
  52. fn verdict_switch_off_takes_cluster_down() {
  53. let v = verdict(&[(true, false), (true, false)], true);
  54. assert!(!v.up);
  55. assert!(v.switch_off);
  56. assert_eq!(v.free, 0);
  57. }
  58. #[test]
  59. fn free_count_all_reachable() {
  60. let mut state = ClusterState::default();
  61. state.nodes.insert(
  62. "n1".into(),
  63. NodeState {
  64. reachable: true,
  65. occupied: false,
  66. },
  67. );
  68. state.nodes.insert(
  69. "n2".into(),
  70. NodeState {
  71. reachable: true,
  72. occupied: false,
  73. },
  74. );
  75. let c = test_cluster("c", &["n1", "n2"]);
  76. assert_eq!(state.free_count(&c), 2);
  77. }
  78. #[test]
  79. fn free_count_occupied_excluded() {
  80. let mut state = ClusterState::default();
  81. state.nodes.insert(
  82. "n1".into(),
  83. NodeState {
  84. reachable: true,
  85. occupied: true,
  86. },
  87. );
  88. state.nodes.insert(
  89. "n2".into(),
  90. NodeState {
  91. reachable: true,
  92. occupied: false,
  93. },
  94. );
  95. let c = test_cluster("c", &["n1", "n2"]);
  96. assert_eq!(state.free_count(&c), 1);
  97. }
  98. #[test]
  99. fn free_count_unreachable_excluded() {
  100. let mut state = ClusterState::default();
  101. state.nodes.insert(
  102. "n1".into(),
  103. NodeState {
  104. reachable: false,
  105. occupied: false,
  106. },
  107. );
  108. state.nodes.insert(
  109. "n2".into(),
  110. NodeState {
  111. reachable: true,
  112. occupied: false,
  113. },
  114. );
  115. let c = test_cluster("c", &["n1", "n2"]);
  116. assert_eq!(state.free_count(&c), 1);
  117. }
  118. #[test]
  119. fn free_count_missing_node_not_free() {
  120. let mut state = ClusterState::default();
  121. state.nodes.insert(
  122. "n1".into(),
  123. NodeState {
  124. reachable: true,
  125. occupied: false,
  126. },
  127. );
  128. // n2 not probed — absent is treated as not free
  129. let c = test_cluster("c", &["n1", "n2"]);
  130. assert_eq!(state.free_count(&c), 1);
  131. }
  132. #[test]
  133. fn select_smallest_with_enough_free() {
  134. let mut state = ClusterState::default();
  135. // big cluster: 4 nodes, 2 free
  136. state.nodes.insert(
  137. "n1".into(),
  138. NodeState {
  139. reachable: true,
  140. occupied: true,
  141. },
  142. );
  143. state.nodes.insert(
  144. "n2".into(),
  145. NodeState {
  146. reachable: true,
  147. occupied: true,
  148. },
  149. );
  150. state.nodes.insert(
  151. "n3".into(),
  152. NodeState {
  153. reachable: true,
  154. occupied: false,
  155. },
  156. );
  157. state.nodes.insert(
  158. "n4".into(),
  159. NodeState {
  160. reachable: true,
  161. occupied: false,
  162. },
  163. );
  164. // small cluster: 2 nodes, 1 free
  165. state.nodes.insert(
  166. "n5".into(),
  167. NodeState {
  168. reachable: true,
  169. occupied: true,
  170. },
  171. );
  172. state.nodes.insert(
  173. "n6".into(),
  174. NodeState {
  175. reachable: true,
  176. occupied: false,
  177. },
  178. );
  179. let cfg = Config {
  180. ssh_user: "u".to_string(),
  181. ssh_key_path: "/k".into(),
  182. clusters: vec![
  183. test_cluster("big", &["n1", "n2", "n3", "n4"]),
  184. test_cluster("small", &["n5", "n6"]),
  185. ],
  186. nccl: {
  187. let mut m = HashMap::new();
  188. m.insert("big".into(), test_nccl());
  189. m.insert("small".into(), test_nccl());
  190. m
  191. },
  192. hf_cache_dir: None,
  193. init_port: 25000,
  194. before_serve: HashMap::new(),
  195. exec_aliases: HashMap::new(),
  196. home_assistant: None,
  197. mikrotik: None,
  198. drop_caches: true,
  199. mount_aliases: HashMap::new(),
  200. recipe_sources: Vec::new(),
  201. recipe_repos: Vec::new(),
  202. docker_image_sources: Vec::new(),
  203. cluster_sources: Vec::new(),
  204. registry: None,
  205. global_mods_dir: None,
  206. };
  207. // Request 1 node — small cluster is smallest with free >= 1
  208. let (c, _) = select_cluster(&cfg, "auto", Some(1), &state).unwrap();
  209. assert_eq!(c.name, "small");
  210. // Request 2 nodes — only big cluster has 2 free
  211. let (c, _) = select_cluster(&cfg, "auto", Some(2), &state).unwrap();
  212. assert_eq!(c.name, "big");
  213. }
  214. #[test]
  215. fn select_cluster_not_found_errors() {
  216. let state = ClusterState::default();
  217. let cfg = Config {
  218. ssh_user: "u".to_string(),
  219. ssh_key_path: "/k".into(),
  220. clusters: vec![],
  221. nccl: HashMap::new(),
  222. hf_cache_dir: None,
  223. init_port: 25000,
  224. before_serve: HashMap::new(),
  225. exec_aliases: HashMap::new(),
  226. home_assistant: None,
  227. mikrotik: None,
  228. drop_caches: true,
  229. mount_aliases: HashMap::new(),
  230. recipe_sources: Vec::new(),
  231. recipe_repos: Vec::new(),
  232. docker_image_sources: Vec::new(),
  233. cluster_sources: Vec::new(),
  234. registry: None,
  235. global_mods_dir: None,
  236. };
  237. assert!(select_cluster(&cfg, "nonexistent", None, &state).is_err());
  238. }
  239. #[test]
  240. fn no_cluster_has_enough_free_errors() {
  241. let mut state = ClusterState::default();
  242. state.nodes.insert(
  243. "n1".into(),
  244. NodeState {
  245. reachable: true,
  246. occupied: true,
  247. },
  248. );
  249. let cfg = Config {
  250. ssh_user: "u".to_string(),
  251. ssh_key_path: "/k".into(),
  252. clusters: vec![test_cluster("c", &["n1"])],
  253. nccl: {
  254. let mut m = HashMap::new();
  255. m.insert("c".into(), test_nccl());
  256. m
  257. },
  258. hf_cache_dir: None,
  259. init_port: 25000,
  260. before_serve: HashMap::new(),
  261. exec_aliases: HashMap::new(),
  262. home_assistant: None,
  263. mikrotik: None,
  264. drop_caches: true,
  265. mount_aliases: HashMap::new(),
  266. recipe_sources: Vec::new(),
  267. recipe_repos: Vec::new(),
  268. docker_image_sources: Vec::new(),
  269. cluster_sources: Vec::new(),
  270. registry: None,
  271. global_mods_dir: None,
  272. };
  273. assert!(select_cluster(&cfg, "auto", Some(1), &state).is_err());
  274. }
  275. #[test]
  276. fn single_node_allows_cluster_without_nccl() {
  277. let mut state = ClusterState::default();
  278. state.nodes.insert(
  279. "n1".into(),
  280. NodeState {
  281. reachable: true,
  282. occupied: false,
  283. },
  284. );
  285. let cfg = Config {
  286. ssh_user: "u".to_string(),
  287. ssh_key_path: "/k".into(),
  288. clusters: vec![test_cluster("c", &["n1"])],
  289. nccl: HashMap::new(), // no NCCL config
  290. hf_cache_dir: None,
  291. init_port: 25000,
  292. before_serve: HashMap::new(),
  293. exec_aliases: HashMap::new(),
  294. home_assistant: None,
  295. mikrotik: None,
  296. drop_caches: true,
  297. mount_aliases: HashMap::new(),
  298. recipe_sources: Vec::new(),
  299. recipe_repos: Vec::new(),
  300. docker_image_sources: Vec::new(),
  301. cluster_sources: Vec::new(),
  302. registry: None,
  303. global_mods_dir: None,
  304. };
  305. // Single-node recipe on a cluster without NCCL config → OK
  306. let (c, nccl) = select_cluster(&cfg, "auto", Some(1), &state).unwrap();
  307. assert_eq!(c.name, "c");
  308. assert!(nccl.is_none());
  309. }
  310. #[test]
  311. fn multi_node_requires_nccl() {
  312. let mut state = ClusterState::default();
  313. state.nodes.insert(
  314. "n1".into(),
  315. NodeState {
  316. reachable: true,
  317. occupied: false,
  318. },
  319. );
  320. state.nodes.insert(
  321. "n2".into(),
  322. NodeState {
  323. reachable: true,
  324. occupied: false,
  325. },
  326. );
  327. let cfg = Config {
  328. ssh_user: "u".to_string(),
  329. ssh_key_path: "/k".into(),
  330. clusters: vec![test_cluster("c", &["n1", "n2"])],
  331. nccl: HashMap::new(), // no NCCL config
  332. hf_cache_dir: None,
  333. init_port: 25000,
  334. before_serve: HashMap::new(),
  335. exec_aliases: HashMap::new(),
  336. home_assistant: None,
  337. mikrotik: None,
  338. drop_caches: true,
  339. mount_aliases: HashMap::new(),
  340. recipe_sources: Vec::new(),
  341. recipe_repos: Vec::new(),
  342. docker_image_sources: Vec::new(),
  343. cluster_sources: Vec::new(),
  344. registry: None,
  345. global_mods_dir: None,
  346. };
  347. // Multi-node recipe on a cluster without NCCL config → error
  348. assert!(select_cluster(&cfg, "auto", Some(2), &state).is_err());
  349. }