Przeglądaj źródła

task: Extract printing-free unified HealthReport collection pass in docker::health — cluster verdict, per-node net/containers, tmux sessions, running-recipes summary as one pure-data model — build one unified per-cluster report model (cluster verdict + per-node table with net/containers + tmux sessions + running-recipes summary) rendered by the shared module, and keep the collection pass standalone under docker::health reusing only cluster_state::verdict, with no web::probe coupling | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): build one unified per-cluster report model, collection free of printing; keep the collection pass standalone under docker::health reusing only cluster_state::verdict — do not couple to web::probe (TASK_0001)

Alexander Korolev 1 miesiąc temu
rodzic
commit
0f0177f302

+ 0 - 373
src/docker/health.rs

@@ -1,373 +0,0 @@
-//! Cluster health — shows node status, running containers, and recipe
-//! deployments across all clusters.
-
-use crate::config::{Cluster, Config};
-use crate::ssh::run_ssh;
-use anyhow::Result;
-use std::collections::HashMap;
-use tracing::warn;
-
-/// Per-node result from the parallel SSH sweep.
-struct NodeHealth {
-    name: String,
-    ib_ip: String,
-    online: bool,
-    containers: Vec<String>,
-    net_up: bool,
-    net_details: Vec<String>,
-}
-
-/// Checks Docker health — shows node status and containers belonging to this cluster.
-///
-/// Cluster-level semantics (shared with the web dashboard via
-/// [`crate::cluster_state::verdict`]): the cluster is UP only when every
-/// node is reachable and the HA switch entities (if configured) are on.
-/// A down cluster prints no containers, even on nodes that are alive.
-pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
-    let nodes = &cluster.nodes;
-    let cluster_label = &cluster.name;
-    let nccl = config.nccl.get(cluster_label);
-
-    // Check HA cluster-level entities (switch, etc.) in parallel.
-    let config_ha = config.clone();
-    let cluster_ha = cluster.clone();
-    let es_handle = crate::locale::spawn_with_locale(async move {
-        crate::home_assistant::extra_entities_states(&config_ha, &cluster_ha).await
-    });
-
-    // Build the network check command snippet for this cluster's NCCL interfaces.
-    let net_script = nccl.as_ref().and_then(|n| {
-        let socket = &n.socket_ifname;
-        // Validate socket interface name to prevent command injection
-        // Must be a valid network interface name: alphanumeric, dots, underscores, hyphens
-        let valid = socket.chars().all(|c| c.is_alphanumeric() || ".-_".contains(c));
-        if !valid {
-            return None;
-        }
-        Some(format!(
-            "echo '---NET---'; echo -n 'socket:' && (ip -o link show dev {socket} 2>/dev/null | grep -qi 'state UP' && echo UP || echo DOWN); echo '---END---'"
-        ))
-    });
-
-    // SSH into each node in parallel.
-    let mut handles = Vec::new();
-    for node in nodes {
-        let config = config.clone();
-        let node = node.clone();
-        let name = node.name.clone();
-        let ib_ip = node.effective_ib_ip().to_string();
-        let cl = cluster_label.clone();
-        let ns = net_script.clone();
-        let docker_cmd = format!(
-            "docker ps --filter label=sm121.cluster={cl} \
-             --format '{{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}' 2>&1"
-        );
-        let cmd = if let Some(ref script) = ns {
-            format!("{docker_cmd}; {script}")
-        } else {
-            docker_cmd
-        };
-        handles.push(crate::locale::spawn_with_locale(async move {
-            let result = run_ssh(&config, &node, cmd, dry_run).await;
-            (name, ib_ip, result)
-        }));
-    }
-
-    // Collect SSH node results.
-    let mut node_health: Vec<NodeHealth> = Vec::new();
-    for h in futures::future::join_all(handles).await {
-        match h {
-            Ok((name, ib_ip, Ok(output))) => {
-                let mut net_up = true;
-                let mut net_details = Vec::new();
-                let mut lines: Vec<String> = Vec::new();
-                let mut in_net = false;
-                for line in output.lines() {
-                    let line = line.trim();
-                    if line.is_empty()
-                        || line.contains("permission denied")
-                        || line.starts_with("docker:")
-                    {
-                        continue;
-                    }
-                    if line == "---NET---" {
-                        in_net = true;
-                        continue;
-                    }
-                    if line == "---END---" {
-                        in_net = false;
-                        continue;
-                    }
-                    if in_net {
-                        net_details.push(line.to_string());
-                        if line.ends_with("DOWN")
-                            || line.ends_with("missing")
-                            || line.ends_with("error")
-                        {
-                            net_up = false;
-                        }
-                        continue;
-                    }
-                    lines.push(line.to_string());
-                }
-                node_health.push(NodeHealth {
-                    name,
-                    ib_ip,
-                    online: true,
-                    containers: lines,
-                    net_up,
-                    net_details,
-                });
-            }
-            Ok((name, ib_ip, Err(_))) => {
-                node_health.push(NodeHealth {
-                    name,
-                    ib_ip,
-                    online: false,
-                    containers: vec![],
-                    net_up: false,
-                    net_details: vec![],
-                });
-            }
-            Err(join_err) => warn!(
-                "{}",
-                fluent_i18n::t!("health-task-join", { "error" => &join_err.to_string() })
-            ),
-        }
-    }
-
-    // Resolve HA states + cluster verdict.
-    let es = es_handle.await.unwrap_or_default();
-    let switch_off = es.blocking_off;
-    let total = nodes.len();
-    let online = node_health.iter().filter(|n| n.online).count();
-    let offline = total - online;
-    let net_ok = node_health.iter().all(|n| !n.online || n.net_up);
-    let reachables: Vec<(bool, bool)> = node_health
-        .iter()
-        .map(|n| (n.online, !n.containers.is_empty()))
-        .collect();
-    let v = crate::cluster_state::verdict(&reachables, switch_off);
-    let container_count: usize = if v.up {
-        node_health.iter().map(|n| n.containers.len()).sum()
-    } else {
-        0
-    };
-
-    print!(
-        "{}",
-        fluent_i18n::t!("health-cluster-header", { "name" => &cluster_label, "nodes" => total })
-    );
-    if !v.up {
-        // Cluster is down as a whole — say why, nothing else to report.
-        if v.switch_off {
-            print!(" {}", fluent_i18n::t!("health-switch-off"));
-        } else {
-            print!(
-                "{}",
-                fluent_i18n::t!("health-partial-online", { "online" => online, "offline" => offline })
-            );
-        }
-        if !es.soft_off.is_empty() {
-            print!(
-                " {}",
-                fluent_i18n::t!("health-soft-off", { "list" => &es.soft_off.join(", ") })
-            );
-        }
-        println!(" {}", fluent_i18n::t!("health-cluster-down"));
-    } else {
-        // `v.up` ⇒ every node is reachable.
-        print!(" {}", fluent_i18n::t!("health-all-online"));
-        if !net_ok {
-            print!(" {}", fluent_i18n::t!("health-interface-down"));
-        }
-        print!(
-            " {}",
-            fluent_i18n::t!("health-free-count", { "free" => v.free })
-        );
-        if !es.soft_off.is_empty() {
-            print!(
-                " {}",
-                fluent_i18n::t!("health-soft-off", { "list" => &es.soft_off.join(", ") })
-            );
-        }
-    }
-    // Container count: only for up clusters — the down path already
-    // printed its header line (with newline), and an offline cluster
-    // reports no containers by definition.
-    if v.up {
-        if container_count > 0 {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-container-count", { "count" => container_count })
-            );
-        } else {
-            print!("{}", fluent_i18n::t!("health-no-containers-inline"));
-        }
-    }
-
-    for nh in &node_health {
-        if !nh.online {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-offline", { "name" => &nh.name, "ip" => &nh.ib_ip })
-            );
-            continue;
-        }
-        let net_status = if !nh.net_up {
-            fluent_i18n::t!("health-net-status", { "details" => nh.net_details.join(", ") })
-        } else {
-            String::new()
-        };
-        if !v.up {
-            // Cluster is down as a whole: the node's containers are not
-            // ours to report — print the node as alive-but-ignored.
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-online-cluster-down", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-            continue;
-        }
-        if nh.containers.is_empty() {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-idle", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-        } else {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-online", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-            for line in &nh.containers {
-                println!("    {line}");
-            }
-        }
-    }
-    Ok(())
-}
-
-/// Quick overview — recipes running on each cluster.
-///
-/// Cluster-level semantics: a container is only reported when the cluster
-/// it is labeled with (`sm121.cluster`) is UP — all nodes reachable and
-/// HA switches on (same [`crate::cluster_state::verdict`] rule as
-/// `health_check` and the web dashboard). Containers on live nodes of a
-/// down cluster are ignored.
-pub async fn health_summary(config: &Config) -> Result<()> {
-    let mut seen = std::collections::HashSet::new();
-    let mut handles = Vec::new();
-    for cluster in &config.clusters {
-        for node in &cluster.nodes {
-            if seen.contains(&node.ssh_ip) {
-                continue;
-            }
-            seen.insert(node.ssh_ip.clone());
-            let config = config.clone();
-            let node = node.clone();
-            let name = node.name.clone();
-            let cmd = "docker ps --filter label=sm121.recipe \
-                       --format '{{.Label \"sm121.recipe\"}}|{{.Label \"sm121.cluster\"}}'"
-                .to_string();
-            handles.push(crate::locale::spawn_with_locale(async move {
-                // Ok/Err doubles as the node's reachability signal.
-                let out = run_ssh(&config, &node, cmd, false).await;
-                (name, out)
-            }));
-        }
-    }
-
-    // HA switch state for every cluster, in parallel.
-    let ha_handles: Vec<(String, _)> = config
-        .clusters
-        .iter()
-        .filter(|c| !c.extra_entities.is_empty())
-        .map(|c| {
-            let config = config.clone();
-            let cluster = c.clone();
-            (
-                c.name.clone(),
-                crate::locale::spawn_with_locale(async move {
-                    crate::home_assistant::extra_entities_states(&config, &cluster).await
-                }),
-            )
-        })
-        .collect();
-
-    let mut reachable: HashMap<String, bool> = HashMap::new();
-    let mut recipe_nodes: HashMap<String, HashMap<String, std::collections::HashSet<String>>> =
-        HashMap::new();
-    for h in futures::future::join_all(handles).await {
-        let (node_name, output) = match h {
-            Ok((node_name, Ok(output))) => {
-                reachable.insert(node_name.clone(), true);
-                (node_name, output)
-            }
-            Ok((node_name, Err(_))) => {
-                reachable.insert(node_name.clone(), false);
-                continue;
-            }
-            Err(e) => {
-                tracing::warn!("health summary task panicked: {e}");
-                continue;
-            }
-        };
-        for line in output.lines() {
-            let line = line.trim();
-            if line.is_empty() {
-                continue;
-            }
-            let mut parts = line.splitn(2, '|');
-            let recipe = parts.next().unwrap_or("").to_string();
-            if recipe.is_empty() {
-                continue;
-            }
-            recipe_nodes
-                .entry(recipe)
-                .or_default()
-                .entry(parts.next().unwrap_or("").to_string())
-                .or_default()
-                .insert(node_name.clone());
-        }
-    }
-
-    let mut cluster_up: HashMap<String, bool> = HashMap::new();
-    let ha_results: HashMap<String, bool> = futures::future::join_all(
-        ha_handles
-            .into_iter()
-            .map(|(name, h)| async move { (name, h.await.unwrap_or_default().blocking_off) }),
-    )
-    .await
-    .into_iter()
-    .collect();
-    for cluster in &config.clusters {
-        let all_reachable = cluster
-            .nodes
-            .iter()
-            .all(|n| reachable.get(&n.name).copied().unwrap_or(false));
-        let switch_off = ha_results.get(&cluster.name).copied().unwrap_or(false);
-        cluster_up.insert(cluster.name.clone(), all_reachable && !switch_off);
-    }
-    let mut lines: Vec<String> = Vec::new();
-    for (recipe, by_cluster) in &recipe_nodes {
-        for (cluster, nodes) in by_cluster {
-            if !cluster_up.get(cluster).copied().unwrap_or(false) {
-                continue; // down cluster — its containers are not ours to report
-            }
-            let mut sorted: Vec<_> = nodes.iter().collect();
-            sorted.sort();
-            let node_list: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
-            lines.push(fluent_i18n::t!("health-recipe-line", { "recipe" => recipe, "nodes" => node_list.len(), "list" => &node_list.join(", ") }));
-        }
-    }
-
-    if lines.is_empty() {
-        println!("\n{}", fluent_i18n::t!("health-no-recipes"));
-        return Ok(());
-    }
-
-    println!("\n{}", fluent_i18n::t!("health-running-recipes"));
-    for line in &lines {
-        println!("{line}");
-    }
-    Ok(())
-}

