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 ) -> Result<Vec<ProviderHost>, ProviderError> {
403 self.fetch_hosts_with_progress(token, cancel, &|_| {})
404 }
405
406 fn fetch_hosts_with_progress(
407 &self,
408 token: &str,
409 cancel: &AtomicBool,
410 progress: &dyn Fn(&str),
411 ) -> Result<Vec<ProviderHost>, ProviderError> {
412 if self.compartment.is_empty() {
413 return Err(ProviderError::Http(
414 "No compartment configured. Run: purple provider add oracle --token ~/.oci/config --compartment <OCID>".to_string(),
415 ));
416 }
417 validate_compartment(&self.compartment)?;
418
419 let config_content = std::fs::read_to_string(token).map_err(|e| {
420 ProviderError::Http(format!("Cannot read OCI config file '{}': {}", token, e))
421 })?;
422 let key_file = extract_key_file(&config_content)?;
423 let expanded = if key_file.starts_with("~/") {
424 if let Some(home) = dirs::home_dir() {
425 format!("{}{}", home.display(), &key_file[1..])
426 } else {
427 key_file.clone()
428 }
429 } else {
430 key_file.clone()
431 };
432 let key_content = std::fs::read_to_string(&expanded).map_err(|e| {
433 ProviderError::Http(format!("Cannot read OCI private key '{}': {}", expanded, e))
434 })?;
435 let creds = parse_oci_config(&config_content, &key_content)?;
436 let rsa_key = parse_private_key(&creds.key_pem)?;
437
438 let regions: Vec<String> = if self.regions.is_empty() {
439 if creds.region.is_empty() {
440 return Err(ProviderError::Http(
441 "No regions configured and OCI config has no default region".to_string(),
442 ));
443 }
444 vec![creds.region.clone()]
445 } else {
446 self.regions.clone()
447 };
448
449 let mut all_hosts = Vec::new();
450 let mut region_failures = 0usize;
451 let total_regions = regions.len();
452 for region in ®ions {
453 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
454 return Err(ProviderError::Cancelled);
455 }
456 progress(&format!("Syncing {} ...", region));
457 match self.fetch_region(&creds, &rsa_key, region, cancel, progress) {
458 Ok(mut hosts) => all_hosts.append(&mut hosts),
459 Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
460 Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
461 Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
462 Err(ProviderError::PartialResult {
463 hosts: mut partial, ..
464 }) => {
465 all_hosts.append(&mut partial);
466 region_failures += 1;
467 }
468 Err(_) => {
469 region_failures += 1;
470 }
471 }
472 }
473 if region_failures > 0 {
474 if all_hosts.is_empty() {
475 return Err(ProviderError::Http(format!(
476 "Failed to sync all {} region(s)",
477 total_regions
478 )));
479 }
480 return Err(ProviderError::PartialResult {
481 hosts: all_hosts,
482 failures: region_failures,
483 total: total_regions,
484 });
485 }
486 Ok(all_hosts)
487 }
488}
489
490impl Oracle {
491 fn signed_get(
493 &self,
494 creds: &OciCredentials,
495 rsa_key: &rsa::RsaPrivateKey,
496 agent: &ureq::Agent,
497 host: &str,
498 url: &str,
499 ) -> Result<ureq::http::Response<ureq::Body>, ProviderError> {
500 let now = SystemTime::now()
501 .duration_since(SystemTime::UNIX_EPOCH)
502 .unwrap_or_default()
503 .as_secs();
504 let date = format_rfc7231(now);
505
506 let path_and_query = if let Some(pos) = url.find(host) {
508 &url[pos + host.len()..]
509 } else {
510 url.splitn(4, '/').nth(3).map_or("/", |p| {
512 &url[url.len() - p.len() - 1..]
514 })
515 };
516
517 let auth = sign_request(creds, rsa_key, &date, host, path_and_query)?;
518
519 agent
520 .get(url)
521 .header("date", &date)
522 .header("Authorization", &auth)
523 .call()
524 .map_err(|e| match e {
525 ureq::Error::StatusCode(401 | 403) => ProviderError::AuthFailed,
526 ureq::Error::StatusCode(429) => ProviderError::RateLimited,
527 ureq::Error::StatusCode(code) => ProviderError::Http(format!("HTTP {}", code)),
528 other => super::map_ureq_error(other),
529 })
530 }
531
532 fn list_compartments(
534 &self,
535 creds: &OciCredentials,
536 rsa_key: &rsa::RsaPrivateKey,
537 agent: &ureq::Agent,
538 region: &str,
539 cancel: &AtomicBool,
540 ) -> Result<Vec<String>, ProviderError> {
541 let host = format!("identity.{}.oraclecloud.com", region);
542 let compartment_encoded = urlencoding_encode(&self.compartment);
543
544 let mut compartment_ids = vec![self.compartment.clone()];
545 let mut next_page: Option<String> = None;
546 for _ in 0..500 {
547 if cancel.load(Ordering::Relaxed) {
548 return Err(ProviderError::Cancelled);
549 }
550
551 let url = match &next_page {
552 Some(page) => format!(
553 "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100&page={}",
554 host,
555 compartment_encoded,
556 urlencoding_encode(page)
557 ),
558 None => format!(
559 "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
560 host, compartment_encoded
561 ),
562 };
563
564 let mut resp = self.signed_get(creds, rsa_key, agent, &host, &url)?;
565
566 let opc_next = resp
567 .headers()
568 .get("opc-next-page")
569 .and_then(|v| v.to_str().ok())
570 .filter(|s| !s.is_empty())
571 .map(String::from);
572
573 let items: Vec<OciCompartment> = resp
574 .body_mut()
575 .read_json()
576 .map_err(|e| ProviderError::Parse(e.to_string()))?;
577
578 compartment_ids.extend(
579 items
580 .into_iter()
581 .filter(|c| c.lifecycle_state == "ACTIVE")
582 .map(|c| c.id),
583 );
584
585 match opc_next {
586 Some(p) => next_page = Some(p),
587 None => break,
588 }
589 }
590 Ok(compartment_ids)
591 }
592
593 fn fetch_region(
594 &self,
595 creds: &OciCredentials,
596 rsa_key: &rsa::RsaPrivateKey,
597 region: &str,
598 cancel: &AtomicBool,
599 progress: &dyn Fn(&str),
600 ) -> Result<Vec<ProviderHost>, ProviderError> {
601 let agent = super::http_agent();
602 let host = format!("iaas.{}.oraclecloud.com", region);
603
604 progress("Listing compartments...");
606 let compartment_ids = self.list_compartments(creds, rsa_key, &agent, region, cancel)?;
607 let total_compartments = compartment_ids.len();
608
609 let mut instances: Vec<OciInstance> = Vec::new();
611 for (ci, comp_id) in compartment_ids.iter().enumerate() {
612 if cancel.load(Ordering::Relaxed) {
613 return Err(ProviderError::Cancelled);
614 }
615 if total_compartments > 1 {
616 progress(&format!(
617 "Listing instances ({}/{} compartments)...",
618 ci + 1,
619 total_compartments
620 ));
621 } else {
622 progress("Listing instances...");
623 }
624 let compartment_encoded = urlencoding_encode(comp_id);
625 let mut next_page: Option<String> = None;
626 for _ in 0..500 {
627 if cancel.load(Ordering::Relaxed) {
628 return Err(ProviderError::Cancelled);
629 }
630
631 let url = match &next_page {
632 Some(page) => format!(
633 "https://{}/20160918/instances?compartmentId={}&limit=100&page={}",
634 host,
635 compartment_encoded,
636 urlencoding_encode(page)
637 ),
638 None => format!(
639 "https://{}/20160918/instances?compartmentId={}&limit=100",
640 host, compartment_encoded
641 ),
642 };
643
644 let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
645
646 let opc_next = resp
647 .headers()
648 .get("opc-next-page")
649 .and_then(|v| v.to_str().ok())
650 .filter(|s| !s.is_empty())
651 .map(String::from);
652
653 let page_items: Vec<OciInstance> = resp
654 .body_mut()
655 .read_json()
656 .map_err(|e| ProviderError::Parse(e.to_string()))?;
657
658 instances.extend(
659 page_items
660 .into_iter()
661 .filter(|i| i.lifecycle_state != "TERMINATED"),
662 );
663
664 match opc_next {
665 Some(p) => next_page = Some(p),
666 None => break,
667 }
668 }
669 }
670
671 progress("Listing VNIC attachments...");
673 let mut attachments: Vec<OciVnicAttachment> = Vec::new();
674 for comp_id in &compartment_ids {
675 if cancel.load(Ordering::Relaxed) {
676 return Err(ProviderError::Cancelled);
677 }
678 let compartment_encoded = urlencoding_encode(comp_id);
679 let mut next_page: Option<String> = None;
680 for _ in 0..500 {
681 if cancel.load(Ordering::Relaxed) {
682 return Err(ProviderError::Cancelled);
683 }
684
685 let url = match &next_page {
686 Some(page) => format!(
687 "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100&page={}",
688 host,
689 compartment_encoded,
690 urlencoding_encode(page)
691 ),
692 None => format!(
693 "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100",
694 host, compartment_encoded
695 ),
696 };
697
698 let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
699
700 let opc_next = resp
701 .headers()
702 .get("opc-next-page")
703 .and_then(|v| v.to_str().ok())
704 .filter(|s| !s.is_empty())
705 .map(String::from);
706
707 let page_items: Vec<OciVnicAttachment> = resp
708 .body_mut()
709 .read_json()
710 .map_err(|e| ProviderError::Parse(e.to_string()))?;
711
712 attachments.extend(page_items);
713
714 match opc_next {
715 Some(p) => next_page = Some(p),
716 None => break,
717 }
718 }
719 }
720
721 let unique_image_ids: Vec<String> = {
723 let mut ids: Vec<String> = instances
724 .iter()
725 .filter_map(|i| i.image_id.clone())
726 .collect();
727 ids.sort_unstable();
728 ids.dedup();
729 ids
730 };
731 let total_images = unique_image_ids.len();
732 let mut image_names: HashMap<String, String> = HashMap::new();
733 for (n, image_id) in unique_image_ids.iter().enumerate() {
734 if cancel.load(Ordering::Relaxed) {
735 return Err(ProviderError::Cancelled);
736 }
737 progress(&format!("Resolving images ({}/{})...", n + 1, total_images));
738
739 let url = format!("https://{}/20160918/images/{}", host, image_id);
740 match self.signed_get(creds, rsa_key, &agent, &host, &url) {
741 Ok(mut resp) => {
742 if let Ok(img) = resp.body_mut().read_json::<OciImage>() {
743 if let Some(name) = img.display_name {
744 image_names.insert(image_id.clone(), name);
745 }
746 }
747 }
748 Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
749 Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
750 Err(_) => {} }
752 }
753
754 let total_instances = instances.len();
756 let mut hosts: Vec<ProviderHost> = Vec::new();
757 let mut fetch_failures = 0usize;
758 for (n, instance) in instances.iter().enumerate() {
759 if cancel.load(Ordering::Relaxed) {
760 return Err(ProviderError::Cancelled);
761 }
762 progress(&format!("Fetching IPs ({}/{})...", n + 1, total_instances));
763
764 let ip = if instance.lifecycle_state == "RUNNING" {
765 match select_vnic_for_instance(&attachments, &instance.id) {
766 Some(vnic_id) => {
767 let url = format!("https://{}/20160918/vnics/{}", host, vnic_id);
768 match self.signed_get(creds, rsa_key, &agent, &host, &url) {
769 Ok(mut resp) => match resp.body_mut().read_json::<OciVnic>() {
770 Ok(vnic) => {
771 let raw = select_ip(&vnic);
772 super::strip_cidr(&raw).to_string()
773 }
774 Err(_) => {
775 fetch_failures += 1;
776 String::new()
777 }
778 },
779 Err(ProviderError::AuthFailed) => {
780 return Err(ProviderError::AuthFailed);
781 }
782 Err(ProviderError::RateLimited) => {
783 return Err(ProviderError::RateLimited);
784 }
785 Err(ProviderError::Http(ref msg)) if msg == "HTTP 404" => {
786 String::new()
788 }
789 Err(_) => {
790 fetch_failures += 1;
791 String::new()
792 }
793 }
794 }
795 None => String::new(),
796 }
797 } else {
798 String::new()
799 };
800
801 let os_name = instance
802 .image_id
803 .as_ref()
804 .and_then(|id| image_names.get(id))
805 .cloned()
806 .unwrap_or_default();
807
808 let mut metadata = Vec::new();
809 metadata.push(("region".to_string(), region.to_string()));
810 metadata.push(("shape".to_string(), instance.shape.clone()));
811 if !os_name.is_empty() {
812 metadata.push(("os".to_string(), os_name));
813 }
814 metadata.push(("status".to_string(), instance.lifecycle_state.clone()));
815
816 hosts.push(ProviderHost {
817 server_id: instance.id.clone(),
818 name: instance.display_name.clone(),
819 ip,
820 tags: extract_tags(&instance.freeform_tags),
821 metadata,
822 });
823 }
824
825 if fetch_failures > 0 {
826 if hosts.is_empty() {
827 return Err(ProviderError::Http(format!(
828 "Failed to fetch details for all {} instances",
829 total_instances
830 )));
831 }
832 return Err(ProviderError::PartialResult {
833 hosts,
834 failures: fetch_failures,
835 total: total_instances,
836 });
837 }
838
839 Ok(hosts)
840 }
841}
842
843fn urlencoding_encode(input: &str) -> String {
845 super::percent_encode(input)
846}
847
848#[cfg(test)]
853mod tests {
854 use super::*;
855
856 fn minimal_config() -> &'static str {
861 "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\nregion=us-ashburn-1\nkey_file=~/.oci/key.pem\n"
862 }
863
864 #[test]
865 fn test_parse_oci_config_valid() {
866 let creds = parse_oci_config(minimal_config(), "PEM_CONTENT").unwrap();
867 assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
868 assert_eq!(creds.user, "ocid1.user.oc1..bbb");
869 assert_eq!(creds.fingerprint, "aa:bb:cc");
870 assert_eq!(creds.region, "us-ashburn-1");
871 assert_eq!(creds.key_pem, "PEM_CONTENT");
872 }
873
874 #[test]
875 fn test_parse_oci_config_missing_tenancy() {
876 let cfg = "[DEFAULT]\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\n";
877 let err = parse_oci_config(cfg, "").unwrap_err();
878 assert!(err.to_string().contains("tenancy"));
879 }
880
881 #[test]
882 fn test_parse_oci_config_missing_user() {
883 let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nfingerprint=aa:bb:cc\n";
884 let err = parse_oci_config(cfg, "").unwrap_err();
885 assert!(err.to_string().contains("user"));
886 }
887
888 #[test]
889 fn test_parse_oci_config_missing_fingerprint() {
890 let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\n";
891 let err = parse_oci_config(cfg, "").unwrap_err();
892 assert!(err.to_string().contains("fingerprint"));
893 }
894
895 #[test]
896 fn test_parse_oci_config_no_default_profile() {
897 let cfg = "[OTHER]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
898 let err = parse_oci_config(cfg, "").unwrap_err();
899 assert!(err.to_string().contains("tenancy"));
900 }
901
902 #[test]
903 fn test_parse_oci_config_multiple_profiles_reads_default() {
904 let cfg = "[OTHER]\ntenancy=wrong\n[DEFAULT]\ntenancy=right\nuser=u\nfingerprint=f\n";
905 let creds = parse_oci_config(cfg, "").unwrap();
906 assert_eq!(creds.tenancy, "right");
907 }
908
909 #[test]
910 fn test_parse_oci_config_whitespace_trimmed() {
911 let cfg = "[DEFAULT]\n tenancy = ocid1.tenancy.oc1..aaa \n user = u \n fingerprint = f \n";
912 let creds = parse_oci_config(cfg, "").unwrap();
913 assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
914 assert_eq!(creds.user, "u");
915 assert_eq!(creds.fingerprint, "f");
916 }
917
918 #[test]
919 fn test_parse_oci_config_crlf() {
920 let cfg = "[DEFAULT]\r\ntenancy=ocid1.tenancy.oc1..aaa\r\nuser=u\r\nfingerprint=f\r\n";
921 let creds = parse_oci_config(cfg, "").unwrap();
922 assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
923 }
924
925 #[test]
926 fn test_parse_oci_config_empty_file() {
927 let err = parse_oci_config("", "").unwrap_err();
928 assert!(err.to_string().contains("tenancy"));
929 }
930
931 #[test]
932 fn test_validate_compartment_valid() {
933 assert!(validate_compartment("ocid1.compartment.oc1..aaaaaaaa1234").is_ok());
934 }
935
936 #[test]
937 fn test_validate_compartment_tenancy_accepted() {
938 assert!(validate_compartment("ocid1.tenancy.oc1..aaaaaaaa1234").is_ok());
939 }
940
941 #[test]
942 fn test_validate_compartment_invalid() {
943 assert!(validate_compartment("ocid1.instance.oc1..xxx").is_err());
944 assert!(validate_compartment("not-an-ocid").is_err());
945 assert!(validate_compartment("").is_err());
946 }
947
948 #[test]
953 fn test_format_rfc7231_known_vector() {
954 assert_eq!(
956 format_rfc7231(1_774_526_400),
957 "Thu, 26 Mar 2026 12:00:00 GMT"
958 );
959 }
960
961 #[test]
962 fn test_format_rfc7231_epoch_zero() {
963 assert_eq!(format_rfc7231(0), "Thu, 01 Jan 1970 00:00:00 GMT");
964 }
965
966 #[test]
967 fn test_format_rfc7231_leap_year() {
968 assert_eq!(
970 format_rfc7231(1_582_934_400),
971 "Sat, 29 Feb 2020 00:00:00 GMT"
972 );
973 }
974
975 fn load_test_key() -> String {
980 std::fs::read_to_string(concat!(
981 env!("CARGO_MANIFEST_DIR"),
982 "/tests/fixtures/test_oci_key.pem"
983 ))
984 .expect("test key fixture missing")
985 }
986
987 fn load_test_key_pkcs1() -> String {
988 std::fs::read_to_string(concat!(
989 env!("CARGO_MANIFEST_DIR"),
990 "/tests/fixtures/test_oci_key_pkcs1.pem"
991 ))
992 .expect("test pkcs1 key fixture missing")
993 }
994
995 fn make_creds(key_pem: String) -> OciCredentials {
996 OciCredentials {
997 tenancy: "ocid1.tenancy.oc1..aaa".into(),
998 user: "ocid1.user.oc1..bbb".into(),
999 fingerprint: "aa:bb:cc:dd".into(),
1000 key_pem,
1001 region: "us-ashburn-1".into(),
1002 }
1003 }
1004
1005 #[test]
1006 fn test_sign_request_authorization_header_format() {
1007 let creds = make_creds(load_test_key());
1008 let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1009 let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1010 let result = sign_request(
1011 &creds,
1012 &rsa_key,
1013 date,
1014 "iaas.us-ashburn-1.oraclecloud.com",
1015 "/20160918/instances",
1016 )
1017 .unwrap();
1018 assert!(result.starts_with("Signature version=\"1\",keyId="));
1019 assert!(result.contains("algorithm=\"rsa-sha256\""));
1020 assert!(result.contains("headers=\"date (request-target) host\""));
1022 assert!(result.contains("signature=\""));
1023 let expected_key_id = format!(
1025 "keyId=\"{}/{}/{}\"",
1026 creds.tenancy, creds.user, creds.fingerprint
1027 );
1028 assert!(
1029 result.contains(&expected_key_id),
1030 "keyId mismatch: expected {} in {}",
1031 expected_key_id,
1032 result
1033 );
1034 }
1035
1036 #[test]
1037 fn test_sign_request_deterministic() {
1038 let key = load_test_key();
1039 let creds1 = make_creds(key.clone());
1040 let creds2 = make_creds(key);
1041 let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1042 let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1043 let host = "iaas.us-ashburn-1.oraclecloud.com";
1044 let path = "/20160918/instances";
1045 let r1 = sign_request(&creds1, &rsa_key, date, host, path).unwrap();
1046 let r2 = sign_request(&creds2, &rsa_key, date, host, path).unwrap();
1047 assert_eq!(r1, r2);
1048 }
1049
1050 #[test]
1051 fn test_sign_request_different_hosts_differ() {
1052 let key = load_test_key();
1053 let creds1 = make_creds(key.clone());
1054 let creds2 = make_creds(key);
1055 let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1056 let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1057 let path = "/20160918/instances";
1058 let r1 = sign_request(
1059 &creds1,
1060 &rsa_key,
1061 date,
1062 "iaas.us-ashburn-1.oraclecloud.com",
1063 path,
1064 )
1065 .unwrap();
1066 let r2 = sign_request(
1067 &creds2,
1068 &rsa_key,
1069 date,
1070 "iaas.us-phoenix-1.oraclecloud.com",
1071 path,
1072 )
1073 .unwrap();
1074 assert_ne!(r1, r2);
1075 }
1076
1077 #[test]
1078 fn test_parse_private_key_pkcs1() {
1079 let pem = load_test_key_pkcs1();
1080 assert!(parse_private_key(&pem).is_ok());
1081 }
1082
1083 #[test]
1084 fn test_parse_private_key_pkcs8() {
1085 let pem = load_test_key();
1086 assert!(parse_private_key(&pem).is_ok());
1087 }
1088
1089 #[test]
1090 fn test_parse_private_key_encrypted_detected() {
1091 let fake_encrypted = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: ...\ndata\n-----END RSA PRIVATE KEY-----";
1092 let err = parse_private_key(fake_encrypted).unwrap_err();
1093 assert!(err.to_string().to_lowercase().contains("encrypt"));
1094 }
1095
1096 #[test]
1097 fn test_parse_private_key_proc_type_encrypted() {
1098 let pem = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nABC\n-----END RSA PRIVATE KEY-----";
1100 let err = parse_private_key(pem).unwrap_err();
1101 assert!(err.to_string().to_lowercase().contains("encrypt"));
1102 }
1103
1104 #[test]
1105 fn test_parse_private_key_malformed() {
1106 let err = parse_private_key("not a pem key at all").unwrap_err();
1107 assert!(err.to_string().contains("Failed to parse OCI private key"));
1108 }
1109
1110 #[test]
1115 fn test_extract_key_file_present() {
1116 let cfg = "[DEFAULT]\ntenancy=t\nkey_file=~/.oci/key.pem\n";
1117 assert_eq!(extract_key_file(cfg).unwrap(), "~/.oci/key.pem");
1118 }
1119
1120 #[test]
1121 fn test_extract_key_file_missing() {
1122 let cfg = "[DEFAULT]\ntenancy=t\n";
1123 assert!(extract_key_file(cfg).is_err());
1124 }
1125
1126 #[test]
1131 fn test_deserialize_list_instances() {
1132 let json = r#"[
1133 {
1134 "id": "ocid1.instance.oc1..aaa",
1135 "displayName": "my-server",
1136 "lifecycleState": "RUNNING",
1137 "shape": "VM.Standard2.1",
1138 "imageId": "ocid1.image.oc1..img",
1139 "freeformTags": {"env": "prod", "team": "ops"}
1140 }
1141 ]"#;
1142 let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1143 assert_eq!(items.len(), 1);
1144 let inst = &items[0];
1145 assert_eq!(inst.id, "ocid1.instance.oc1..aaa");
1146 assert_eq!(inst.display_name, "my-server");
1147 assert_eq!(inst.shape, "VM.Standard2.1");
1148 let tags = inst.freeform_tags.as_ref().unwrap();
1149 assert_eq!(tags.get("env").map(String::as_str), Some("prod"));
1150 assert_eq!(tags.get("team").map(String::as_str), Some("ops"));
1151 }
1152
1153 #[test]
1154 fn test_deserialize_list_instances_empty() {
1155 let json = r#"[]"#;
1156 let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1157 assert_eq!(items.len(), 0);
1158 }
1159
1160 #[test]
1161 fn test_deserialize_list_instances_null_image_id() {
1162 let json = r#"[
1163 {
1164 "id": "ocid1.instance.oc1..bbb",
1165 "displayName": "no-image",
1166 "lifecycleState": "STOPPED",
1167 "shape": "VM.Standard2.1"
1168 }
1169 ]"#;
1170 let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1171 assert_eq!(items.len(), 1);
1172 assert!(items[0].image_id.is_none());
1173 assert!(items[0].freeform_tags.is_none());
1174 }
1175
1176 #[test]
1177 fn test_deserialize_vnic_attachment_is_primary() {
1178 let json = r#"[
1179 {
1180 "instanceId": "ocid1.instance.oc1..aaa",
1181 "vnicId": "ocid1.vnic.oc1..vvv",
1182 "lifecycleState": "ATTACHED",
1183 "isPrimary": true
1184 }
1185 ]"#;
1186 let items: Vec<OciVnicAttachment> = serde_json::from_str(json).unwrap();
1187 assert_eq!(items.len(), 1);
1188 let att = &items[0];
1189 assert_eq!(att.instance_id, "ocid1.instance.oc1..aaa");
1190 assert_eq!(att.vnic_id.as_deref(), Some("ocid1.vnic.oc1..vvv"));
1191 assert_eq!(att.lifecycle_state, "ATTACHED");
1192 assert_eq!(att.is_primary, Some(true));
1193 }
1194
1195 #[test]
1196 fn test_deserialize_vnic_public_and_private() {
1197 let json = r#"{"publicIp": "1.2.3.4", "privateIp": "10.0.0.5"}"#;
1198 let vnic: OciVnic = serde_json::from_str(json).unwrap();
1199 assert_eq!(vnic.public_ip.as_deref(), Some("1.2.3.4"));
1200 assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1201 }
1202
1203 #[test]
1204 fn test_deserialize_vnic_private_only() {
1205 let json = r#"{"privateIp": "10.0.0.5"}"#;
1206 let vnic: OciVnic = serde_json::from_str(json).unwrap();
1207 assert!(vnic.public_ip.is_none());
1208 assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1209 }
1210
1211 #[test]
1212 fn test_deserialize_image() {
1213 let json = r#"{"displayName": "Oracle-Linux-8.9"}"#;
1214 let img: OciImage = serde_json::from_str(json).unwrap();
1215 assert_eq!(img.display_name.as_deref(), Some("Oracle-Linux-8.9"));
1216 }
1217
1218 #[test]
1219 fn test_deserialize_error_body() {
1220 let json = r#"{"code": "NotAuthenticated", "message": "Missing or invalid credentials."}"#;
1221 let err: OciErrorBody = serde_json::from_str(json).unwrap();
1222 assert_eq!(err.code.as_deref(), Some("NotAuthenticated"));
1223 assert_eq!(
1224 err.message.as_deref(),
1225 Some("Missing or invalid credentials.")
1226 );
1227 }
1228
1229 #[test]
1230 fn test_deserialize_error_body_missing_fields() {
1231 let json = r#"{}"#;
1232 let err: OciErrorBody = serde_json::from_str(json).unwrap();
1233 assert!(err.code.is_none());
1234 assert!(err.message.is_none());
1235 }
1236
1237 #[test]
1242 fn test_select_ip_public_preferred() {
1243 let vnic = OciVnic {
1244 public_ip: Some("1.2.3.4".to_string()),
1245 private_ip: Some("10.0.0.1".to_string()),
1246 };
1247 assert_eq!(select_ip(&vnic), "1.2.3.4");
1248 }
1249
1250 #[test]
1251 fn test_select_ip_private_fallback() {
1252 let vnic = OciVnic {
1253 public_ip: None,
1254 private_ip: Some("10.0.0.1".to_string()),
1255 };
1256 assert_eq!(select_ip(&vnic), "10.0.0.1");
1257 }
1258
1259 #[test]
1260 fn test_select_ip_empty() {
1261 let vnic = OciVnic {
1262 public_ip: None,
1263 private_ip: None,
1264 };
1265 assert_eq!(select_ip(&vnic), "");
1266 }
1267
1268 #[test]
1269 fn test_select_primary_vnic() {
1270 let attachments = vec![
1271 OciVnicAttachment {
1272 instance_id: "inst-1".to_string(),
1273 vnic_id: Some("vnic-secondary".to_string()),
1274 lifecycle_state: "ATTACHED".to_string(),
1275 is_primary: Some(false),
1276 },
1277 OciVnicAttachment {
1278 instance_id: "inst-1".to_string(),
1279 vnic_id: Some("vnic-primary".to_string()),
1280 lifecycle_state: "ATTACHED".to_string(),
1281 is_primary: Some(true),
1282 },
1283 ];
1284 assert_eq!(
1285 select_vnic_for_instance(&attachments, "inst-1"),
1286 Some("vnic-primary".to_string())
1287 );
1288 }
1289
1290 #[test]
1291 fn test_select_vnic_no_primary_uses_first() {
1292 let attachments = vec![
1293 OciVnicAttachment {
1294 instance_id: "inst-1".to_string(),
1295 vnic_id: Some("vnic-first".to_string()),
1296 lifecycle_state: "ATTACHED".to_string(),
1297 is_primary: None,
1298 },
1299 OciVnicAttachment {
1300 instance_id: "inst-1".to_string(),
1301 vnic_id: Some("vnic-second".to_string()),
1302 lifecycle_state: "ATTACHED".to_string(),
1303 is_primary: None,
1304 },
1305 ];
1306 assert_eq!(
1307 select_vnic_for_instance(&attachments, "inst-1"),
1308 Some("vnic-first".to_string())
1309 );
1310 }
1311
1312 #[test]
1313 fn test_select_vnic_no_attachment() {
1314 let attachments: Vec<OciVnicAttachment> = vec![];
1315 assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1316 }
1317
1318 #[test]
1319 fn test_select_vnic_filters_by_instance_id() {
1320 let attachments = vec![OciVnicAttachment {
1321 instance_id: "inst-other".to_string(),
1322 vnic_id: Some("vnic-other".to_string()),
1323 lifecycle_state: "ATTACHED".to_string(),
1324 is_primary: Some(true),
1325 }];
1326 assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1327 }
1328
1329 #[test]
1330 fn test_extract_freeform_tags() {
1331 let mut map = std::collections::HashMap::new();
1332 map.insert("env".to_string(), "prod".to_string());
1333 map.insert("role".to_string(), "".to_string());
1334 map.insert("team".to_string(), "ops".to_string());
1335 let tags = extract_tags(&Some(map));
1336 assert_eq!(tags, vec!["env:prod", "role", "team:ops"]);
1338 }
1339
1340 #[test]
1341 fn test_extract_freeform_tags_empty() {
1342 let tags = extract_tags(&None);
1343 assert!(tags.is_empty());
1344 }
1345
1346 #[test]
1351 fn test_oci_regions_count() {
1352 assert_eq!(OCI_REGIONS.len(), 38);
1353 }
1354
1355 #[test]
1356 fn test_oci_regions_no_duplicates() {
1357 let mut ids: Vec<&str> = OCI_REGIONS.iter().map(|(id, _)| *id).collect();
1358 ids.sort_unstable();
1359 let before = ids.len();
1360 ids.dedup();
1361 assert_eq!(ids.len(), before, "duplicate region IDs found");
1362 }
1363
1364 #[test]
1365 fn test_oci_region_groups_cover_all() {
1366 use std::collections::HashSet;
1367 let group_indices: HashSet<usize> = OCI_REGION_GROUPS
1368 .iter()
1369 .flat_map(|(_, s, e)| *s..*e)
1370 .collect();
1371 let all_indices: HashSet<usize> = (0..OCI_REGIONS.len()).collect();
1372 assert_eq!(
1373 group_indices, all_indices,
1374 "region groups must cover all region indices exactly"
1375 );
1376 for (_, start, end) in OCI_REGION_GROUPS {
1377 assert!(*end <= OCI_REGIONS.len());
1378 assert!(start < end);
1379 }
1380 }
1381
1382 #[test]
1383 fn test_oracle_provider_name() {
1384 let oracle = Oracle {
1385 regions: Vec::new(),
1386 compartment: String::new(),
1387 };
1388 assert_eq!(oracle.name(), "oracle");
1389 assert_eq!(oracle.short_label(), "oci");
1390 }
1391
1392 #[test]
1393 fn test_oracle_empty_compartment_error() {
1394 let oracle = Oracle {
1395 regions: Vec::new(),
1396 compartment: String::new(),
1397 };
1398 let cancel = AtomicBool::new(false);
1399 let err = oracle
1400 .fetch_hosts_with_progress("some_token", &cancel, &|_| {})
1401 .unwrap_err();
1402 assert!(err.to_string().contains("compartment"));
1403 }
1404
1405 #[test]
1410 fn test_malformed_json_instance_list() {
1411 let result = serde_json::from_str::<Vec<OciInstance>>("not json");
1412 assert!(result.is_err());
1413 }
1414
1415 #[test]
1416 fn test_parse_private_key_empty_string() {
1417 let err = parse_private_key("").unwrap_err();
1418 assert!(
1419 err.to_string().contains("Failed to parse OCI private key"),
1420 "got: {}",
1421 err
1422 );
1423 }
1424
1425 #[test]
1426 fn test_parse_oci_config_missing_region_defaults_empty() {
1427 let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
1428 let creds = parse_oci_config(cfg, "").unwrap();
1429 assert_eq!(creds.region, "");
1430 }
1431
1432 #[test]
1433 fn test_sign_request_headers_exact() {
1434 let creds = make_creds(load_test_key());
1435 let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1436 let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1437 let result = sign_request(
1438 &creds,
1439 &rsa_key,
1440 date,
1441 "iaas.us-ashburn-1.oraclecloud.com",
1442 "/20160918/instances",
1443 )
1444 .unwrap();
1445 assert!(
1447 result.contains("headers=\"date (request-target) host\""),
1448 "headers field mismatch in: {}",
1449 result
1450 );
1451 }
1452
1453 #[test]
1454 fn test_sign_request_key_id_format() {
1455 let creds = make_creds(load_test_key());
1456 let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1457 let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1458 let result = sign_request(
1459 &creds,
1460 &rsa_key,
1461 date,
1462 "iaas.us-ashburn-1.oraclecloud.com",
1463 "/20160918/instances",
1464 )
1465 .unwrap();
1466 let expected = format!(
1467 "keyId=\"{}/{}/{}\"",
1468 creds.tenancy, creds.user, creds.fingerprint
1469 );
1470 assert!(
1471 result.contains(&expected),
1472 "expected keyId {} in: {}",
1473 expected,
1474 result
1475 );
1476 }
1477
1478 #[test]
1479 fn test_deserialize_multiple_instances() {
1480 let json = r#"[
1481 {
1482 "id": "ocid1.instance.oc1..aaa",
1483 "displayName": "server-1",
1484 "lifecycleState": "RUNNING",
1485 "shape": "VM.Standard2.1"
1486 },
1487 {
1488 "id": "ocid1.instance.oc1..bbb",
1489 "displayName": "server-2",
1490 "lifecycleState": "STOPPED",
1491 "shape": "VM.Standard.E4.Flex",
1492 "imageId": "ocid1.image.oc1..img2"
1493 }
1494 ]"#;
1495 let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1496 assert_eq!(items.len(), 2);
1497 assert_eq!(items[0].id, "ocid1.instance.oc1..aaa");
1498 assert_eq!(items[0].display_name, "server-1");
1499 assert_eq!(items[1].id, "ocid1.instance.oc1..bbb");
1500 assert_eq!(items[1].display_name, "server-2");
1501 assert_eq!(items[1].image_id.as_deref(), Some("ocid1.image.oc1..img2"));
1502 }
1503
1504 #[test]
1505 fn test_extract_freeform_tags_special_chars() {
1506 let mut map = std::collections::HashMap::new();
1507 map.insert("path".to_string(), "/usr/local/bin".to_string());
1508 map.insert("env:tier".to_string(), "prod/us-east".to_string());
1509 let tags = extract_tags(&Some(map));
1510 assert!(tags.contains(&"env:tier:prod/us-east".to_string()));
1511 assert!(tags.contains(&"path:/usr/local/bin".to_string()));
1512 }
1513
1514 #[test]
1519 fn test_http_list_instances_roundtrip() {
1520 let mut server = mockito::Server::new();
1521 let mock = server
1522 .mock("GET", "/20160918/instances")
1523 .match_query(mockito::Matcher::AllOf(vec![
1524 mockito::Matcher::UrlEncoded(
1525 "compartmentId".into(),
1526 "ocid1.compartment.oc1..aaa".into(),
1527 ),
1528 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1529 ]))
1530 .match_header("Authorization", mockito::Matcher::Any)
1531 .with_status(200)
1532 .with_header("content-type", "application/json")
1533 .with_body(
1534 r#"[
1535 {
1536 "id": "ocid1.instance.oc1..inst1",
1537 "displayName": "web-prod-1",
1538 "lifecycleState": "RUNNING",
1539 "shape": "VM.Standard2.1",
1540 "imageId": "ocid1.image.oc1..img1",
1541 "freeformTags": {"env": "prod", "team": "web"}
1542 }
1543 ]"#,
1544 )
1545 .create();
1546
1547 let agent = super::super::http_agent();
1548 let url = format!(
1549 "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1550 server.url()
1551 );
1552 let items: Vec<OciInstance> = agent
1553 .get(&url)
1554 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1555 .call()
1556 .unwrap()
1557 .body_mut()
1558 .read_json()
1559 .unwrap();
1560
1561 assert_eq!(items.len(), 1);
1562 assert_eq!(items[0].id, "ocid1.instance.oc1..inst1");
1563 assert_eq!(items[0].display_name, "web-prod-1");
1564 assert_eq!(items[0].lifecycle_state, "RUNNING");
1565 assert_eq!(items[0].shape, "VM.Standard2.1");
1566 assert_eq!(items[0].image_id.as_deref(), Some("ocid1.image.oc1..img1"));
1567 let tags = items[0].freeform_tags.as_ref().unwrap();
1568 assert_eq!(tags.get("env").unwrap(), "prod");
1569 assert_eq!(tags.get("team").unwrap(), "web");
1570 mock.assert();
1571 }
1572
1573 #[test]
1574 fn test_http_list_instances_pagination() {
1575 let mut server = mockito::Server::new();
1576 let page1 = server
1577 .mock("GET", "/20160918/instances")
1578 .match_query(mockito::Matcher::AllOf(vec![
1579 mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1580 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1581 ]))
1582 .with_status(200)
1583 .with_header("content-type", "application/json")
1584 .with_header("opc-next-page", "page2-token")
1585 .with_body(
1586 r#"[{"id": "ocid1.instance.oc1..a", "displayName": "srv-1", "lifecycleState": "RUNNING", "shape": "VM.Standard2.1"}]"#,
1587 )
1588 .create();
1589 let page2 = server
1590 .mock("GET", "/20160918/instances")
1591 .match_query(mockito::Matcher::AllOf(vec![
1592 mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1593 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1594 mockito::Matcher::UrlEncoded("page".into(), "page2-token".into()),
1595 ]))
1596 .with_status(200)
1597 .with_header("content-type", "application/json")
1598 .with_body(
1599 r#"[{"id": "ocid1.instance.oc1..b", "displayName": "srv-2", "lifecycleState": "STOPPED", "shape": "VM.Standard.E4.Flex"}]"#,
1600 )
1601 .create();
1602
1603 let agent = super::super::http_agent();
1604 let resp1 = agent
1606 .get(&format!(
1607 "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1608 server.url()
1609 ))
1610 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1611 .call()
1612 .unwrap();
1613 let next_page = resp1
1614 .headers()
1615 .get("opc-next-page")
1616 .and_then(|v| v.to_str().ok())
1617 .map(String::from);
1618 let items1: Vec<OciInstance> =
1619 serde_json::from_str(&resp1.into_body().read_to_string().unwrap()).unwrap();
1620 assert_eq!(items1.len(), 1);
1621 assert_eq!(items1[0].id, "ocid1.instance.oc1..a");
1622 assert_eq!(next_page.as_deref(), Some("page2-token"));
1623
1624 let items2: Vec<OciInstance> = agent
1626 .get(&format!(
1627 "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100&page=page2-token",
1628 server.url()
1629 ))
1630 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1631 .call()
1632 .unwrap()
1633 .body_mut()
1634 .read_json()
1635 .unwrap();
1636 assert_eq!(items2.len(), 1);
1637 assert_eq!(items2[0].id, "ocid1.instance.oc1..b");
1638
1639 page1.assert();
1640 page2.assert();
1641 }
1642
1643 #[test]
1644 fn test_http_list_instances_auth_failure() {
1645 let mut server = mockito::Server::new();
1646 let mock = server
1647 .mock("GET", "/20160918/instances")
1648 .match_query(mockito::Matcher::Any)
1649 .with_status(401)
1650 .with_body(r#"{"code": "NotAuthenticated", "message": "The required information to complete authentication was not provided."}"#)
1651 .create();
1652
1653 let agent = super::super::http_agent();
1654 let result = agent
1655 .get(&format!(
1656 "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1657 server.url()
1658 ))
1659 .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1660 .call();
1661
1662 match result {
1663 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
1665 }
1666 mock.assert();
1667 }
1668
1669 #[test]
1670 fn test_http_vnic_attachments_roundtrip() {
1671 let mut server = mockito::Server::new();
1672 let mock = server
1673 .mock("GET", "/20160918/vnicAttachments")
1674 .match_query(mockito::Matcher::AllOf(vec![
1675 mockito::Matcher::UrlEncoded(
1676 "compartmentId".into(),
1677 "ocid1.compartment.oc1..aaa".into(),
1678 ),
1679 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1680 ]))
1681 .match_header("Authorization", mockito::Matcher::Any)
1682 .with_status(200)
1683 .with_header("content-type", "application/json")
1684 .with_body(
1685 r#"[
1686 {
1687 "instanceId": "ocid1.instance.oc1..inst1",
1688 "vnicId": "ocid1.vnic.oc1..vnic1",
1689 "lifecycleState": "ATTACHED",
1690 "isPrimary": true
1691 },
1692 {
1693 "instanceId": "ocid1.instance.oc1..inst1",
1694 "vnicId": "ocid1.vnic.oc1..vnic2",
1695 "lifecycleState": "ATTACHED",
1696 "isPrimary": false
1697 }
1698 ]"#,
1699 )
1700 .create();
1701
1702 let agent = super::super::http_agent();
1703 let url = format!(
1704 "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1705 server.url()
1706 );
1707 let items: Vec<OciVnicAttachment> = agent
1708 .get(&url)
1709 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1710 .call()
1711 .unwrap()
1712 .body_mut()
1713 .read_json()
1714 .unwrap();
1715
1716 assert_eq!(items.len(), 2);
1717 assert_eq!(items[0].instance_id, "ocid1.instance.oc1..inst1");
1718 assert_eq!(items[0].vnic_id.as_deref(), Some("ocid1.vnic.oc1..vnic1"));
1719 assert_eq!(items[0].lifecycle_state, "ATTACHED");
1720 assert_eq!(items[0].is_primary, Some(true));
1721 assert_eq!(items[1].is_primary, Some(false));
1722 mock.assert();
1723 }
1724
1725 #[test]
1726 fn test_http_vnic_attachments_auth_failure() {
1727 let mut server = mockito::Server::new();
1728 let mock = server
1729 .mock("GET", "/20160918/vnicAttachments")
1730 .match_query(mockito::Matcher::Any)
1731 .with_status(401)
1732 .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1733 .create();
1734
1735 let agent = super::super::http_agent();
1736 let result = agent
1737 .get(&format!(
1738 "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1739 server.url()
1740 ))
1741 .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1742 .call();
1743
1744 match result {
1745 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
1747 }
1748 mock.assert();
1749 }
1750
1751 #[test]
1752 fn test_http_get_vnic_roundtrip() {
1753 let mut server = mockito::Server::new();
1754 let mock = server
1755 .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1756 .match_header("Authorization", mockito::Matcher::Any)
1757 .with_status(200)
1758 .with_header("content-type", "application/json")
1759 .with_body(r#"{"publicIp": "129.146.10.1", "privateIp": "10.0.0.5"}"#)
1760 .create();
1761
1762 let agent = super::super::http_agent();
1763 let url = format!("{}/20160918/vnics/ocid1.vnic.oc1..vnic1", server.url());
1764 let vnic: OciVnic = agent
1765 .get(&url)
1766 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1767 .call()
1768 .unwrap()
1769 .body_mut()
1770 .read_json()
1771 .unwrap();
1772
1773 assert_eq!(vnic.public_ip.as_deref(), Some("129.146.10.1"));
1774 assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1775 mock.assert();
1776 }
1777
1778 #[test]
1779 fn test_http_get_vnic_auth_failure() {
1780 let mut server = mockito::Server::new();
1781 let mock = server
1782 .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1783 .match_query(mockito::Matcher::Any)
1784 .with_status(401)
1785 .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1786 .create();
1787
1788 let agent = super::super::http_agent();
1789 let result = agent
1790 .get(&format!(
1791 "{}/20160918/vnics/ocid1.vnic.oc1..vnic1",
1792 server.url()
1793 ))
1794 .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1795 .call();
1796
1797 match result {
1798 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
1800 }
1801 mock.assert();
1802 }
1803
1804 #[test]
1805 fn test_http_get_image_roundtrip() {
1806 let mut server = mockito::Server::new();
1807 let mock = server
1808 .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1809 .match_header("Authorization", mockito::Matcher::Any)
1810 .with_status(200)
1811 .with_header("content-type", "application/json")
1812 .with_body(r#"{"displayName": "Oracle-Linux-8.8-2024.01.26-0"}"#)
1813 .create();
1814
1815 let agent = super::super::http_agent();
1816 let url = format!("{}/20160918/images/ocid1.image.oc1..img1", server.url());
1817 let image: OciImage = agent
1818 .get(&url)
1819 .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1820 .call()
1821 .unwrap()
1822 .body_mut()
1823 .read_json()
1824 .unwrap();
1825
1826 assert_eq!(
1827 image.display_name.as_deref(),
1828 Some("Oracle-Linux-8.8-2024.01.26-0")
1829 );
1830 mock.assert();
1831 }
1832
1833 #[test]
1834 fn test_http_get_image_auth_failure() {
1835 let mut server = mockito::Server::new();
1836 let mock = server
1837 .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1838 .match_query(mockito::Matcher::Any)
1839 .with_status(401)
1840 .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1841 .create();
1842
1843 let agent = super::super::http_agent();
1844 let result = agent
1845 .get(&format!(
1846 "{}/20160918/images/ocid1.image.oc1..img1",
1847 server.url()
1848 ))
1849 .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1850 .call();
1851
1852 match result {
1853 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
1855 }
1856 mock.assert();
1857 }
1858
1859 #[test]
1862 fn test_deserialize_compartment() {
1863 let json = r#"[
1864 {"id": "ocid1.compartment.oc1..child1", "lifecycleState": "ACTIVE"},
1865 {"id": "ocid1.compartment.oc1..child2", "lifecycleState": "DELETED"}
1866 ]"#;
1867 let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1868 assert_eq!(items.len(), 2);
1869 assert_eq!(items[0].id, "ocid1.compartment.oc1..child1");
1870 assert_eq!(items[0].lifecycle_state, "ACTIVE");
1871 assert_eq!(items[1].lifecycle_state, "DELETED");
1872 }
1873
1874 #[test]
1875 fn test_deserialize_compartment_empty() {
1876 let json = r#"[]"#;
1877 let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1878 assert_eq!(items.len(), 0);
1879 }
1880
1881 #[test]
1882 fn test_http_list_compartments_roundtrip() {
1883 let mut server = mockito::Server::new();
1884 let mock = server
1885 .mock("GET", "/20160918/compartments")
1886 .match_query(mockito::Matcher::AllOf(vec![
1887 mockito::Matcher::UrlEncoded(
1888 "compartmentId".into(),
1889 "ocid1.tenancy.oc1..root".into(),
1890 ),
1891 mockito::Matcher::UrlEncoded("compartmentIdInSubtree".into(), "true".into()),
1892 mockito::Matcher::UrlEncoded("lifecycleState".into(), "ACTIVE".into()),
1893 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1894 ]))
1895 .with_status(200)
1896 .with_header("content-type", "application/json")
1897 .with_body(
1898 r#"[
1899 {"id": "ocid1.compartment.oc1..prod", "lifecycleState": "ACTIVE"},
1900 {"id": "ocid1.compartment.oc1..staging", "lifecycleState": "ACTIVE"},
1901 {"id": "ocid1.compartment.oc1..old", "lifecycleState": "DELETED"}
1902 ]"#,
1903 )
1904 .create();
1905
1906 let agent = super::super::http_agent();
1907 let url = format!(
1908 "{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1909 server.url(),
1910 "ocid1.tenancy.oc1..root"
1911 );
1912 let items: Vec<OciCompartment> = agent
1913 .get(&url)
1914 .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
1915 .header("Authorization", "Signature version=\"1\",keyId=\"test\"")
1916 .call()
1917 .unwrap()
1918 .body_mut()
1919 .read_json()
1920 .unwrap();
1921
1922 let active: Vec<_> = items
1924 .iter()
1925 .filter(|c| c.lifecycle_state == "ACTIVE")
1926 .collect();
1927 assert_eq!(active.len(), 2);
1928 assert_eq!(active[0].id, "ocid1.compartment.oc1..prod");
1929 assert_eq!(active[1].id, "ocid1.compartment.oc1..staging");
1930 mock.assert();
1931 }
1932
1933 #[test]
1934 fn test_http_list_compartments_auth_failure() {
1935 let mut server = mockito::Server::new();
1936 let mock = server
1937 .mock("GET", "/20160918/compartments")
1938 .match_query(mockito::Matcher::Any)
1939 .with_status(401)
1940 .with_body(r#"{"code": "NotAuthenticated", "message": "Not authenticated"}"#)
1941 .create();
1942
1943 let agent = super::super::http_agent();
1944 let result = agent
1945 .get(&format!(
1946 "{}/20160918/compartments?compartmentId=x&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1947 server.url()
1948 ))
1949 .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
1950 .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1951 .call();
1952
1953 match result {
1954 Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
1956 }
1957 mock.assert();
1958 }
1959}