|
@@ -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(())
|
|
|
|
|
-}
|
|
|