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
14pub struct Oracle {
16 pub regions: Vec<String>,
17 pub compartment: String,
18}
19
20#[derive(Debug)]
22struct OciCredentials {
23 tenancy: String,
24 user: String,
25 fingerprint: String,
26 key_pem: String,
27 region: String,
28}
29
30fn 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 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
92fn 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
124fn 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
136const 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
145fn format_rfc7231(epoch_secs: u64) -> String {
149 let d = super::epoch_to_date(epoch_secs);
150 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
164fn 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 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
185fn 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#[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#[derive(Deserialize)]
273#[allow(dead_code)]
274struct OciErrorBody {
275 code: Option<String>,
276 message: Option<String>,
277}
278
279fn 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
331pub const OCI_REGIONS: &[(&str, &str)] = &[
336 ("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 ("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 ("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
385impl 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 ®ions {
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
498fn authority_of(base: &str) -> &str {
502 base.split_once("://").map(|(_, a)| a).unwrap_or(base)
503}
504
505#[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 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 let path_and_query = if let Some(pos) = url.find(host) {
533 &url[pos + host.len()..]
534 } else {
535 url.splitn(4, '/').nth(3).map_or("/", |p| {
537 &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 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 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 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 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 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 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(_) => {} }
791 }
792
793 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 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
882fn urlencoding_encode(input: &str) -> String {
884 super::percent_encode(input)
885}
886
887#[cfg(test)]
892#[path = "oracle_tests.rs"]
893mod tests;