Skip to main content

cloud/provider/
ovh_floating_ip.rs

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