Skip to main content

floating_ip_adapters/
ovh.rs

1//! [`OvhFloatingIp`] — OVH Additional IPs (R594-F5).
2//!
3//! OVH Additional IPs move via API within a **datacentre/country region**
4//! (the eu-west GRA/RBX/SBG trio has cross-DC flexibility *within* that
5//! region) — verified 2026-07, W267 §Tier 1. [`ovh_datacenter_region`] maps
6//! OVH datacenter codes onto the coarse region buckets W267 names; extend
7//! it as more OVH datacenters are onboarded.
8//!
9//! **No OVH driver exists anywhere in this codebase today** (OVH boxes in
10//! `.yah/infra/machines/` are brought up over SSH as `provider = "static"`
11//! — see `us-east-001.toml` / `us-west-001.toml`). This client is
12//! purpose-built for exactly the floating-IP endpoints this adapter needs,
13//! modeled on OVH's publicly documented `dedicated/server` + `ip` API families
14//! (`ipMove` / IP routing lookup). **Auth is a placeholder**: OVH's real API
15//! uses an application key + secret + consumer key with a timestamped HMAC
16//! signature, not a bearer token — the `consumer_key` field below is sent as a
17//! raw header so the *shape* of the adapter is right. Swap in real OVH
18//! request-signing before any live use. Live verification against OVH's actual
19//! API is explicitly deferred to the operator (same as every other cloud envoy
20//! adapter).
21
22use anyhow::{bail, Context, Result};
23use async_trait::async_trait;
24use floating_ip::{FloatingIpMachine, FloatingIpProvider, FloatingIpState, FloatingIpTarget};
25use serde::Deserialize;
26
27const OVH_BASE: &str = "https://api.ovh.com/1.0";
28
29/// OVH Additional-IP client. See module docs re: the auth placeholder.
30#[derive(Clone)]
31pub struct OvhFloatingIp {
32    http: reqwest::Client,
33    consumer_key: String,
34    base_url: String,
35}
36
37impl OvhFloatingIp {
38    /// Build against `api.ovh.com`.
39    pub fn new(consumer_key: impl Into<String>) -> Self {
40        Self {
41            http: reqwest::Client::new(),
42            consumer_key: consumer_key.into(),
43            base_url: OVH_BASE.to_string(),
44        }
45    }
46
47    /// Override the base URL — used by the in-process axum mocks below.
48    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
49        self.base_url = url.into();
50        self
51    }
52}
53
54#[async_trait]
55impl FloatingIpProvider for OvhFloatingIp {
56    fn id(&self) -> &'static str {
57        "ovh"
58    }
59
60    /// Assumes the OVH `serviceName` equals `machine.name` (the same
61    /// name-is-the-key convention the Hetzner adapter uses for its
62    /// `GET /servers?name=` lookup) — `GET /dedicated/server/{serviceName}`
63    /// confirms the box exists at OVH before anything is attempted.
64    async fn resolve_target(&self, machine: &FloatingIpMachine) -> Result<FloatingIpTarget> {
65        let zone = ovh_zone_for(machine)?;
66        let resp = self
67            .http
68            .get(format!(
69                "{}/dedicated/server/{}",
70                self.base_url, machine.name
71            ))
72            .header("X-Ovh-Consumer", &self.consumer_key)
73            .send()
74            .await
75            .context("ovh: GET /dedicated/server/{serviceName}")?;
76        let status = resp.status();
77        if status == reqwest::StatusCode::NOT_FOUND {
78            bail!("ovh: no dedicated server named {:?}", machine.name);
79        }
80        if !status.is_success() {
81            let body = resp.text().await.unwrap_or_default();
82            bail!(
83                "ovh GET /dedicated/server/{} failed: {status} {body}",
84                machine.name
85            );
86        }
87        Ok(FloatingIpTarget {
88            attach_id: machine.name.clone(),
89            zone,
90        })
91    }
92
93    /// `GET /ip/{ip}` — modeled on OVH's IP-service + routing info; see
94    /// module docs re: exact endpoint shape needing live verification.
95    async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState> {
96        let resp = self
97            .http
98            .get(format!("{}/ip/{}", self.base_url, ip_id))
99            .header("X-Ovh-Consumer", &self.consumer_key)
100            .send()
101            .await
102            .context("ovh: GET /ip/{ip}")?;
103        let status = resp.status();
104        if !status.is_success() {
105            let body = resp.text().await.unwrap_or_default();
106            bail!("ovh GET /ip/{ip_id} failed: {status} {body}");
107        }
108        let parsed: OvhIpInfo = resp
109            .json()
110            .await
111            .context("ovh: decode GET /ip/{ip} response")?;
112        Ok(FloatingIpState {
113            zone: ovh_datacenter_region(&parsed.datacenter)?.to_string(),
114            attached_to: parsed.routed_to.filter(|s| !s.is_empty()),
115        })
116    }
117
118    /// `POST /dedicated/server/{serviceName}/ipMove` — moves `ip_id` onto
119    /// the dedicated server named `target.attach_id`.
120    async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
121        let resp = self
122            .http
123            .post(format!(
124                "{}/dedicated/server/{}/ipMove",
125                self.base_url, target.attach_id
126            ))
127            .header("X-Ovh-Consumer", &self.consumer_key)
128            .json(&serde_json::json!({ "ip": ip_id }))
129            .send()
130            .await
131            .context("ovh: POST /dedicated/server/{serviceName}/ipMove")?;
132        let status = resp.status();
133        if !status.is_success() {
134            let body = resp.text().await.unwrap_or_default();
135            bail!(
136                "ovh POST /dedicated/server/{}/ipMove failed: {status} {body}",
137                target.attach_id
138            );
139        }
140        Ok(())
141    }
142}
143
144/// Pure conversion: OVH datacenter code → coarse mobility region. The
145/// eu-west trio (GRA/RBX/SBG) is called out explicitly in W267 as having
146/// cross-DC flexibility within the region; extend as more OVH DCs are
147/// onboarded.
148fn ovh_datacenter_region(dc: &str) -> Result<&'static str> {
149    match dc.to_ascii_lowercase().as_str() {
150        "gra" | "rbx" | "sbg" => Ok("eu-west"),
151        "bhs" => Ok("ca-east"),
152        "waw" => Ok("eu-central-pl"),
153        "syd" => Ok("au-east"),
154        "sgp" => Ok("ap-southeast"),
155        other => bail!("ovh: unknown datacenter {other:?}, cannot derive mobility region"),
156    }
157}
158
159/// `location` is provisioning-only and OVH has no auto-provision driver in this
160/// codebase (`cloud::config::provider_has_machine_driver` doesn't list
161/// `"ovh"`), so today's OVH machines declare `region` (the coarser,
162/// provider-neutral geo label) but not `location`. Prefer the precise
163/// datacenter-derived region when `location` happens to carry an OVH DC code
164/// (future onboarding may start setting it informationally); fall back to the
165/// already-coarse `region` field otherwise — our region labels (`"us-east"`,
166/// `"eu-west"`, …) are deliberately at the same granularity OVH's own zone
167/// concept needs.
168fn ovh_zone_for(machine: &FloatingIpMachine) -> Result<String> {
169    if let Some(dc) = machine.location.as_deref().filter(|s| !s.is_empty()) {
170        return Ok(ovh_datacenter_region(dc)?.to_string());
171    }
172    machine.region.clone().with_context(|| {
173        format!(
174            "ovh: machine {:?} has neither `location` nor `region` set — cannot derive its mobility zone",
175            machine.name
176        )
177    })
178}
179
180#[derive(Deserialize)]
181struct OvhIpInfo {
182    datacenter: String,
183    #[serde(rename = "routedTo", default)]
184    routed_to: Option<String>,
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use floating_ip::on_ingress_owner_changed;
191    use std::sync::atomic::{AtomicU32, Ordering};
192    use std::sync::{Arc, Mutex};
193
194    fn gra_machine(name: &str) -> FloatingIpMachine {
195        FloatingIpMachine {
196            name: name.into(),
197            provider: "ovh".into(),
198            location: None,
199            region: Some("eu-west".into()),
200            ingress_floating_ip: None,
201        }
202    }
203
204    /// Spin up an in-process axum server standing in for OVH's API. See
205    /// `hetzner.rs`'s `spawn_mock` for the pattern this mirrors.
206    async fn spawn_mock(
207        datacenter: &'static str,
208        initial_routed_to: Option<String>,
209    ) -> (String, Arc<AtomicU32>, tokio::task::JoinHandle<()>) {
210        let routed_to: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(initial_routed_to));
211        let move_calls = Arc::new(AtomicU32::new(0));
212
213        let server_exists_route =
214            axum::routing::get(|| async { axum::Json(serde_json::json!({ "datacenter": "gra" })) });
215
216        let ip_get_route = {
217            let routed_to = routed_to.clone();
218            axum::routing::get(move || {
219                let routed_to = routed_to.clone();
220                async move {
221                    let current = routed_to.lock().unwrap().clone();
222                    axum::Json(serde_json::json!({
223                        "datacenter": datacenter,
224                        "routedTo": current,
225                    }))
226                }
227            })
228        };
229
230        let ip_move_route = {
231            let routed_to = routed_to.clone();
232            let calls = move_calls.clone();
233            axum::routing::post(
234                move |axum::extract::Path(service_name): axum::extract::Path<String>,
235                      axum::Json(_body): axum::Json<serde_json::Value>| {
236                    let routed_to = routed_to.clone();
237                    let calls = calls.clone();
238                    async move {
239                        calls.fetch_add(1, Ordering::SeqCst);
240                        *routed_to.lock().unwrap() = Some(service_name);
241                        axum::Json(serde_json::json!({}))
242                    }
243                },
244            )
245        };
246
247        let app = axum::Router::new()
248            .route("/dedicated/server/{service_name}", server_exists_route)
249            .route("/ip/{ip}", ip_get_route)
250            .route("/dedicated/server/{service_name}/ipMove", ip_move_route);
251
252        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
253        let addr = listener.local_addr().unwrap();
254        let handle = tokio::spawn(async move {
255            let _ = axum::serve(listener, app).await;
256        });
257
258        (format!("http://{addr}"), move_calls, handle)
259    }
260
261    #[tokio::test]
262    async fn ingress_owner_flip_drives_exactly_one_reassign_call() {
263        let (base, calls, handle) = spawn_mock("gra", Some("old-server".into())).await;
264        let client = OvhFloatingIp::new("test-consumer-key").with_base_url(base);
265        let machine = gra_machine("edge-b");
266
267        let outcome = on_ingress_owner_changed(&client, &machine, "51.81.85.200")
268            .await
269            .unwrap();
270        assert!(outcome.reassigned, "owner flip must drive a reassign");
271        assert_eq!(outcome.attached_to, "edge-b");
272        assert_eq!(calls.load(Ordering::SeqCst), 1);
273
274        handle.abort();
275    }
276
277    #[tokio::test]
278    async fn reapplying_the_same_owner_is_a_zero_call_noop() {
279        let (base, calls, handle) = spawn_mock("gra", Some("edge-b".into())).await;
280        let client = OvhFloatingIp::new("test-consumer-key").with_base_url(base);
281        let machine = gra_machine("edge-b");
282
283        let outcome = on_ingress_owner_changed(&client, &machine, "51.81.85.200")
284            .await
285            .unwrap();
286        assert!(
287            !outcome.reassigned,
288            "re-applying the same owner must be a no-op"
289        );
290        assert_eq!(calls.load(Ordering::SeqCst), 0, "must not call ipMove");
291
292        handle.abort();
293    }
294
295    #[tokio::test]
296    async fn cross_region_target_is_rejected_before_any_reassign_call() {
297        // IP is homed to bhs (ca-east); target machine is eu-west.
298        let (base, calls, handle) = spawn_mock("bhs", None).await;
299        let client = OvhFloatingIp::new("test-consumer-key").with_base_url(base);
300        let machine = gra_machine("edge-b"); // region = eu-west
301
302        let err = on_ingress_owner_changed(&client, &machine, "51.81.85.200")
303            .await
304            .unwrap_err();
305        let msg = format!("{err:#}");
306        assert!(
307            msg.contains("zone"),
308            "expected a zone-mismatch error, got: {msg}"
309        );
310        assert_eq!(
311            calls.load(Ordering::SeqCst),
312            0,
313            "region mismatch must never call ipMove"
314        );
315
316        handle.abort();
317    }
318
319    #[test]
320    fn ovh_datacenter_region_maps_eu_west_trio() {
321        assert_eq!(ovh_datacenter_region("gra").unwrap(), "eu-west");
322        assert_eq!(ovh_datacenter_region("rbx").unwrap(), "eu-west");
323        assert_eq!(ovh_datacenter_region("sbg").unwrap(), "eu-west");
324        assert_eq!(ovh_datacenter_region("bhs").unwrap(), "ca-east");
325    }
326
327    #[test]
328    fn ovh_datacenter_region_rejects_unknown() {
329        assert!(ovh_datacenter_region("xyz").is_err());
330    }
331
332    #[test]
333    fn ovh_zone_for_prefers_location_over_region() {
334        let mut m = gra_machine("edge-b");
335        m.location = Some("bhs".into());
336        assert_eq!(ovh_zone_for(&m).unwrap(), "ca-east");
337    }
338
339    #[test]
340    fn ovh_zone_for_falls_back_to_region() {
341        let m = gra_machine("edge-b"); // location: None, region: eu-west
342        assert_eq!(ovh_zone_for(&m).unwrap(), "eu-west");
343    }
344
345    #[test]
346    fn ovh_zone_for_errors_with_neither_field() {
347        let mut m = gra_machine("edge-b");
348        m.region = None;
349        assert!(ovh_zone_for(&m).is_err());
350    }
351}