|
@@ -2,8 +2,10 @@
|
|
|
//! running sm121 containers (the stop-confirm dialog names them).
|
|
//! running sm121 containers (the stop-confirm dialog names them).
|
|
|
|
|
|
|
|
use crate::config::Config;
|
|
use crate::config::Config;
|
|
|
|
|
+use crate::config::Recipe;
|
|
|
use crate::ssh::run_ssh;
|
|
use crate::ssh::run_ssh;
|
|
|
-use std::collections::HashMap;
|
|
|
|
|
|
|
+use crate::web::flare_status::{OrbitRank, parse_orbit_groups, parse_orbit_ranks};
|
|
|
|
|
+use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
|
|
/// Nodes already warned as unreachable (the probe runs every 30 s —
|
|
/// Nodes already warned as unreachable (the probe runs every 30 s —
|
|
|
/// log only the reachable → unreachable transition, clear on recovery).
|
|
/// log only the reachable → unreachable transition, clear on recovery).
|
|
@@ -71,97 +73,6 @@ pub fn created_epoch(s: &str) -> Option<u64> {
|
|
|
u64::try_from(secs).ok()
|
|
u64::try_from(secs).ok()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Parse `flare orbit status --cluster <c>` into (group, created-epoch) pairs.
|
|
|
|
|
-///
|
|
|
|
|
-/// The status table lists `… GROUPS` per node plus a `held by
|
|
|
|
|
-/// <name>-<YYYYMMDDTHHMMSSZ>-<hex>` detail line; a group name carries its
|
|
|
|
|
-/// start time, so the dashboard gets an uptime without a run ledger (the same
|
|
|
|
|
-/// reason the docker path reads `CreatedAt`).
|
|
|
|
|
-pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
|
|
|
|
|
- let mut out: Vec<(String, Option<u64>)> = Vec::new();
|
|
|
|
|
- // The GROUPS column reads `<group> rank <n>`; the start time only appears
|
|
|
|
|
- // in the `held by <group>-<stamp>-<hex>` detail line, so collect names
|
|
|
|
|
- // first and attach a timestamp when one is found.
|
|
|
|
|
- for line in stdout.lines() {
|
|
|
|
|
- // A group that exited still prints its row: never count it as running.
|
|
|
|
|
- if !(line.contains("exited") || line.contains("stopped"))
|
|
|
|
|
- && let Some((before, _)) = line.split_once(" rank ")
|
|
|
|
|
- && let Some(name) = before.split_whitespace().last()
|
|
|
|
|
- && !name.starts_with("gx10")
|
|
|
|
|
- && !name.contains("GiB")
|
|
|
|
|
- && name
|
|
|
|
|
- .chars()
|
|
|
|
|
- .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
|
|
|
|
- && !out.iter().any(|(n, _)| n == name)
|
|
|
|
|
- {
|
|
|
|
|
- out.push((name.to_string(), None));
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- let live: String = stdout
|
|
|
|
|
- .lines()
|
|
|
|
|
- .filter(|l| !(l.contains("exited") || l.contains("stopped")))
|
|
|
|
|
- .collect::<Vec<_>>()
|
|
|
|
|
- .join("\n");
|
|
|
|
|
- for tok in live.split(|c: char| c.is_whitespace() || c == ',') {
|
|
|
|
|
- let t = tok.trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
|
|
|
|
|
- let parts: Vec<&str> = t.split('-').collect();
|
|
|
|
|
- if parts.len() < 3 {
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- let base = parts[..parts.len() - 2].join("-");
|
|
|
|
|
- let stamp = parts[parts.len() - 2];
|
|
|
|
|
- let Some(stamp) = stamp.strip_suffix('Z') else {
|
|
|
|
|
- continue;
|
|
|
|
|
- };
|
|
|
|
|
- let Some((d, hms)) = stamp.split_once('T') else {
|
|
|
|
|
- continue;
|
|
|
|
|
- };
|
|
|
|
|
- if d.len() != 8
|
|
|
|
|
- || hms.len() != 6
|
|
|
|
|
- || base.is_empty()
|
|
|
|
|
- || !base
|
|
|
|
|
- .chars()
|
|
|
|
|
- .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.'))
|
|
|
|
|
- {
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- let num = |s: &str| s.parse::<i64>().ok();
|
|
|
|
|
- let (Some(y), Some(mo), Some(da)) = (num(&d[0..4]), num(&d[4..6]), num(&d[6..8])) else {
|
|
|
|
|
- continue;
|
|
|
|
|
- };
|
|
|
|
|
- let (Some(h), Some(mi), Some(se)) = (num(&hms[0..2]), num(&hms[2..4]), num(&hms[4..6]))
|
|
|
|
|
- else {
|
|
|
|
|
- continue;
|
|
|
|
|
- };
|
|
|
|
|
- let y2 = y - if mo <= 2 { 1 } else { 0 };
|
|
|
|
|
- let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
|
|
|
|
|
- let yoe = y2 - era * 400;
|
|
|
|
|
- let mp = (mo + 9) % 12;
|
|
|
|
|
- let doy = (153 * mp + 2) / 5 + da - 1;
|
|
|
|
|
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
|
|
|
- let secs = (era * 146_097 + doe - 719_468) * 86_400 + h * 3600 + mi * 60 + se;
|
|
|
|
|
- let created = u64::try_from(secs).ok();
|
|
|
|
|
- match out.iter_mut().find(|(n, _)| n == &base) {
|
|
|
|
|
- Some(slot) => slot.1 = created,
|
|
|
|
|
- None => out.push((base.to_string(), created)),
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- out
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// The ssh_ip of a node by its name (the probe keys its map on node names).
|
|
|
|
|
-fn node_ip_of<'a>(config: &'a crate::config::Config, name: &str) -> &'a String {
|
|
|
|
|
- config
|
|
|
|
|
- .clusters
|
|
|
|
|
- .iter()
|
|
|
|
|
- .flat_map(|c| c.nodes.iter())
|
|
|
|
|
- .find(|n| n.name == *name)
|
|
|
|
|
- .map(|n| &n.ssh_ip)
|
|
|
|
|
- .unwrap_or(&EMPTY_IP)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-static EMPTY_IP: std::sync::LazyLock<String> = std::sync::LazyLock::new(String::new);
|
|
|
|
|
-
|
|
|
|
|
/// Per-node state with containers.
|
|
/// Per-node state with containers.
|
|
|
#[derive(Clone, Debug, Default)]
|
|
#[derive(Clone, Debug, Default)]
|
|
|
pub struct WebNodeState {
|
|
pub struct WebNodeState {
|
|
@@ -178,11 +89,13 @@ pub struct FlareLane {
|
|
|
pub cluster: String,
|
|
pub cluster: String,
|
|
|
/// The orbit cluster name actually queried (`[flare.clusters.<name>]`).
|
|
/// The orbit cluster name actually queried (`[flare.clusters.<name>]`).
|
|
|
pub orbit: String,
|
|
pub orbit: String,
|
|
|
- /// Controller: our node name + ssh ip.
|
|
|
|
|
|
|
+ /// Controller: our node name.
|
|
|
pub controller: String,
|
|
pub controller: String,
|
|
|
- pub controller_ip: String,
|
|
|
|
|
/// `(group, start-epoch)` as printed by `orbit status`.
|
|
/// `(group, start-epoch)` as printed by `orbit status`.
|
|
|
pub groups: Vec<(String, Option<u64>)>,
|
|
pub groups: Vec<(String, Option<u64>)>,
|
|
|
|
|
+ /// Per-node rank rows from the same table — every node of the group,
|
|
|
|
|
+ /// not only the controller.
|
|
|
|
|
+ pub ranks: Vec<OrbitRank>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Fleet-wide probe: ask every **paired** cluster its own orbit name.
|
|
/// Fleet-wide probe: ask every **paired** cluster its own orbit name.
|
|
@@ -196,6 +109,17 @@ pub struct FlareLane {
|
|
|
/// flat default (`sparks`) applied to `6nodes-switch`/`1node-*`/`ds4`/`qwen38`
|
|
/// flat default (`sparks`) applied to `6nodes-switch`/`1node-*`/`ds4`/`qwen38`
|
|
|
/// — they share nodes with the paired clusters — made the sparks lane report
|
|
/// — they share nodes with the paired clusters — made the sparks lane report
|
|
|
/// itself as a lane of whichever unpaired cluster happened to be probed.
|
|
/// itself as a lane of whichever unpaired cluster happened to be probed.
|
|
|
|
|
+/// The recipe's `required_nodes` for a group base name (the recipe dir and
|
|
|
|
|
+/// `[flare].name` are normally the same). Used to decide which paired cluster
|
|
|
|
|
+/// owns a group: `pair12` also *sees* a prod4 group on its shared nodes, but
|
|
|
|
|
+/// its node count is not the package's TP.
|
|
|
|
|
+fn required_nodes_for_group(config: &crate::config::Config, group: &str) -> Option<u32> {
|
|
|
|
|
+ let path = crate::config::recipe_file(group, &config.recipe_sources)?;
|
|
|
|
|
+ let text = std::fs::read_to_string(path).ok()?;
|
|
|
|
|
+ let r: Recipe = toml::from_str(&text).ok()?;
|
|
|
|
|
+ r.required_nodes
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
|
|
pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
|
|
|
let Some(f) = config.flare.as_ref() else {
|
|
let Some(f) = config.flare.as_ref() else {
|
|
|
return Vec::new();
|
|
return Vec::new();
|
|
@@ -224,12 +148,41 @@ pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
|
|
|
if groups.is_empty() {
|
|
if groups.is_empty() {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
+ let rows = parse_orbit_ranks(&o);
|
|
|
|
|
+ // Orbit node names → our node names (rank order from the config).
|
|
|
|
|
+ let mut by_orbit: HashMap<String, String> = HashMap::new();
|
|
|
|
|
+ for n in &cluster.nodes {
|
|
|
|
|
+ by_orbit.insert(
|
|
|
|
|
+ crate::native::flare::orbit_node_name(config, &cluster.name, n),
|
|
|
|
|
+ n.name.clone(),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ let mut ranks: Vec<OrbitRank> = Vec::new();
|
|
|
|
|
+ for row in rows {
|
|
|
|
|
+ if let Some(our) = by_orbit.get(&row.node) {
|
|
|
|
|
+ ranks.push(OrbitRank {
|
|
|
|
|
+ node: our.clone(),
|
|
|
|
|
+ group: row.group,
|
|
|
|
|
+ created: row.created,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // A group can be *visible* in several paired clusters that share
|
|
|
|
|
+ // nodes (all8/pair12 both show the prod4 group). Only the cluster
|
|
|
|
|
+ // whose node count matches the group's rank rows and the recipe's
|
|
|
|
|
+ // required_nodes owns it.
|
|
|
|
|
+ let req = required_nodes_for_group(config, &groups[0].0);
|
|
|
|
|
+ let owned = ranks.len() == cluster.nodes.len()
|
|
|
|
|
+ && req.is_none_or(|n| n as usize == cluster.nodes.len());
|
|
|
|
|
+ if !owned {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
out.push(FlareLane {
|
|
out.push(FlareLane {
|
|
|
cluster: cluster.name.clone(),
|
|
cluster: cluster.name.clone(),
|
|
|
orbit: orbit.clone(),
|
|
orbit: orbit.clone(),
|
|
|
controller: node.name.clone(),
|
|
controller: node.name.clone(),
|
|
|
- controller_ip: node.ssh_ip.clone(),
|
|
|
|
|
groups,
|
|
groups,
|
|
|
|
|
+ ranks,
|
|
|
});
|
|
});
|
|
|
break; // this orbit answered; the cluster needs no other candidate
|
|
break; // this orbit answered; the cluster needs no other candidate
|
|
|
}
|
|
}
|
|
@@ -355,18 +308,28 @@ pub async fn probe(config: &Config) -> Fleet {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- // A group is reported once, on the node that runs `orbit`
|
|
|
|
|
- // (the loop is per node — without the guard every node would
|
|
|
|
|
- // report the same group).
|
|
|
|
|
- let ip = node_ip_of(config, &name);
|
|
|
|
|
- for lane in lanes.iter().filter(|l| &l.controller_ip == ip) {
|
|
|
|
|
- for (group, created) in &lane.groups {
|
|
|
|
|
|
|
+ // Every rank row marks its own node busy — a FLARE group has
|
|
|
|
|
+ // one rank per node, so the topology/LED board must show all
|
|
|
|
|
+ // of them, not only the controller that runs `orbit`.
|
|
|
|
|
+ // Several paired clusters can *see* the same group on shared
|
|
|
|
|
+ // nodes (all8/pair12 also show a prod4 group); dedup per
|
|
|
|
|
+ // (node, group) keeps one pseudo-container, owned by the first
|
|
|
|
|
+ // lane (the actual cluster).
|
|
|
|
|
+ let mut flare_seen: HashSet<(String, String)> = HashSet::new();
|
|
|
|
|
+ for lane in &lanes {
|
|
|
|
|
+ for rank in &lane.ranks {
|
|
|
|
|
+ if rank.node != name {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if !flare_seen.insert((name.clone(), rank.group.clone())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
containers.push(ContainerSnap {
|
|
containers.push(ContainerSnap {
|
|
|
- name: group.clone(),
|
|
|
|
|
|
|
+ name: rank.group.clone(),
|
|
|
status: "Up (flare)".into(),
|
|
status: "Up (flare)".into(),
|
|
|
cluster: lane.cluster.clone(),
|
|
cluster: lane.cluster.clone(),
|
|
|
- recipe: group.clone(),
|
|
|
|
|
- created: *created,
|
|
|
|
|
|
|
+ recipe: rank.group.clone(),
|
|
|
|
|
+ created: rank.created,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -440,7 +403,7 @@ mod created_tests {
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod orbit_tests {
|
|
mod orbit_tests {
|
|
|
- use super::parse_orbit_groups;
|
|
|
|
|
|
|
+ use crate::web::flare_status::parse_orbit_groups;
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn orbit_status_yields_group_and_start_time() {
|
|
fn orbit_status_yields_group_and_start_time() {
|
|
@@ -465,7 +428,7 @@ mod orbit_tests {
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod orbit_dead_tests {
|
|
mod orbit_dead_tests {
|
|
|
- use super::parse_orbit_groups;
|
|
|
|
|
|
|
+ use crate::web::flare_status::parse_orbit_groups;
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn dead_groups_are_not_running() {
|
|
fn dead_groups_are_not_running() {
|