Skip to main content

purple_ssh/providers/
oracle.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::SystemTime;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
7use rsa::pkcs1::DecodeRsaPrivateKey;
8use rsa::pkcs8::DecodePrivateKey;
9use rsa::signature::{SignatureEncoding, Signer};
10use serde::Deserialize;
11
12use super::{Provider, ProviderError, ProviderHost};
13
14/// Oracle Cloud Infrastructure provider configuration.
15pub struct Oracle {
16    pub regions: Vec<String>,
17    pub compartment: String,
18}
19
20/// Parsed OCI API credentials.
21#[derive(Debug)]
22struct OciCredentials {
23    tenancy: String,
24    user: String,
25    fingerprint: String,
26    key_pem: String,
27    region: String,
28}
29
30/// Parse an OCI config file and return credentials.
31///
32/// Only the `[DEFAULT]` profile is read (case-sensitive). The `key_pem`
33/// field comes from the already-read key file content passed as
34/// `key_content`.
35fn parse_oci_config(content: &str, key_content: &str) -> Result<OciCredentials, ProviderError> {
36    let mut in_default = false;
37    let mut tenancy: Option<String> = None;
38    let mut user: Option<String> = None;
39    let mut fingerprint: Option<String> = None;
40    let mut region: Option<String> = None;
41
42    for raw_line in content.lines() {
43        // Strip CRLF by stripping trailing \r after lines() removes \n
44        let line = raw_line.trim_end_matches('\r');
45        let trimmed = line.trim();
46
47        if trimmed.starts_with('[') && trimmed.ends_with(']') {
48            let profile = &trimmed[1..trimmed.len() - 1];
49            in_default = profile == "DEFAULT";
50            continue;
51        }
52
53        if !in_default {
54            continue;
55        }
56
57        if trimmed.starts_with('#') || trimmed.is_empty() {
58            continue;
59        }
60
61        if let Some(eq) = trimmed.find('=') {
62            let key = trimmed[..eq].trim();
63            let val = trimmed[eq + 1..].trim().to_string();
64            match key {
65                "tenancy" => tenancy = Some(val),
66                "user" => user = Some(val),
67                "fingerprint" => fingerprint = Some(val),
68                "region" => region = Some(val),
69                _ => {}
70            }
71        }
72    }
73
74    let tenancy = tenancy
75        .ok_or_else(|| ProviderError::Http("OCI config missing 'tenancy' in [DEFAULT]".into()))?;
76    let user =
77        user.ok_or_else(|| ProviderError::Http("OCI config missing 'user' in [DEFAULT]".into()))?;
78    let fingerprint = fingerprint.ok_or_else(|| {
79        ProviderError::Http("OCI config missing 'fingerprint' in [DEFAULT]".into())
80    })?;
81    let region = region.unwrap_or_default();
82
83    Ok(OciCredentials {
84        tenancy,
85        user,
86        fingerprint,
87        key_pem: key_content.to_string(),
88        region,
89    })
90}
91
92/// Extract the `key_file` path from the `[DEFAULT]` profile of an OCI
93/// config file.
94fn extract_key_file(config_content: &str) -> Result<String, ProviderError> {
95    let mut in_default = false;
96
97    for raw_line in config_content.lines() {
98        let line = raw_line.trim_end_matches('\r');
99        let trimmed = line.trim();
100
101        if trimmed.starts_with('[') && trimmed.ends_with(']') {
102            let profile = &trimmed[1..trimmed.len() - 1];
103            in_default = profile == "DEFAULT";
104            continue;
105        }
106
107        if !in_default || trimmed.starts_with('#') || trimmed.is_empty() {
108            continue;
109        }
110
111        if let Some(eq) = trimmed.find('=') {
112            let key = trimmed[..eq].trim();
113            if key == "key_file" {
114                return Ok(trimmed[eq + 1..].trim().to_string());
115            }
116        }
117    }
118
119    Err(ProviderError::Http(
120        "OCI config missing 'key_file' in [DEFAULT]".into(),
121    ))
122}
123
124/// Validate that an OCID string has a compartment or tenancy prefix.
125fn validate_compartment(ocid: &str) -> Result<(), ProviderError> {
126    if ocid.starts_with("ocid1.compartment.oc1..") || ocid.starts_with("ocid1.tenancy.oc1..") {
127        Ok(())
128    } else {
129        Err(ProviderError::Http(format!(
130            "Invalid compartment OCID: '{}'. Must start with 'ocid1.compartment.oc1..' or 'ocid1.tenancy.oc1..'",
131            ocid
132        )))
133    }
134}
135
136// ---------------------------------------------------------------------------
137// RFC 7231 date formatting
138// ---------------------------------------------------------------------------
139
140const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
141const MONTHS: [&str; 12] = [
142    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
143];
144
145/// Format a Unix timestamp as an RFC 7231 date string.
146///
147/// Example: `Thu, 26 Mar 2026 12:00:00 GMT`
148fn format_rfc7231(epoch_secs: u64) -> String {
149    let d = super::epoch_to_date(epoch_secs);
150    // Day of week: Jan 1 1970 was a Thursday (index 0 in WEEKDAYS)
151    let weekday = WEEKDAYS[(d.epoch_days % 7) as usize];
152    format!(
153        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
154        weekday,
155        d.day,
156        MONTHS[(d.month - 1) as usize],
157        d.year,
158        d.hours,
159        d.minutes,
160        d.seconds,
161    )
162}
163
164// ---------------------------------------------------------------------------
165// RSA private key parsing
166// ---------------------------------------------------------------------------
167
168/// Parse a PEM-encoded RSA private key (PKCS#1 or PKCS#8).
169fn parse_private_key(pem: &str) -> Result<rsa::RsaPrivateKey, ProviderError> {
170    if pem.contains("ENCRYPTED") {
171        return Err(ProviderError::Http(
172            "OCI private key is encrypted. Please provide an unencrypted key.".into(),
173        ));
174    }
175
176    // Try PKCS#1 first, then PKCS#8
177    if let Ok(key) = rsa::RsaPrivateKey::from_pkcs1_pem(pem) {
178        return Ok(key);
179    }
180
181    rsa::RsaPrivateKey::from_pkcs8_pem(pem)
182        .map_err(|e| ProviderError::Http(format!("Failed to parse OCI private key: {}", e)))
183}
184
185// ---------------------------------------------------------------------------
186// HTTP request signing
187// ---------------------------------------------------------------------------
188
189/// Build the OCI `Authorization` header value for a GET request.
190///
191/// Signs `date`, `(request-target)` and `host` headers using RSA-SHA256.
192/// The caller must parse the RSA private key once and pass it in to avoid
193/// re-parsing on every request.
194fn sign_request(
195    creds: &OciCredentials,
196    rsa_key: &rsa::RsaPrivateKey,
197    date: &str,
198    host: &str,
199    path_and_query: &str,
200) -> Result<String, ProviderError> {
201    let signing_string = format!(
202        "date: {}\n(request-target): get {}\nhost: {}",
203        date, path_and_query, host
204    );
205
206    let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(rsa_key.clone());
207    let signature = signing_key.sign(signing_string.as_bytes());
208    let sig_b64 = STANDARD.encode(signature.to_bytes());
209
210    let key_id = format!("{}/{}/{}", creds.tenancy, creds.user, creds.fingerprint);
211    Ok(format!(
212        "Signature version=\"1\",keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"date (request-target) host\",signature=\"{}\"",
213        key_id, sig_b64
214    ))
215}
216
217// ---------------------------------------------------------------------------
218// JSON response models
219// ---------------------------------------------------------------------------
220
221#[derive(Deserialize)]
222struct OciCompartment {
223    id: String,
224    #[serde(rename = "lifecycleState")]
225    lifecycle_state: String,
226}
227
228#[derive(Deserialize)]
229struct OciInstance {
230    id: String,
231    #[serde(rename = "displayName")]
232    display_name: String,
233    #[serde(rename = "lifecycleState")]
234    lifecycle_state: String,
235    shape: String,
236    #[serde(rename = "imageId")]
237    image_id: Option<String>,
238    #[serde(rename = "freeformTags")]
239    freeform_tags: Option<std::collections::HashMap<String, String>>,
240}
241
242#[derive(Deserialize)]
243struct OciVnicAttachment {
244    #[serde(rename = "instanceId")]
245    instance_id: String,
246    #[serde(rename = "vnicId")]
247    vnic_id: Option<String>,
248    #[serde(rename = "lifecycleState")]
249    lifecycle_state: String,
250    #[serde(rename = "isPrimary")]
251    is_primary: Option<bool>,
252}
253
254#[derive(Deserialize)]
255struct OciVnic {
256    #[serde(rename = "publicIp")]
257    public_ip: Option<String>,
258    #[serde(rename = "privateIp")]
259    private_ip: Option<String>,
260}
261
262#[derive(Deserialize)]
263struct OciImage {
264    #[serde(rename = "displayName")]
265    display_name: Option<String>,
266}
267
268// ureq 3.x does not expose the response body on StatusCode errors, so we
269// cannot parse OCI error JSON from failed responses. Other providers in this
270// codebase handle errors the same way (status code only). Kept for future use
271// if ureq adds body-on-error support.
272#[derive(Deserialize)]
273#[allow(dead_code)]
274struct OciErrorBody {
275    code: Option<String>,
276    message: Option<String>,
277}
278
279// ---------------------------------------------------------------------------
280// IP selection, VNIC mapping and helpers
281// ---------------------------------------------------------------------------
282
283fn select_ip(vnic: &OciVnic) -> String {
284    if let Some(ip) = &vnic.public_ip {
285        if !ip.is_empty() {
286            return ip.clone();
287        }
288    }
289    if let Some(ip) = &vnic.private_ip {
290        if !ip.is_empty() {
291            return ip.clone();
292        }
293    }
294    String::new()
295}
296
297fn select_vnic_for_instance(
298    attachments: &[OciVnicAttachment],
299    instance_id: &str,
300) -> Option<String> {
301    let matching: Vec<_> = attachments
302        .iter()
303        .filter(|a| a.instance_id == instance_id && a.lifecycle_state == "ATTACHED")
304        .collect();
305    if let Some(primary) = matching.iter().find(|a| a.is_primary == Some(true)) {
306        return primary.vnic_id.clone();
307    }
308    matching.first().and_then(|a| a.vnic_id.clone())
309}
310
311fn extract_tags(freeform_tags: &Option<std::collections::HashMap<String, String>>) -> Vec<String> {
312    match freeform_tags {
313        Some(tags) => {
314            let mut result: Vec<String> = tags
315                .iter()
316                .map(|(k, v)| {
317                    if v.is_empty() {
318                        k.clone()
319                    } else {
320                        format!("{}:{}", k, v)
321                    }
322                })
323                .collect();
324            result.sort();
325            result
326        }
327        None => Vec::new(),
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Region constants
333// ---------------------------------------------------------------------------
334
335pub const OCI_REGIONS: &[(&str, &str)] = &[
336    // Americas (0..12)
337    ("us-ashburn-1", "Ashburn"),
338    ("us-phoenix-1", "Phoenix"),
339    ("us-sanjose-1", "San Jose"),
340    ("us-chicago-1", "Chicago"),
341    ("ca-toronto-1", "Toronto"),
342    ("ca-montreal-1", "Montreal"),
343    ("br-saopaulo-1", "Sao Paulo"),
344    ("br-vinhedo-1", "Vinhedo"),
345    ("mx-queretaro-1", "Queretaro"),
346    ("mx-monterrey-1", "Monterrey"),
347    ("cl-santiago-1", "Santiago"),
348    ("co-bogota-1", "Bogota"),
349    // EMEA (12..29)
350    ("eu-amsterdam-1", "Amsterdam"),
351    ("eu-frankfurt-1", "Frankfurt"),
352    ("eu-zurich-1", "Zurich"),
353    ("eu-stockholm-1", "Stockholm"),
354    ("eu-marseille-1", "Marseille"),
355    ("eu-milan-1", "Milan"),
356    ("eu-paris-1", "Paris"),
357    ("eu-madrid-1", "Madrid"),
358    ("eu-jovanovac-1", "Jovanovac"),
359    ("uk-london-1", "London"),
360    ("uk-cardiff-1", "Cardiff"),
361    ("me-jeddah-1", "Jeddah"),
362    ("me-abudhabi-1", "Abu Dhabi"),
363    ("me-dubai-1", "Dubai"),
364    ("me-riyadh-1", "Riyadh"),
365    ("af-johannesburg-1", "Johannesburg"),
366    ("il-jerusalem-1", "Jerusalem"),
367    // Asia Pacific (29..38)
368    ("ap-tokyo-1", "Tokyo"),
369    ("ap-osaka-1", "Osaka"),
370    ("ap-seoul-1", "Seoul"),
371    ("ap-chuncheon-1", "Chuncheon"),
372    ("ap-singapore-1", "Singapore"),
373    ("ap-sydney-1", "Sydney"),
374    ("ap-melbourne-1", "Melbourne"),
375    ("ap-mumbai-1", "Mumbai"),
376    ("ap-hyderabad-1", "Hyderabad"),
377];
378
379pub const OCI_REGION_GROUPS: &[(&str, usize, usize)] = &[
380    ("Americas", 0, 12),
381    ("EMEA", 12, 29),
382    ("Asia Pacific", 29, 38),
383];
384
385// ---------------------------------------------------------------------------
386// Provider trait implementation
387// ---------------------------------------------------------------------------
388
389impl Provider for Oracle {
390    fn name(&self) -> &str {
391        "oracle"
392    }
393
394    fn short_label(&self) -> &str {
395        "oci"
396    }
397
398    fn fetch_hosts_cancellable(
399        &self,
400        token: &str,
401        cancel: &AtomicBool,
402        env: &crate::runtime::env::Env,
403    ) -> Result<Vec<ProviderHost>, ProviderError> {
404        self.fetch_hosts_with_progress(token, cancel, env, &|_| {})
405    }
406
407    fn fetch_hosts_with_progress(
408        &self,
409        token: &str,
410        cancel: &AtomicBool,
411        env: &crate::runtime::env::Env,
412        progress: &dyn Fn(&str),
413    ) -> Result<Vec<ProviderHost>, ProviderError> {
414        if self.compartment.is_empty() {
415            return Err(ProviderError::Http(
416                "No compartment configured. Run: purple provider add oracle --token ~/.oci/config --compartment <OCID>".to_string(),
417            ));
418        }
419        validate_compartment(&self.compartment)?;
420
421        let config_content = std::fs::read_to_string(token).map_err(|e| {
422            ProviderError::Http(format!("Cannot read OCI config file '{}': {}", token, e))
423        })?;
424        let key_file = extract_key_file(&config_content)?;
425        let expanded = if let Some(rest) = key_file.strip_prefix("~/") {
426            match env.paths() {
427                Some(p) => p.home().join(rest).to_string_lossy().into_owned(),
428                None => key_file.clone(),
429            }
430        } else {
431            key_file.clone()
432        };
433        let key_content = std::fs::read_to_string(&expanded).map_err(|e| {
434            ProviderError::Http(format!("Cannot read OCI private key '{}': {}", expanded, e))
435        })?;
436        let creds = parse_oci_config(&config_content, &key_content)?;
437        let rsa_key = parse_private_key(&creds.key_pem)?;
438
439        let regions: Vec<String> = if self.regions.is_empty() {
440            if creds.region.is_empty() {
441                return Err(ProviderError::Http(
442                    "No regions configured and OCI config has no default region".to_string(),
443                ));
444            }
445            vec![creds.region.clone()]
446        } else {
447            self.regions.clone()
448        };
449
450        let mut all_hosts = Vec::new();
451        let mut region_failures = 0usize;
452        let total_regions = regions.len();
453        for region in &regions {
454            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
455                return Err(ProviderError::Cancelled);
456            }
457            progress(&format!("Syncing {} ...", region));
458            let iaas_base = format!("https://iaas.{}.oraclecloud.com", region);
459            let identity_base = format!("https://identity.{}.oraclecloud.com", region);
460            let ctx = OciRegionCtx {
461                region,
462                iaas_base: &iaas_base,
463                identity_base: &identity_base,
464            };
465            match self.fetch_region(&creds, &rsa_key, &ctx, cancel, progress) {
466                Ok(mut hosts) => all_hosts.append(&mut hosts),
467                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
468                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
469                Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
470                Err(ProviderError::PartialResult {
471                    hosts: mut partial, ..
472                }) => {
473                    all_hosts.append(&mut partial);
474                    region_failures += 1;
475                }
476                Err(_) => {
477                    region_failures += 1;
478                }
479            }
480        }
481        if region_failures > 0 {
482            if all_hosts.is_empty() {
483                return Err(ProviderError::Http(format!(
484                    "Failed to sync all {} region(s)",
485                    total_regions
486                )));
487            }
488            return Err(ProviderError::PartialResult {
489                hosts: all_hosts,
490                failures: region_failures,
491                total: total_regions,
492            });
493        }
494        Ok(all_hosts)
495    }
496}
497
498/// Authority (host[:port]) portion of a scheme-qualified base URL, used as
499/// the signing host. `https://iaas.eu.oraclecloud.com` -> `iaas.eu.oraclecloud.com`;
500/// a mock `http://127.0.0.1:1234` -> `127.0.0.1:1234`.
501fn authority_of(base: &str) -> &str {
502    base.split_once("://").map(|(_, a)| a).unwrap_or(base)
503}
504
505/// Per-region endpoint context for one OCI fetch: the region label plus the
506/// full scheme+host of the Compute and Identity endpoints. Real OCI hosts in
507/// production, a mock URL in tests.
508#[derive(Clone, Copy)]
509struct OciRegionCtx<'a> {
510    region: &'a str,
511    iaas_base: &'a str,
512    identity_base: &'a str,
513}
514
515impl Oracle {
516    /// Perform a signed GET request against the OCI API.
517    fn signed_get(
518        &self,
519        creds: &OciCredentials,
520        rsa_key: &rsa::RsaPrivateKey,
521        agent: &ureq::Agent,
522        host: &str,
523        url: &str,
524    ) -> Result<ureq::http::Response<ureq::Body>, ProviderError> {
525        let now = SystemTime::now()
526            .duration_since(SystemTime::UNIX_EPOCH)
527            .unwrap_or_default()
528            .as_secs();
529        let date = format_rfc7231(now);
530
531        // Extract path+query from URL (everything after the host part)
532        let path_and_query = if let Some(pos) = url.find(host) {
533            &url[pos + host.len()..]
534        } else {
535            // Fallback: strip scheme + host
536            url.splitn(4, '/').nth(3).map_or("/", |p| {
537                // We need the leading slash
538                &url[url.len() - p.len() - 1..]
539            })
540        };
541
542        let auth = sign_request(creds, rsa_key, &date, host, path_and_query)?;
543
544        agent
545            .get(url)
546            .header("date", &date)
547            .header("Authorization", &auth)
548            .call()
549            .map_err(|e| match e {
550                ureq::Error::StatusCode(401 | 403) => ProviderError::AuthFailed,
551                ureq::Error::StatusCode(429) => ProviderError::RateLimited,
552                ureq::Error::StatusCode(code) => ProviderError::Http(format!("HTTP {}", code)),
553                other => super::map_ureq_error(other),
554            })
555    }
556
557    /// List active sub-compartments (Identity API supports compartmentIdInSubtree).
558    /// `identity_base` is the full scheme+host of the Identity endpoint
559    /// (`https://identity.{region}.oraclecloud.com` in production, a mock URL
560    /// in tests). The signing host is derived from it so signed_get's path
561    /// extraction stays consistent.
562    fn list_compartments(
563        &self,
564        creds: &OciCredentials,
565        rsa_key: &rsa::RsaPrivateKey,
566        agent: &ureq::Agent,
567        identity_base: &str,
568        cancel: &AtomicBool,
569    ) -> Result<Vec<String>, ProviderError> {
570        let host = authority_of(identity_base);
571        let compartment_encoded = urlencoding_encode(&self.compartment);
572
573        let mut compartment_ids = vec![self.compartment.clone()];
574        let mut next_page: Option<String> = None;
575        for _ in 0..500 {
576            if cancel.load(Ordering::Relaxed) {
577                return Err(ProviderError::Cancelled);
578            }
579
580            let url = match &next_page {
581                Some(page) => format!(
582                    "{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100&page={}",
583                    identity_base,
584                    compartment_encoded,
585                    urlencoding_encode(page)
586                ),
587                None => format!(
588                    "{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
589                    identity_base, compartment_encoded
590                ),
591            };
592
593            let mut resp = self.signed_get(creds, rsa_key, agent, host, &url)?;
594
595            let opc_next = resp
596                .headers()
597                .get("opc-next-page")
598                .and_then(|v| v.to_str().ok())
599                .filter(|s| !s.is_empty())
600                .map(String::from);
601
602            let items: Vec<OciCompartment> = resp
603                .body_mut()
604                .read_json()
605                .map_err(|e| ProviderError::Parse(e.to_string()))?;
606
607            compartment_ids.extend(
608                items
609                    .into_iter()
610                    .filter(|c| c.lifecycle_state == "ACTIVE")
611                    .map(|c| c.id),
612            );
613
614            match opc_next {
615                Some(p) => next_page = Some(p),
616                None => break,
617            }
618        }
619        Ok(compartment_ids)
620    }
621
622    /// Fetch one region against explicit endpoint bases. `iaas_base` and
623    /// `identity_base` are the full scheme+host of the Compute and Identity
624    /// endpoints (real OCI hosts in production, a mock URL in tests). The
625    /// signing host is derived from `iaas_base`.
626    fn fetch_region(
627        &self,
628        creds: &OciCredentials,
629        rsa_key: &rsa::RsaPrivateKey,
630        ctx: &OciRegionCtx,
631        cancel: &AtomicBool,
632        progress: &dyn Fn(&str),
633    ) -> Result<Vec<ProviderHost>, ProviderError> {
634        let OciRegionCtx {
635            region,
636            iaas_base,
637            identity_base,
638        } = *ctx;
639        let agent = super::http_agent();
640        let host = authority_of(iaas_base);
641
642        // Step 0: Discover all compartments (root + sub-compartments)
643        progress("Listing compartments...");
644        let compartment_ids =
645            self.list_compartments(creds, rsa_key, &agent, identity_base, cancel)?;
646        let total_compartments = compartment_ids.len();
647
648        // Step 1: List instances across all compartments (paginated per compartment)
649        let mut instances: Vec<OciInstance> = Vec::new();
650        for (ci, comp_id) in compartment_ids.iter().enumerate() {
651            if cancel.load(Ordering::Relaxed) {
652                return Err(ProviderError::Cancelled);
653            }
654            if total_compartments > 1 {
655                progress(&format!(
656                    "Listing instances ({}/{} compartments)...",
657                    ci + 1,
658                    total_compartments
659                ));
660            } else {
661                progress("Listing instances...");
662            }
663            let compartment_encoded = urlencoding_encode(comp_id);
664            let mut next_page: Option<String> = None;
665            for _ in 0..500 {
666                if cancel.load(Ordering::Relaxed) {
667                    return Err(ProviderError::Cancelled);
668                }
669
670                let url = match &next_page {
671                    Some(page) => format!(
672                        "{}/20160918/instances?compartmentId={}&limit=100&page={}",
673                        iaas_base,
674                        compartment_encoded,
675                        urlencoding_encode(page)
676                    ),
677                    None => format!(
678                        "{}/20160918/instances?compartmentId={}&limit=100",
679                        iaas_base, compartment_encoded
680                    ),
681                };
682
683                let mut resp = self.signed_get(creds, rsa_key, &agent, host, &url)?;
684
685                let opc_next = resp
686                    .headers()
687                    .get("opc-next-page")
688                    .and_then(|v| v.to_str().ok())
689                    .filter(|s| !s.is_empty())
690                    .map(String::from);
691
692                let page_items: Vec<OciInstance> = resp
693                    .body_mut()
694                    .read_json()
695                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
696
697                instances.extend(
698                    page_items
699                        .into_iter()
700                        .filter(|i| i.lifecycle_state != "TERMINATED"),
701                );
702
703                match opc_next {
704                    Some(p) => next_page = Some(p),
705                    None => break,
706                }
707            }
708        }
709
710        // Step 2: List VNIC attachments across all compartments (paginated per compartment)
711        progress("Listing VNIC attachments...");
712        let mut attachments: Vec<OciVnicAttachment> = Vec::new();
713        for comp_id in &compartment_ids {
714            if cancel.load(Ordering::Relaxed) {
715                return Err(ProviderError::Cancelled);
716            }
717            let compartment_encoded = urlencoding_encode(comp_id);
718            let mut next_page: Option<String> = None;
719            for _ in 0..500 {
720                if cancel.load(Ordering::Relaxed) {
721                    return Err(ProviderError::Cancelled);
722                }
723
724                let url = match &next_page {
725                    Some(page) => format!(
726                        "{}/20160918/vnicAttachments?compartmentId={}&limit=100&page={}",
727                        iaas_base,
728                        compartment_encoded,
729                        urlencoding_encode(page)
730                    ),
731                    None => format!(
732                        "{}/20160918/vnicAttachments?compartmentId={}&limit=100",
733                        iaas_base, compartment_encoded
734                    ),
735                };
736
737                let mut resp = self.signed_get(creds, rsa_key, &agent, host, &url)?;
738
739                let opc_next = resp
740                    .headers()
741                    .get("opc-next-page")
742                    .and_then(|v| v.to_str().ok())
743                    .filter(|s| !s.is_empty())
744                    .map(String::from);
745
746                let page_items: Vec<OciVnicAttachment> = resp
747                    .body_mut()
748                    .read_json()
749                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
750
751                attachments.extend(page_items);
752
753                match opc_next {
754                    Some(p) => next_page = Some(p),
755                    None => break,
756                }
757            }
758        }
759
760        // Step 3: Resolve images (N+1 per unique imageId)
761        let unique_image_ids: Vec<String> = {
762            let mut ids: Vec<String> = instances
763                .iter()
764                .filter_map(|i| i.image_id.clone())
765                .collect();
766            ids.sort_unstable();
767            ids.dedup();
768            ids
769        };
770        let total_images = unique_image_ids.len();
771        let mut image_names: HashMap<String, String> = HashMap::new();
772        for (n, image_id) in unique_image_ids.iter().enumerate() {
773            if cancel.load(Ordering::Relaxed) {
774                return Err(ProviderError::Cancelled);
775            }
776            progress(&format!("Resolving images ({}/{})...", n + 1, total_images));
777
778            let url = format!("{}/20160918/images/{}", iaas_base, image_id);
779            match self.signed_get(creds, rsa_key, &agent, host, &url) {
780                Ok(mut resp) => {
781                    if let Ok(img) = resp.body_mut().read_json::<OciImage>() {
782                        if let Some(name) = img.display_name {
783                            image_names.insert(image_id.clone(), name);
784                        }
785                    }
786                }
787                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
788                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
789                Err(_) => {} // Non-fatal: skip silently
790            }
791        }
792
793        // Step 4: Get VNIC + build hosts (N+1 per VNIC for RUNNING instances)
794        let total_instances = instances.len();
795        let mut hosts: Vec<ProviderHost> = Vec::new();
796        let mut fetch_failures = 0usize;
797        for (n, instance) in instances.iter().enumerate() {
798            if cancel.load(Ordering::Relaxed) {
799                return Err(ProviderError::Cancelled);
800            }
801            progress(&format!("Fetching IPs ({}/{})...", n + 1, total_instances));
802
803            let ip = if instance.lifecycle_state == "RUNNING" {
804                match select_vnic_for_instance(&attachments, &instance.id) {
805                    Some(vnic_id) => {
806                        let url = format!("{}/20160918/vnics/{}", iaas_base, vnic_id);
807                        match self.signed_get(creds, rsa_key, &agent, host, &url) {
808                            Ok(mut resp) => match resp.body_mut().read_json::<OciVnic>() {
809                                Ok(vnic) => {
810                                    let raw = select_ip(&vnic);
811                                    super::strip_cidr(&raw).to_string()
812                                }
813                                Err(_) => {
814                                    fetch_failures += 1;
815                                    String::new()
816                                }
817                            },
818                            Err(ProviderError::AuthFailed) => {
819                                return Err(ProviderError::AuthFailed);
820                            }
821                            Err(ProviderError::RateLimited) => {
822                                return Err(ProviderError::RateLimited);
823                            }
824                            Err(ProviderError::Http(ref msg)) if msg == "HTTP 404" => {
825                                // 404: race condition, silent skip
826                                String::new()
827                            }
828                            Err(_) => {
829                                fetch_failures += 1;
830                                String::new()
831                            }
832                        }
833                    }
834                    None => String::new(),
835                }
836            } else {
837                String::new()
838            };
839
840            let os_name = instance
841                .image_id
842                .as_ref()
843                .and_then(|id| image_names.get(id))
844                .cloned()
845                .unwrap_or_default();
846
847            let mut metadata = Vec::new();
848            metadata.push(("region".to_string(), region.to_string()));
849            metadata.push(("shape".to_string(), instance.shape.clone()));
850            if !os_name.is_empty() {
851                metadata.push(("os".to_string(), os_name));
852            }
853            metadata.push(("status".to_string(), instance.lifecycle_state.clone()));
854
855            hosts.push(ProviderHost {
856                server_id: instance.id.clone(),
857                name: instance.display_name.clone(),
858                ip,
859                tags: extract_tags(&instance.freeform_tags),
860                metadata,
861            });
862        }
863
864        if fetch_failures > 0 {
865            if hosts.is_empty() {
866                return Err(ProviderError::Http(format!(
867                    "Failed to fetch details for all {} instances",
868                    total_instances
869                )));
870            }
871            return Err(ProviderError::PartialResult {
872                hosts,
873                failures: fetch_failures,
874                total: total_instances,
875            });
876        }
877
878        Ok(hosts)
879    }
880}
881
882/// Minimal percent-encoding for query parameter values (delegates to shared implementation).
883fn urlencoding_encode(input: &str) -> String {
884    super::percent_encode(input)
885}
886
887// ---------------------------------------------------------------------------
888// Tests
889// ---------------------------------------------------------------------------
890
891#[cfg(test)]
892#[path = "oracle_tests.rs"]
893mod tests;