+ 309 - 0
src/docker/health/collect.rs

@@ -0,0 +1,309 @@
+//! Printing-free collection pass behind the `health` command.
+//!
+//! Gathers per-node docker rows over SSH, HA entity states, the shared
+//! cluster verdict ([`crate::cluster_state::verdict`]), tmux entries, and
+//! — in fleet mode — the running-recipes sweep. Pure data out; consumers
+//! render [`super::HealthReport`]s. Independent of the web dashboard's
+//! probe module.
+
+use super::{FleetHealth, HealthReport, NodeHealth, RecipesSummary};
+use crate::cluster_state::ClusterVerdict;
+use crate::config::{Cluster, Config};
+use crate::ssh::run_ssh;
+use anyhow::{Context, Result};
+use std::collections::{HashMap, HashSet};
+use tokio::task::JoinHandle;
+use tracing::warn;
+
+/// One collection call. `filter` is the optional `--cluster` name
+/// (`None` = all clusters); the fleet running-recipes summary is
+/// populated only when no filter is passed.
+pub async fn collect(config: &Config, filter: Option<&str>, dry_run: bool) -> Result<FleetHealth> {
+    let clusters: Vec<Cluster> = match filter {
+        Some(name) => vec![
+            config
+                .clusters
+                .iter()
+                .find(|c| c.name == name)
+                .with_context(|| format!("cluster '{name}' not found"))?
+                .clone(),
+        ],
+        None => config.clusters.clone(),
+    };
+    // Fleet recipe sweep (no-filter mode only): spawned before the
+    // per-cluster passes so it overlaps them, joined in `build_summary`.
+    let sweep = if filter.is_none() {
+        spawn_recipe_sweep(config, &clusters)
+    } else {
+        Vec::new()
+    };
+    let mut reports = Vec::with_capacity(clusters.len());
+    for cluster in &clusters {
+        reports.push(collect_cluster(config, cluster, dry_run).await);
+    }
+    let recipes = if filter.is_none() {
+        Some(build_summary(&clusters, &reports, sweep).await)
+    } else {
+        None
+    };
+    Ok(FleetHealth {
+        clusters: reports,
+        recipes,
+    })
+}
+
+/// Collect one cluster: HA states + per-node SSH sweep in parallel,
+/// then the shared verdict, then the tmux entries.
+async fn collect_cluster(config: &Config, cluster: &Cluster, dry_run: bool) -> HealthReport {
+    let net_script = net_check_script(
+        config
+            .nccl
+            .get(&cluster.name)
+            .map(|n| n.socket_ifname.as_str()),
+    );
+
+    // Check HA cluster-level entities (switch, etc.) in parallel.
+    let config_ha = config.clone();
+    let cluster_ha = cluster.clone();
+    let es_handle = crate::locale::spawn_with_locale(async move {
+        crate::home_assistant::extra_entities_states(&config_ha, &cluster_ha).await
+    });
+
+    // SSH into each node in parallel.
+    let mut handles = Vec::new();
+    for node in &cluster.nodes {
+        let config = config.clone();
+        let node = node.clone();
+        let name = node.name.clone();
+        let ib_ip = node.effective_ib_ip().to_string();
+        let cl = cluster.name.clone();
+        let ns = net_script.clone();
+        let docker_cmd = format!(
+            "docker ps --filter label=sm121.cluster={cl} \
+             --format '{{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}' 2>&1"
+        );
+        let cmd = if let Some(ref script) = ns {
+            format!("{docker_cmd}; {script}")
+        } else {
+            docker_cmd
+        };
+        handles.push(crate::locale::spawn_with_locale(async move {
+            let result = run_ssh(&config, &node, cmd, dry_run).await;
+            (name, ib_ip, result)
+        }));
+    }
+
+    // Collect SSH node results.
+    let mut nodes: Vec<NodeHealth> = Vec::new();
+    for h in futures::future::join_all(handles).await {
+        match h {
+            Ok((name, ib_ip, Ok(output))) => {
+                let (containers, net_up, net_details) = parse_ps_output(&output);
+                nodes.push(NodeHealth {
+                    name,
+                    ib_ip,
+                    online: true,
+                    containers,
+                    net_up,
+                    net_details,
+                });
+            }
+            Ok((name, ib_ip, Err(_))) => {
+                // A failing `docker ps` counts as offline.
+                nodes.push(NodeHealth {
+                    name,
+                    ib_ip,
+                    online: false,
+                    containers: vec![],
+                    net_up: false,
+                    net_details: vec![],
+                });
+            }
+            Err(join_err) => warn!(
+                "{}",
+                fluent_i18n::t!("health-task-join", { "error" => &join_err.to_string() })
+            ),
+        }
+    }
+
+    let es = es_handle.await.unwrap_or_default();
+    let verdict = verdict_for(&nodes, es.blocking_off);
+    let tmux = crate::tmux::discover::tmux_health_for_cluster(config, cluster).await;
+    HealthReport {
+        cluster: cluster.name.clone(),
+        verdict,
+        soft_off: es.soft_off,
+        nodes,
+        tmux,
+    }
+}
+
+/// Cluster verdict from per-node rows — reachables assembled as
+/// `(online, !containers.is_empty())` and fed to the one sanctioned rule,
+/// [`crate::cluster_state::verdict`].
+pub(super) fn verdict_for(nodes: &[NodeHealth], switch_off: bool) -> ClusterVerdict {
+    let reachables: Vec<(bool, bool)> = nodes
+        .iter()
+        .map(|n| (n.online, !n.containers.is_empty()))
+        .collect();
+    crate::cluster_state::verdict(&reachables, switch_off)
+}
+
+/// Build the `ip -o link` net-check snippet appended to the per-node
+/// `docker ps` command. `None` (check silently omitted) when no
+/// `socket_ifname` is configured or the name fails validation
+/// (alphanumeric / `. - _` only — command-injection guard).
+pub(super) fn net_check_script(socket_ifname: Option<&str>) -> Option<String> {
+    let socket = socket_ifname?;
+    let valid = socket
+        .chars()
+        .all(|c| c.is_alphanumeric() || ".-_".contains(c));
+    if !valid {
+        return None;
+    }
+    Some(format!(
+        "echo '---NET---'; echo -n 'socket:' && (ip -o link show dev {socket} 2>/dev/null | grep -qi 'state UP' && echo UP || echo DOWN); echo '---END---'"
+    ))
+}
+
+/// Parse one node's raw sweep output into `(containers, net_up,
+/// net_details)`. Skips empty lines, "permission denied" noise, and
+/// `docker:` error lines; the `---NET---`/`---END---` block carries the
+/// `ip -o link` details, and a detail line ending in
+/// `DOWN`/`missing`/`error` flips `net_up`.
+pub(super) fn parse_ps_output(output: &str) -> (Vec<String>, bool, Vec<String>) {
+    let mut containers = Vec::new();
+    let mut net_up = true;
+    let mut net_details = Vec::new();
+    let mut in_net = false;
+    for line in output.lines() {
+        let line = line.trim();
+        if line.is_empty() || line.contains("permission denied") || line.starts_with("docker:") {
+            continue;
+        }
+        if line == "---NET---" {
+            in_net = true;
+            continue;
+        }
+        if line == "---END---" {
+            in_net = false;
+            continue;
+        }
+        if in_net {
+            net_details.push(line.to_string());
+            if line.ends_with("DOWN") || line.ends_with("missing") || line.ends_with("error") {
+                net_up = false;
+            }
+            continue;
+        }
+        containers.push(line.to_string());
+    }
+    (containers, net_up, net_details)
+}
+
+/// Fleet sweep: one SSH per unique node (`ssh_ip` dedup across clusters)
+/// listing running recipe/cluster labels. Ok/Err doubles as the node's
+/// reachability signal; results are keyed by node name (missing name =
+/// unreachable).
+fn spawn_recipe_sweep(
+    config: &Config,
+    clusters: &[Cluster],
+) -> Vec<JoinHandle<(String, anyhow::Result<String>)>> {
+    let mut seen = HashSet::new();
+    let mut handles = Vec::new();
+    for cluster in clusters {
+        for node in &cluster.nodes {
+            if seen.contains(&node.ssh_ip) {
+                continue;
+            }
+            seen.insert(node.ssh_ip.clone());
+            let config = config.clone();
+            let node = node.clone();
+            let name = node.name.clone();
+            let cmd = "docker ps --filter label=sm121.recipe \
+                       --format '{{.Label \"sm121.recipe\"}}|{{.Label \"sm121.cluster\"}}'"
+                .to_string();
+            handles.push(crate::locale::spawn_with_locale(async move {
+                (name, run_ssh(&config, &node, cmd, false).await)
+            }));
+        }
+    }
+    handles
+}
+
+/// Build the running-recipes summary from the sweep. Cluster up/down
+/// comes from the shared verdict: reachability from the sweep (per node
+/// name, missing = unreachable), switch state from the already-collected
+/// per-cluster reports. Down clusters' containers are excluded.
+pub(super) async fn build_summary(
+    clusters: &[Cluster],
+    reports: &[HealthReport],
+    sweep: Vec<JoinHandle<(String, anyhow::Result<String>)>>,
+) -> RecipesSummary {
+    let mut reachable: HashMap<String, bool> = HashMap::new();
+    let mut by_recipe: HashMap<String, HashMap<String, HashSet<String>>> = HashMap::new();
+    for h in futures::future::join_all(sweep).await {
+        let (node_name, output) = match h {
+            Ok((node_name, Ok(output))) => {
+                reachable.insert(node_name.clone(), true);
+                (node_name, output)
+            }
+            Ok((node_name, Err(_))) => {
+                reachable.insert(node_name.clone(), false);
+                continue;
+            }
+            Err(e) => {
+                warn!("health summary task panicked: {e}");
+                continue;
+            }
+        };
+        for line in output.lines() {
+            let line = line.trim();
+            if line.is_empty() {
+                continue;
+            }
+            let mut parts = line.splitn(2, '|');
+            let recipe = parts.next().unwrap_or("").to_string();
+            if recipe.is_empty() {
+                continue;
+            }
+            by_recipe
+                .entry(recipe)
+                .or_default()
+                .entry(parts.next().unwrap_or("").to_string())
+                .or_default()
+                .insert(node_name.clone());
+        }
+    }
+
+    let mut summary = RecipesSummary::default();
+    for (recipe, by_cluster) in by_recipe {
+        for (cluster, nodes) in by_cluster {
+            // Down clusters' containers are not ours to report — same
+            // shared verdict rule as the per-cluster reports.
+            let Some(cl) = clusters.iter().find(|c| c.name == cluster) else {
+                continue;
+            };
+            let reachables: Vec<(bool, bool)> = cl
+                .nodes
+                .iter()
+                .map(|n| (reachable.get(&n.name).copied().unwrap_or(false), false))
+                .collect();
+            let switch_off = reports
+                .iter()
+                .find(|r| r.cluster == cluster)
+                .is_some_and(|r| r.verdict.switch_off);
+            if !crate::cluster_state::verdict(&reachables, switch_off).up {
+                continue;
+            }
+            let mut sorted: Vec<_> = nodes.into_iter().collect();
+            sorted.sort();
+            summary
+                .by_recipe
+                .entry(recipe.clone())
+                .or_default()
+                .insert(cluster, sorted);
+        }
+    }
+    summary
+}

