cloud_scanner_cli/
boavizta_api_v1.rs

1//!  A service to retrieve cloud resource impacts from Boavizta API.
2use crate::impact_provider::{CloudResourceWithImpacts, ImpactProvider, ImpactsValues};
3use anyhow::Result;
4use boavizta_api_sdk::apis::cloud_api;
5use boavizta_api_sdk::apis::component_api;
6use boavizta_api_sdk::apis::configuration;
7use boavizta_api_sdk::apis::utils_api;
8use chrono::Utc;
9use std::time::{Duration, Instant};
10
11use crate::model::{
12    CloudResource, EstimatedInventory, EstimationMetadata, ExecutionStatistics, Inventory,
13    ResourceDetails,
14};
15use boavizta_api_sdk::models::{Cloud, Disk, UsageCloud};
16
17/// Access data of Boavizta API
18pub struct BoaviztaApiV1 {
19    configuration: boavizta_api_sdk::apis::configuration::Configuration,
20}
21
22/// Create a new instance of service to access Boavizta API by passing API URL.
23impl BoaviztaApiV1 {
24    pub fn new(api_url: &str) -> Self {
25        let mut configuration = configuration::Configuration::new();
26        configuration.base_path = api_url.to_string();
27        BoaviztaApiV1 { configuration }
28    }
29
30    // Returns the version of Boavizta API
31    async fn get_api_version(&self) -> Option<String> {
32        let res = utils_api::version_v1_utils_version_get(&self.configuration).await;
33        if let Ok(serde_json::Value::String(v)) = res {
34            Some(v)
35        } else {
36            error!("Cannot fetch API version");
37            None
38        }
39    }
40
41    // Returns the raw impacts (json) of an instance from Boavizta API for the duration of use (hours)
42    async fn get_raws_impacts(
43        &self,
44        cr: CloudResource,
45        usage_duration_hours: &f32,
46        verbose: bool,
47    ) -> Option<serde_json::Value> {
48        let resource_details = cr.resource_details;
49        let criteria = vec!["gwp".to_owned(), "adp".to_owned(), "pe".to_owned()];
50
51        match resource_details {
52            ResourceDetails::Instance {
53                instance_type,
54                usage,
55            } => {
56                let mut usage_cloud: UsageCloud = UsageCloud::new();
57
58                //usage_cloud.hours_life_time = Some(usage_duration_hours.to_owned());
59                usage_cloud.usage_location = Some(Some(cr.location.iso_country_code.to_owned()));
60
61                if let Some(instance_usage) = usage {
62                    usage_cloud.time_workload = Some(instance_usage.average_cpu_load);
63                }
64
65                let mut cloud: Cloud = Cloud::new();
66                cloud.provider = Some(Some(String::from("aws")));
67                cloud.instance_type = Some(Some(instance_type.clone()));
68                cloud.usage = Some(Some(Box::new(usage_cloud)));
69
70                let res = cloud_api::instance_cloud_impact_v1_cloud_instance_post(
71                    &self.configuration,
72                    Some(verbose),
73                    Some(usage_duration_hours.to_owned().into()),
74                    Some(criteria),
75                    Some(cloud),
76                )
77                .await;
78
79                match res {
80                    Ok(res) => Some(res),
81                    Err(e) => {
82                        warn!(
83                            "Warning: Cannot get impacts from API for instance type {}: {}",
84                            instance_type, e
85                        );
86                        None
87                    }
88                }
89            }
90
91            ResourceDetails::BlockStorage {
92                storage_type,
93                usage,
94                attached_instances: _,
95            } => {
96                //let duration: f32 = usage.unwrap().usage_duration_seconds.into();
97                let disk = Disk {
98                    capacity: Some(Some(usage.unwrap().size_gb)),
99                    units: None,
100                    usage: None,
101                    r#type: None,
102                    density: None,
103                    manufacturer: None,
104                    model: None,
105                    layers: None,
106                };
107
108                match storage_type.as_str() {
109                    "st1" | "sc1" | "standard" => {
110                        // This is a HDD
111                        let res = component_api::disk_impact_bottom_up_v1_component_hdd_post(
112                            &self.configuration,
113                            Some(verbose),
114                            Some(usage_duration_hours.to_owned().into()),
115                            Some("DEFAULT"),
116                            Some(criteria),
117                            Some(disk),
118                        )
119                        .await;
120                        match res {
121                            Ok(res) => Some(res),
122                            Err(e) => {
123                                warn!(
124                                    "Warning: Cannot get HHD impact from API for storage type {}: {}",
125                                    storage_type, e
126                                );
127                                None
128                            }
129                        }
130                    }
131                    "gp2" | "gp3" | "io1" | "io2" => {
132                        // Use impacts of an SSD
133                        let res = component_api::disk_impact_bottom_up_v1_component_ssd_post(
134                            &self.configuration,
135                            Some(verbose),
136                            Some(usage_duration_hours.to_owned().into()),
137                            Some("DEFAULT"),
138                            Some(criteria),
139                            Some(disk),
140                        )
141                        .await;
142                        match res {
143                            Ok(res) => Some(res),
144                            Err(e) => {
145                                warn!(
146                                    "Warning: Cannot get SSD impact from API for storage type {}: {}",
147                                    storage_type, e
148                                );
149                                None
150                            }
151                        }
152                    }
153                    _ => {
154                        warn!(
155                            "Unknown storage type ({:?}), defaulting to using impacts of an SSD {:?}",
156                            storage_type.as_str(),
157                            disk
158                        );
159                        // All other types are considered SSD
160                        let res = component_api::disk_impact_bottom_up_v1_component_ssd_post(
161                            &self.configuration,
162                            Some(verbose),
163                            Some(usage_duration_hours.to_owned().into()),
164                            Some("DEFAULT"),
165                            Some(criteria),
166                            Some(disk),
167                        )
168                        .await;
169                        match res {
170                            Ok(res) => Some(res),
171                            Err(e) => {
172                                warn!(
173                                    "Warning: Cannot get SSD impact from API for type {}: {}",
174                                    storage_type, e
175                                );
176                                None
177                            }
178                        }
179                    }
180                }
181            }
182            _ => {
183                warn!("Warning: This type of cloud resource is not supported.");
184                None
185            }
186        }
187    }
188
189    /// Get the impacts of a single CloudResource
190    async fn get_resource_with_impacts(
191        &self,
192        resource: &CloudResource,
193        usage_duration_hours: &f32,
194        verbose: bool,
195    ) -> CloudResourceWithImpacts {
196        let raw_impacts = self
197            .get_raws_impacts(resource.clone(), usage_duration_hours, verbose)
198            .await;
199        boa_impacts_to_cloud_resource_with_impacts(resource, &raw_impacts, usage_duration_hours)
200    }
201}
202
203#[async_trait]
204impl ImpactProvider for BoaviztaApiV1 {
205    /// Get cloud resources impacts from the Boavizta API
206    /// The usage_duration_hours parameters allow to retrieve the impacts for a given duration.
207    async fn get_impacts(
208        &self,
209        inventory: Inventory,
210        usage_duration_hours: &f32,
211        verbose: bool,
212    ) -> Result<EstimatedInventory> {
213        let impact_query_start_time = Instant::now();
214
215        let mut v: Vec<CloudResourceWithImpacts> = Vec::new();
216        for resource in inventory.resources.iter() {
217            let cri = self
218                .get_resource_with_impacts(resource, usage_duration_hours, verbose)
219                .await;
220            v.push(cri.clone());
221        }
222
223        let mut inventory_duration = Duration::from_millis(0);
224        if let Some(exec_stats) = inventory.metadata.execution_statistics {
225            inventory_duration = exec_stats.inventory_duration;
226        }
227        let impact_estimation_duration = impact_query_start_time.elapsed();
228        let execution_statistics = ExecutionStatistics {
229            inventory_duration,
230            impact_estimation_duration,
231            total_duration: inventory_duration + impact_estimation_duration,
232        };
233
234        let estimated_inventory: EstimatedInventory = EstimatedInventory {
235            impacting_resources: v,
236            metadata: EstimationMetadata {
237                estimation_date: Some(Utc::now()),
238                description: Some("Estimation using Boavizta API".to_string()),
239                cloud_scanner_version: Some(crate::get_version()),
240                boavizta_api_version: self.get_api_version().await,
241                execution_statistics: Some(execution_statistics),
242            },
243        };
244        Ok(estimated_inventory)
245    }
246}
247
248/// Convert raw results from Boavizta API into model objects
249pub fn boa_impacts_to_cloud_resource_with_impacts(
250    cloud_resource: &CloudResource,
251    raw_result: &Option<serde_json::Value>,
252    impacts_duration_hours: &f32,
253) -> CloudResourceWithImpacts {
254    let resource_impacts: Option<ImpactsValues>;
255    if let Some(results) = raw_result {
256        debug!("Raw results before conversion: {}", results);
257
258        let impacts = &results["impacts"];
259
260        let resource_details = cloud_resource.resource_details.clone();
261
262        match resource_details {
263            ResourceDetails::Instance {
264                instance_type: _,
265                usage: _,
266            } => {
267                resource_impacts = Some(ImpactsValues {
268                    adp_manufacture_kgsbeq: impacts["adp"]["embedded"]["value"].as_f64().unwrap(),
269                    adp_use_kgsbeq: impacts["adp"]["use"]["value"].as_f64().unwrap(),
270                    pe_manufacture_megajoules: impacts["pe"]["embedded"]["value"].as_f64().unwrap(),
271                    pe_use_megajoules: impacts["pe"]["use"]["value"].as_f64().unwrap(),
272                    gwp_manufacture_kgco2eq: impacts["gwp"]["embedded"]["value"].as_f64().unwrap(),
273                    gwp_use_kgco2eq: impacts["gwp"]["use"]["value"].as_f64().unwrap(),
274                    raw_data: raw_result.clone(),
275                });
276            }
277            ResourceDetails::BlockStorage {
278                storage_type: _,
279                usage: _,
280                attached_instances: _,
281            } => {
282                // TODO: handle empty values differently, it could be better to have an option to be explicit about null values.
283                info!("Impacts of the use phase of storage are not counted (only embedded impacts are counted).");
284                resource_impacts = Some(ImpactsValues {
285                    adp_manufacture_kgsbeq: impacts["adp"]["embedded"]["value"].as_f64().unwrap(),
286                    adp_use_kgsbeq: 0 as f64,
287                    pe_manufacture_megajoules: impacts["pe"]["embedded"]["value"].as_f64().unwrap(),
288                    pe_use_megajoules: 0 as f64,
289                    gwp_manufacture_kgco2eq: impacts["gwp"]["embedded"]["value"].as_f64().unwrap(),
290                    gwp_use_kgco2eq: 0 as f64,
291                    raw_data: raw_result.clone(),
292                });
293            }
294            _ => {
295                resource_impacts = None;
296            }
297        }
298    } else {
299        debug!(
300            "Skipped resource: {:#?} while converting impacts, it has no impact data",
301            cloud_resource
302        );
303        resource_impacts = None;
304    };
305    CloudResourceWithImpacts {
306        cloud_resource: cloud_resource.clone(),
307        impacts_values: resource_impacts,
308        impacts_duration_hours: impacts_duration_hours.to_owned(),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314
315    use super::*;
316    use crate::get_version;
317    use crate::model::{
318        CloudProvider, CloudResource, InstanceState, InstanceUsage, InventoryMetadata,
319        ResourceDetails, StorageUsage,
320    };
321    use crate::usage_location::UsageLocation;
322    use assert_json_diff::assert_json_include;
323    use assert_json_diff::{assert_json_matches, CompareMode, Config, NumericMode};
324
325    const TEST_API_URL: &str = "https://api.boavizta.org";
326    // Test against local  version of Boavizta API
327    // const TEST_API_URL: &str = "http:/localhost:5000";
328    // Test against dev version of Boavizta API
329    // const TEST_API_URL: &str = "https://dev.api.boavizta.org";
330
331    const DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR: &str =
332        include_str!("../test-data/DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR.json");
333    const DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR_VERBOSE: &str =
334        include_str!("../test-data/DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR_VERBOSE.json");
335
336    const DEFAULT_RAW_IMPACTS_OF_HDD: &str =
337        include_str!("../test-data/DEFAULT_RAW_IMPACTS_OF_HDD.json");
338
339    const DEFAULT_RAW_IMPACTS_OF_SSD_1000GB_1HR: &str =
340        include_str!("../test-data/DEFAULT_RAW_IMPACTS_OF_SSD_1000GB_1HR.json");
341
342    #[tokio::test]
343    async fn retrieve_instance_types_through_sdk_works() {
344        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
345        let provider = Some("aws");
346
347        let res = cloud_api::server_get_all_archetype_name_v1_cloud_instance_all_instances_get(
348            &api.configuration,
349            provider,
350        )
351        .await
352        .unwrap();
353        println!("{:?}", res);
354    }
355
356    #[tokio::test]
357    async fn get_api_version() {
358        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
359        let version = api.get_api_version().await;
360        let expected = Some("1.3.7".to_owned());
361        assert_eq!(version, expected, "Versions do not match");
362    }
363
364    #[tokio::test]
365    async fn should_retrieve_raw_default_impacts_aws_fr() {
366        let instance1: CloudResource = CloudResource {
367            provider: CloudProvider::AWS,
368            id: "inst-1".to_string(),
369            location: UsageLocation::try_from("eu-west-3").unwrap(),
370            resource_details: ResourceDetails::Instance {
371                instance_type: "m6g.xlarge".to_string(),
372                usage: Some(InstanceUsage {
373                    average_cpu_load: 100.0,
374                    state: InstanceState::Running,
375                }),
376            },
377            tags: Vec::new(),
378        };
379        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
380        let one_hour = 1.0 as f32;
381        let res = api
382            .get_raws_impacts(instance1, &one_hour, false)
383            .await
384            .unwrap();
385
386        let expected: serde_json::Value =
387            serde_json::from_str(DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR).unwrap();
388        assert_json_include!(actual: res, expected: expected);
389    }
390
391    #[tokio::test]
392    async fn get_verbose_raw_impacts_of_a_hdd() {
393        let hdd: CloudResource = CloudResource {
394            provider: CloudProvider::AWS,
395            id: "disk-1".to_string(),
396
397            location: UsageLocation::try_from("eu-west-3").unwrap(),
398            resource_details: ResourceDetails::BlockStorage {
399                storage_type: "st1".to_string(),
400                usage: Some(StorageUsage { size_gb: 1000 }),
401                attached_instances: None,
402            },
403            tags: Vec::new(),
404        };
405
406        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
407        let one_hour = 1.0 as f32;
408        let res = api.get_raws_impacts(hdd, &one_hour, true).await.unwrap();
409
410        let expected: serde_json::Value = serde_json::from_str(DEFAULT_RAW_IMPACTS_OF_HDD).unwrap();
411
412        let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat);
413        assert_json_matches!(res, expected, config);
414    }
415
416    // not sure why it fails, ignoring it for now
417    #[tokio::test]
418    async fn get_verbose_raw_impacts_of_a_ssd() {
419        let ssd: CloudResource = CloudResource {
420            provider: CloudProvider::AWS,
421            id: "disk-1".to_string(),
422            location: UsageLocation::try_from("eu-west-3").unwrap(),
423            resource_details: ResourceDetails::BlockStorage {
424                storage_type: "gp2".to_string(),
425                usage: Some(StorageUsage { size_gb: 1000 }),
426                attached_instances: None,
427            },
428            tags: Vec::new(),
429        };
430
431        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
432        let one_hour = 1.0 as f32;
433        let res = api.get_raws_impacts(ssd, &one_hour, true).await.unwrap();
434
435        let expected: serde_json::Value =
436            serde_json::from_str(DEFAULT_RAW_IMPACTS_OF_SSD_1000GB_1HR).unwrap();
437
438        let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat);
439        assert_json_matches!(res, expected, config);
440    }
441
442    #[tokio::test]
443    async fn returns_different_pe_impacts_for_different_cpu_load() {
444        let instance1: CloudResource = CloudResource {
445            provider: CloudProvider::AWS,
446            id: "inst-1".to_string(),
447            location: UsageLocation::try_from("eu-west-3").unwrap(),
448            resource_details: ResourceDetails::Instance {
449                instance_type: "m6g.xlarge".to_string(),
450                usage: Some(InstanceUsage {
451                    average_cpu_load: 100.0,
452                    state: InstanceState::Running,
453                }),
454            },
455            tags: Vec::new(),
456        };
457
458        let instance1_1percent = CloudResource {
459            provider: CloudProvider::AWS,
460            id: "inst-2".to_string(),
461            location: UsageLocation::try_from("eu-west-3").unwrap(),
462            resource_details: ResourceDetails::Instance {
463                instance_type: "m6g.xlarge".to_string(),
464                usage: Some(InstanceUsage {
465                    average_cpu_load: 1.0,
466                    state: InstanceState::Running,
467                }),
468            },
469            tags: Vec::new(),
470        };
471
472        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
473        let one_hour = 1.0 as f32;
474
475        let mut instances: Vec<CloudResource> = Vec::new();
476        instances.push(instance1);
477        instances.push(instance1_1percent);
478
479        let inventory = Inventory {
480            metadata: InventoryMetadata {
481                inventory_date: None,
482                description: None,
483                cloud_scanner_version: Some(get_version()),
484                execution_statistics: None,
485            },
486            resources: instances,
487        };
488
489        let res = api.get_impacts(inventory, &one_hour, false).await.unwrap();
490
491        let r0 = res.impacting_resources[0].impacts_values.clone().unwrap();
492        let r1 = res.impacting_resources[1].impacts_values.clone().unwrap();
493        assert_eq!(0.212, r0.pe_use_megajoules);
494        assert_eq!(0.088, r1.pe_use_megajoules);
495    }
496
497    #[tokio::test]
498    async fn should_retrieve_multiple_default_impacts_fr() {
499        let instance1: CloudResource = CloudResource {
500            provider: CloudProvider::AWS,
501            id: "inst-1".to_string(),
502            location: UsageLocation::try_from("eu-west-3").unwrap(),
503            resource_details: ResourceDetails::Instance {
504                instance_type: "m6g.xlarge".to_string(),
505                usage: Some(InstanceUsage {
506                    average_cpu_load: 100.0,
507                    state: InstanceState::Running,
508                }),
509            },
510            tags: Vec::new(),
511        };
512
513        let instance2: CloudResource = CloudResource {
514            provider: CloudProvider::AWS,
515            id: "inst-2".to_string(),
516            location: UsageLocation::try_from("eu-west-3").unwrap(),
517            resource_details: ResourceDetails::Instance {
518                instance_type: "m6g.xlarge".to_string(),
519                usage: Some(InstanceUsage {
520                    average_cpu_load: 100.0,
521                    state: InstanceState::Running,
522                }),
523            },
524            tags: Vec::new(),
525        };
526
527        let instance3: CloudResource = CloudResource {
528            provider: CloudProvider::AWS,
529            id: "inst-3".to_string(),
530            location: UsageLocation::try_from("eu-west-3").unwrap(),
531            resource_details: ResourceDetails::Instance {
532                instance_type: "type-not-in-boa".to_string(),
533                usage: Some(InstanceUsage {
534                    average_cpu_load: 100.0,
535                    state: InstanceState::Running,
536                }),
537            },
538            tags: Vec::new(),
539        };
540
541        let mut instances: Vec<CloudResource> = Vec::new();
542        instances.push(instance1);
543        instances.push(instance2);
544        instances.push(instance3);
545        let one_hour = 1.0 as f32;
546
547        let inventory = Inventory {
548            metadata: InventoryMetadata {
549                inventory_date: None,
550                description: None,
551                cloud_scanner_version: Some(get_version()),
552                execution_statistics: None,
553            },
554            resources: instances,
555        };
556
557        let api: BoaviztaApiV1 = BoaviztaApiV1::new(TEST_API_URL);
558        let res = api.get_impacts(inventory, &one_hour, false).await.unwrap();
559
560        assert_eq!(3, res.impacting_resources.len());
561        assert_eq!(res.impacting_resources[0].cloud_resource.id, "inst-1");
562        assert_eq!(res.impacting_resources[1].cloud_resource.id, "inst-2");
563
564        let r0 = res.impacting_resources[0].impacts_values.clone().unwrap();
565        let r1 = res.impacting_resources[1].impacts_values.clone().unwrap();
566
567        assert_eq!(0.212, r0.pe_use_megajoules);
568        assert_eq!(0.212, r1.pe_use_megajoules);
569        assert!(
570            res.impacting_resources[2].impacts_values.clone().is_none(),
571            "This instance should return None impacts because it's type is unknown from API"
572        );
573    }
574
575    #[test]
576    fn should_convert_basic_results_to_impacts() {
577        let instance1: CloudResource = CloudResource {
578            provider: CloudProvider::AWS,
579            id: "inst-1".to_string(),
580            location: UsageLocation::try_from("eu-west-3").unwrap(),
581            resource_details: ResourceDetails::Instance {
582                instance_type: "m6g.xlarge".to_string(),
583                usage: Some(InstanceUsage {
584                    average_cpu_load: 100.0,
585                    state: InstanceState::Running,
586                }),
587            },
588            tags: Vec::new(),
589        };
590
591        let raw_impacts =
592            Some(serde_json::from_str(DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR).unwrap());
593        let one_hour: f32 = 1 as f32;
594        let cloud_resource_with_impacts: CloudResourceWithImpacts =
595            boa_impacts_to_cloud_resource_with_impacts(&instance1, &raw_impacts, &one_hour);
596        assert!(
597            cloud_resource_with_impacts.impacts_values.is_some(),
598            "Empty impacts"
599        );
600
601        assert_eq!(
602            0.212,
603            cloud_resource_with_impacts
604                .impacts_values
605                .as_ref()
606                .unwrap()
607                .pe_use_megajoules
608        );
609
610        assert_eq!(
611            0.212,
612            cloud_resource_with_impacts
613                .impacts_values
614                .unwrap()
615                .raw_data
616                .unwrap()["impacts"]["pe"]["use"]["value"]
617                .as_f64()
618                .unwrap()
619        );
620    }
621    #[test]
622    fn convert_verbose_results_to_impacts() {
623        let instance1: CloudResource = CloudResource {
624            provider: CloudProvider::AWS,
625            id: "inst-1".to_string(),
626            location: UsageLocation::try_from("eu-west-3").unwrap(),
627            resource_details: ResourceDetails::Instance {
628                instance_type: "m6g.xlarge".to_string(),
629                usage: Some(InstanceUsage {
630                    average_cpu_load: 100.0,
631                    state: InstanceState::Running,
632                }),
633            },
634            tags: Vec::new(),
635        };
636
637        let raw_impacts =
638            Some(serde_json::from_str(DEFAULT_RAW_IMPACTS_OF_M6GXLARGE_1HRS_FR_VERBOSE).unwrap());
639        let one_hour: f32 = 1 as f32;
640        let cloud_resource_with_impacts: CloudResourceWithImpacts =
641            boa_impacts_to_cloud_resource_with_impacts(&instance1, &raw_impacts, &one_hour);
642        assert!(
643            cloud_resource_with_impacts.impacts_values.is_some(),
644            "Emtpy impacts"
645        );
646
647        assert_eq!(
648            0.0005454,
649            cloud_resource_with_impacts
650                .impacts_values
651                .unwrap()
652                .raw_data
653                .unwrap()["verbose"]["CPU-1"]["impacts"]["gwp"]["embedded"]["value"]
654                .as_f64()
655                .unwrap()
656        );
657    }
658}