| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- //! `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<String> {
- 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 <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.entity()).collect();
- let disable_extras: Vec<String> = 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<Vec<Node>> {
- let targets: Vec<Node> = if node_names.is_empty() {
- cluster.nodes.clone()
- } else {
- let mut found: Vec<Node> = Vec::new();
- let mut missing: Vec<String> = 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;
|