lmrc_hetzner/
differ.rs

1//! Hetzner Cloud infrastructure differ
2//!
3//! This module implements the InfrastructureDiffer port for Hetzner Cloud,
4//! comparing desired state with actual state to determine changes needed.
5
6use crate::domain::*;
7use crate::ports::{DifferError, InfrastructureDiffer};
8use crate::{FirewallManager, HetznerClient, LoadBalancerManager, NetworkManager, ServerManager};
9use async_trait::async_trait;
10use tracing::{debug, info};
11
12/// Hetzner infrastructure differ adapter
13///
14/// Compares desired infrastructure specification with actual Hetzner Cloud state
15/// to determine what changes need to be made.
16pub struct HetznerDiffer {
17    server_manager: ServerManager,
18    network_manager: NetworkManager,
19    firewall_manager: FirewallManager,
20    loadbalancer_manager: LoadBalancerManager,
21}
22
23impl HetznerDiffer {
24    /// Create a new Hetzner differ with the given API token
25    pub fn new(api_token: String) -> Result<Self, DifferError> {
26        let client = HetznerClient::builder()
27            .api_token(api_token)
28            .build()
29            .map_err(|e| DifferError::ConnectionError(e.to_string()))?;
30
31        Ok(Self {
32            server_manager: ServerManager::new(client.clone()),
33            network_manager: NetworkManager::new(client.clone()),
34            firewall_manager: FirewallManager::new(client.clone()),
35            loadbalancer_manager: LoadBalancerManager::new(client),
36        })
37    }
38
39    async fn diff_servers(&self, desired: &[ServerSpec]) -> Result<Vec<DiffItem>, DifferError> {
40        let mut diffs = Vec::new();
41
42        // Get actual servers
43        let actual_servers = self
44            .server_manager
45            .list_servers()
46            .await
47            .map_err(|e| DifferError::ConnectionError(e.to_string()))?;
48
49        // Check desired servers
50        for spec in desired {
51            if let Some(existing) = actual_servers.iter().find(|s| s.name == spec.name()) {
52                // Server exists - check if configuration matches
53                let mut changes = Vec::new();
54
55                // Check server type (size)
56                if existing.server_type.name != spec.size() {
57                    changes.push(format!(
58                        "Server type: {} → {}",
59                        existing.server_type.name,
60                        spec.size()
61                    ));
62                }
63
64                // Check location
65                if existing.datacenter.location.name != spec.location() {
66                    changes.push(format!(
67                        "Location: {} → {}",
68                        existing.datacenter.location.name,
69                        spec.location()
70                    ));
71                }
72
73                // Check image (if present)
74                if let Some(ref img) = existing.image
75                    && img.description != spec.image()
76                {
77                    changes.push(format!("Image: {} → {}", img.description, spec.image()));
78                }
79
80                // Check labels
81                let desired_role = spec.server_type().to_string();
82                if existing.labels.get("role").map(|s| s.as_str()) != Some(desired_role.as_str()) {
83                    changes.push(format!(
84                        "Role label: {:?} → {}",
85                        existing.labels.get("role"),
86                        desired_role
87                    ));
88                }
89
90                if changes.is_empty() {
91                    diffs.push(
92                        DiffItem::new(
93                            "server".to_string(),
94                            spec.name().to_string(),
95                            DiffType::NoChange,
96                        )
97                        .with_details(vec!["Server matches desired state".to_string()]),
98                    );
99                } else {
100                    diffs.push(
101                        DiffItem::new(
102                            "server".to_string(),
103                            spec.name().to_string(),
104                            DiffType::Update,
105                        )
106                        .with_details(changes),
107                    );
108                }
109            } else {
110                // Server needs to be created
111                diffs.push(
112                    DiffItem::new(
113                        "server".to_string(),
114                        spec.name().to_string(),
115                        DiffType::Create,
116                    )
117                    .with_details(vec![format!(
118                        "Create {} server at {}",
119                        spec.size(),
120                        spec.location()
121                    )]),
122                );
123            }
124        }
125
126        // Check for servers to delete
127        let desired_names: Vec<&str> = desired.iter().map(|s| s.name()).collect();
128        for actual in &actual_servers {
129            if !desired_names.contains(&actual.name.as_str()) {
130                diffs.push(
131                    DiffItem::new("server".to_string(), actual.name.clone(), DiffType::Delete)
132                        .with_details(vec!["Server not in desired state".to_string()]),
133                );
134            }
135        }
136
137        Ok(diffs)
138    }
139
140    async fn diff_network(&self, desired: &NetworkSpec) -> Result<Vec<DiffItem>, DifferError> {
141        let mut diffs = Vec::new();
142
143        // Get actual network
144        if let Some(existing) = self
145            .network_manager
146            .get_network_by_name(desired.name())
147            .await
148            .map_err(|e| DifferError::ConnectionError(e.to_string()))?
149        {
150            // Network exists - check if configuration matches
151            let mut changes = Vec::new();
152
153            // Check IP range
154            if existing.ip_range != desired.ip_range() {
155                changes.push(format!(
156                    "IP range: {} → {} (requires recreation)",
157                    existing.ip_range,
158                    desired.ip_range()
159                ));
160            }
161
162            // Check subnets
163            let actual_subnet_count = existing.subnets.len();
164            let desired_subnet_count = desired.subnets().len();
165            if actual_subnet_count != desired_subnet_count {
166                changes.push(format!(
167                    "Subnets: {} → {}",
168                    actual_subnet_count, desired_subnet_count
169                ));
170            }
171
172            if changes.is_empty() {
173                diffs.push(
174                    DiffItem::new(
175                        "network".to_string(),
176                        desired.name().to_string(),
177                        DiffType::NoChange,
178                    )
179                    .with_details(vec!["Network matches desired state".to_string()]),
180                );
181            } else {
182                diffs.push(
183                    DiffItem::new(
184                        "network".to_string(),
185                        desired.name().to_string(),
186                        DiffType::Update,
187                    )
188                    .with_details(changes),
189                );
190            }
191        } else {
192            diffs.push(
193                DiffItem::new(
194                    "network".to_string(),
195                    desired.name().to_string(),
196                    DiffType::Create,
197                )
198                .with_details(vec![format!(
199                    "Create network with range {}",
200                    desired.ip_range()
201                )]),
202            );
203        }
204
205        Ok(diffs)
206    }
207
208    async fn diff_firewall(&self, desired: &FirewallSpec) -> Result<Vec<DiffItem>, DifferError> {
209        let mut diffs = Vec::new();
210
211        // Get actual firewall
212        if let Some(existing) = self
213            .firewall_manager
214            .get_firewall_by_name(desired.name())
215            .await
216            .map_err(|e| DifferError::ConnectionError(e.to_string()))?
217        {
218            // Firewall exists - check if rules match
219            let mut changes = Vec::new();
220
221            // Check rule count
222            if existing.rules.len() != desired.rules().len() {
223                changes.push(format!(
224                    "Rules count: {} → {}",
225                    existing.rules.len(),
226                    desired.rules().len()
227                ));
228            }
229
230            // Deep comparison of rules (simplified - just check count and protocols)
231            for (i, desired_rule) in desired.rules().iter().enumerate() {
232                if let Some(actual_rule) = existing.rules.get(i) {
233                    if actual_rule.protocol != desired_rule.protocol {
234                        changes.push(format!(
235                            "Rule {} protocol: {} → {}",
236                            i, actual_rule.protocol, desired_rule.protocol
237                        ));
238                    }
239                    if actual_rule.direction != desired_rule.direction {
240                        changes.push(format!(
241                            "Rule {} direction: {} → {}",
242                            i, actual_rule.direction, desired_rule.direction
243                        ));
244                    }
245                }
246            }
247
248            if changes.is_empty() {
249                diffs.push(
250                    DiffItem::new(
251                        "firewall".to_string(),
252                        desired.name().to_string(),
253                        DiffType::NoChange,
254                    )
255                    .with_details(vec!["Firewall matches desired state".to_string()]),
256                );
257            } else {
258                diffs.push(
259                    DiffItem::new(
260                        "firewall".to_string(),
261                        desired.name().to_string(),
262                        DiffType::Update,
263                    )
264                    .with_details(changes),
265                );
266            }
267        } else {
268            diffs.push(
269                DiffItem::new(
270                    "firewall".to_string(),
271                    desired.name().to_string(),
272                    DiffType::Create,
273                )
274                .with_details(vec![format!(
275                    "Create firewall with {} rules",
276                    desired.rules().len()
277                )]),
278            );
279        }
280
281        Ok(diffs)
282    }
283
284    async fn diff_load_balancer(
285        &self,
286        desired: &LoadBalancerSpec,
287    ) -> Result<Vec<DiffItem>, DifferError> {
288        let mut diffs = Vec::new();
289
290        // Get actual load balancer
291        if let Some(_existing) = self
292            .loadbalancer_manager
293            .get_load_balancer_by_name(desired.name())
294            .await
295            .map_err(|e| DifferError::ConnectionError(e.to_string()))?
296        {
297            diffs.push(
298                DiffItem::new(
299                    "loadbalancer".to_string(),
300                    desired.name().to_string(),
301                    DiffType::NoChange,
302                )
303                .with_details(vec!["Load balancer already exists".to_string()]),
304            );
305        } else {
306            diffs.push(
307                DiffItem::new(
308                    "loadbalancer".to_string(),
309                    desired.name().to_string(),
310                    DiffType::Create,
311                )
312                .with_details(vec![format!(
313                    "Create {} load balancer at {}",
314                    desired.load_balancer_type(),
315                    desired.location()
316                )]),
317            );
318        }
319
320        Ok(diffs)
321    }
322}
323
324#[async_trait]
325impl InfrastructureDiffer for HetznerDiffer {
326    async fn diff(&self, spec: &InfrastructureSpec) -> Result<InfrastructureDiff, DifferError> {
327        info!("Computing infrastructure diff");
328
329        let mut all_diffs = Vec::new();
330
331        // Diff network
332        debug!("Diffing network");
333        all_diffs.extend(self.diff_network(&spec.network).await?);
334
335        // Diff firewall
336        debug!("Diffing firewall");
337        all_diffs.extend(self.diff_firewall(&spec.firewall).await?);
338
339        // Diff servers
340        debug!("Diffing servers");
341        all_diffs.extend(self.diff_servers(&spec.servers).await?);
342
343        // Diff load balancer
344        debug!("Diffing load balancer");
345        all_diffs.extend(self.diff_load_balancer(&spec.loadbalancer).await?);
346
347        info!("Diff complete: {} changes detected", all_diffs.len());
348        Ok(InfrastructureDiff::new(all_diffs))
349    }
350}