topo.rs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. //! Physical topology derivation for the dashboard map.
  2. //!
  3. //! Pure config → map translation: nodes, cables, HA glyphs. The UI draws
  4. //! the fixed scene; the snapshot builder only attaches live HA states.
  5. use crate::config::Config;
  6. use serde::Serialize;
  7. /// One cable on the map: `a` → `b`, where `a` is a switch entity id, the
  8. /// pseudo-switch `"lan"` (the passive 10G), or a node name (DAC), and `b`
  9. /// is a node name.
  10. #[derive(Serialize, Clone, Debug, PartialEq)]
  11. pub struct TopoLink {
  12. /// "200g" (HA-managed fabric switch), "dac" (direct-attach copper),
  13. /// "10g" (passive 10G switch).
  14. pub kind: String,
  15. pub a: String,
  16. pub b: String,
  17. }
  18. /// An HA-managed device glyph: the blocking fabric switch ("ib") or an
  19. /// auxiliary entity like a fan ("aux").
  20. #[derive(Serialize, Clone, Debug, PartialEq)]
  21. pub struct TopoSwitch {
  22. pub entity: String,
  23. pub kind: String,
  24. }
  25. /// HA state for a map glyph: `on`/`off`, `None` = unknown/unreachable.
  26. #[derive(Serialize, Clone, Debug, PartialEq)]
  27. pub struct TopoState {
  28. pub entity: String,
  29. pub on: Option<bool>,
  30. }
  31. /// The fixed physical map, derived from config facts (see [`derive_topo`]).
  32. #[derive(Serialize, Clone, Debug, Default, PartialEq)]
  33. pub struct TopoSnap {
  34. /// Every configured node, sorted by slot (digits parsed from the name).
  35. pub nodes: Vec<String>,
  36. pub links: Vec<TopoLink>,
  37. pub switches: Vec<TopoSwitch>,
  38. /// HA states for [`TopoSnap::switches`] (filled by the snapshot builder).
  39. pub states: Vec<TopoState>,
  40. }
  41. /// Derive the fixed physical topology from the config alone:
  42. ///
  43. /// - every configured node occupies its slot (`dgx7.lan` → slot 7) —
  44. /// absent slots render as "planned";
  45. /// - a node with an `ib_ip` hangs on its cluster's *blocking* switch
  46. /// entity that is not a node plug (the 200G fabric, HA-managed) —
  47. /// or, when the cluster has no blocking switch (`2nodes-dac`), the IB
  48. /// link is direct-attach copper between consecutive ranks;
  49. /// - every node hangs on the passive 10G switch (pseudo-entity `"lan"`,
  50. /// no HA state);
  51. /// - extra entities that are neither blocking-fabric switches nor node
  52. /// plugs appear as auxiliary glyphs (a fan).
  53. pub(crate) fn derive_topo(config: &Config) -> TopoSnap {
  54. let slot = |n: &str| {
  55. n.chars()
  56. .filter(|c| c.is_ascii_digit())
  57. .collect::<String>()
  58. .parse::<u32>()
  59. .unwrap_or(u32::MAX)
  60. };
  61. let mut nodes: Vec<String> = Vec::new();
  62. let mut links: Vec<TopoLink> = Vec::new();
  63. let mut switches: Vec<TopoSwitch> = Vec::new();
  64. let push_switch = |switches: &mut Vec<TopoSwitch>, entity: &str, kind: &str| {
  65. if !switches.iter().any(|s| s.entity == entity) {
  66. switches.push(TopoSwitch {
  67. entity: entity.to_string(),
  68. kind: kind.to_string(),
  69. });
  70. }
  71. };
  72. for c in &config.clusters {
  73. let node_entities: Vec<String> = c.nodes.iter().filter_map(|n| n.entity.clone()).collect();
  74. for n in &c.nodes {
  75. if !nodes.contains(&n.name) {
  76. nodes.push(n.name.clone());
  77. }
  78. }
  79. let ib: Vec<&crate::config::Node> = c.nodes.iter().filter(|n| n.ib_ip.is_some()).collect();
  80. let blocking = c
  81. .extra_entities
  82. .iter()
  83. .find(|e| e.blocks() && !node_entities.contains(&e.entity().to_string()));
  84. if let Some(sw) = blocking {
  85. push_switch(&mut switches, sw.entity(), "ib");
  86. for n in ib {
  87. let link = TopoLink {
  88. kind: "200g".into(),
  89. a: sw.entity().to_string(),
  90. b: n.name.clone(),
  91. };
  92. // the same physical cable appears in every cluster that
  93. // shares the fabric — one line on the map
  94. if !links.contains(&link) {
  95. links.push(link);
  96. }
  97. }
  98. } else {
  99. // An IB subnet with no switch fabric: direct-attach copper,
  100. // point-to-point between consecutive ranks.
  101. let mut ranked = ib;
  102. ranked.sort_by_key(|n| n.rank);
  103. for w in ranked.windows(2) {
  104. links.push(TopoLink {
  105. kind: "dac".into(),
  106. a: w[0].name.clone(),
  107. b: w[1].name.clone(),
  108. });
  109. }
  110. }
  111. for e in &c.extra_entities {
  112. if !e.blocks() && !node_entities.contains(&e.entity().to_string()) {
  113. push_switch(&mut switches, e.entity(), "aux");
  114. }
  115. }
  116. }
  117. nodes.sort_by_key(|n| (slot(n), n.clone()));
  118. for n in &nodes {
  119. links.push(TopoLink {
  120. kind: "10g".into(),
  121. a: "lan".into(),
  122. b: n.clone(),
  123. });
  124. }
  125. TopoSnap {
  126. nodes,
  127. links,
  128. switches,
  129. states: Vec::new(),
  130. }
  131. }
  132. #[cfg(test)]
  133. mod tests {
  134. use super::*;
  135. use std::collections::HashMap;
  136. fn node(name: &str, ib: Option<&str>, entity: Option<&str>, rank: u32) -> crate::config::Node {
  137. crate::config::Node {
  138. name: name.into(),
  139. ssh_ip: "10.0.0.1".into(),
  140. ib_ip: ib.map(String::from),
  141. rank,
  142. host_key: None,
  143. entity: entity.map(String::from),
  144. }
  145. }
  146. fn cluster(
  147. name: &str,
  148. nodes: Vec<crate::config::Node>,
  149. extra: Vec<crate::config::ExtraEntity>,
  150. ) -> crate::config::Cluster {
  151. crate::config::Cluster {
  152. name: name.into(),
  153. master_ib_ip: None,
  154. nodes,
  155. extra_entities: extra,
  156. }
  157. }
  158. #[test]
  159. fn derive_topo_fabric_dac_lan() {
  160. let config = Config {
  161. ssh_user: "u".into(),
  162. ssh_key_path: "/k".into(),
  163. nccl: HashMap::new(),
  164. hf_cache_dir: None,
  165. init_port: 25000,
  166. before_serve: HashMap::new(),
  167. exec_aliases: HashMap::new(),
  168. home_assistant: None,
  169. mikrotik: None,
  170. global_mods_dir: None,
  171. drop_caches: true,
  172. mount_aliases: HashMap::new(),
  173. recipe_sources: Vec::new(),
  174. recipe_repos: Vec::new(),
  175. docker_image_sources: Vec::new(),
  176. cluster_sources: Vec::new(),
  177. registry: None,
  178. clusters: vec![
  179. // fabric: blocking switch + ib nodes → 200g links
  180. cluster(
  181. "fabric",
  182. vec![
  183. node("dgx2.lan", Some("10.0.200.2"), Some("switch.dgx2"), 1),
  184. node("dgx1.lan", Some("10.0.200.1"), Some("switch.dgx1"), 0),
  185. ],
  186. vec![
  187. crate::config::ExtraEntity::Simple("switch.sw6".into()),
  188. crate::config::ExtraEntity::Detailed {
  189. entity: "switch.dgx1".into(),
  190. blocks: true,
  191. },
  192. ],
  193. ),
  194. // dac: ib nodes, no blocking switch → direct chain by rank
  195. cluster(
  196. "dac",
  197. vec![
  198. node("dgx1.lan", Some("10.0.100.1"), Some("switch.dgx1"), 0),
  199. node("dgx2.lan", Some("10.0.100.2"), Some("switch.dgx2"), 1),
  200. ],
  201. vec![],
  202. ),
  203. // lan-only nodes (no ib_ip), plus a fan extra
  204. cluster(
  205. "solo",
  206. vec![node("dgx10.lan", None, Some("switch.dgx10"), 0)],
  207. vec![crate::config::ExtraEntity::Detailed {
  208. entity: "switch.fan".into(),
  209. blocks: false,
  210. }],
  211. ),
  212. ],
  213. };
  214. let t = derive_topo(&config);
  215. // slots: dgx1 < dgx2 < dgx10 (numeric, not lexicographic)
  216. assert_eq!(t.nodes, vec!["dgx1.lan", "dgx2.lan", "dgx10.lan"]);
  217. // 200g: every ib node of the fabric cluster → the blocking switch
  218. assert_eq!(t.links.iter().filter(|l| l.kind == "200g").count(), 2);
  219. assert!(
  220. t.links
  221. .iter()
  222. .any(|l| l.kind == "200g" && l.a == "switch.sw6" && l.b == "dgx1.lan")
  223. );
  224. // dac chain between the ranks (dgx1 → dgx2)
  225. assert!(
  226. t.links
  227. .iter()
  228. .any(|l| l.kind == "dac" && l.a == "dgx1.lan" && l.b == "dgx2.lan")
  229. );
  230. // every node hangs on the passive lan
  231. assert_eq!(t.links.iter().filter(|l| l.kind == "10g").count(), 3);
  232. // switch glyphs: the fabric switch (ib, node-plug entities excluded)
  233. // and the fan (aux) — deduped across clusters
  234. assert_eq!(
  235. t.switches,
  236. vec![
  237. TopoSwitch {
  238. entity: "switch.sw6".into(),
  239. kind: "ib".into()
  240. },
  241. TopoSwitch {
  242. entity: "switch.fan".into(),
  243. kind: "aux".into()
  244. }
  245. ]
  246. );
  247. }
  248. }