Преглед на файлове

feat: add 'power switch --from <src> --to <tgt>' command

Switches between clusters: shuts down nodes + HA turn_off entities
unique to the source cluster, then HA turn_on all nodes and entities
in the target cluster.

Usage:
  sm121-launcher power switch --from 4 --to 2        # prefix matching works
  sm121-launcher -d power switch --from 4 --to 2     # dry-run

Logic:
  1. Nodes in  but NOT in  (by name) → SSH sudo shutdown now
  2. Wait  (default 30s)
  3. HA turn_off entities for those nodes + extra_entities unique to
  4. HA turn_on all node entities + extra_entities in
Alexander Korolev преди 4 месеца
родител
ревизия
fc645f8d70
променени са 3 файла, в които са добавени 137 реда и са изтрити 0 реда
  1. 9 0
      src/cli.rs
  2. 5 0
      src/main.rs
  3. 123 0
      src/power.rs

+ 9 - 0
src/cli.rs

@@ -142,6 +142,15 @@ pub enum PowerAction {
         #[arg(long)]
         node: Option<String>,
     },
+    /// Switch clusters: disable nodes from source, enable nodes in target.
+    Switch {
+        /// Current cluster (nodes + entities to shut down).
+        #[arg(long)]
+        from: String,
+        /// Target cluster (nodes + entities to wake up).
+        #[arg(long)]
+        to: String,
+    },
 }
 
 #[derive(Subcommand)]

+ 5 - 0
src/main.rs

@@ -158,6 +158,11 @@ async fn main() -> Result<()> {
                 let cluster = select_cluster_by_name(&config, &cluster)?;
                 power::power_on(&config, &cluster, node.as_deref(), dry_run).await?;
             }
+            cli::PowerAction::Switch { from, to } => {
+                let from = select_cluster_by_name(&config, &from)?;
+                let to = select_cluster_by_name(&config, &to)?;
+                power::power_switch(&config, &from, &to, dry_run).await?;
+            }
         },
     }
 

+ 123 - 0
src/power.rs

@@ -183,6 +183,129 @@ async fn call(ha: &HaClient, entity: &str, service: &'static str) -> Result<()>
     }
 }
 
+/// Flow for `power switch --from <src> --to <tgt>`:
+/// 1. Shut down + HA turn_off nodes/entities unique to `from`,
+/// 2. HA turn_on all nodes/entities in `to`.
+pub async fn power_switch(
+    config: &Config,
+    from: &Cluster,
+    to: &Cluster,
+    dry_run: bool,
+) -> Result<()> {
+    let to_names: std::collections::HashSet<&str> =
+        to.nodes.iter().map(|n| n.name.as_str()).collect();
+    let disable_nodes: Vec<Node> = from
+        .nodes
+        .iter()
+        .filter(|n| !to_names.contains(n.name.as_str()))
+        .cloned()
+        .collect();
+    let to_extras: std::collections::HashSet<&str> =
+        to.extra_entities.iter().map(|e| e.as_str()).collect();
+    let disable_extras: Vec<&String> = from
+        .extra_entities
+        .iter()
+        .filter(|e| !to_extras.contains(e.as_str()))
+        .collect();
+
+    info!(
+        "🔌 switch: {} → {} ({} node(s) + {} entity(ies) to disable)",
+        from.name,
+        to.name,
+        disable_nodes.len(),
+        disable_extras.len()
+    );
+
+    if dry_run {
+        for node in &disable_nodes {
+            println!(
+                "would: ssh {}@{} sudo shutdown now",
+                config.ssh_user, node.ssh_ip
+            );
+            if let Some(entity) = &node.entity {
+                println!("would: HA turn_off {}", entity);
+            }
+        }
+        for extra in &disable_extras {
+            println!("would: HA turn_off {} (from extras)", extra);
+        }
+        for node in &to.nodes {
+            if let Some(entity) = &node.entity {
+                println!("would: HA turn_on {}", entity);
+            }
+        }
+        for extra in &to.extra_entities {
+            println!("would: HA turn_on {} (to extras)", extra);
+        }
+        return Ok(());
+    }
+
+    // SSH shutdown nodes not in target
+    let mut handles = Vec::with_capacity(disable_nodes.len());
+    for node in disable_nodes.iter().cloned() {
+        let c = config.clone();
+        handles.push(tokio::spawn(async move {
+            (
+                node.name.clone(),
+                crate::ssh::run_ssh(&c, &node, "sudo shutdown now".into(), false).await,
+            )
+        }));
+    }
+    for h in handles {
+        let (name, r) = h.await?;
+        if let Err(e) = r {
+            warn!("✗ {} shutdown: {}", name, e);
+        }
+    }
+
+    let delay = config
+        .home_assistant
+        .as_ref()
+        .map(|c| c.shutdown_delay_seconds)
+        .unwrap_or(30);
+    if !disable_nodes.is_empty() {
+        info!("⏳ waiting {}s for shutdown", delay);
+        tokio::time::sleep(Duration::from_secs(delay)).await;
+    }
+
+    if let Some(ha_cfg) = &config.home_assistant {
+        let ha = HaClient::new(ha_cfg)?;
+        crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
+
+        for node in &disable_nodes {
+            if let Some(entity) = &node.entity {
+                if let Err(e) = split_entity(entity) {
+                    warn!("{}: entity '{}': {}", node.name, entity, e);
+                    continue;
+                }
+                if dry_run {
+                    println!("would: HA turn_off {}", entity);
+                } else {
+                    let _ = ha.turn_off(entity).await;
+                }
+            }
+        }
+        for extra in &disable_extras {
+            if dry_run {
+                println!("would: HA turn_off {} (from extras)", extra);
+            } else {
+                let _ = ha.turn_off(extra).await;
+            }
+        }
+
+        info!(
+            "💡 switch: powering on {} node(s) + {} entity(ies) to {}",
+            to.nodes.len(),
+            to.extra_entities.len(),
+            to.name
+        );
+        toggle_entities(&ha, "turn_on", &to.nodes, &to.extra_entities, "to").await;
+    } else {
+        info!("no [home_assistant] config; OS shutdown was performed");
+    }
+    Ok(())
+}
+
 fn select_targets(cluster: &Cluster, node_name: Option<&str>, op: &str) -> Result<Vec<Node>> {
     let targets: Vec<Node> = match node_name {
         Some(name) => cluster