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