| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- //! Physical topology derivation for the dashboard map.
- //!
- //! Pure config → map translation: nodes, cables, HA glyphs. The UI draws
- //! the fixed scene; the snapshot builder only attaches live HA states.
- use crate::config::Config;
- use serde::Serialize;
- /// One cable on the map: `a` → `b`, where `a` is a switch entity id, the
- /// pseudo-switch `"lan"` (the passive 10G), or a node name (DAC), and `b`
- /// is a node name.
- #[derive(Serialize, Clone, Debug, PartialEq)]
- pub struct TopoLink {
- /// "200g" (HA-managed fabric switch), "dac" (direct-attach copper),
- /// "10g" (passive 10G switch).
- pub kind: String,
- pub a: String,
- pub b: String,
- }
- /// An HA-managed device glyph: the blocking fabric switch ("ib") or an
- /// auxiliary entity like a fan ("aux").
- #[derive(Serialize, Clone, Debug, PartialEq)]
- pub struct TopoSwitch {
- pub entity: String,
- pub kind: String,
- }
- /// HA state for a map glyph: `on`/`off`, `None` = unknown/unreachable.
- #[derive(Serialize, Clone, Debug, PartialEq)]
- pub struct TopoState {
- pub entity: String,
- pub on: Option<bool>,
- }
- /// The fixed physical map, derived from config facts (see [`derive_topo`]).
- #[derive(Serialize, Clone, Debug, Default, PartialEq)]
- pub struct TopoSnap {
- /// Every configured node, sorted by slot (digits parsed from the name).
- pub nodes: Vec<String>,
- pub links: Vec<TopoLink>,
- pub switches: Vec<TopoSwitch>,
- /// HA states for [`TopoSnap::switches`] (filled by the snapshot builder).
- pub states: Vec<TopoState>,
- }
- /// Derive the fixed physical topology from the config alone:
- ///
- /// - every configured node occupies its slot (`dgx7.lan` → slot 7) —
- /// absent slots render as "planned";
- /// - a node with an `ib_ip` hangs on its cluster's *blocking* switch
- /// entity that is not a node plug (the 200G fabric, HA-managed) —
- /// or, when the cluster has no blocking switch (`2nodes-dac`), the IB
- /// link is direct-attach copper between consecutive ranks;
- /// - every node hangs on the passive 10G switch (pseudo-entity `"lan"`,
- /// no HA state);
- /// - extra entities that are neither blocking-fabric switches nor node
- /// plugs appear as auxiliary glyphs (a fan).
- pub(crate) fn derive_topo(config: &Config) -> TopoSnap {
- let slot = |n: &str| {
- n.chars()
- .filter(|c| c.is_ascii_digit())
- .collect::<String>()
- .parse::<u32>()
- .unwrap_or(u32::MAX)
- };
- let mut nodes: Vec<String> = Vec::new();
- let mut links: Vec<TopoLink> = Vec::new();
- let mut switches: Vec<TopoSwitch> = Vec::new();
- let push_switch = |switches: &mut Vec<TopoSwitch>, entity: &str, kind: &str| {
- if !switches.iter().any(|s| s.entity == entity) {
- switches.push(TopoSwitch {
- entity: entity.to_string(),
- kind: kind.to_string(),
- });
- }
- };
- for c in &config.clusters {
- let node_entities: Vec<String> = c.nodes.iter().filter_map(|n| n.entity.clone()).collect();
- for n in &c.nodes {
- if !nodes.contains(&n.name) {
- nodes.push(n.name.clone());
- }
- }
- let ib: Vec<&crate::config::Node> = c.nodes.iter().filter(|n| n.ib_ip.is_some()).collect();
- let blocking = c
- .extra_entities
- .iter()
- .find(|e| e.blocks() && !node_entities.contains(&e.entity().to_string()));
- if let Some(sw) = blocking {
- push_switch(&mut switches, sw.entity(), "ib");
- for n in ib {
- let link = TopoLink {
- kind: "200g".into(),
- a: sw.entity().to_string(),
- b: n.name.clone(),
- };
- // the same physical cable appears in every cluster that
- // shares the fabric — one line on the map
- if !links.contains(&link) {
- links.push(link);
- }
- }
- } else {
- // An IB subnet with no switch fabric: direct-attach copper,
- // point-to-point between consecutive ranks.
- let mut ranked = ib;
- ranked.sort_by_key(|n| n.rank);
- for w in ranked.windows(2) {
- links.push(TopoLink {
- kind: "dac".into(),
- a: w[0].name.clone(),
- b: w[1].name.clone(),
- });
- }
- }
- for e in &c.extra_entities {
- if !e.blocks() && !node_entities.contains(&e.entity().to_string()) {
- push_switch(&mut switches, e.entity(), "aux");
- }
- }
- }
- nodes.sort_by_key(|n| (slot(n), n.clone()));
- for n in &nodes {
- links.push(TopoLink {
- kind: "10g".into(),
- a: "lan".into(),
- b: n.clone(),
- });
- }
- TopoSnap {
- nodes,
- links,
- switches,
- states: Vec::new(),
- }
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use std::collections::HashMap;
- fn node(name: &str, ib: Option<&str>, entity: Option<&str>, rank: u32) -> crate::config::Node {
- crate::config::Node {
- name: name.into(),
- ssh_ip: "10.0.0.1".into(),
- ib_ip: ib.map(String::from),
- rank,
- host_key: None,
- entity: entity.map(String::from),
- }
- }
- fn cluster(
- name: &str,
- nodes: Vec<crate::config::Node>,
- extra: Vec<crate::config::ExtraEntity>,
- ) -> crate::config::Cluster {
- crate::config::Cluster {
- name: name.into(),
- master_ib_ip: None,
- nodes,
- extra_entities: extra,
- }
- }
- #[test]
- fn derive_topo_fabric_dac_lan() {
- let config = Config {
- ssh_user: "u".into(),
- ssh_key_path: "/k".into(),
- nccl: HashMap::new(),
- hf_cache_dir: None,
- init_port: 25000,
- before_serve: HashMap::new(),
- exec_aliases: HashMap::new(),
- home_assistant: None,
- mikrotik: None,
- global_mods_dir: None,
- drop_caches: true,
- mount_aliases: HashMap::new(),
- recipe_sources: Vec::new(),
- docker_image_sources: Vec::new(),
- cluster_sources: Vec::new(),
- clusters: vec![
- // fabric: blocking switch + ib nodes → 200g links
- cluster(
- "fabric",
- vec![
- node("dgx2.lan", Some("10.0.200.2"), Some("switch.dgx2"), 1),
- node("dgx1.lan", Some("10.0.200.1"), Some("switch.dgx1"), 0),
- ],
- vec![
- crate::config::ExtraEntity::Simple("switch.sw6".into()),
- crate::config::ExtraEntity::Detailed {
- entity: "switch.dgx1".into(),
- blocks: true,
- },
- ],
- ),
- // dac: ib nodes, no blocking switch → direct chain by rank
- cluster(
- "dac",
- vec![
- node("dgx1.lan", Some("10.0.100.1"), Some("switch.dgx1"), 0),
- node("dgx2.lan", Some("10.0.100.2"), Some("switch.dgx2"), 1),
- ],
- vec![],
- ),
- // lan-only nodes (no ib_ip), plus a fan extra
- cluster(
- "solo",
- vec![node("dgx10.lan", None, Some("switch.dgx10"), 0)],
- vec![crate::config::ExtraEntity::Detailed {
- entity: "switch.fan".into(),
- blocks: false,
- }],
- ),
- ],
- };
- let t = derive_topo(&config);
- // slots: dgx1 < dgx2 < dgx10 (numeric, not lexicographic)
- assert_eq!(t.nodes, vec!["dgx1.lan", "dgx2.lan", "dgx10.lan"]);
- // 200g: every ib node of the fabric cluster → the blocking switch
- assert_eq!(t.links.iter().filter(|l| l.kind == "200g").count(), 2);
- assert!(
- t.links
- .iter()
- .any(|l| l.kind == "200g" && l.a == "switch.sw6" && l.b == "dgx1.lan")
- );
- // dac chain between the ranks (dgx1 → dgx2)
- assert!(
- t.links
- .iter()
- .any(|l| l.kind == "dac" && l.a == "dgx1.lan" && l.b == "dgx2.lan")
- );
- // every node hangs on the passive lan
- assert_eq!(t.links.iter().filter(|l| l.kind == "10g").count(), 3);
- // switch glyphs: the fabric switch (ib, node-plug entities excluded)
- // and the fan (aux) — deduped across clusters
- assert_eq!(
- t.switches,
- vec![
- TopoSwitch {
- entity: "switch.sw6".into(),
- kind: "ib".into()
- },
- TopoSwitch {
- entity: "switch.fan".into(),
- kind: "aux".into()
- }
- ]
- );
- }
- }
|