+ 62 - 0
src/docker/health/mod.rs

@@ -0,0 +1,62 @@
+//! Cluster health — pure-data collection + printing consumers.
+//!
+//! [`collect`] is the printing-free collection pass: one call per
+//! `health` invocation gathers a [`HealthReport`] per requested cluster
+//! (verdict via the shared [`crate::cluster_state::verdict`], per-node
+//! docker rows, tmux entries) plus — when no cluster filter is given —
+//! the fleet-level [`RecipesSummary`]. The printing functions
+//! ([`print_health`], [`health_summary`]) render that model; nothing
+//! here touches the web dashboard's probe.
+
+mod collect;
+mod print;
+#[cfg(test)]
+mod tests;
+
+pub use collect::collect;
+pub use print::{health_check, health_summary, print_health};
+
+use crate::cluster_state::ClusterVerdict;
+use crate::tmux::discover::TmuxHealthEntry;
+use std::collections::HashMap;
+
+/// Per-node row from the parallel SSH sweep.
+pub struct NodeHealth {
+    pub name: String,
+    pub ib_ip: String,
+    /// SSH connection succeeded (a failing `docker ps` counts as offline).
+    pub online: bool,
+    /// Raw `docker ps` lines (`name\tstatus\timage`).
+    pub containers: Vec<String>,
+    /// NCCL socket interface is up (always `true` when unchecked).
+    pub net_up: bool,
+    pub net_details: Vec<String>,
+}
+
+/// Unified per-cluster health report — pure data, no printing.
+pub struct HealthReport {
+    pub cluster: String,
+    /// Cluster verdict from the one sanctioned rule,
+    /// [`crate::cluster_state::verdict`] (up / switch_off / free).
+    pub verdict: ClusterVerdict,
+    /// Non-blocking HA entities that are off — display only.
+    pub soft_off: Vec<String>,
+    pub nodes: Vec<NodeHealth>,
+    /// tmux-deployment health entries (one per node per tmux recipe).
+    pub tmux: Vec<TmuxHealthEntry>,
+}
+
+/// Fleet-level running-recipes summary (recipe → cluster → nodes).
+/// Down clusters are excluded; node lists are sorted.
+#[derive(Default)]
+pub struct RecipesSummary {
+    pub by_recipe: HashMap<String, HashMap<String, Vec<String>>>,
+}
+
+/// One collection pass over the fleet.
+pub struct FleetHealth {
+    /// One report per requested cluster, in config order.
+    pub clusters: Vec<HealthReport>,
+    /// Running-recipes summary; `None` when a cluster filter was passed.
+    pub recipes: Option<RecipesSummary>,
+}

