//! `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, MikrotikConfig, Node}; use crate::home_assistant::Client as HaClient; mod mikro; /// 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")); mikro::dry_off(config, &targets, &extra_ids(cluster)); 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. // Mikrotik names are skipped here — they get a graceful RouterOS // API shutdown in step 4 (after HA, so the router dies last). let mikrotik_off: Vec = 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", config.mikrotik.as_ref(), &targets, &extra_ids(cluster), "cluster", ) .await } else { info!("{}", fluent_i18n::t!("power-off-no-ha")); // No HA at all — still shut down any mikrotik device referenced. mikro::collect_off(config, &targets, &extra_ids(cluster)) }; // 4) RouterOS graceful shutdown for each referenced device. mikro::shutdown_named(config, &mikrotik_off).await; 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")); mikro::dry_on(config, &targets, &extra_ids(cluster)); return Ok(()); } info!( "{}", fluent_i18n::t!("power-on-head", { "nodes" => targets.len() }) ); let _ = toggle_entities( &ha, "turn_on", config.mikrotik.as_ref(), &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() } /// Handle one entity id for a power toggle: dispatch `[mikrotik]` /// devices to the RouterOS API path (or their HA relay), everything else /// to HA with `domain.name` validation. async fn toggle_one( ha: &HaClient, service: &'static str, mikrotik: Option<&MikrotikConfig>, entity: &str, name_for_log: &str, mikrotik_off: &mut Vec, ) { // Mikrotik devices are addressed by exact name — dispatch before any // HA validation (a router name could otherwise parse as a fictional // `mikrotik.` HA entity). if let Some(dev) = mikro::device(mikrotik, entity) { if service == "turn_off" { if !mikrotik_off.iter().any(|n| n == &dev.name) { mikrotik_off.push(dev.name.clone()); } info!( "{}", fluent_i18n::t!("power-off-mikrotik-skip", { "entity" => entity.to_string(), "address" => dev.address.clone() }) ); return; } if let Some(relay) = &dev.on_entity { if let Err(e) = call(ha, relay, service).await { warn!( "{}", fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => relay.clone(), "label" => name_for_log.to_string(), "error" => e.to_string() }) ); } } else { warn!( "{}", fluent_i18n::t!("power-on-mikrotik-manual", { "name" => dev.name.clone() }) ); } return; } if let Err(e) = split_entity(entity) { warn!( "{}", fluent_i18n::t!("ha-invalid-entity", { "name" => name_for_log.to_string(), "entity" => entity.to_string(), "error" => e.to_string() }) ); return; } if let Err(e) = call(ha, entity, service).await { warn!( "{}", fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity.to_string(), "error" => e.to_string() }) ); } } /// Call HA `service` for each per-node entity, then each cluster-level /// `extra_entities`. Entities that name a `[mikrotik]` device are NOT /// sent to HA: for `turn_off` their device names are returned (the caller /// does the RouterOS API shutdown afterwards); for `turn_on` an optional /// `on_entity` relay is toggled in HA instead, or a manual-power warning /// is logged. async fn toggle_entities( ha: &HaClient, service: &'static str, mikrotik: Option<&MikrotikConfig>, targets: &[Node], extras: &[String], extras_label: &'static str, ) -> Vec { let mut mikrotik_off = Vec::new(); for node in targets { if let Some(entity) = &node.entity { toggle_one(ha, service, mikrotik, entity, &node.name, &mut mikrotik_off).await; } else { warn!( "{}", fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name }) ); } } for extra in extras { toggle_one( ha, service, mikrotik, extra, extras_label, &mut mikrotik_off, ) .await; } mikrotik_off } /// 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 { mikro::dry_switch_off(config, &disable_nodes, &disable_extras); mikro::dry_switch_on(config, &to.nodes, &extra_ids(to)); 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). let mikrotik_off = toggle_entities( &ha, "turn_off", config.mikrotik.as_ref(), &disable_nodes, &disable_extras, "from", ) .await; // RouterOS devices named above: graceful API shutdown (after HA). mikro::shutdown_named(config, &mikrotik_off).await; info!( "{}", fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name }) ); let _ = toggle_entities( &ha, "turn_on", config.mikrotik.as_ref(), &to.nodes, &extra_ids(to), "to", ) .await; } else { info!("{}", fluent_i18n::t!("power-switch-no-ha")); // No HA, but mikrotik devices named in `from` still get the // graceful API shutdown. mikro::shutdown_named( config, &mikro::collect_off(config, &disable_nodes, &disable_extras), ) .await; } 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;