|
|
@@ -2,7 +2,6 @@
|
|
|
//! deployments across all clusters.
|
|
|
|
|
|
use crate::config::{Cluster, Config};
|
|
|
-use crate::home_assistant::Client as HaClient;
|
|
|
use crate::ssh::run_ssh;
|
|
|
use anyhow::Result;
|
|
|
use std::collections::HashMap;
|
|
|
@@ -19,28 +18,22 @@ struct NodeHealth {
|
|
|
}
|
|
|
|
|
|
/// 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 ha_handle = if let Some(ref ha_cfg) = config.home_assistant {
|
|
|
- let ha = HaClient::new(ha_cfg).ok();
|
|
|
- let entities = cluster.extra_entities.clone();
|
|
|
- Some(crate::locale::spawn_with_locale(async move {
|
|
|
- let client = ha.as_ref()?;
|
|
|
- for e in &entities {
|
|
|
- match client.get_state(e).await {
|
|
|
- Some(ref s) if s == "on" => continue,
|
|
|
- _ => return Some(false),
|
|
|
- }
|
|
|
- }
|
|
|
- Some(true)
|
|
|
- }))
|
|
|
- } else {
|
|
|
- None
|
|
|
- };
|
|
|
+ let config_ha = config.clone();
|
|
|
+ let cluster_ha = cluster.clone();
|
|
|
+ let ha_handle = crate::locale::spawn_with_locale(async move {
|
|
|
+ crate::home_assistant::extra_entities_off(&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| {
|
|
|
@@ -143,41 +136,61 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Resolve HA result.
|
|
|
- let ha_ok = match ha_handle {
|
|
|
- Some(h) => h.await.unwrap_or(Some(true)).unwrap_or(true),
|
|
|
- None => true,
|
|
|
- };
|
|
|
-
|
|
|
+ // Resolve HA result + cluster verdict.
|
|
|
+ let switch_off = ha_handle.await.unwrap_or(false);
|
|
|
let total = nodes.len();
|
|
|
let online = node_health.iter().filter(|n| n.online).count();
|
|
|
let offline = total - online;
|
|
|
- let container_count: usize = node_health.iter().map(|n| n.containers.len()).sum();
|
|
|
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 !ha_ok && !cluster.extra_entities.is_empty() {
|
|
|
- print!(" {}", fluent_i18n::t!("health-switch-off"));
|
|
|
- } else if !net_ok {
|
|
|
- print!(" {}", fluent_i18n::t!("health-interface-down"));
|
|
|
- } else if online == total {
|
|
|
- print!(" {}", fluent_i18n::t!("health-all-online"));
|
|
|
+ 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 })
|
|
|
+ );
|
|
|
+ }
|
|
|
+ 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-partial-online", { "online" => online, "offline" => offline })
|
|
|
+ " {}",
|
|
|
+ fluent_i18n::t!("health-free-count", { "free" => v.free })
|
|
|
);
|
|
|
}
|
|
|
- if container_count > 0 {
|
|
|
- println!(
|
|
|
- "{}",
|
|
|
- fluent_i18n::t!("health-container-count", { "count" => container_count })
|
|
|
- );
|
|
|
- } else {
|
|
|
- print!("{}", fluent_i18n::t!("health-no-containers-inline"));
|
|
|
+ // 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 {
|
|
|
@@ -193,6 +206,15 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
|
|
|
} 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!(
|
|
|
"{}",
|
|
|
@@ -212,6 +234,12 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
|
|
|
}
|
|
|
|
|
|
/// 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();
|
|
|
@@ -228,17 +256,48 @@ pub async fn health_summary(config: &Config) -> Result<()> {
|
|
|
--format '{{.Label \"sm121.recipe\"}}|{{.Label \"sm121.cluster\"}}'"
|
|
|
.to_string();
|
|
|
handles.push(crate::locale::spawn_with_locale(async move {
|
|
|
- let out = run_ssh(&config, &node, cmd, false)
|
|
|
- .await
|
|
|
- .unwrap_or_default();
|
|
|
+ // Ok/Err doubles as the node's reachability signal.
|
|
|
+ let out = run_ssh(&config, &node, cmd, false).await;
|
|
|
(name, out)
|
|
|
}));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- let mut recipe_nodes: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
|
|
|
+ // 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_off(&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) = h.unwrap_or_default();
|
|
|
+ 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() {
|
|
|
@@ -252,24 +311,50 @@ pub async fn health_summary(config: &Config) -> Result<()> {
|
|
|
recipe_nodes
|
|
|
.entry(recipe)
|
|
|
.or_default()
|
|
|
+ .entry(parts.next().unwrap_or("").to_string())
|
|
|
+ .or_default()
|
|
|
.insert(node_name.clone());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- if recipe_nodes.is_empty() {
|
|
|
+ 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(false)) }),
|
|
|
+ )
|
|
|
+ .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 (recipe, nodes) in &recipe_nodes {
|
|
|
- let mut sorted: Vec<_> = nodes.iter().collect();
|
|
|
- sorted.sort();
|
|
|
- let node_list: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
|
|
|
- println!(
|
|
|
- "{}",
|
|
|
- fluent_i18n::t!("health-recipe-line", { "recipe" => recipe, "nodes" => node_list.len(), "list" => &node_list.join(", ") })
|
|
|
- );
|
|
|
+ for line in &lines {
|
|
|
+ println!("{line}");
|
|
|
}
|
|
|
Ok(())
|
|
|
}
|