+ 157 - 0
src/docker/health/print.rs

@@ -0,0 +1,157 @@
+//! Printing consumers for the collected [`HealthReport`] model.
+//!
+//! All output vocabulary lives here (and in
+//! [`crate::tmux::discover::print_tmux_health`] for the tmux section);
+//! the data comes from [`super::collect`] untouched.
+
+use super::{FleetHealth, HealthReport};
+use crate::config::{Cluster, Config};
+use anyhow::Result;
+
+/// `health` command, single cluster: collect that cluster and print it
+/// (docker rows + tmux section).
+///
+/// Cluster-level semantics (shared with the web dashboard via
+/// [`crate::cluster_state::verdict`]): the cluster is UP only when every
+/// node is reachable and the HA switch entities (if configured) are on.
+/// A down cluster prints no containers, even on nodes that are alive.
+pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
+    let fleet = super::collect(config, Some(&cluster.name), dry_run).await?;
+    let Some(report) = fleet.clusters.first() else {
+        return Ok(());
+    };
+    print_health(report);
+    crate::tmux::discover::print_tmux_health(&report.tmux);
+    Ok(())
+}
+
+/// Print one cluster's report: header/verdict line + per-node rows.
+/// Container rows are reported only when the cluster is up.
+pub fn print_health(report: &HealthReport) {
+    let nodes = &report.nodes;
+    let v = &report.verdict;
+    let total = nodes.len();
+    let online = nodes.iter().filter(|n| n.online).count();
+    let offline = total - online;
+    let net_ok = nodes.iter().all(|n| !n.online || n.net_up);
+    let container_count: usize = if v.up {
+        nodes.iter().map(|n| n.containers.len()).sum()
+    } else {
+        0
+    };
+
+    print!(
+        "{}",
+        fluent_i18n::t!("health-cluster-header", { "name" => &report.cluster, "nodes" => total })
+    );
+    if !v.up {
+        // Cluster is down as a whole — say why, nothing else to report.
+        if v.switch_off {
+            print!(" {}", fluent_i18n::t!("health-switch-off"));
+        } else {
+            print!(
+                "{}",
+                fluent_i18n::t!("health-partial-online", { "online" => online, "offline" => offline })
+            );
+        }
+        if !report.soft_off.is_empty() {
+            print!(
+                " {}",
+                fluent_i18n::t!("health-soft-off", { "list" => &report.soft_off.join(", ") })
+            );
+        }
+        println!(" {}", fluent_i18n::t!("health-cluster-down"));
+    } else {
+        // `v.up` ⇒ every node is reachable.
+        print!(" {}", fluent_i18n::t!("health-all-online"));
+        if !net_ok {
+            print!(" {}", fluent_i18n::t!("health-interface-down"));
+        }
+        print!(
+            " {}",
+            fluent_i18n::t!("health-free-count", { "free" => v.free })
+        );
+        if !report.soft_off.is_empty() {
+            print!(
+                " {}",
+                fluent_i18n::t!("health-soft-off", { "list" => &report.soft_off.join(", ") })
+            );
+        }
+    }
+    // Container count: only for up clusters — the down path already
+    // printed its header line (with newline), and an offline cluster
+    // reports no containers by definition.
+    if v.up {
+        if container_count > 0 {
+            println!(
+                "{}",
+                fluent_i18n::t!("health-container-count", { "count" => container_count })
+            );
+        } else {
+            print!("{}", fluent_i18n::t!("health-no-containers-inline"));
+        }
+    }
+
+    for nh in nodes {
+        if !nh.online {
+            println!(
+                "{}",
+                fluent_i18n::t!("health-node-offline", { "name" => &nh.name, "ip" => &nh.ib_ip })
+            );
+            continue;
+        }
+        let net_status = if !nh.net_up {
+            fluent_i18n::t!("health-net-status", { "details" => nh.net_details.join(", ") })
+        } else {
+            String::new()
+        };
+        if !v.up {
+            // Cluster is down as a whole: the node's containers are not
+            // ours to report — print the node as alive-but-ignored.
+            println!(
+                "{}",
+                fluent_i18n::t!("health-node-online-cluster-down", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
+            );
+            continue;
+        }
+        if nh.containers.is_empty() {
+            println!(
+                "{}",
+                fluent_i18n::t!("health-node-idle", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
+            );
+        } else {
+            println!(
+                "{}",
+                fluent_i18n::t!("health-node-online", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
+            );
+            for line in &nh.containers {
+                println!("    {line}");
+            }
+        }
+    }
+}
+
+/// Fleet running-recipes summary — prints only when a summary was
+/// collected (no `--cluster` filter). Down clusters were already
+/// excluded during collection.
+pub fn health_summary(fleet: &FleetHealth) {
+    let Some(summary) = &fleet.recipes else {
+        return;
+    };
+    let mut lines: Vec<String> = Vec::new();
+    for (recipe, by_cluster) in &summary.by_recipe {
+        for nodes in by_cluster.values() {
+            lines.push(fluent_i18n::t!("health-recipe-line", { "recipe" => recipe, "nodes" => nodes.len(), "list" => &nodes.join(", ") }));
+        }
+    }
+
+    if lines.is_empty() {
+        println!("\n{}", fluent_i18n::t!("health-no-recipes"));
+        return;
+    }
+
+    println!("\n{}", fluent_i18n::t!("health-running-recipes"));
+    for line in &lines {
+        println!("{line}");
+    }
+}

+ 139 - 0
src/docker/health/tests.rs

@@ -0,0 +1,139 @@
+//! Hardware-free tests for the [`crate::docker::health`] collection
+//! model (verdict assembly, sweep parsing, summary building).
+
+use super::collect::{build_summary, net_check_script, parse_ps_output, verdict_for};
+use super::{HealthReport, NodeHealth, RecipesSummary};
+use crate::cluster_state::ClusterVerdict;
+use crate::config::{Cluster, Node};
+use crate::home_assistant::EntityStates;
+
+fn node(name: &str, online: bool, containers: &[&str]) -> NodeHealth {
+    NodeHealth {
+        name: name.into(),
+        ib_ip: "10.0.0.1".into(),
+        online,
+        containers: containers.iter().map(|s| (*s).to_string()).collect(),
+        net_up: true,
+        net_details: vec![],
+    }
+}
+
+fn test_cluster(name: &str, node_names: &[&str]) -> Cluster {
+    Cluster {
+        name: name.into(),
+        master_ib_ip: None,
+        nodes: node_names
+            .iter()
+            .enumerate()
+            .map(|(i, n)| Node {
+                name: (*n).to_string(),
+                ssh_ip: format!("10.0.0.{i}"),
+                ib_ip: None,
+                rank: i as u32,
+                host_key: None,
+                entity: None,
+            })
+            .collect(),
+        extra_entities: vec![],
+    }
+}
+
+fn report(cluster: &str, switch_off: bool) -> HealthReport {
+    HealthReport {
+        cluster: cluster.into(),
+        verdict: ClusterVerdict {
+            up: true,
+            switch_off,
+            free: 0,
+        },
+        soft_off: vec![],
+        nodes: vec![],
+        tmux: vec![],
+    }
+}
+
+#[test]
+fn verdict_all_online_counts_free() {
+    let nodes = vec![node("a", true, &[]), node("b", true, &["c1"])];
+    let v = verdict_for(&nodes, false);
+    assert_eq!(
+        v,
+        ClusterVerdict {
+            up: true,
+            switch_off: false,
+            free: 1
+        }
+    );
+}
+
+#[test]
+fn verdict_offline_node_downs_cluster() {
+    let nodes = vec![node("a", true, &["c1"]), node("b", false, &[])];
+    let v = verdict_for(&nodes, false);
+    assert!(!v.up);
+    assert_eq!(v.free, 0);
+}
+
+#[test]
+fn verdict_from_entity_states_switch_off_downs_cluster() {
+    let nodes = vec![node("a", true, &[]), node("b", true, &["c1"])];
+    let es = EntityStates {
+        blocking_off: true,
+        soft_off: vec!["switch.fan".into()],
+    };
+    let v = verdict_for(&nodes, es.blocking_off);
+    assert!(!v.up && v.switch_off && v.free == 0);
+}
+
+#[test]
+fn parse_ps_output_splits_containers_and_net() {
+    let out = "sm121_ds4_0\tUp 2 hours\timg:tag\n\
+               \n\
+               sudo: permission denied\n\
+               docker: error line\n\
+               ---NET---\n\
+               socket:UP\n\
+               ---END---\n";
+    let (containers, net_up, details) = parse_ps_output(out);
+    assert_eq!(containers, vec!["sm121_ds4_0\tUp 2 hours\timg:tag"]);
+    assert!(net_up);
+    assert_eq!(details, vec!["socket:UP"]);
+}
+
+#[test]
+fn parse_ps_output_down_missing_error_flip_net_up() {
+    for tail in ["socket:DOWN", "socket:missing", "socket:error"] {
+        let out = format!("---NET---\n{tail}\n---END---\n");
+        assert!(!parse_ps_output(&out).1, "{tail} must flip net_up");
+    }
+}
+
+#[test]
+fn net_check_script_validates_interface_name() {
+    assert!(net_check_script(Some("eth0")).is_some());
+    assert!(net_check_script(Some("enp2s0.100")).is_some());
+    assert!(net_check_script(Some("ib-weird_1")).is_some());
+    assert!(net_check_script(Some("bad;rm -rf")).is_none());
+    assert!(net_check_script(None).is_none());
+}
+
+#[tokio::test]
+async fn build_summary_excludes_down_clusters_and_sorts_nodes() {
+    let clusters = vec![
+        test_cluster("up", &["n0", "n0b"]),
+        test_cluster("down", &["n1"]),
+    ];
+    // Sweep: both "up" nodes reachable with ds4|up; "down" node unreachable.
+    let sweep: Vec<tokio::task::JoinHandle<(String, anyhow::Result<String>)>> = vec![
+        tokio::spawn(async { ("n0".into(), Ok("ds4|up".into())) }),
+        tokio::spawn(async { ("n0b".into(), Ok("ds4|up".into())) }),
+        tokio::spawn(async { ("n1".into(), Err(anyhow::anyhow!("ssh down"))) }),
+    ];
+    let reports = vec![report("up", false), report("down", false)];
+    let summary: RecipesSummary = build_summary(&clusters, &reports, sweep).await;
+    assert_eq!(
+        summary.by_recipe["ds4"]["up"],
+        vec!["n0".to_string(), "n0b".to_string()]
+    );
+    assert!(!summary.by_recipe["ds4"].contains_key("down"));
+}

+ 5 - 8
src/main.rs

@@ -420,17 +420,14 @@ async fn handle_health(
             Some(ref name) => {
                 let cluster = select_cluster_by_name(config, name)?;
                 docker::health::health_check(config, &cluster, false).await?;
-                let tmux_entries = tmux::discover::tmux_health_for_cluster(config, &cluster).await;
-                tmux::discover::print_tmux_health(&tmux_entries);
             }
             None => {
-                for cluster in &config.clusters {
-                    docker::health::health_check(config, cluster, false).await?;
-                    let tmux_entries =
-                        tmux::discover::tmux_health_for_cluster(config, cluster).await;
-                    tmux::discover::print_tmux_health(&tmux_entries);
+                let fleet = docker::health::collect(config, None, false).await?;
+                for report in &fleet.clusters {
+                    docker::health::print_health(report);
+                    tmux::discover::print_tmux_health(&report.tmux);
                 }
-                docker::health::health_summary(config).await?;
+                docker::health::health_summary(&fleet);
             }
         }
     }