ends.rs 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  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. }
  53. impl HaPowerController {
  54. pub fn new(ha: HaClient) -> Self {
  55. Self { ha }
  56. }
  57. }
  58. impl PowerController for HaPowerController {
  59. async fn entity_is_on(&self, entity: &str) -> Option<bool> {
  60. self.ha.get_state(entity).await.map(|s| s == "on")
  61. }
  62. async fn turn_on(&self, entity: &str) -> Result<()> {
  63. self.ha.turn_on(entity).await
  64. }
  65. }
  66. pub struct SshNodePinger;
  67. impl NodePinger for SshNodePinger {
  68. async fn is_reachable(&self, config: &Config, node: &Node) -> bool {
  69. run_ssh(config, node, "echo ok".into(), false).await.is_ok()
  70. }
  71. }
  72. /// Build a [`HaPowerController`] from config, returning `None` if HA is
  73. /// not configured.
  74. pub fn ha_controller_from_config(config: &Config) -> Option<HaPowerController> {
  75. let ha_cfg = config.home_assistant.as_ref()?;
  76. let ha = match HaClient::new(ha_cfg) {
  77. Ok(c) => c,
  78. Err(e) => {
  79. warn!("Failed to construct HA client: {}", e);
  80. return None;
  81. }
  82. };
  83. warn_if_token_looks_short(&ha_cfg.token);
  84. Some(HaPowerController::new(ha))
  85. }
  86. /// Collect all entity IDs (per-node + cluster extras) for a cluster.
  87. pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
  88. let mut entities: Vec<String> = Vec::new();
  89. for node in &cluster.nodes {
  90. if let Some(entity) = &node.entity {
  91. entities.push(entity.clone());
  92. }
  93. }
  94. entities.extend(cluster.extra_entities.iter().cloned());
  95. entities
  96. }