Skip to main content

cloud/provider/
hetzner_floating_ip.rs

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