Skip to main content

purple_ssh/providers/
ovh.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2
3use serde::Deserialize;
4use sha1::{Digest, Sha1};
5
6use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
7
8/// OVH API endpoints. Users pick from these in the region picker.
9pub const OVH_ENDPOINTS: &[(&str, &str)] = &[
10    ("eu", "Europe (eu.api.ovh.com)"),
11    ("ca", "Canada (ca.api.ovh.com)"),
12    ("us", "US (api.us.ovhcloud.com)"),
13];
14
15pub const OVH_ENDPOINT_GROUPS: &[(&str, usize, usize)] = &[("API Endpoint", 0, 3)];
16
17pub struct Ovh {
18    pub project: String,
19    pub endpoint: String,
20}
21
22fn endpoint_url(endpoint: &str) -> &'static str {
23    match endpoint {
24        "ca" => "https://ca.api.ovh.com/1.0",
25        "us" => "https://api.us.ovhcloud.com/1.0",
26        _ => "https://eu.api.ovh.com/1.0",
27    }
28}
29
30#[derive(Deserialize)]
31struct OvhInstance {
32    id: String,
33    name: String,
34    status: String,
35    #[serde(default)]
36    region: String,
37    #[serde(rename = "ipAddresses", default)]
38    ip_addresses: Vec<OvhIpAddress>,
39    #[serde(default)]
40    flavor: Option<OvhFlavor>,
41    #[serde(default)]
42    image: Option<OvhImage>,
43}
44
45#[derive(Deserialize)]
46struct OvhIpAddress {
47    ip: String,
48    #[serde(rename = "type")]
49    ip_type: String,
50    version: u8,
51}
52
53#[derive(Deserialize)]
54struct OvhFlavor {
55    #[serde(default)]
56    name: String,
57}
58
59#[derive(Deserialize)]
60struct OvhImage {
61    #[serde(default)]
62    name: Option<String>,
63}
64
65/// Parse "app_key:app_secret:consumer_key" token format.
66fn parse_token(token: &str) -> Result<(&str, &str, &str), ProviderError> {
67    let parts: Vec<&str> = token.splitn(3, ':').collect();
68    if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
69        return Err(ProviderError::AuthFailed);
70    }
71    Ok((parts[0], parts[1], parts[2]))
72}
73
74/// Compute OVH API signature.
75/// Format: "$1$" + SHA1(app_secret + "+" + consumer_key + "+" + METHOD + "+" + url + "+" + body + "+" + timestamp)
76fn sign_request(
77    app_secret: &str,
78    consumer_key: &str,
79    method: &str,
80    url: &str,
81    body: &str,
82    timestamp: u64,
83) -> String {
84    let pre_hash = format!(
85        "{}+{}+{}+{}+{}+{}",
86        app_secret, consumer_key, method, url, body, timestamp
87    );
88    let mut hasher = Sha1::new();
89    hasher.update(pre_hash.as_bytes());
90    let hash = hasher.finalize();
91    format!("$1${}", hex_encode(&hash))
92}
93
94fn hex_encode(bytes: &[u8]) -> String {
95    bytes.iter().map(|b| format!("{:02x}", b)).collect()
96}
97
98/// Select best IP: public IPv4 > public IPv6 > private IPv4.
99fn select_ip(addresses: &[OvhIpAddress]) -> Option<String> {
100    addresses
101        .iter()
102        .find(|a| a.ip_type == "public" && a.version == 4)
103        .or_else(|| {
104            addresses
105                .iter()
106                .find(|a| a.ip_type == "public" && a.version == 6)
107        })
108        .or_else(|| {
109            addresses
110                .iter()
111                .find(|a| a.ip_type == "private" && a.version == 4)
112        })
113        .map(|a| super::strip_cidr(&a.ip).to_string())
114}
115
116impl Provider for Ovh {
117    fn name(&self) -> &str {
118        "ovh"
119    }
120
121    fn short_label(&self) -> &str {
122        "ovh"
123    }
124
125    fn fetch_hosts_cancellable(
126        &self,
127        token: &str,
128        cancel: &AtomicBool,
129    ) -> Result<Vec<ProviderHost>, ProviderError> {
130        let (app_key, app_secret, consumer_key) = parse_token(token)?;
131        let agent = super::http_agent();
132        let base = endpoint_url(&self.endpoint);
133
134        if self.project.is_empty() {
135            return Err(ProviderError::Execute(
136                "OVH project ID is required. Set it in the provider config.".to_string(),
137            ));
138        }
139
140        if cancel.load(Ordering::Relaxed) {
141            return Err(ProviderError::Cancelled);
142        }
143
144        // Step 1: Get server time
145        let time_url = format!("{}/auth/time", base);
146        let server_time: u64 = agent
147            .get(&time_url)
148            .call()
149            .map_err(map_ureq_error)?
150            .body_mut()
151            .read_json()
152            .map_err(|e| ProviderError::Parse(e.to_string()))?;
153
154        if cancel.load(Ordering::Relaxed) {
155            return Err(ProviderError::Cancelled);
156        }
157
158        let instances_url = format!(
159            "{}/cloud/project/{}/instance",
160            base,
161            super::percent_encode(&self.project)
162        );
163
164        let signature = sign_request(
165            app_secret,
166            consumer_key,
167            "GET",
168            &instances_url,
169            "",
170            server_time,
171        );
172
173        let instances: Vec<OvhInstance> = agent
174            .get(&instances_url)
175            .header("X-Ovh-Application", app_key)
176            .header("X-Ovh-Timestamp", &server_time.to_string())
177            .header("X-Ovh-Consumer", consumer_key)
178            .header("X-Ovh-Signature", &signature)
179            .header("Content-Type", "application/json;charset=utf-8")
180            .call()
181            .map_err(map_ureq_error)?
182            .body_mut()
183            .read_json()
184            .map_err(|e| ProviderError::Parse(e.to_string()))?;
185
186        let mut hosts = Vec::with_capacity(instances.len());
187        for instance in &instances {
188            if let Some(ip) = select_ip(&instance.ip_addresses) {
189                let mut metadata = Vec::with_capacity(4);
190                if !instance.region.is_empty() {
191                    metadata.push(("region".to_string(), instance.region.clone()));
192                }
193                if let Some(ref flavor) = instance.flavor {
194                    if !flavor.name.is_empty() {
195                        metadata.push(("type".to_string(), flavor.name.clone()));
196                    }
197                }
198                if let Some(ref image) = instance.image {
199                    if let Some(ref name) = image.name {
200                        if !name.is_empty() {
201                            metadata.push(("image".to_string(), name.clone()));
202                        }
203                    }
204                }
205                if !instance.status.is_empty() {
206                    metadata.push(("status".to_string(), instance.status.clone()));
207                }
208                hosts.push(ProviderHost {
209                    server_id: instance.id.clone(),
210                    name: instance.name.clone(),
211                    ip,
212                    tags: Vec::new(),
213                    metadata,
214                });
215            }
216        }
217
218        Ok(hosts)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_parse_token_valid() {
228        let (ak, as_, ck) = parse_token("app-key:app-secret:consumer-key").unwrap();
229        assert_eq!(ak, "app-key");
230        assert_eq!(as_, "app-secret");
231        assert_eq!(ck, "consumer-key");
232    }
233
234    #[test]
235    fn test_parse_token_missing_part() {
236        assert!(parse_token("key:secret").is_err());
237    }
238
239    #[test]
240    fn test_parse_token_empty_part() {
241        assert!(parse_token("key::consumer").is_err());
242        assert!(parse_token(":secret:consumer").is_err());
243    }
244
245    #[test]
246    fn test_parse_token_colon_in_consumer_key() {
247        let (ak, as_, ck) = parse_token("key:secret:consumer:with:colons").unwrap();
248        assert_eq!(ak, "key");
249        assert_eq!(as_, "secret");
250        assert_eq!(ck, "consumer:with:colons");
251    }
252
253    #[test]
254    fn test_sign_request_format() {
255        let sig = sign_request(
256            "EgWIz07P0HYwtQDs",
257            "MtSwSrPpNjqfVSmJhLbPyr2i45lSwPU1",
258            "GET",
259            "https://eu.api.ovh.com/1.0/cloud/project/abc/instance",
260            "",
261            1366560945,
262        );
263        assert!(sig.starts_with("$1$"), "signature must start with $1$");
264        assert_eq!(sig.len(), 3 + 40, "should be $1$ + 40 hex chars");
265        assert!(sig[3..].chars().all(|c| c.is_ascii_hexdigit()));
266    }
267
268    #[test]
269    fn test_sign_request_deterministic() {
270        let sig1 = sign_request("s", "c", "GET", "https://example.com", "", 12345);
271        let sig2 = sign_request("s", "c", "GET", "https://example.com", "", 12345);
272        assert_eq!(sig1, sig2);
273    }
274
275    #[test]
276    fn test_sign_request_different_timestamps() {
277        let sig1 = sign_request("s", "c", "GET", "https://example.com", "", 1);
278        let sig2 = sign_request("s", "c", "GET", "https://example.com", "", 2);
279        assert_ne!(sig1, sig2);
280    }
281
282    #[test]
283    fn test_hex_encode() {
284        assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
285        assert_eq!(hex_encode(&[0x00, 0xff]), "00ff");
286    }
287
288    #[test]
289    fn test_parse_instance_response() {
290        let json = r#"[
291            {
292                "id": "uuid-123",
293                "name": "web-1",
294                "status": "ACTIVE",
295                "region": "GRA11",
296                "ipAddresses": [
297                    {"ip": "1.2.3.4", "type": "public", "version": 4},
298                    {"ip": "10.0.0.1", "type": "private", "version": 4}
299                ],
300                "flavor": {"name": "b2-7"},
301                "image": {"name": "Ubuntu 22.04"}
302            }
303        ]"#;
304        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
305        assert_eq!(instances.len(), 1);
306        assert_eq!(instances[0].id, "uuid-123");
307        assert_eq!(instances[0].name, "web-1");
308        assert_eq!(instances[0].status, "ACTIVE");
309        assert_eq!(instances[0].region, "GRA11");
310        assert_eq!(instances[0].ip_addresses.len(), 2);
311        assert_eq!(instances[0].flavor.as_ref().unwrap().name, "b2-7");
312        assert_eq!(
313            instances[0].image.as_ref().unwrap().name.as_deref(),
314            Some("Ubuntu 22.04")
315        );
316    }
317
318    #[test]
319    fn test_parse_instance_minimal_fields() {
320        let json = r#"[{"id": "x", "name": "y", "status": "BUILD"}]"#;
321        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
322        assert_eq!(instances.len(), 1);
323        assert!(instances[0].ip_addresses.is_empty());
324        assert!(instances[0].flavor.is_none());
325        assert!(instances[0].image.is_none());
326    }
327
328    #[test]
329    fn test_select_ip_prefers_public_ipv4() {
330        let addrs = vec![
331            OvhIpAddress {
332                ip: "10.0.0.1".into(),
333                ip_type: "private".into(),
334                version: 4,
335            },
336            OvhIpAddress {
337                ip: "1.2.3.4".into(),
338                ip_type: "public".into(),
339                version: 4,
340            },
341            OvhIpAddress {
342                ip: "2001:db8::1".into(),
343                ip_type: "public".into(),
344                version: 6,
345            },
346        ];
347        assert_eq!(select_ip(&addrs).unwrap(), "1.2.3.4");
348    }
349
350    #[test]
351    fn test_select_ip_falls_back_to_public_ipv6() {
352        let addrs = vec![
353            OvhIpAddress {
354                ip: "10.0.0.1".into(),
355                ip_type: "private".into(),
356                version: 4,
357            },
358            OvhIpAddress {
359                ip: "2001:db8::1/64".into(),
360                ip_type: "public".into(),
361                version: 6,
362            },
363        ];
364        assert_eq!(select_ip(&addrs).unwrap(), "2001:db8::1");
365    }
366
367    #[test]
368    fn test_select_ip_falls_back_to_private_ipv4() {
369        let addrs = vec![OvhIpAddress {
370            ip: "10.0.0.1".into(),
371            ip_type: "private".into(),
372            version: 4,
373        }];
374        assert_eq!(select_ip(&addrs).unwrap(), "10.0.0.1");
375    }
376
377    #[test]
378    fn test_select_ip_empty() {
379        assert!(select_ip(&[]).is_none());
380    }
381
382    #[test]
383    fn test_http_instances_roundtrip() {
384        let mut server = mockito::Server::new();
385        let time_mock = server
386            .mock("GET", "/1.0/auth/time")
387            .with_status(200)
388            .with_body("1700000000")
389            .create();
390
391        let instances_mock = server
392            .mock("GET", "/1.0/cloud/project/proj-123/instance")
393            .match_header("X-Ovh-Application", "app-key")
394            .match_header("X-Ovh-Consumer", "consumer-key")
395            .with_status(200)
396            .with_header("content-type", "application/json")
397            .with_body(
398                r#"[{
399                    "id": "i-1",
400                    "name": "web-1",
401                    "status": "ACTIVE",
402                    "region": "GRA11",
403                    "ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}],
404                    "flavor": {"name": "b2-7"},
405                    "image": {"name": "Ubuntu 22.04"}
406                }]"#,
407            )
408            .create();
409
410        let base_url = server.url();
411        let token = "app-key:app-secret:consumer-key";
412        let (app_key, app_secret, consumer_key) = parse_token(token).unwrap();
413        let agent = super::super::http_agent();
414
415        // Fetch time
416        let time_url = format!("{}/1.0/auth/time", base_url);
417        let server_time: u64 = agent
418            .get(&time_url)
419            .call()
420            .unwrap()
421            .body_mut()
422            .read_json()
423            .unwrap();
424
425        // Fetch instances
426        let instances_url = format!("{}/1.0/cloud/project/proj-123/instance", base_url);
427        let sig = sign_request(
428            app_secret,
429            consumer_key,
430            "GET",
431            &instances_url,
432            "",
433            server_time,
434        );
435        let instances: Vec<OvhInstance> = agent
436            .get(&instances_url)
437            .header("X-Ovh-Application", app_key)
438            .header("X-Ovh-Timestamp", &server_time.to_string())
439            .header("X-Ovh-Consumer", consumer_key)
440            .header("X-Ovh-Signature", &sig)
441            .call()
442            .unwrap()
443            .body_mut()
444            .read_json()
445            .unwrap();
446
447        assert_eq!(instances.len(), 1);
448        assert_eq!(instances[0].name, "web-1");
449        assert_eq!(select_ip(&instances[0].ip_addresses).unwrap(), "1.2.3.4");
450
451        time_mock.assert();
452        instances_mock.assert();
453    }
454
455    #[test]
456    fn test_http_instances_auth_failure() {
457        let mut server = mockito::Server::new();
458        let time_mock = server
459            .mock("GET", "/1.0/auth/time")
460            .with_status(200)
461            .with_body("1700000000")
462            .create();
463
464        let instances_mock = server
465            .mock("GET", "/1.0/cloud/project/proj-123/instance")
466            .with_status(401)
467            .with_body(r#"{"message": "Invalid credentials"}"#)
468            .create();
469
470        let agent = super::super::http_agent();
471        let base_url = server.url();
472
473        let _: u64 = agent
474            .get(&format!("{}/1.0/auth/time", base_url))
475            .call()
476            .unwrap()
477            .body_mut()
478            .read_json()
479            .unwrap();
480
481        let result = agent
482            .get(&format!("{}/1.0/cloud/project/proj-123/instance", base_url))
483            .call();
484
485        assert!(result.is_err());
486        let err = super::map_ureq_error(result.unwrap_err());
487        assert!(matches!(err, ProviderError::AuthFailed));
488
489        time_mock.assert();
490        instances_mock.assert();
491    }
492
493    #[test]
494    fn test_rejects_empty_project() {
495        let ovh = Ovh {
496            project: String::new(),
497            endpoint: String::new(),
498        };
499        let cancel = AtomicBool::new(false);
500        let result = ovh.fetch_hosts_cancellable("ak:as:ck", &cancel);
501        let msg = result.unwrap_err().to_string();
502        assert!(msg.contains("project ID is required"));
503    }
504
505    #[test]
506    fn test_rejects_invalid_token_before_network() {
507        let ovh = Ovh {
508            project: "proj".to_string(),
509            endpoint: String::new(),
510        };
511        let cancel = AtomicBool::new(false);
512        let result = ovh.fetch_hosts_cancellable("bad-token", &cancel);
513        assert!(matches!(result.unwrap_err(), ProviderError::AuthFailed));
514    }
515
516    #[test]
517    fn test_endpoint_url_eu() {
518        assert_eq!(endpoint_url("eu"), "https://eu.api.ovh.com/1.0");
519        assert_eq!(endpoint_url(""), "https://eu.api.ovh.com/1.0");
520        assert_eq!(endpoint_url("unknown"), "https://eu.api.ovh.com/1.0");
521    }
522
523    #[test]
524    fn test_endpoint_url_ca() {
525        assert_eq!(endpoint_url("ca"), "https://ca.api.ovh.com/1.0");
526    }
527
528    #[test]
529    fn test_endpoint_url_us() {
530        assert_eq!(endpoint_url("us"), "https://api.us.ovhcloud.com/1.0");
531    }
532
533    #[test]
534    fn test_sign_request_known_vector() {
535        // OVH documentation reference vector
536        let sig = sign_request(
537            "EgWIz07P0HYwtQDs",
538            "MtSwSrPpNjqfVSmJhLbPyr2i45lSwPU1",
539            "GET",
540            "https://eu.api.ovh.com/1.0/auth/time",
541            "",
542            1366560945,
543        );
544        assert_eq!(sig, "$1$069f8fd9c1fbec55d67f24f80e65cb1a14f09dce");
545    }
546
547    #[test]
548    fn test_sign_request_with_body() {
549        let sig_empty = sign_request("s", "c", "GET", "https://x.com", "", 1);
550        let sig_body = sign_request("s", "c", "POST", "https://x.com", r#"{"key":"val"}"#, 1);
551        assert_ne!(sig_empty, sig_body);
552    }
553
554    #[test]
555    fn test_sign_request_different_methods() {
556        let get = sign_request("s", "c", "GET", "https://x.com", "", 1);
557        let post = sign_request("s", "c", "POST", "https://x.com", "", 1);
558        assert_ne!(get, post);
559    }
560
561    #[test]
562    fn test_parse_token_empty_string() {
563        assert!(parse_token("").is_err());
564    }
565
566    #[test]
567    fn test_parse_token_only_colons() {
568        assert!(parse_token("::").is_err());
569    }
570
571    #[test]
572    fn test_parse_token_trailing_colon() {
573        assert!(parse_token("key:secret:").is_err());
574    }
575
576    #[test]
577    fn test_parse_instance_extra_fields_ignored() {
578        let json = r#"[{
579            "id": "uuid-123",
580            "name": "web-1",
581            "status": "ACTIVE",
582            "created": "2024-01-15T10:30:00Z",
583            "planCode": "d2-2.runabove",
584            "monthlyBilling": null,
585            "sshKey": {"id": "key-1"},
586            "currentMonthOutgoingTraffic": 12345,
587            "operationIds": [],
588            "ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4, "gatewayIp": "1.2.3.1", "networkId": "net-1"}],
589            "flavor": {"name": "b2-7", "available": true, "disk": 50, "ram": 7168, "vcpus": 2},
590            "image": {"name": "Ubuntu 22.04", "type": "linux", "user": "ubuntu", "visibility": "public"}
591        }]"#;
592        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
593        assert_eq!(instances.len(), 1);
594        assert_eq!(instances[0].name, "web-1");
595    }
596
597    #[test]
598    fn test_parse_instance_null_flavor_and_image() {
599        let json =
600            r#"[{"id": "x", "name": "y", "status": "BUILD", "flavor": null, "image": null}]"#;
601        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
602        assert!(instances[0].flavor.is_none());
603        assert!(instances[0].image.is_none());
604    }
605
606    #[test]
607    fn test_parse_empty_instance_list() {
608        let instances: Vec<OvhInstance> = serde_json::from_str("[]").unwrap();
609        assert!(instances.is_empty());
610    }
611
612    #[test]
613    fn test_select_ip_private_ipv6_only_returns_none() {
614        let addrs = vec![OvhIpAddress {
615            ip: "fd00::1".into(),
616            ip_type: "private".into(),
617            version: 6,
618        }];
619        assert!(select_ip(&addrs).is_none());
620    }
621
622    #[test]
623    fn test_select_ip_unknown_type_returns_none() {
624        let addrs = vec![OvhIpAddress {
625            ip: "1.2.3.4".into(),
626            ip_type: "floating".into(),
627            version: 4,
628        }];
629        assert!(select_ip(&addrs).is_none());
630    }
631
632    #[test]
633    fn test_select_ip_public_ipv4_with_cidr() {
634        let addrs = vec![OvhIpAddress {
635            ip: "1.2.3.4/32".into(),
636            ip_type: "public".into(),
637            version: 4,
638        }];
639        assert_eq!(select_ip(&addrs).unwrap(), "1.2.3.4");
640    }
641
642    #[test]
643    fn test_select_ip_multiple_public_ipv4_uses_first() {
644        let addrs = vec![
645            OvhIpAddress {
646                ip: "1.1.1.1".into(),
647                ip_type: "public".into(),
648                version: 4,
649            },
650            OvhIpAddress {
651                ip: "2.2.2.2".into(),
652                ip_type: "public".into(),
653                version: 4,
654            },
655        ];
656        assert_eq!(select_ip(&addrs).unwrap(), "1.1.1.1");
657    }
658
659    #[test]
660    fn test_metadata_all_fields_present() {
661        let json = r#"[{
662            "id": "i-1", "name": "web", "status": "ACTIVE", "region": "GRA11",
663            "ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}],
664            "flavor": {"name": "b2-7"},
665            "image": {"name": "Ubuntu 22.04"}
666        }]"#;
667        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
668        let inst = &instances[0];
669        // Simulate the metadata assembly from fetch_hosts_cancellable
670        let mut metadata = Vec::with_capacity(4);
671        if !inst.region.is_empty() {
672            metadata.push(("region".to_string(), inst.region.clone()));
673        }
674        if let Some(ref flavor) = inst.flavor {
675            if !flavor.name.is_empty() {
676                metadata.push(("type".to_string(), flavor.name.clone()));
677            }
678        }
679        if let Some(ref image) = inst.image {
680            if let Some(ref name) = image.name {
681                if !name.is_empty() {
682                    metadata.push(("image".to_string(), name.clone()));
683                }
684            }
685        }
686        if !inst.status.is_empty() {
687            metadata.push(("status".to_string(), inst.status.clone()));
688        }
689        assert_eq!(metadata.len(), 4);
690        assert_eq!(metadata[0], ("region".to_string(), "GRA11".to_string()));
691        assert_eq!(metadata[1], ("type".to_string(), "b2-7".to_string()));
692        assert_eq!(
693            metadata[2],
694            ("image".to_string(), "Ubuntu 22.04".to_string())
695        );
696        assert_eq!(metadata[3], ("status".to_string(), "ACTIVE".to_string()));
697    }
698
699    #[test]
700    fn test_metadata_no_optional_fields() {
701        let json = r#"[{"id": "i-1", "name": "web", "status": "", "region": ""}]"#;
702        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
703        let inst = &instances[0];
704        let mut metadata = Vec::new();
705        if !inst.region.is_empty() {
706            metadata.push(("region".to_string(), inst.region.clone()));
707        }
708        if let Some(ref flavor) = inst.flavor {
709            if !flavor.name.is_empty() {
710                metadata.push(("type".to_string(), flavor.name.clone()));
711            }
712        }
713        if !inst.status.is_empty() {
714            metadata.push(("status".to_string(), inst.status.clone()));
715        }
716        assert!(metadata.is_empty());
717    }
718
719    #[test]
720    fn test_instance_no_ip_skipped() {
721        let json = r#"[
722            {"id": "i-1", "name": "has-ip", "status": "ACTIVE", "ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}]},
723            {"id": "i-2", "name": "no-ip", "status": "ACTIVE", "ipAddresses": []},
724            {"id": "i-3", "name": "private-v6-only", "status": "ACTIVE", "ipAddresses": [{"ip": "fd00::1", "type": "private", "version": 6}]}
725        ]"#;
726        let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
727        let hosts: Vec<_> = instances
728            .iter()
729            .filter_map(|inst| select_ip(&inst.ip_addresses).map(|ip| (inst.name.clone(), ip)))
730            .collect();
731        assert_eq!(hosts.len(), 1);
732        assert_eq!(hosts[0].0, "has-ip");
733    }
734
735    #[test]
736    fn test_name_and_short_label() {
737        let ovh = Ovh {
738            project: String::new(),
739            endpoint: String::new(),
740        };
741        assert_eq!(ovh.name(), "ovh");
742        assert_eq!(ovh.short_label(), "ovh");
743    }
744
745    #[test]
746    fn test_cancellation_returns_cancelled() {
747        let cancel = AtomicBool::new(true);
748        let ovh = Ovh {
749            project: "test-project".to_string(),
750            endpoint: String::new(),
751        };
752        let result = ovh.fetch_hosts_cancellable("AK:AS:CK", &cancel);
753        assert!(matches!(result, Err(ProviderError::Cancelled)));
754    }
755}