ends.rs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. //! End-effector traits — abstract operations that touch real hardware
  2. //! (SSH, Home Assistant, Docker). Control logic in [`cluster_state`]
  3. //! and [`orchestrate`] works with these traits so it can be tested
  4. //! without live infrastructure.
  5. use anyhow::Result;
  6. use crate::cluster_state::ClusterState;
  7. use crate::config::{Cluster, Config, Node};
  8. /// Probe nodes and return their aggregate state.
  9. pub trait NodeProber {
  10. /// Probe all unique nodes across the config.
  11. fn probe(&self, config: &Config) -> impl std::future::Future<Output = ClusterState> + Send;
  12. }
  13. /// Power on/off smart plugs via Home Assistant.
  14. ///
  15. /// Intentionally models only the **power-on** path (`entity_is_on` /
  16. /// `turn_on`): orchestrate's `prepare_cluster` auto-powers-on a cluster
  17. /// when nodes are unreachable, and that is the only place the trait is
  18. /// used. `power off` is a direct user command (`power.rs`) that talks to
  19. /// `HaClient` directly — it does not flow through orchestrate, so it has
  20. /// no need of the trait abstraction. If orchestrate ever needs to power
  21. /// off, add `turn_off` here and route `power_off` through it.
  22. pub trait PowerController {
  23. /// Check whether a HA entity is currently in state `"on"`.
  24. fn entity_is_on(&self, entity: &str) -> impl std::future::Future<Output = Option<bool>> + Send;
  25. /// Turn on a HA entity (power on the node/switch).
  26. fn turn_on(&self, entity: &str) -> impl std::future::Future<Output = Result<()>> + Send;
  27. }
  28. /// Ping a node via SSH to test reachability.
  29. pub trait NodePinger {
  30. /// Whether the node accepts SSH connections.
  31. fn is_reachable(
  32. &self,
  33. config: &Config,
  34. node: &Node,
  35. ) -> impl std::future::Future<Output = bool> + Send;
  36. }
  37. // ---------------------------------------------------------------------------
  38. // Real implementations
  39. // ---------------------------------------------------------------------------
  40. use crate::home_assistant::Client as HaClient;
  41. use crate::home_assistant::warn_if_token_looks_short;
  42. use crate::ssh::run_ssh;
  43. use tracing::warn;
  44. pub struct SshProber;
  45. impl NodeProber for SshProber {
  46. async fn probe(&self, config: &Config) -> ClusterState {
  47. ClusterState::probe(config).await
  48. }
  49. }
  50. pub struct HaPowerController {
  51. ha: HaClient,
  52. config: Config,
  53. }
  54. impl HaPowerController {
  55. pub fn new(ha: HaClient, config: Config) -> Self {
  56. Self { ha, config }
  57. }
  58. }
  59. impl PowerController for HaPowerController {
  60. async fn entity_is_on(&self, entity: &str) -> Option<bool> {
  61. // `[mikrotik]` device: liveness through the RouterOS API login
  62. // (logged in = on; unreachable/rejected = off, like a dead switch).
  63. if let Some(dev) = self
  64. .config
  65. .mikrotik
  66. .as_ref()
  67. .and_then(|m| m.devices.iter().find(|d| d.name == entity))
  68. {
  69. return Some(crate::mikrotik::ping_device(dev).await.is_ok());
  70. }
  71. self.ha.get_state(entity).await.map(|s| s == "on")
  72. }
  73. async fn turn_on(&self, entity: &str) -> Result<()> {
  74. // RouterOS has no power-ON API: route through the device's
  75. // `on_entity` HA relay if configured; otherwise the operator plugs
  76. // it in manually (same semantics as `power on`), never an error.
  77. if let Some(dev) = self
  78. .config
  79. .mikrotik
  80. .as_ref()
  81. .and_then(|m| m.devices.iter().find(|d| d.name == entity))
  82. {
  83. match &dev.on_entity {
  84. Some(relay) => self.ha.turn_on(relay).await?,
  85. None => {
  86. warn!("RouterOS {entity} has no on_entity relay — power it on manually");
  87. }
  88. }
  89. return Ok(());
  90. }
  91. self.ha.turn_on(entity).await
  92. }
  93. }
  94. pub struct SshNodePinger;
  95. impl NodePinger for SshNodePinger {
  96. async fn is_reachable(&self, config: &Config, node: &Node) -> bool {
  97. run_ssh(config, node, "echo ok".into(), false).await.is_ok()
  98. }
  99. }
  100. /// Build a [`HaPowerController`] from config, returning `None` if HA is
  101. /// not configured.
  102. pub fn ha_controller_from_config(config: &Config) -> Option<HaPowerController> {
  103. let ha_cfg = config.home_assistant.as_ref()?;
  104. let ha = match HaClient::new(ha_cfg) {
  105. Ok(c) => c,
  106. Err(e) => {
  107. warn!("Failed to construct HA client: {}", e);
  108. return None;
  109. }
  110. };
  111. warn_if_token_looks_short(&ha_cfg.token);
  112. Some(HaPowerController::new(ha, config.clone()))
  113. }
  114. /// Collect all entity IDs (per-node + cluster extras) for a cluster.
  115. pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
  116. let mut entities: Vec<String> = Vec::new();
  117. for node in &cluster.nodes {
  118. if let Some(entity) = &node.entity {
  119. entities.push(entity.clone());
  120. }
  121. }
  122. entities.extend(
  123. cluster
  124. .extra_entities
  125. .iter()
  126. .map(|e| e.entity().to_string()),
  127. );
  128. entities
  129. }