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