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