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 ) -> 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 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 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 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 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 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}