1use std::collections::HashSet;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use serde::Deserialize;
5
6use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
7
8pub struct Scaleway {
9 pub zones: Vec<String>,
10}
11
12pub const SCW_ZONES: &[(&str, &str)] = &[
15 ("fr-par-1", "Paris 1"),
17 ("fr-par-2", "Paris 2"),
18 ("fr-par-3", "Paris 3"),
19 ("nl-ams-1", "Amsterdam 1"),
21 ("nl-ams-2", "Amsterdam 2"),
22 ("nl-ams-3", "Amsterdam 3"),
23 ("pl-waw-1", "Warsaw 1"),
25 ("pl-waw-2", "Warsaw 2"),
26 ("pl-waw-3", "Warsaw 3"),
27 ("it-mil-1", "Milan 1"),
29];
30
31pub const SCW_ZONE_GROUPS: &[(&str, usize, usize)] = &[
33 ("Paris", 0, 3),
34 ("Amsterdam", 3, 6),
35 ("Warsaw", 6, 9),
36 ("Milan", 9, 10),
37];
38
39#[derive(Deserialize)]
42struct ListServersResponse {
43 #[serde(default)]
44 servers: Vec<ScalewayServer>,
45 #[serde(default)]
46 total_count: u64,
47}
48
49#[derive(Deserialize)]
50struct ScalewayServer {
51 id: String,
52 name: String,
53 #[serde(default)]
54 state: String,
55 #[serde(default)]
56 commercial_type: String,
57 #[serde(default)]
58 tags: Vec<String>,
59 #[serde(default)]
60 public_ips: Vec<ServerIp>,
61 #[serde(default)]
62 public_ip: Option<LegacyPublicIp>,
63 #[serde(default)]
64 private_ip: Option<String>,
65 #[serde(default)]
66 image: Option<ScalewayImage>,
67 #[serde(default)]
68 #[allow(dead_code)]
69 zone: String,
71}
72
73#[derive(Deserialize)]
74struct ServerIp {
75 #[serde(default)]
76 address: String,
77 #[serde(default)]
78 family: String,
79}
80
81#[derive(Deserialize)]
82struct LegacyPublicIp {
83 #[serde(default)]
84 address: String,
85}
86
87#[derive(Deserialize)]
88struct ScalewayImage {
89 #[serde(default)]
90 name: Option<String>,
91}
92
93fn build_metadata(server: &ScalewayServer, zone: &str) -> Vec<(String, String)> {
95 let mut metadata = Vec::new();
96 if !zone.is_empty() {
97 metadata.push(("zone".to_string(), zone.to_string()));
98 }
99 if !server.commercial_type.is_empty() {
100 metadata.push(("type".to_string(), server.commercial_type.clone()));
101 }
102 if let Some(ref image) = server.image {
103 if let Some(ref name) = image.name {
104 if !name.is_empty() {
105 metadata.push(("image".to_string(), name.clone()));
106 }
107 }
108 }
109 if !server.state.is_empty() {
110 metadata.push(("status".to_string(), server.state.clone()));
111 }
112 metadata
113}
114
115fn select_ip(server: &ScalewayServer) -> Option<String> {
118 if let Some(ip) = server
120 .public_ips
121 .iter()
122 .find(|ip| ip.family == "inet" && !ip.address.is_empty())
123 {
124 return Some(super::strip_cidr(&ip.address).to_string());
125 }
126 if let Some(ip) = server
128 .public_ips
129 .iter()
130 .find(|ip| ip.family == "inet6" && !ip.address.is_empty())
131 {
132 return Some(super::strip_cidr(&ip.address).to_string());
133 }
134 if let Some(ref legacy) = server.public_ip {
136 if !legacy.address.is_empty() {
137 return Some(legacy.address.clone());
138 }
139 }
140 if let Some(ref priv_ip) = server.private_ip {
142 if !priv_ip.is_empty() {
143 return Some(priv_ip.clone());
144 }
145 }
146 None
147}
148
149impl Provider for Scaleway {
150 fn name(&self) -> &str {
151 "scaleway"
152 }
153
154 fn short_label(&self) -> &str {
155 "scw"
156 }
157
158 fn fetch_hosts_cancellable(
159 &self,
160 token: &str,
161 cancel: &AtomicBool,
162 _env: &crate::runtime::env::Env,
163 ) -> Result<Vec<ProviderHost>, ProviderError> {
164 self.fetch_hosts_with_progress(token, cancel, _env, &|_| {})
165 }
166
167 fn fetch_hosts_with_progress(
168 &self,
169 token: &str,
170 cancel: &AtomicBool,
171 _env: &crate::runtime::env::Env,
172 progress: &dyn Fn(&str),
173 ) -> Result<Vec<ProviderHost>, ProviderError> {
174 if self.zones.is_empty() {
175 return Err(ProviderError::Http(
176 "No Scaleway zones configured. Add zones in the provider settings.".to_string(),
177 ));
178 }
179
180 let valid_codes: HashSet<&str> = SCW_ZONES.iter().map(|(c, _)| *c).collect();
181 for zone in &self.zones {
182 if !valid_codes.contains(zone.as_str()) {
183 return Err(ProviderError::Http(format!(
184 "Unknown Scaleway zone '{}'. Check your provider settings.",
185 zone
186 )));
187 }
188 }
189
190 let agent = super::http_agent();
191 let total_zones = self.zones.len();
192 let mut all_hosts = Vec::new();
193 let mut failed_zones = 0usize;
194
195 for (i, zone) in self.zones.iter().enumerate() {
196 if cancel.load(Ordering::Relaxed) {
197 return Err(ProviderError::Cancelled);
198 }
199
200 progress(&format!("Fetching {} ({}/{})...", zone, i + 1, total_zones));
201
202 match fetch_zone(&agent, token, zone, cancel) {
203 Ok(hosts) => all_hosts.extend(hosts),
204 Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
205 Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
206 Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
207 Err(_) => {
208 failed_zones += 1;
209 continue;
210 }
211 }
212 }
213
214 let mut parts = vec![format!("{} instances", all_hosts.len())];
216 if failed_zones > 0 {
217 parts.push(format!("{} of {} zones failed", failed_zones, total_zones));
218 }
219 progress(&parts.join(", "));
220
221 if failed_zones > 0 {
222 if all_hosts.is_empty() {
223 return Err(ProviderError::Http(format!(
224 "All {} zones failed. Check your credentials and zone configuration.",
225 total_zones,
226 )));
227 }
228 return Err(ProviderError::PartialResult {
229 hosts: all_hosts,
230 failures: failed_zones,
231 total: total_zones,
232 });
233 }
234
235 Ok(all_hosts)
236 }
237}
238
239fn fetch_zone(
241 agent: &ureq::Agent,
242 token: &str,
243 zone: &str,
244 cancel: &AtomicBool,
245) -> Result<Vec<ProviderHost>, ProviderError> {
246 let mut hosts = Vec::new();
247 let mut page = 1u64;
248 let per_page = 100;
249
250 loop {
251 if cancel.load(Ordering::Relaxed) {
252 return Err(ProviderError::Cancelled);
253 }
254
255 let url = format!(
256 "https://api.scaleway.com/instance/v1/zones/{}/servers?page={}&per_page={}",
257 zone, page, per_page
258 );
259 let resp: ListServersResponse = agent
260 .get(&url)
261 .header("X-Auth-Token", token)
262 .call()
263 .map_err(map_ureq_error)?
264 .body_mut()
265 .read_json()
266 .map_err(|e| ProviderError::Parse(format!("{}: {}", zone, e)))?;
267
268 if resp.servers.is_empty() {
269 break;
270 }
271
272 let count = resp.servers.len();
273
274 for server in &resp.servers {
275 if let Some(ip) = select_ip(server) {
276 hosts.push(ProviderHost {
277 server_id: server.id.clone(),
278 name: server.name.clone(),
279 ip,
280 tags: server.tags.clone(),
281 metadata: build_metadata(server, zone),
282 });
283 }
284 }
285
286 let total = resp.total_count;
287 if (count as u64) < per_page || (total > 0 && page * per_page >= total) {
288 break;
289 }
290 page += 1;
291 if page > 500 {
292 break;
293 }
294 }
295
296 Ok(hosts)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
308 fn test_parse_list_servers_response() {
309 let json = r#"{
310 "servers": [
311 {
312 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
313 "name": "web-1",
314 "state": "running",
315 "commercial_type": "DEV1-S",
316 "tags": ["production"],
317 "public_ips": [
318 {"id": "ip-1", "address": "51.15.1.2", "family": "inet"}
319 ],
320 "zone": "fr-par-1"
321 }
322 ]
323 }"#;
324 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
325 assert_eq!(resp.servers.len(), 1);
326 assert_eq!(resp.servers[0].name, "web-1");
327 assert_eq!(resp.servers[0].state, "running");
328 assert_eq!(resp.servers[0].commercial_type, "DEV1-S");
329 }
330
331 #[test]
332 fn test_parse_server_with_public_ips() {
333 let json = r#"{
334 "servers": [{
335 "id": "abc",
336 "name": "dual",
337 "public_ips": [
338 {"address": "51.15.1.2", "family": "inet"},
339 {"address": "2001:bc8::1", "family": "inet6"}
340 ],
341 "tags": []
342 }]
343 }"#;
344 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
345 assert_eq!(resp.servers[0].public_ips.len(), 2);
346 assert_eq!(resp.servers[0].public_ips[0].family, "inet");
347 assert_eq!(resp.servers[0].public_ips[1].family, "inet6");
348 }
349
350 #[test]
351 fn test_parse_server_with_legacy_public_ip() {
352 let json = r#"{
353 "servers": [{
354 "id": "abc",
355 "name": "legacy",
356 "public_ips": [],
357 "public_ip": {"address": "51.15.1.2", "dynamic": false},
358 "tags": []
359 }]
360 }"#;
361 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
362 assert_eq!(
363 resp.servers[0].public_ip.as_ref().unwrap().address,
364 "51.15.1.2"
365 );
366 }
367
368 #[test]
369 fn test_parse_server_extra_fields_ignored() {
370 let json = r#"{
371 "servers": [{
372 "id": "abc",
373 "name": "full",
374 "state": "running",
375 "commercial_type": "GP1-M",
376 "tags": ["web"],
377 "public_ips": [{"address": "1.2.3.4", "family": "inet"}],
378 "created_at": "2024-01-01T00:00:00Z",
379 "disk": 25,
380 "memory": 2147483648,
381 "arch": "x86_64",
382 "hostname": "full",
383 "zone": "fr-par-1"
384 }]
385 }"#;
386 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
387 assert_eq!(resp.servers[0].name, "full");
388 }
389
390 fn server_with_ips(
395 public_ips: Vec<ServerIp>,
396 public_ip: Option<LegacyPublicIp>,
397 private_ip: Option<String>,
398 ) -> ScalewayServer {
399 ScalewayServer {
400 id: "test".to_string(),
401 name: "test".to_string(),
402 state: String::new(),
403 commercial_type: String::new(),
404 tags: vec![],
405 public_ips,
406 public_ip,
407 private_ip,
408 image: None,
409 zone: String::new(),
410 }
411 }
412
413 #[test]
414 fn test_select_ip_prefers_v4_over_v6() {
415 let server = server_with_ips(
416 vec![
417 ServerIp {
418 address: "51.15.1.2".to_string(),
419 family: "inet".to_string(),
420 },
421 ServerIp {
422 address: "2001:bc8::1".to_string(),
423 family: "inet6".to_string(),
424 },
425 ],
426 None,
427 None,
428 );
429 assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
430 }
431
432 #[test]
433 fn test_select_ip_v6_only() {
434 let server = server_with_ips(
435 vec![ServerIp {
436 address: "2001:bc8::1".to_string(),
437 family: "inet6".to_string(),
438 }],
439 None,
440 None,
441 );
442 assert_eq!(select_ip(&server), Some("2001:bc8::1".to_string()));
443 }
444
445 #[test]
446 fn test_select_ip_empty_public_ips_uses_legacy() {
447 let server = server_with_ips(
448 vec![],
449 Some(LegacyPublicIp {
450 address: "51.15.1.2".to_string(),
451 }),
452 None,
453 );
454 assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
455 }
456
457 #[test]
458 fn test_select_ip_falls_back_to_private() {
459 let server = server_with_ips(vec![], None, Some("10.0.0.5".to_string()));
460 assert_eq!(select_ip(&server), Some("10.0.0.5".to_string()));
461 }
462
463 #[test]
464 fn test_select_ip_no_ip_returns_none() {
465 let server = server_with_ips(vec![], None, None);
466 assert_eq!(select_ip(&server), None);
467 }
468
469 #[test]
470 fn test_select_ip_empty_address_skipped() {
471 let server = server_with_ips(
472 vec![ServerIp {
473 address: String::new(),
474 family: "inet".to_string(),
475 }],
476 None,
477 None,
478 );
479 assert_eq!(select_ip(&server), None);
480 }
481
482 #[test]
483 fn test_select_ip_v6_cidr_stripped() {
484 let server = server_with_ips(
485 vec![ServerIp {
486 address: "2001:bc8::1/128".to_string(),
487 family: "inet6".to_string(),
488 }],
489 None,
490 None,
491 );
492 assert_eq!(select_ip(&server), Some("2001:bc8::1".to_string()));
493 }
494
495 #[test]
496 fn test_select_ip_multiple_v4_uses_first() {
497 let server = server_with_ips(
498 vec![
499 ServerIp {
500 address: "51.15.1.2".to_string(),
501 family: "inet".to_string(),
502 },
503 ServerIp {
504 address: "51.15.1.3".to_string(),
505 family: "inet".to_string(),
506 },
507 ],
508 None,
509 None,
510 );
511 assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
512 }
513
514 #[test]
515 fn test_select_ip_empty_private_skipped() {
516 let server = server_with_ips(vec![], None, Some(String::new()));
517 assert_eq!(select_ip(&server), None);
518 }
519
520 #[test]
525 fn test_tags_preserved() {
526 let json = r#"{
527 "servers": [{
528 "id": "abc",
529 "name": "tagged",
530 "public_ips": [{"address": "1.2.3.4", "family": "inet"}],
531 "tags": ["web", "production", "eu"]
532 }]
533 }"#;
534 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
535 assert_eq!(resp.servers[0].tags, vec!["web", "production", "eu"]);
536 }
537
538 #[test]
539 fn test_default_tags_empty() {
540 let json = r#"{
541 "servers": [{"id": "abc", "name": "no-tags", "public_ips": []}]
542 }"#;
543 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
544 assert!(resp.servers[0].tags.is_empty());
545 }
546
547 #[test]
552 fn test_metadata_from_server() {
553 let server = ScalewayServer {
554 id: "abc".to_string(),
555 name: "web-1".to_string(),
556 state: "running".to_string(),
557 commercial_type: "DEV1-S".to_string(),
558 tags: vec![],
559 public_ips: vec![ServerIp {
560 address: "1.2.3.4".to_string(),
561 family: "inet".to_string(),
562 }],
563 public_ip: None,
564 private_ip: None,
565 image: Some(ScalewayImage {
566 name: Some("Ubuntu 22.04 Jammy Jellyfish".to_string()),
567 }),
568 zone: "fr-par-1".to_string(),
569 };
570 let ip = select_ip(&server).unwrap();
571 assert_eq!(ip, "1.2.3.4");
572
573 let metadata = build_metadata(&server, "fr-par-1");
574 assert_eq!(
575 metadata,
576 vec![
577 ("zone".to_string(), "fr-par-1".to_string()),
578 ("type".to_string(), "DEV1-S".to_string()),
579 (
580 "image".to_string(),
581 "Ubuntu 22.04 Jammy Jellyfish".to_string()
582 ),
583 ("status".to_string(), "running".to_string()),
584 ]
585 );
586 }
587
588 #[test]
589 fn test_metadata_uses_zone_param_not_server_field() {
590 let server = ScalewayServer {
591 id: "abc".to_string(),
592 name: "web-1".to_string(),
593 state: "running".to_string(),
594 commercial_type: String::new(),
595 tags: vec![],
596 public_ips: vec![],
597 public_ip: None,
598 private_ip: None,
599 image: None,
600 zone: "nl-ams-2".to_string(),
601 };
602 let metadata = build_metadata(&server, "fr-par-1");
603 assert_eq!(metadata[0], ("zone".to_string(), "fr-par-1".to_string()));
604 }
605
606 #[test]
607 fn test_metadata_empty_fields_omitted() {
608 let server = ScalewayServer {
609 id: "abc".to_string(),
610 name: "bare".to_string(),
611 state: String::new(),
612 commercial_type: String::new(),
613 tags: vec![],
614 public_ips: vec![ServerIp {
615 address: "1.2.3.4".to_string(),
616 family: "inet".to_string(),
617 }],
618 public_ip: None,
619 private_ip: None,
620 image: None,
621 zone: String::new(),
622 };
623 let metadata = build_metadata(&server, "");
624 assert!(metadata.is_empty());
625 }
626
627 #[test]
632 fn test_empty_server_list_stops_pagination() {
633 let json = r#"{"servers": []}"#;
634 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
635 assert!(resp.servers.is_empty());
636 }
637
638 #[test]
643 fn test_scw_zones_count() {
644 assert_eq!(SCW_ZONES.len(), 10);
645 }
646
647 #[test]
648 fn test_scw_zone_groups_cover_all_zones() {
649 let total: usize = SCW_ZONE_GROUPS.iter().map(|&(_, s, e)| e - s).sum();
650 assert_eq!(total, SCW_ZONES.len());
651 let mut expected_start = 0;
652 for &(_, start, end) in SCW_ZONE_GROUPS {
653 assert_eq!(start, expected_start, "Gap or overlap in zone groups");
654 assert!(end > start, "Empty zone group");
655 expected_start = end;
656 }
657 assert_eq!(expected_start, SCW_ZONES.len());
658 }
659
660 #[test]
661 fn test_scw_zones_no_duplicates() {
662 let mut seen = HashSet::new();
663 for (code, _) in SCW_ZONES {
664 assert!(seen.insert(code), "Duplicate zone: {}", code);
665 }
666 }
667
668 #[test]
669 fn test_scw_zones_contains_common() {
670 let codes: Vec<&str> = SCW_ZONES.iter().map(|(c, _)| *c).collect();
671 assert!(codes.contains(&"fr-par-1"));
672 assert!(codes.contains(&"nl-ams-1"));
673 assert!(codes.contains(&"pl-waw-1"));
674 assert!(codes.contains(&"it-mil-1"));
675 }
676
677 #[test]
682 fn test_scaleway_provider_name() {
683 let scw = Scaleway { zones: vec![] };
684 assert_eq!(scw.name(), "scaleway");
685 assert_eq!(scw.short_label(), "scw");
686 }
687
688 #[test]
689 fn test_scaleway_no_zones_error() {
690 let scw = Scaleway { zones: vec![] };
691 let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
692 match result {
693 Err(ProviderError::Http(msg)) => assert!(msg.contains("No Scaleway zones")),
694 other => panic!("Expected Http error, got: {:?}", other),
695 }
696 }
697
698 #[test]
699 fn test_scaleway_invalid_zone_error() {
700 let scw = Scaleway {
701 zones: vec!["xx-invalid-1".to_string()],
702 };
703 let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
704 match result {
705 Err(ProviderError::Http(msg)) => assert!(msg.contains("Unknown Scaleway zone")),
706 other => panic!("Expected Http error for invalid zone, got: {:?}", other),
707 }
708 }
709
710 #[test]
711 fn test_scaleway_mixed_valid_invalid_zone_error() {
712 let scw = Scaleway {
713 zones: vec!["fr-par-1".to_string(), "xx-fake-9".to_string()],
714 };
715 let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
716 match result {
717 Err(ProviderError::Http(msg)) => assert!(msg.contains("xx-fake-9")),
718 other => panic!("Expected Http error for invalid zone, got: {:?}", other),
719 }
720 }
721
722 #[test]
727 fn test_server_id_is_uuid_string() {
728 let json = r#"{
729 "servers": [{
730 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
731 "name": "uuid-test",
732 "public_ips": [],
733 "tags": []
734 }]
735 }"#;
736 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
737 assert_eq!(resp.servers[0].id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
738 }
739
740 #[test]
745 fn test_image_name_parsed() {
746 let json = r#"{
747 "servers": [{
748 "id": "abc",
749 "name": "with-image",
750 "image": {"id": "img-1", "name": "Ubuntu 22.04 Jammy Jellyfish"},
751 "public_ips": [],
752 "tags": []
753 }]
754 }"#;
755 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
756 assert_eq!(
757 resp.servers[0].image.as_ref().unwrap().name.as_deref(),
758 Some("Ubuntu 22.04 Jammy Jellyfish")
759 );
760 }
761
762 #[test]
763 fn test_image_null_handled() {
764 let json = r#"{
765 "servers": [{
766 "id": "abc",
767 "name": "no-image",
768 "image": null,
769 "public_ips": [],
770 "tags": []
771 }]
772 }"#;
773 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
774 assert!(resp.servers[0].image.is_none());
775 }
776
777 #[test]
782 fn test_private_ip_parsed() {
783 let json = r#"{
784 "servers": [{
785 "id": "abc",
786 "name": "priv",
787 "private_ip": "10.1.2.3",
788 "public_ips": [],
789 "tags": []
790 }]
791 }"#;
792 let resp: ListServersResponse = serde_json::from_str(json).unwrap();
793 assert_eq!(resp.servers[0].private_ip.as_deref(), Some("10.1.2.3"));
794 }
795
796 #[test]
801 fn test_http_list_servers_roundtrip() {
802 let mut server = mockito::Server::new();
803 let mock = server
804 .mock("GET", "/instance/v1/zones/fr-par-1/servers")
805 .match_query(mockito::Matcher::AllOf(vec![
806 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
807 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
808 ]))
809 .match_header("X-Auth-Token", "scw-secret-token-123")
810 .with_status(200)
811 .with_header("content-type", "application/json")
812 .with_body(
813 r#"{
814 "servers": [
815 {
816 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
817 "name": "web-prod-1",
818 "state": "running",
819 "commercial_type": "DEV1-S",
820 "tags": ["production", "web"],
821 "public_ips": [
822 {"address": "51.15.42.10", "family": "inet"},
823 {"address": "2001:bc8:1200::1", "family": "inet6"}
824 ],
825 "private_ip": "10.68.0.5",
826 "image": {"id": "img-1", "name": "Ubuntu 22.04 Jammy Jellyfish"},
827 "zone": "fr-par-1"
828 }
829 ],
830 "total_count": 1
831 }"#,
832 )
833 .create();
834
835 let agent = super::super::http_agent();
836 let url = format!(
837 "{}/instance/v1/zones/fr-par-1/servers?page=1&per_page=100",
838 server.url()
839 );
840 let resp: ListServersResponse = agent
841 .get(&url)
842 .header("X-Auth-Token", "scw-secret-token-123")
843 .call()
844 .unwrap()
845 .body_mut()
846 .read_json()
847 .unwrap();
848
849 assert_eq!(resp.servers.len(), 1);
850 let s = &resp.servers[0];
851 assert_eq!(s.id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
852 assert_eq!(s.name, "web-prod-1");
853 assert_eq!(s.state, "running");
854 assert_eq!(s.commercial_type, "DEV1-S");
855 assert_eq!(s.tags, vec!["production", "web"]);
856 assert_eq!(s.public_ips.len(), 2);
857 assert_eq!(s.public_ips[0].address, "51.15.42.10");
858 assert_eq!(s.public_ips[0].family, "inet");
859 assert_eq!(select_ip(s), Some("51.15.42.10".to_string()));
860 assert_eq!(resp.total_count, 1);
861 mock.assert();
862 }
863
864 #[test]
865 fn test_http_list_servers_pagination() {
866 let mut server = mockito::Server::new();
867 let page1 = server
868 .mock("GET", "/instance/v1/zones/nl-ams-1/servers")
869 .match_query(mockito::Matcher::AllOf(vec![
870 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
871 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
872 ]))
873 .with_status(200)
874 .with_header("content-type", "application/json")
875 .with_body(
876 r#"{
877 "servers": [{"id": "s1", "name": "a", "public_ips": [{"address": "1.1.1.1", "family": "inet"}], "tags": []}],
878 "total_count": 2
879 }"#,
880 )
881 .create();
882 let page2 = server
883 .mock("GET", "/instance/v1/zones/nl-ams-1/servers")
884 .match_query(mockito::Matcher::AllOf(vec![
885 mockito::Matcher::UrlEncoded("page".into(), "2".into()),
886 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
887 ]))
888 .with_status(200)
889 .with_header("content-type", "application/json")
890 .with_body(
891 r#"{
892 "servers": [{"id": "s2", "name": "b", "public_ips": [{"address": "2.2.2.2", "family": "inet"}], "tags": []}],
893 "total_count": 2
894 }"#,
895 )
896 .create();
897
898 let agent = super::super::http_agent();
899 let r1: ListServersResponse = agent
901 .get(&format!(
902 "{}/instance/v1/zones/nl-ams-1/servers?page=1&per_page=100",
903 server.url()
904 ))
905 .header("X-Auth-Token", "tk")
906 .call()
907 .unwrap()
908 .body_mut()
909 .read_json()
910 .unwrap();
911 assert_eq!(r1.servers.len(), 1);
912 assert_eq!(r1.total_count, 2);
913 let r2: ListServersResponse = agent
915 .get(&format!(
916 "{}/instance/v1/zones/nl-ams-1/servers?page=2&per_page=100",
917 server.url()
918 ))
919 .header("X-Auth-Token", "tk")
920 .call()
921 .unwrap()
922 .body_mut()
923 .read_json()
924 .unwrap();
925 assert_eq!(r2.servers.len(), 1);
926 page1.assert();
927 page2.assert();
928 }
929
930 #[test]
931 fn test_http_list_servers_auth_failure() {
932 let mut server = mockito::Server::new();
933 let mock = server
934 .mock("GET", "/instance/v1/zones/fr-par-1/servers")
935 .match_query(mockito::Matcher::Any)
936 .with_status(401)
937 .with_body(r#"{"message": "Invalid authentication token"}"#)
938 .create();
939
940 let agent = super::super::http_agent();
941 let result = agent
942 .get(&format!(
943 "{}/instance/v1/zones/fr-par-1/servers?page=1&per_page=100",
944 server.url()
945 ))
946 .header("X-Auth-Token", "bad-token")
947 .call();
948
949 match result {
950 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
952 }
953 mock.assert();
954 }
955}