Browse Source

feat: health checks HA cluster entities (switch) to detect switched-off clusters

- Add get_state() to HA client for reading entity state
- health_check queries extra_entities via HA in parallel with SSH
- Shows 🚫 switch off when cluster switch entity is not 'on'
- 4nodes-switch correctly reports switch off, 2nodes-dac all online
Alexander Korolev 4 months ago
parent
commit
0c19edf62d
2 changed files with 50 additions and 2 deletions
  1. 32 2
      src/docker/health.rs
  2. 18 0
      src/home_assistant.rs

+ 32 - 2
src/docker/health.rs

@@ -2,6 +2,7 @@
 //! 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;
@@ -18,8 +19,28 @@ struct NodeHealth {
 pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
     let nodes = &cluster.nodes;
     let cluster_label = &cluster.name;
+
+    // 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(tokio::spawn(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
+    };
+
+    // SSH into each node in parallel.
     let mut handles = Vec::new();
-    for node in nodes {
+    for node in &cluster.nodes {
         let config = config.clone();
         let node = node.clone();
         let name = node.name.clone();
@@ -35,6 +56,7 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
         }));
     }
 
+    // Collect SSH node results.
     let mut node_health: Vec<NodeHealth> = Vec::new();
     for h in futures::future::join_all(handles).await {
         match h {
@@ -67,13 +89,21 @@ 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,
+    };
+
     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();
 
     print!("=== {} === {} nodes", cluster_label, total);
-    if online == total {
+    if !ha_ok && !cluster.extra_entities.is_empty() {
+        print!(" 🚫 switch off");
+    } else if online == total {
         print!(" ✅ all online");
     } else {
         print!(" ({} online, {} offline)", online, offline);

+ 18 - 0
src/home_assistant.rs

@@ -99,6 +99,24 @@ impl Client {
         info!("HA turn_on {}", entity_id);
         self.call_service(entity_id, "turn_on").await
     }
+
+    /// GET `/api/states/<entity_id>` and return the state string.
+    /// Returns `None` if the entity doesn't exist or isn't reachable.
+    pub async fn get_state(&self, entity_id: &str) -> Option<String> {
+        let url = format!("{}/api/states/{}", self.base_url, entity_id);
+        let resp = self
+            .http
+            .get(&url)
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .ok()?;
+        if !resp.status().is_success() {
+            return None;
+        }
+        let body: serde_json::Value = resp.json().await.ok()?;
+        body.get("state").and_then(|s| s.as_str()).map(String::from)
+    }
 }
 
 /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build