tests.rs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. use super::*;
  2. use crate::power::toggle::off_extras;
  3. fn cfg_with_ha() -> Config {
  4. let toml = r#"
  5. ssh_user = "u"
  6. ssh_key_path = "/k"
  7. [[clusters]]
  8. name = "x"
  9. master_ib_ip = "1.1.1.1"
  10. nodes = [
  11. { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
  12. { name = "n2", ssh_ip = "10.0.0.2", ib_ip = "10.0.0.2", rank = 1 },
  13. ]
  14. extra_entities = ["switch.network_x"]
  15. [nccl."x"]
  16. ib_hca = "h"
  17. socket_ifname = "s"
  18. ib_gid_index = 1
  19. ib_disable = 0
  20. [home_assistant]
  21. url = "http://ha.local:8123"
  22. token = "abcdef0123456789abcdef0123456789"
  23. shutdown_delay_seconds = 5
  24. "#;
  25. toml::from_str(toml).unwrap()
  26. }
  27. fn cfg_no_ha() -> Config {
  28. let toml = r#"
  29. ssh_user = "u"
  30. ssh_key_path = "/k"
  31. [[clusters]]
  32. name = "x"
  33. master_ib_ip = "1.1.1.1"
  34. nodes = [
  35. { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
  36. ]
  37. [nccl."x"]
  38. ib_hca = "h"
  39. socket_ifname = "s"
  40. ib_gid_index = 1
  41. ib_disable = 0
  42. "#;
  43. toml::from_str(toml).unwrap()
  44. }
  45. #[test]
  46. fn select_targets_filters_by_node() {
  47. let cfg = cfg_with_ha();
  48. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  49. let t = select_targets(&c, &["n1".to_string()], "test").unwrap();
  50. assert_eq!(t.len(), 1);
  51. assert_eq!(t[0].name, "n1");
  52. }
  53. #[test]
  54. fn select_targets_all() {
  55. let cfg = cfg_with_ha();
  56. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  57. let t = select_targets(&c, &[], "test").unwrap();
  58. assert_eq!(t.len(), 2);
  59. }
  60. #[test]
  61. fn select_targets_multiple_nodes() {
  62. // `stop --node a --node b --off` must power off *both* nodes, not just
  63. // the first. (Regression for I-5.)
  64. let cfg = cfg_with_ha();
  65. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  66. let t = select_targets(&c, &["n1".to_string(), "n2".to_string()], "test").unwrap();
  67. assert_eq!(t.len(), 2);
  68. let names: Vec<_> = t.iter().map(|n| n.name.as_str()).collect();
  69. assert!(names.contains(&"n1"));
  70. assert!(names.contains(&"n2"));
  71. }
  72. #[test]
  73. fn select_targets_partial_missing_errors() {
  74. let cfg = cfg_with_ha();
  75. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  76. // One valid + one missing → error listing the missing one.
  77. let r = select_targets(&c, &["n1".to_string(), "nope".to_string()], "test");
  78. assert!(r.is_err());
  79. assert!(r.unwrap_err().to_string().contains("nope"));
  80. }
  81. #[test]
  82. fn select_targets_unknown_node_errors() {
  83. let cfg = cfg_with_ha();
  84. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  85. let r = select_targets(&c, &["nope".to_string()], "test");
  86. assert!(r.is_err());
  87. }
  88. #[test]
  89. fn power_on_requires_ha_config() {
  90. let cfg = cfg_no_ha();
  91. assert!(cfg.home_assistant.is_none());
  92. }
  93. #[test]
  94. fn cluster_extra_entities_parsed() {
  95. use crate::config::ExtraEntity;
  96. let cfg = cfg_with_ha();
  97. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  98. assert_eq!(
  99. c.extra_entities,
  100. vec![ExtraEntity::Simple("switch.network_x".into())]
  101. );
  102. }
  103. #[test]
  104. fn off_extras_whole_cluster_keeps_extras_subset_skips_them() {
  105. use std::collections::HashSet;
  106. let empty: HashSet<String> = HashSet::new();
  107. // A whole-cluster power off (no --node) turns the shared fabric off too.
  108. let cfg = cfg_with_ha();
  109. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  110. assert_eq!(
  111. off_extras(&c, &[], &empty),
  112. vec!["switch.network_x".to_string()]
  113. );
  114. // A subset power off (--node) must NOT take the shared switch down —
  115. // the nodes that stay up depend on it (NCCL over the fabric).
  116. assert_eq!(
  117. off_extras(&c, &["n1".to_string()], &empty),
  118. Vec::<String>::new()
  119. );
  120. assert_eq!(
  121. off_extras(&c, &["n1".to_string(), "n2".to_string()], &empty),
  122. Vec::<String>::new()
  123. );
  124. }
  125. #[test]
  126. fn off_extras_whole_cluster_keeps_extra_held_by_other_active_cluster() {
  127. // The fabric switch is shared across clusters. A whole-cluster off of `x`
  128. // must NOT turn off `switch.network_x` while another cluster that also
  129. // lists it has an open run — that serve depends on the same switch.
  130. use std::collections::HashSet;
  131. let cfg = cfg_with_ha();
  132. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  133. let held: HashSet<String> = ["switch.network_x".to_string()].into_iter().collect();
  134. // held → the shared switch is kept on, nothing is turned off
  135. assert_eq!(off_extras(&c, &[], &held), Vec::<String>::new());
  136. // an unrelated held entity does not protect this cluster's switch
  137. let other: HashSet<String> = ["switch.somewhere_else".to_string()].into_iter().collect();
  138. assert_eq!(
  139. off_extras(&c, &[], &other),
  140. vec!["switch.network_x".to_string()]
  141. );
  142. }
  143. #[test]
  144. fn extra_entity_forms_blocking_and_soft() {
  145. use crate::config::ExtraEntity;
  146. let toml = r#"
  147. ssh_user = "u"
  148. ssh_key_path = "/k"
  149. [[clusters]]
  150. name = "x"
  151. nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
  152. extra_entities = ["switch.plug", { entity = "switch.fan", blocks = false }, { entity = "switch.other" }]
  153. "#;
  154. let cfg: crate::config::Config = toml::from_str(toml).unwrap();
  155. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  156. let e = &c.extra_entities;
  157. assert_eq!(e.len(), 3);
  158. assert!(matches!(&e[0], ExtraEntity::Simple(s) if s == "switch.plug"));
  159. assert!(e[0].blocks());
  160. assert!(
  161. matches!(&e[1], ExtraEntity::Detailed { entity, blocks } if entity == "switch.fan" && !blocks)
  162. );
  163. assert!(!e[1].blocks());
  164. // table form without `blocks` defaults to blocking
  165. assert!(
  166. matches!(&e[2], ExtraEntity::Detailed { entity, blocks } if entity == "switch.other" && *blocks)
  167. );
  168. assert!(e[2].blocks());
  169. assert_eq!(e[1].entity(), "switch.fan");
  170. }
  171. #[test]
  172. fn cluster_without_extra_entities_is_empty() {
  173. let cfg = cfg_no_ha();
  174. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  175. assert!(c.extra_entities.is_empty());
  176. }
  177. #[test]
  178. fn mikrotik_dispatch_lookup() {
  179. let cfg = cfg_with_ha();
  180. // No [mikrotik] in this config — every entity stays on the HA path.
  181. assert!(super::mikro::device(cfg.mikrotik.as_ref(), "switch.n1").is_none());
  182. let toml = r#"
  183. ssh_user = "u"
  184. ssh_key_path = "/k"
  185. [[clusters]]
  186. name = "x"
  187. nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0, entity = "router.lab" }]
  188. extra_entities = ["router.lab", "switch.plug"]
  189. [mikrotik]
  190. [[mikrotik.devices]]
  191. name = "router.lab"
  192. address = "192.168.88.1"
  193. username = "admin"
  194. password = "p"
  195. "#;
  196. let cfg: crate::config::Config = toml::from_str(toml).unwrap();
  197. let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
  198. let off = super::mikro::collect_off(&cfg, &c.nodes, &super::extra_ids(&c));
  199. assert_eq!(off, vec!["router.lab".to_string()]);
  200. // same device referenced twice (node + extra) → listed once
  201. let dev = super::mikro::device(cfg.mikrotik.as_ref(), "router.lab");
  202. assert!(dev.is_some());
  203. }
  204. #[test]
  205. fn decide_on_cycles_only_when_plug_on_and_node_down() {
  206. use crate::power::OnAction;
  207. use crate::power::decide_on;
  208. assert_eq!(decide_on(Some("on"), true), OnAction::Cycle);
  209. assert_eq!(decide_on(Some("on"), false), OnAction::AlreadyOn);
  210. assert_eq!(decide_on(Some("off"), true), OnAction::TurnOn);
  211. assert_eq!(decide_on(None, true), OnAction::TurnOn);
  212. }