1use crate::domain::*;
7use crate::ports::{DifferError, InfrastructureDiffer};
8use crate::{FirewallManager, HetznerClient, LoadBalancerManager, NetworkManager, ServerManager};
9use async_trait::async_trait;
10use tracing::{debug, info};
11
12pub struct HetznerDiffer {
17 server_manager: ServerManager,
18 network_manager: NetworkManager,
19 firewall_manager: FirewallManager,
20 loadbalancer_manager: LoadBalancerManager,
21}
22
23impl HetznerDiffer {
24 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 let actual_servers = self
44 .server_manager
45 .list_servers()
46 .await
47 .map_err(|e| DifferError::ConnectionError(e.to_string()))?;
48
49 for spec in desired {
51 if let Some(existing) = actual_servers.iter().find(|s| s.name == spec.name()) {
52 let mut changes = Vec::new();
54
55 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 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 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 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 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 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 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 let mut changes = Vec::new();
152
153 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 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 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 let mut changes = Vec::new();
220
221 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 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 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 debug!("Diffing network");
333 all_diffs.extend(self.diff_network(&spec.network).await?);
334
335 debug!("Diffing firewall");
337 all_diffs.extend(self.diff_firewall(&spec.firewall).await?);
338
339 debug!("Diffing servers");
341 all_diffs.extend(self.diff_servers(&spec.servers).await?);
342
343 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}