ends.rs 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  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. pub trait PowerController {
  15. /// Check whether a HA entity is currently in state `"on"`.
  16. fn entity_is_on(&self, entity: &str) -> impl std::future::Future<Output = Option<bool>> + Send;
  17. /// Turn on a HA entity (power on the node/switch).
  18. fn turn_on(&self, entity: &str) -> impl std::future::Future<Output = Result<()>> + Send;
  19. }
  20. /// Ping a node via SSH to test reachability.
  21. pub trait NodePinger {
  22. /// Whether the node accepts SSH connections.
  23. fn is_reachable(
  24. &self,
  25. config: &Config,
  26. node: &Node,
  27. ) -> impl std::future::Future<Output = bool> + Send;
  28. }
  29. // ---------------------------------------------------------------------------
  30. // Real implementations
  31. // ---------------------------------------------------------------------------
  32. use crate::home_assistant::Client as HaClient;
  33. use crate::home_assistant::warn_if_token_looks_short;
  34. use crate::ssh::run_ssh;
  35. use tracing::warn;
  36. pub struct SshProber;
  37. impl NodeProber for SshProber {
  38. async fn probe(&self, config: &Config) -> ClusterState {
  39. ClusterState::probe(config).await
  40. }
  41. }
  42. pub struct HaPowerController {
  43. ha: HaClient,
  44. }
  45. impl HaPowerController {
  46. pub fn new(ha: HaClient) -> Self {
  47. Self { ha }
  48. }
  49. }
  50. impl PowerController for HaPowerController {
  51. async fn entity_is_on(&self, entity: &str) -> Option<bool> {
  52. self.ha.get_state(entity).await.map(|s| s == "on")
  53. }
  54. async fn turn_on(&self, entity: &str) -> Result<()> {
  55. self.ha.turn_on(entity).await
  56. }
  57. }
  58. pub struct SshNodePinger;
  59. impl NodePinger for SshNodePinger {
  60. async fn is_reachable(&self, config: &Config, node: &Node) -> bool {
  61. run_ssh(config, node, "echo ok".into(), false).await.is_ok()
  62. }
  63. }
  64. /// Build a [`HaPowerController`] from config, returning `None` if HA is
  65. /// not configured.
  66. pub fn ha_controller_from_config(config: &Config) -> Option<HaPowerController> {
  67. let ha_cfg = config.home_assistant.as_ref()?;
  68. let ha = match HaClient::new(ha_cfg) {
  69. Ok(c) => c,
  70. Err(e) => {
  71. warn!("Failed to construct HA client: {}", e);
  72. return None;
  73. }
  74. };
  75. warn_if_token_looks_short(&ha_cfg.token);
  76. Some(HaPowerController::new(ha))
  77. }
  78. /// Collect all entity IDs (per-node + cluster extras) for a cluster.
  79. pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
  80. let mut entities: Vec<String> = Vec::new();
  81. for node in &cluster.nodes {
  82. if let Some(entity) = &node.entity {
  83. entities.push(entity.clone());
  84. }
  85. }
  86. entities.extend(cluster.extra_entities.iter().cloned());
  87. entities
  88. }