//! `power on` / `power off` subcommands. //! //! Power-off: SSH `sudo shutdown now` to each node → wait //! `[home_assistant].shutdown_delay_seconds` (default 30s) → POST //! `turn_off` to Home Assistant for each per-node `entity` and each //! cluster-level `extra_entities`. //! //! Power-on: just POST `turn_on` to Home Assistant for each per-node //! `entity` and each cluster-level `extra_entities` (the smart plug //! handles waking the machine). use anyhow::Result; use std::time::Duration; use tracing::{info, warn}; use crate::home_assistant::split_entity; use crate::config::{Cluster, Config, Node}; use crate::home_assistant::Client as HaClient; /// Flow for `power off`: SSH shutdown + wait + HA `turn_off`. pub async fn power_off( config: &Config, cluster: &Cluster, node_names: &[String], dry_run: bool, ) -> Result<()> { let targets = select_targets(cluster, node_names, "power off")?; if dry_run { warn!("{}", fluent_i18n::t!("power-off-dryrun")); for node in &targets { println!( "{}", fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() }) ); if let Some(entity) = &node.entity { println!( "{}", fluent_i18n::t!("power-off-entity-would", { "entity" => entity }) ); } } for extra in &cluster.extra_entities { let id = extra.entity().to_string(); println!( "{}", fluent_i18n::t!("power-switch-would-off-extra", { "extra" => &id }) ); } return Ok(()); } // 1) SSH shutdown every target in parallel. // NOTE: This is fire-and-forget — if a node is unreachable, its // SSH failure is logged but the flow continues. The HA `turn_off` // still fires for all targets regardless of SSH success. This is // by design: already-offline nodes get their HA entity toggled off, // which is idempotent. info!( "{}", fluent_i18n::t!("power-off-head", { "nodes" => targets.len() }) ); let mut handles = Vec::with_capacity(targets.len()); for node in targets.iter().cloned() { let config = config.clone(); handles.push(crate::locale::spawn_with_locale(async move { let cmd = "sudo shutdown now".to_string(); let r = crate::ssh::run_ssh(&config, &node, cmd.clone(), false).await; (node, cmd, r) })); } let mut any_ssh_failed = false; for h in handles { let (node, cmd, r) = h.await?; if let Err(e) = r { any_ssh_failed = true; warn!( "{}", fluent_i18n::t!("power-off-shutdown-failed", { "name" => &node.name, "cmd" => &cmd, "error" => e.to_string() }) ); } } // 2) Wait so the OS actually shuts down before we cut power. let delay = config .home_assistant .as_ref() .map(|c| c.shutdown_delay_seconds) .unwrap_or(30); info!( "{}", fluent_i18n::t!("power-off-waiting", { "secs" => delay }) ); tokio::time::sleep(Duration::from_secs(delay)).await; // 3) HA turn_off: per-node entities, then cluster-level extras. 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); toggle_entities(&ha, "turn_off", &targets, &extra_ids(cluster), "cluster").await; } else { info!("{}", fluent_i18n::t!("power-off-no-ha")); } if any_ssh_failed { warn!("{}", fluent_i18n::t!("power-off-ssh-failures")); } Ok(()) } /// Flow for `power on`: HA `turn_on` for each node with an `entity` /// and each cluster-level `extra_entities`. No SSH needed — the smart /// plug handles waking the machine. pub async fn power_on( config: &Config, cluster: &Cluster, node_names: &[String], dry_run: bool, ) -> Result<()> { let targets = select_targets(cluster, node_names, "power on")?; let Some(ha_cfg) = &config.home_assistant else { anyhow::bail!( "[home_assistant] section is required for `power on` \ (the launcher has no other way to wake a node)" ); }; let ha = HaClient::new(ha_cfg)?; crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token); if dry_run { warn!("{}", fluent_i18n::t!("power-on-dryrun")); for node in &targets { if let Some(entity) = &node.entity { println!( "{}", fluent_i18n::t!("power-on-would-on", { "entity" => entity }) ); } else { println!( "{}", fluent_i18n::t!("power-on-would-skip", { "name" => &node.name }) ); } } for extra in &cluster.extra_entities { let id = extra.entity().to_string(); println!( "{}", fluent_i18n::t!("power-on-would-on", { "entity" => &id }) ); } return Ok(()); } info!( "{}", fluent_i18n::t!("power-on-head", { "nodes" => targets.len() }) ); toggle_entities(&ha, "turn_on", &targets, &extra_ids(cluster), "cluster").await; Ok(()) } /// Entity ids of a cluster's `extra_entities` (both forms). fn extra_ids(cluster: &Cluster) -> Vec { cluster .extra_entities .iter() .map(|e| e.entity().to_string()) .collect() } /// Call HA `service` for each per-node entity, then each cluster-level /// `extra_entities`. Validation is the same in both cases (entity IDs /// must be `domain.name`). async fn toggle_entities( ha: &HaClient, service: &'static str, targets: &[Node], extras: &[String], extras_label: &'static str, ) { for node in targets { if let Some(entity) = &node.entity { if let Err(e) = split_entity(entity) { warn!( "{}", fluent_i18n::t!("ha-invalid-entity", { "name" => &node.name, "entity" => entity, "error" => e.to_string() }) ); continue; } if let Err(e) = call(ha, entity, service).await { warn!( "{}", fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity, "error" => e.to_string() }) ); } } else { warn!( "{}", fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name }) ); } } for extra in extras { if let Err(e) = split_entity(extra) { warn!( "{}", fluent_i18n::t!("ha-extra-invalid", { "label" => extras_label, "extra" => extra, "error" => e.to_string() }) ); continue; } if let Err(e) = call(ha, extra, service).await { warn!( "{}", fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => extra, "label" => extras_label, "error" => e.to_string() }) ); } } } /// Dispatch to the right HA service. Inlined because each call only /// differs by `service`, and inlining keeps the trait surface small. async fn call(ha: &HaClient, entity: &str, service: &'static str) -> Result<()> { match service { "turn_off" => ha.turn_off(entity).await, "turn_on" => ha.turn_on(entity).await, other => anyhow::bail!("unsupported HA service '{}'", other), } } /// Flow for `power switch --from --to `: /// 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 = 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.entity()).collect(); let disable_extras: Vec = from .extra_entities .iter() .filter(|e| !to_extras.contains(e.entity())) .map(|e| e.entity().to_string()) .collect(); info!( "{}", fluent_i18n::t!("power-switch-head", { "from" => &from.name, "to" => &to.name, "nodes" => disable_nodes.len(), "entities" => disable_extras.len() }) ); if dry_run { for node in &disable_nodes { println!( "{}", fluent_i18n::t!("power-off-would", { "action" => format!("ssh {}@{} sudo shutdown now", config.ssh_user, node.ssh_ip), "target" => String::new() }) ); if let Some(entity) = &node.entity { println!( "{}", fluent_i18n::t!("power-switch-would-off", { "entity" => entity }) ); } } for extra in &disable_extras { println!( "{}", fluent_i18n::t!("power-switch-would-off-extra", { "extra" => extra }) ); } for node in &to.nodes { if let Some(entity) = &node.entity { println!( "{}", fluent_i18n::t!("power-switch-would-on", { "entity" => entity }) ); } } for extra in &to.extra_entities { let id = extra.entity().to_string(); println!( "{}", fluent_i18n::t!("power-switch-would-on-extra", { "extra" => &id }) ); } 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(crate::locale::spawn_with_locale(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!( "{}", fluent_i18n::t!("power-switch-shutdown-failed", { "name" => &name, "error" => e.to_string() }) ); } } let delay = config .home_assistant .as_ref() .map(|c| c.shutdown_delay_seconds) .unwrap_or(30); if !disable_nodes.is_empty() { info!( "{}", fluent_i18n::t!("power-switch-waiting", { "secs" => delay }) ); tokio::time::sleep(Duration::from_secs(delay)).await; } else { // No nodes to shut down — only extra_entities differ. HA // turn_off / turn_on runs immediately without delay. } 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); // Disable phase: per-node entities + extras unique to `from`. // Routed through `toggle_entities` (which validates + logs failures) // instead of ad-hoc loops with `let _ =` (which swallowed errors) // and dead `if dry_run` branches (dry-run returned at the top). toggle_entities(&ha, "turn_off", &disable_nodes, &disable_extras, "from").await; info!( "{}", fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name }) ); toggle_entities(&ha, "turn_on", &to.nodes, &extra_ids(to), "to").await; } else { info!("{}", fluent_i18n::t!("power-switch-no-ha")); } Ok(()) } fn select_targets(cluster: &Cluster, node_names: &[String], op: &str) -> Result> { let targets: Vec = if node_names.is_empty() { cluster.nodes.clone() } else { let mut found: Vec = Vec::new(); let mut missing: Vec = Vec::new(); for nn in node_names { if let Some(node) = cluster.nodes.iter().find(|n| &n.name == nn) { found.push(node.clone()); } else { missing.push(nn.clone()); } } if !missing.is_empty() { anyhow::bail!( "{}: node(s) {} not found in cluster '{}'", op, missing.join(", "), cluster.name ); } found }; if targets.is_empty() { anyhow::bail!("{}: cluster '{}' has no nodes", op, cluster.name); } Ok(targets) } #[cfg(test)] mod tests;