1use 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#[derive(Clone)]
31pub struct OvhFloatingIp {
32 http: reqwest::Client,
33 consumer_key: String,
34 base_url: String,
35}
36
37impl OvhFloatingIp {
38 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 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 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 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 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
144fn 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
159fn 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 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 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"); 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"); 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}