1use std::sync::atomic::{AtomicBool, Ordering};
2
3use serde::Deserialize;
4use sha1::{Digest, Sha1};
5
6use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
7
8pub 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
65fn 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
74fn 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
98fn 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 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 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 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 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 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}