Skip to main content

purple_ssh/providers/
scaleway.rs

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
12/// All Scaleway availability zones with display names.
13/// Single source of truth. SCW_ZONE_GROUPS references slices of this array.
14pub const SCW_ZONES: &[(&str, &str)] = &[
15    // Paris (0..3)
16    ("fr-par-1", "Paris 1"),
17    ("fr-par-2", "Paris 2"),
18    ("fr-par-3", "Paris 3"),
19    // Amsterdam (3..6)
20    ("nl-ams-1", "Amsterdam 1"),
21    ("nl-ams-2", "Amsterdam 2"),
22    ("nl-ams-3", "Amsterdam 3"),
23    // Warsaw (6..9)
24    ("pl-waw-1", "Warsaw 1"),
25    ("pl-waw-2", "Warsaw 2"),
26    ("pl-waw-3", "Warsaw 3"),
27    // Milan (9..10)
28    ("it-mil-1", "Milan 1"),
29];
30
31/// Zone group labels with start..end indices into SCW_ZONES.
32pub 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// --- Serde response models ---
40
41#[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    // Deserialized from API but we use the zone parameter from the request URL
70    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
93/// Build metadata key-value pairs for a server.
94fn 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
115/// Select the best IP for a server.
116/// Prefers public IPv4 > public IPv6 > legacy public_ip > private_ip.
117fn select_ip(server: &ScalewayServer) -> Option<String> {
118    // Prefer public IPv4 from public_ips
119    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    // Fall back to public IPv6 from public_ips
127    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    // Fall back to legacy public_ip field
135    if let Some(ref legacy) = server.public_ip {
136        if !legacy.address.is_empty() {
137            return Some(legacy.address.clone());
138        }
139    }
140    // Fall back to private_ip
141    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    /// Real API host. Overridable per call via `fetch_from` so tests can point
151    /// the full fetch pipeline at a mock server.
152    const API_BASE: &'static str = "https://api.scaleway.com";
153
154    /// Fetch hosts against an explicit API base. Production passes `API_BASE`;
155    /// tests pass a mock server URL. The single seam that makes zone fan-out,
156    /// URL construction, the auth header, error mapping, pagination and
157    /// `ProviderHost` mapping testable end to end. Preserves partial-failure
158    /// semantics: only `base_url` is routed through.
159    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        // Summary
216        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
269/// Fetch all servers in a single zone (handles pagination).
270fn 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    // =========================================================================
335    // Response parsing
336    // =========================================================================
337
338    #[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    // =========================================================================
422    // IP selection
423    // =========================================================================
424
425    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    // =========================================================================
552    // Tags
553    // =========================================================================
554
555    #[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    // =========================================================================
579    // Metadata
580    // =========================================================================
581
582    #[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    // =========================================================================
659    // Pagination
660    // =========================================================================
661
662    #[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    // =========================================================================
670    // Zone constants
671    // =========================================================================
672
673    #[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    // =========================================================================
709    // Provider trait
710    // =========================================================================
711
712    #[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    // =========================================================================
754    // Server ID is UUID string
755    // =========================================================================
756
757    #[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    // =========================================================================
772    // Image parsing
773    // =========================================================================
774
775    #[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    // =========================================================================
809    // Private IP field
810    // =========================================================================
811
812    #[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    // =========================================================================
828    // HTTP roundtrip tests (mockito)
829    // =========================================================================
830
831    #[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        // Page 1
931        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        // Page 2
945        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)) => {} // expected
982            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        // Exercises the production fetch path end to end: zone fan-out, URL
990        // construction, auth header, JSON deserialize, ProviderHost mapping and
991        // pagination termination. A single zone keeps one mock endpoint enough.
992        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}