cloud_scanner_cli/
model.rs

1//!  Business Entities of cloud Scanner
2use anyhow::Context;
3use chrono::{DateTime, Utc};
4use rocket_okapi::okapi::schemars;
5use rocket_okapi::okapi::schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9use std::time::Duration;
10use std::{fmt, fs};
11
12use crate::impact_provider::CloudResourceWithImpacts;
13use crate::usage_location::UsageLocation;
14
15/// Statistics about program execution
16#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub struct ExecutionStatistics {
19    pub inventory_duration: Duration,
20    pub impact_estimation_duration: Duration,
21    pub total_duration: Duration,
22}
23
24impl fmt::Display for ExecutionStatistics {
25    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
26        write!(f, "{:?}", self)
27    }
28}
29
30/// A list of cloud resources and metadata that describes the inventory itself
31#[derive(Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub struct Inventory {
34    pub metadata: InventoryMetadata,
35    pub resources: Vec<CloudResource>,
36}
37
38/// Details about the inventory
39#[derive(Clone, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub struct InventoryMetadata {
42    /// The date when the inventory was generated
43    pub inventory_date: Option<DateTime<Utc>>,
44    /// A free text description of the inventory
45    pub description: Option<String>,
46    /// The version of the cloud scanner that generated the inventory
47    pub cloud_scanner_version: Option<String>,
48    /// Statistics about program execution
49    pub execution_statistics: Option<ExecutionStatistics>,
50}
51
52/// Load inventory from a file
53pub async fn load_inventory_from_file(inventory_file_path: &Path) -> anyhow::Result<Inventory> {
54    let content = fs::read_to_string(inventory_file_path).context("cannot read inventory file")?;
55    load_inventory_fom_json(&content).await
56}
57
58/// Load an inventory from its json representation
59pub async fn load_inventory_fom_json(json_inventory: &str) -> anyhow::Result<Inventory> {
60    let inventory: Inventory =
61        serde_json::from_str(json_inventory).context("malformed json inventory data")?;
62    Ok(inventory)
63}
64
65/// An estimated inventory: impacting resources with their estimated impacts
66#[derive(Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub struct EstimatedInventory {
69    pub metadata: EstimationMetadata,
70    pub impacting_resources: Vec<CloudResourceWithImpacts>,
71}
72
73/// Details about the estimation
74#[derive(Clone, Serialize, Deserialize, JsonSchema)]
75#[serde(rename_all = "snake_case")]
76pub struct EstimationMetadata {
77    /// The date when the estimation was generated
78    pub estimation_date: Option<DateTime<Utc>>,
79    /// A free text description of the estimation
80    pub description: Option<String>,
81    /// The version of the cloud scanner that provided the estimation
82    pub cloud_scanner_version: Option<String>,
83    /// The version of the Boavizta api that provided the estimation
84    pub boavizta_api_version: Option<String>,
85    /// Statistics about program execution
86    pub execution_statistics: Option<ExecutionStatistics>,
87}
88
89///  A cloud resource (could be an instance, block storage or any other resource)
90#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
91pub struct CloudResource {
92    pub provider: CloudProvider,
93    pub id: String,
94    ///  The location where cloud resources are running.
95    pub location: UsageLocation,
96    pub resource_details: ResourceDetails,
97    pub tags: Vec<CloudResourceTag>,
98}
99
100impl fmt::Display for CloudResource {
101    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
102        write!(f, "{:?}", self)
103    }
104}
105
106impl CloudResource {
107    /// Convert tags into a format supported by prometheus metrics label (like `tag_key_1:tag_value_1;tag_key_2:tag_value_2;`)
108    pub fn tags_as_metric_label_value(&self) -> String {
109        let mut res = "".to_string();
110        for tag in self.tags.iter() {
111            let val = tag.value.clone().unwrap_or("".parse().unwrap());
112            res.push_str(&tag.key);
113            res.push(':');
114            res.push_str(&val);
115            res.push(';');
116        }
117        res
118    }
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
122pub enum CloudProvider {
123    AWS,
124    OVH,
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum ResourceDetails {
130    Instance {
131        instance_type: String,
132        usage: Option<InstanceUsage>,
133    },
134    BlockStorage {
135        storage_type: String,
136        usage: Option<StorageUsage>,
137        attached_instances: Option<Vec<StorageAttachment>>,
138    },
139    ObjectStorage,
140}
141
142#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "snake_case")]
144pub struct InstanceUsage {
145    pub average_cpu_load: f64,
146    pub state: InstanceState,
147}
148
149#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
150#[serde(rename_all = "snake_case")]
151pub enum InstanceState {
152    #[default]
153    Running,
154    Stopped,
155}
156
157#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub struct StorageUsage {
160    pub size_gb: i32,
161}
162
163#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
164#[serde(rename_all = "snake_case")]
165pub struct StorageAttachment {
166    pub instance_id: String,
167}
168
169/// A tag (just a mandatory key + optional value)
170#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
171#[serde(rename_all = "snake_case")]
172pub struct CloudResourceTag {
173    pub key: String,
174    pub value: Option<String>,
175}
176
177///  Parse a  tag kay and value from a String (coming from CLI or query strings) .
178///  Tags are expected to be int the form "Tag name=Tag value"
179impl TryFrom<String> for CloudResourceTag {
180    type Error = &'static str;
181
182    fn try_from(key_value: String) -> Result<Self, Self::Error> {
183        let t: Vec<&str> = key_value.split('=').collect();
184        if t.is_empty() {
185            Err("Cannot split the tag name from value. Maybe a missing equal ('=') sign between tag names and values ?")
186        } else {
187            let key = t.first().unwrap().to_string();
188            if let Some(val) = t.get(1) {
189                Ok(CloudResourceTag {
190                    key,
191                    value: Some(val.to_string()),
192                })
193            } else {
194                Ok(CloudResourceTag { key, value: None })
195            }
196        }
197    }
198}
199
200impl CloudResource {
201    /// Returns true it _all_ the tags passed in argument are defined and have the same values on the cloud resource
202    fn has_matching_tagmap(&self, tags: &HashMap<String, CloudResourceTag>) -> bool {
203        tags.iter().all(|(filter_key, filter_tag)| {
204            let tag_map: HashMap<String, Option<String>> = vec_to_map(self.tags.clone());
205            tag_map.get(filter_key) == Some(&filter_tag.value)
206        })
207    }
208
209    pub fn has_matching_tags(&self, filter_tags: &[String]) -> bool {
210        let mut filter = HashMap::new();
211        filter_tags.iter().for_each(|f| {
212            let res = CloudResourceTag::try_from(f.to_owned());
213            if let Ok(crt) = res {
214                filter.insert(crt.key.clone(), crt);
215            } else {
216                error!("Skipped filter");
217            }
218        });
219        self.has_matching_tagmap(&filter)
220    }
221}
222
223pub fn vec_to_map(tagv: Vec<CloudResourceTag>) -> HashMap<String, Option<String>> {
224    let mut tagh: HashMap<String, Option<String>> = HashMap::new();
225    tagv.iter().for_each(|t| {
226        tagh.insert(t.key.clone(), t.value.clone());
227    });
228    tagh
229}
230
231#[cfg(test)]
232mod tests {
233    use crate::model::{
234        load_inventory_from_file, CloudProvider, CloudResource, CloudResourceTag, Inventory,
235        ResourceDetails,
236    };
237    use crate::usage_location::UsageLocation;
238    use std::collections::HashMap;
239    use std::path::Path;
240
241    #[test]
242    pub fn a_cloud_resource_can_be_displayed() {
243        let instance1: CloudResource = CloudResource {
244            provider: CloudProvider::AWS,
245            id: "inst-1".to_string(),
246            location: UsageLocation::try_from("eu-west-1").unwrap(),
247            resource_details: ResourceDetails::Instance {
248                instance_type: "t2.fictive".to_string(),
249                usage: None,
250            },
251            tags: Vec::new(),
252        };
253
254        assert_eq!("CloudResource { provider: AWS, id: \"inst-1\", location: UsageLocation { aws_region: \"eu-west-1\", iso_country_code: \"IRL\" }, resource_details: Instance { instance_type: \"t2.fictive\", usage: None }, tags: [] }", format!("{:?}", instance1));
255    }
256
257    #[test]
258    pub fn parse_tag() {
259        let tag_string = "name1=val1".to_string();
260        let res = CloudResourceTag::try_from(tag_string).unwrap();
261        assert_eq!(res.key, "name1", "Wrong key");
262        assert_eq!(res.value.unwrap(), "val1", "Wrong value");
263
264        let tag_string = "name1".to_string();
265        let res = CloudResourceTag::try_from(tag_string).unwrap();
266        assert_eq!(res.key, "name1", "Wrong key");
267        assert_eq!(res.value, None, "Wrong value");
268    }
269    #[test]
270    pub fn parse_tags_with_spaces() {
271        let tag_string = "name 1=val 1".to_string();
272        let res = CloudResourceTag::try_from(tag_string).unwrap();
273        assert_eq!(res.key, "name 1", "Wrong key");
274        assert_eq!(res.value.unwrap(), "val 1", "Wrong value");
275    }
276
277    #[test]
278    pub fn match_tags() {
279        let mut filtertags = HashMap::new();
280        filtertags.insert(
281            "Name".to_string(),
282            CloudResourceTag {
283                key: "Name".to_string(),
284                value: Some("App1".to_string()),
285            },
286        );
287
288        let mut instance1tags: Vec<CloudResourceTag> = Vec::new();
289        instance1tags.push(CloudResourceTag {
290            key: "Name".to_string(),
291            value: Some("App1".to_string()),
292        });
293
294        let instance1: CloudResource = CloudResource {
295            provider: CloudProvider::AWS,
296            id: "inst-1".to_string(),
297            location: UsageLocation::try_from("eu-west-1").unwrap(),
298            resource_details: ResourceDetails::Instance {
299                instance_type: "t2.fictive".to_string(),
300                usage: None,
301            },
302            tags: instance1tags,
303        };
304
305        assert_eq!(
306            true,
307            instance1.has_matching_tagmap(&filtertags),
308            "Tags should match"
309        );
310
311        let mut other_name_tag = filtertags.clone();
312        // Changing the content of Name tag
313        other_name_tag.insert(
314            "Name".to_string(),
315            CloudResourceTag {
316                key: "Name".to_string(),
317                value: Some("OtherApp".to_string()),
318            },
319        );
320        assert_eq!(
321            false,
322            instance1.has_matching_tagmap(&other_name_tag),
323            "Tags should not match"
324        );
325
326        let mut more_tags = filtertags.clone();
327        // Adding an extra tag that is not on the instance
328        more_tags.insert(
329            "Env".to_string(),
330            CloudResourceTag {
331                key: "Env".to_string(),
332                value: Some("PROD".to_string()),
333            },
334        );
335        assert_eq!(
336            false,
337            instance1.has_matching_tagmap(&more_tags),
338            "Tags should not match"
339        );
340
341        let mut tag_without_val = filtertags.clone();
342        // Adding an extra tag that is not on the instance
343        tag_without_val.insert(
344            "Name".to_string(),
345            CloudResourceTag {
346                key: "Name".to_string(),
347                value: None,
348            },
349        );
350        assert_eq!(
351            false,
352            instance1.has_matching_tagmap(&tag_without_val),
353            "Tag without a value should not match"
354        );
355
356        // Trying an empty filter
357        let empty_filter = HashMap::new();
358        assert_eq!(
359            true,
360            instance1.has_matching_tagmap(&empty_filter),
361            "Tags should match"
362        );
363
364        // When the name of tag used to filter is an empty string....
365        let mut empty_tag_name_in_filter = HashMap::new();
366
367        let empty_key: String = "".to_string();
368        empty_tag_name_in_filter.insert(
369            empty_key.clone(),
370            CloudResourceTag {
371                key: empty_key,
372                value: Some("whatever".to_string()),
373            },
374        );
375        assert_eq!(
376            true,
377            instance1.has_matching_tagmap(&empty_filter),
378            "Tags should match (i.e. we should ignore this invalid filter"
379        );
380    }
381    #[test]
382    pub fn format_tags_as_metric_label() {
383        let tag1 = CloudResourceTag {
384            key: "name1".to_string(),
385            value: Some("value1".to_string()),
386        };
387        let tag2 = CloudResourceTag {
388            key: "name2".to_string(),
389            value: Some("value2".to_string()),
390        };
391
392        let cr = CloudResource {
393            provider: CloudProvider::AWS,
394            id: "123".to_string(),
395            location: UsageLocation {
396                aws_region: "eu-west-3".to_string(),
397                iso_country_code: "FR".to_string(),
398            },
399            resource_details: ResourceDetails::ObjectStorage,
400            tags: vec![tag1, tag2],
401        };
402
403        let tag_label_value = cr.tags_as_metric_label_value();
404
405        assert_eq!(
406            "name1:value1;name2:value2;", tag_label_value,
407            "could not convert tags to metric label values"
408        );
409    }
410
411    #[tokio::test]
412    async fn test_load_inventory_from_json() {
413        const INVENTORY: &str = include_str!("../test-data/AWS_INVENTORY.json");
414        let result = crate::model::load_inventory_fom_json(INVENTORY)
415            .await
416            .unwrap();
417        assert_eq!(result.resources.len(), 4);
418    }
419
420    #[tokio::test]
421    async fn test_load_inventory_from_file() {
422        let inventory_file_path: &Path = Path::new("./test-data/AWS_INVENTORY.json");
423        let inventory: Inventory = load_inventory_from_file(inventory_file_path).await.unwrap();
424        assert_eq!(
425            inventory.resources.len(),
426            4,
427            "Wrong number of resources in the inventory file"
428        );
429    }
430
431    #[tokio::test]
432    async fn test_load_inventory_from_formatted_file() {
433        let inventory_file_path: &Path = Path::new("./test-data/AWS_INVENTORY_FORMATTED.json");
434        let inventory: Inventory = load_inventory_from_file(inventory_file_path).await.unwrap();
435        assert_eq!(
436            inventory.resources.len(),
437            2,
438            "Wrong number of resources in the inventory file"
439        );
440    }
441}