Skip to main content

floating_ip_adapters/
hetzner.rs

1//! [`HetznerFloatingIp`] — Hetzner Cloud floating IPs (R594-F5).
2//!
3//! Hetzner floating IPs reassign via API within a **network zone** (+ same
4//! project — a single envoy is already scoped to one Hetzner project by
5//! convention, mirroring `cloud`'s `HetznerEnvoy`) — verified 2026-07,
6//! W267 §Tier 1. [`hetzner_network_zone`] maps Hetzner's Cloud API location
7//! codes onto the three network zones Hetzner documents (`eu-central`,
8//! `us-east`, `us-west`); extend it as new locations open.
9//!
10//! A thin, purpose-built client scoped to exactly the floating-IP endpoints
11//! this adapter needs (`GET /servers`, `GET /floating_ips/{id}`,
12//! `POST /floating_ips/{id}/actions/assign`) rather than a reuse of `cloud`'s
13//! `HetznerDriver` (which is VPS + S3 bucket lifecycle-scoped) or of the shared
14//! `yah-hetzner` client. That separation is now structural rather than a
15//! judgement call: R859-F3 moved this crate below `cloud`, and `yah-hetzner`
16//! sits above it.
17
18use anyhow::{bail, Context, Result};
19use async_trait::async_trait;
20use floating_ip::{FloatingIpMachine, FloatingIpProvider, FloatingIpState, FloatingIpTarget};
21use serde::Deserialize;
22
23const HETZNER_BASE: &str = "https://api.hetzner.cloud/v1";
24
25/// Hetzner Cloud floating-IP client. Bearer-token auth, same as `cloud`'s
26/// `HetznerDriver` Cloud API calls.
27#[derive(Clone)]
28pub struct HetznerFloatingIp {
29    http: reqwest::Client,
30    token: String,
31    base_url: String,
32}
33
34impl HetznerFloatingIp {
35    /// Build against `api.hetzner.cloud`.
36    pub fn new(token: impl Into<String>) -> Self {
37        Self {
38            http: reqwest::Client::new(),
39            token: token.into(),
40            base_url: HETZNER_BASE.to_string(),
41        }
42    }
43
44    /// Override the base URL — used by the in-process axum mocks below;
45    /// production callers shouldn't need this.
46    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
47        self.base_url = url.into();
48        self
49    }
50}
51
52#[async_trait]
53impl FloatingIpProvider for HetznerFloatingIp {
54    fn id(&self) -> &'static str {
55        "hetzner"
56    }
57
58    /// `GET /servers?name=<machine.name>` — same name→id lookup convention
59    /// `cloud`'s `HetznerEnvoy` driver already relies on (cloud-init sets the
60    /// Hetzner server's `name` to `MachineConfig.name`).
61    async fn resolve_target(&self, machine: &FloatingIpMachine) -> Result<FloatingIpTarget> {
62        let zone = hetzner_network_zone(machine.location()).with_context(|| {
63            format!(
64                "floating_ip: resolving target for machine {:?}",
65                machine.name
66            )
67        })?;
68        let resp = self
69            .http
70            .get(format!("{}/servers", self.base_url))
71            .query(&[("name", machine.name.as_str())])
72            .bearer_auth(&self.token)
73            .send()
74            .await
75            .context("hetzner: GET /servers")?;
76        let status = resp.status();
77        if !status.is_success() {
78            let body = resp.text().await.unwrap_or_default();
79            bail!("hetzner GET /servers failed: {status} {body}");
80        }
81        let parsed: HetznerServersResponse = resp
82            .json()
83            .await
84            .context("hetzner: decode GET /servers response")?;
85        let server = parsed
86            .servers
87            .into_iter()
88            .find(|s| s.name.as_deref() == Some(machine.name.as_str()))
89            .with_context(|| format!("hetzner: no server named {:?}", machine.name))?;
90        Ok(FloatingIpTarget {
91            attach_id: server.id.to_string(),
92            zone: zone.to_string(),
93        })
94    }
95
96    /// `GET /floating_ips/{id}`.
97    async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState> {
98        let resp = self
99            .http
100            .get(format!("{}/floating_ips/{}", self.base_url, ip_id))
101            .bearer_auth(&self.token)
102            .send()
103            .await
104            .context("hetzner: GET /floating_ips/{id}")?;
105        let status = resp.status();
106        if !status.is_success() {
107            let body = resp.text().await.unwrap_or_default();
108            bail!("hetzner GET /floating_ips/{ip_id} failed: {status} {body}");
109        }
110        let parsed: HetznerFloatingIpResponse = resp
111            .json()
112            .await
113            .context("hetzner: decode GET /floating_ips/{id} response")?;
114        Ok(FloatingIpState {
115            zone: parsed.floating_ip.home_location.network_zone,
116            attached_to: parsed.floating_ip.server.map(|id| id.to_string()),
117        })
118    }
119
120    /// `POST /floating_ips/{id}/actions/assign`.
121    async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
122        let server_id: u64 = target.attach_id.parse().with_context(|| {
123            format!(
124                "hetzner: attach_id {:?} is not a numeric server id",
125                target.attach_id
126            )
127        })?;
128        let resp = self
129            .http
130            .post(format!(
131                "{}/floating_ips/{}/actions/assign",
132                self.base_url, ip_id
133            ))
134            .bearer_auth(&self.token)
135            .json(&serde_json::json!({ "server": server_id }))
136            .send()
137            .await
138            .context("hetzner: POST /floating_ips/{id}/actions/assign")?;
139        let status = resp.status();
140        if !status.is_success() {
141            let body = resp.text().await.unwrap_or_default();
142            bail!("hetzner POST /floating_ips/{ip_id}/actions/assign failed: {status} {body}");
143        }
144        Ok(())
145    }
146}
147
148/// Pure conversion: Hetzner Cloud API location code → Hetzner network
149/// zone. Hetzner documents three network zones today (`eu-central`,
150/// `us-east`, `us-west`); extend this table as Hetzner opens new
151/// locations/zones.
152fn hetzner_network_zone(location: &str) -> Result<&'static str> {
153    match location {
154        "hil" => Ok("us-west"),
155        "ash" => Ok("us-east"),
156        "fsn1" | "nbg1" | "hel1" => Ok("eu-central"),
157        "sin" => Ok("ap-southeast"),
158        "" => bail!("hetzner: machine has no `location` set — required to derive its network zone"),
159        other => bail!("hetzner: unknown location {other:?}, cannot derive network zone"),
160    }
161}
162
163#[derive(Deserialize)]
164struct HetznerServersResponse {
165    servers: Vec<HetznerServerLite>,
166}
167
168#[derive(Deserialize)]
169struct HetznerServerLite {
170    id: u64,
171    #[serde(default)]
172    name: Option<String>,
173}
174
175#[derive(Deserialize)]
176struct HetznerFloatingIpResponse {
177    floating_ip: HetznerFloatingIpBody,
178}
179
180#[derive(Deserialize)]
181struct HetznerFloatingIpBody {
182    home_location: HetznerHomeLocation,
183    server: Option<u64>,
184}
185
186#[derive(Deserialize)]
187struct HetznerHomeLocation {
188    network_zone: String,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use floating_ip::on_ingress_owner_changed;
195    use std::sync::atomic::{AtomicU32, Ordering};
196    use std::sync::{Arc, Mutex};
197
198    fn hil_machine(name: &str) -> FloatingIpMachine {
199        FloatingIpMachine {
200            name: name.into(),
201            provider: "hetzner".into(),
202            location: Some("hil".into()),
203            region: Some("us-west".into()),
204            ingress_floating_ip: None,
205        }
206    }
207
208    /// Spin up an in-process axum server standing in for Hetzner's Cloud
209    /// API (same convention as `cloud`'s `reconciler/pond.rs` /
210    /// `reconciler/static_asset.rs` tests: bind 127.0.0.1:0, serve
211    /// canned/stateful JSON, point the client's `with_base_url` at it).
212    /// `attached` is the floating IP's mutable current-server state so a test
213    /// can drive two calls (flip, then re-apply) against one mock and observe
214    /// the call count.
215    async fn spawn_mock(
216        network_zone: &'static str,
217        initial_server: Option<u64>,
218    ) -> (String, Arc<AtomicU32>, tokio::task::JoinHandle<()>) {
219        let attached = Arc::new(Mutex::new(initial_server));
220        let assign_calls = Arc::new(AtomicU32::new(0));
221
222        let servers_route = {
223            axum::routing::get(move || async move {
224                axum::Json(serde_json::json!({ "servers": [ { "id": 555, "name": "edge-a" } ] }))
225            })
226        };
227
228        let floating_ip_get = {
229            let attached = attached.clone();
230            axum::routing::get(move || {
231                let attached = attached.clone();
232                async move {
233                    let server = *attached.lock().unwrap();
234                    axum::Json(serde_json::json!({
235                        "floating_ip": {
236                            "home_location": { "network_zone": network_zone },
237                            "server": server,
238                        }
239                    }))
240                }
241            })
242        };
243
244        let assign_route = {
245            let attached = attached.clone();
246            let calls = assign_calls.clone();
247            axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
248                let attached = attached.clone();
249                let calls = calls.clone();
250                async move {
251                    calls.fetch_add(1, Ordering::SeqCst);
252                    let server = body.get("server").and_then(|v| v.as_u64());
253                    *attached.lock().unwrap() = server;
254                    axum::Json(serde_json::json!({ "action": { "status": "success" } }))
255                }
256            })
257        };
258
259        let app = axum::Router::new()
260            .route("/servers", servers_route)
261            .route("/floating_ips/{id}", floating_ip_get)
262            .route("/floating_ips/{id}/actions/assign", assign_route);
263
264        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
265        let addr = listener.local_addr().unwrap();
266        let handle = tokio::spawn(async move {
267            let _ = axum::serve(listener, app).await;
268        });
269
270        (format!("http://{addr}"), assign_calls, handle)
271    }
272
273    #[tokio::test]
274    async fn ingress_owner_flip_drives_exactly_one_reassign_call() {
275        // Floating IP currently attached to server 999 ("old" node);
276        // resolving the new owner ("edge-a") always yields server 555 in
277        // this mock — a real flip.
278        let (base, calls, handle) = spawn_mock("us-west", Some(999)).await;
279        let client = HetznerFloatingIp::new("test-token").with_base_url(base);
280        let machine = hil_machine("edge-a");
281
282        let outcome = on_ingress_owner_changed(&client, &machine, "42")
283            .await
284            .unwrap();
285        assert!(outcome.reassigned, "owner flip must drive a reassign");
286        assert_eq!(outcome.attached_to, "555");
287        assert_eq!(calls.load(Ordering::SeqCst), 1);
288
289        handle.abort();
290    }
291
292    #[tokio::test]
293    async fn reapplying_the_same_owner_is_a_zero_call_noop() {
294        // Floating IP already attached to server 555 == the resolved target.
295        let (base, calls, handle) = spawn_mock("us-west", Some(555)).await;
296        let client = HetznerFloatingIp::new("test-token").with_base_url(base);
297        let machine = hil_machine("edge-a");
298
299        let outcome = on_ingress_owner_changed(&client, &machine, "42")
300            .await
301            .unwrap();
302        assert!(
303            !outcome.reassigned,
304            "re-applying the same owner must be a no-op"
305        );
306        assert_eq!(
307            calls.load(Ordering::SeqCst),
308            0,
309            "must not call the reassign endpoint"
310        );
311
312        handle.abort();
313    }
314
315    #[tokio::test]
316    async fn cross_zone_target_is_rejected_before_any_reassign_call() {
317        // Floating IP is homed to eu-central; the target machine is us-west.
318        let (base, calls, handle) = spawn_mock("eu-central", None).await;
319        let client = HetznerFloatingIp::new("test-token").with_base_url(base);
320        let machine = hil_machine("edge-a"); // us-west location
321
322        let err = on_ingress_owner_changed(&client, &machine, "42")
323            .await
324            .unwrap_err();
325        let msg = format!("{err:#}");
326        assert!(
327            msg.contains("zone"),
328            "expected a zone-mismatch error, got: {msg}"
329        );
330        assert_eq!(
331            calls.load(Ordering::SeqCst),
332            0,
333            "zone mismatch must never call reassign"
334        );
335
336        handle.abort();
337    }
338
339    #[test]
340    fn hetzner_network_zone_maps_known_locations() {
341        assert_eq!(hetzner_network_zone("hil").unwrap(), "us-west");
342        assert_eq!(hetzner_network_zone("ash").unwrap(), "us-east");
343        assert_eq!(hetzner_network_zone("fsn1").unwrap(), "eu-central");
344        assert_eq!(hetzner_network_zone("nbg1").unwrap(), "eu-central");
345        assert_eq!(hetzner_network_zone("hel1").unwrap(), "eu-central");
346    }
347
348    #[test]
349    fn hetzner_network_zone_rejects_unknown_or_missing() {
350        assert!(hetzner_network_zone("mars1").is_err());
351        assert!(hetzner_network_zone("").is_err());
352    }
353}