1use 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#[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#[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#[derive(Clone, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub struct InventoryMetadata {
42 pub inventory_date: Option<DateTime<Utc>>,
44 pub description: Option<String>,
46 pub cloud_scanner_version: Option<String>,
48 pub execution_statistics: Option<ExecutionStatistics>,
50}
51
52pub 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
58pub 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#[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#[derive(Clone, Serialize, Deserialize, JsonSchema)]
75#[serde(rename_all = "snake_case")]
76pub struct EstimationMetadata {
77 pub estimation_date: Option<DateTime<Utc>>,
79 pub description: Option<String>,
81 pub cloud_scanner_version: Option<String>,
83 pub boavizta_api_version: Option<String>,
85 pub execution_statistics: Option<ExecutionStatistics>,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
91pub struct CloudResource {
92 pub provider: CloudProvider,
93 pub id: String,
94 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 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#[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
177impl 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 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 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 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 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 let empty_filter = HashMap::new();
358 assert_eq!(
359 true,
360 instance1.has_matching_tagmap(&empty_filter),
361 "Tags should match"
362 );
363
364 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}