Skip to main content

rusticity_core/
efs.rs

1use crate::config::AwsConfig;
2use anyhow::Result;
3
4fn fmt_bytes(bytes: i64) -> String {
5    const GIB: i64 = 1 << 30;
6    const MIB: i64 = 1 << 20;
7    const KIB: i64 = 1 << 10;
8    if bytes >= GIB {
9        format!("{:.1} GiB", bytes as f64 / GIB as f64)
10    } else if bytes >= MIB {
11        format!("{:.1} MiB", bytes as f64 / MIB as f64)
12    } else if bytes >= KIB {
13        format!("{:.1} KiB", bytes as f64 / KIB as f64)
14    } else {
15        format!("{} B", bytes)
16    }
17}
18
19#[derive(Clone, Debug)]
20pub struct EfsFileSystem {
21    pub file_system_id: String,
22    pub file_system_arn: String,
23    pub name: String,
24    pub creation_token: String,
25    pub encrypted: String,
26    pub kms_key_id: String,
27    pub total_size: String,
28    pub size_in_standard: String,
29    pub size_in_ia: String,
30    pub size_in_archive: String,
31    pub provisioned_throughput: String,
32    pub throughput_mode: String,
33    pub life_cycle_state: String,
34    pub number_of_mount_targets: String,
35    pub owner_id: String,
36    pub creation_time: String,
37    pub performance_mode: String,
38    pub availability_zone: String,
39    pub replication_overwrite_protection: String,
40    /// Raw byte sizes for percentage calculations
41    pub total_size_bytes: i64,
42    pub size_in_standard_bytes: i64,
43    pub size_in_ia_bytes: i64,
44    pub size_in_archive_bytes: i64,
45    /// DNS name: <fs-id>.efs.<region>.amazonaws.com
46    pub dns_name: String,
47    /// Tags as (key, value) pairs
48    pub tags: Vec<(String, String)>,
49}
50
51pub struct EfsClient {
52    config: AwsConfig,
53}
54
55impl EfsClient {
56    pub fn new(config: AwsConfig) -> Self {
57        Self { config }
58    }
59
60    pub async fn list_file_systems(&self) -> Result<Vec<EfsFileSystem>> {
61        let client = self.config.efs_client();
62
63        let mut file_systems: Vec<EfsFileSystem> = Vec::new();
64        let mut marker: Option<String> = None;
65
66        loop {
67            let mut request = client.describe_file_systems();
68            if let Some(m) = marker {
69                request = request.marker(m);
70            }
71
72            let response = request.send().await?;
73
74            for fs in response.file_systems.unwrap_or_default() {
75                let name = fs.name().map(|s| s.to_string()).unwrap_or_default();
76
77                let sizes = fs.size_in_bytes();
78                let total_size = sizes.map(|s| fmt_bytes(s.value)).unwrap_or_default();
79                let size_in_standard = sizes
80                    .and_then(|s| s.value_in_standard)
81                    .map(fmt_bytes)
82                    .unwrap_or_default();
83                let size_in_ia = sizes
84                    .and_then(|s| s.value_in_ia)
85                    .map(fmt_bytes)
86                    .unwrap_or_default();
87                let size_in_archive = sizes
88                    .and_then(|s| s.value_in_archive)
89                    .map(fmt_bytes)
90                    .unwrap_or_default();
91
92                let provisioned_throughput = fs
93                    .provisioned_throughput_in_mibps()
94                    .map(|v| format!("{:.1}", v))
95                    .unwrap_or_default();
96
97                let throughput_mode = fs
98                    .throughput_mode()
99                    .map(|m| format!("{:?}", m))
100                    .unwrap_or_default();
101
102                let life_cycle_state = format!("{:?}", fs.life_cycle_state());
103
104                let performance_mode = format!("{:?}", fs.performance_mode());
105
106                let availability_zone = fs.availability_zone_name().unwrap_or("").to_string();
107
108                let encrypted = match fs.encrypted() {
109                    Some(true) => "Yes".to_string(),
110                    Some(false) => "No".to_string(),
111                    None => String::new(),
112                };
113
114                let kms_key_id = fs.kms_key_id().unwrap_or("").to_string();
115
116                let replication_overwrite_protection = fs
117                    .file_system_protection()
118                    .and_then(|p| p.replication_overwrite_protection())
119                    .map(|r| format!("{:?}", r))
120                    .unwrap_or_default();
121
122                let creation_time = fs
123                    .creation_time()
124                    .fmt(aws_smithy_types::date_time::Format::DateTime)
125                    .unwrap_or_default();
126
127                file_systems.push(EfsFileSystem {
128                    file_system_id: fs.file_system_id.clone(),
129                    file_system_arn: fs.file_system_arn().unwrap_or("").to_string(),
130                    name,
131                    creation_token: fs.creation_token.clone(),
132                    encrypted,
133                    kms_key_id,
134                    total_size,
135                    size_in_standard,
136                    size_in_ia,
137                    size_in_archive,
138                    provisioned_throughput,
139                    throughput_mode,
140                    life_cycle_state,
141                    number_of_mount_targets: fs.number_of_mount_targets.to_string(),
142                    owner_id: fs.owner_id.clone(),
143                    creation_time,
144                    performance_mode,
145                    availability_zone,
146                    replication_overwrite_protection,
147                    total_size_bytes: sizes.map(|s| s.value).unwrap_or(0),
148                    size_in_standard_bytes: sizes.and_then(|s| s.value_in_standard).unwrap_or(0),
149                    size_in_ia_bytes: sizes.and_then(|s| s.value_in_ia).unwrap_or(0),
150                    size_in_archive_bytes: sizes.and_then(|s| s.value_in_archive).unwrap_or(0),
151                    dns_name: format!(
152                        "{}.efs.{}.amazonaws.com",
153                        fs.file_system_id.as_str(),
154                        // extract region from ARN or use "unknown"
155                        fs.file_system_arn()
156                            .and_then(|a| a.split(':').nth(3))
157                            .unwrap_or("unknown")
158                    ),
159                    tags: fs
160                        .tags
161                        .iter()
162                        .map(|t| (t.key.clone(), t.value.clone()))
163                        .collect(),
164                });
165            }
166
167            marker = response.next_marker;
168            if marker.is_none() {
169                break;
170            }
171        }
172
173        // Sort by creation_time descending
174        file_systems.sort_by(|a, b| b.creation_time.cmp(&a.creation_time));
175
176        Ok(file_systems)
177    }
178
179    /// List the access points for a file system.
180    pub async fn describe_access_points(
181        &self,
182        file_system_id: &str,
183    ) -> Result<Vec<EfsAccessPoint>> {
184        let client = self.config.efs_client();
185        let mut out: Vec<EfsAccessPoint> = Vec::new();
186        let mut next_token: Option<String> = None;
187
188        loop {
189            let mut req = client
190                .describe_access_points()
191                .file_system_id(file_system_id);
192            if let Some(t) = next_token {
193                req = req.next_token(t);
194            }
195            let resp = req.send().await?;
196
197            for ap in resp.access_points() {
198                let posix_user = ap
199                    .posix_user()
200                    .map(|p| format!("{}:{}", p.uid(), p.gid()))
201                    .unwrap_or_default();
202
203                let (path, creation_info) = match ap.root_directory() {
204                    Some(rd) => {
205                        let path = rd.path().unwrap_or("/").to_string();
206                        let ci = rd
207                            .creation_info()
208                            .map(|c| {
209                                format!("{}:{} {}", c.owner_uid(), c.owner_gid(), c.permissions())
210                            })
211                            .unwrap_or_default();
212                        (path, ci)
213                    }
214                    None => (String::new(), String::new()),
215                };
216
217                out.push(EfsAccessPoint {
218                    access_point_id: ap.access_point_id().unwrap_or("").to_string(),
219                    name: ap.name().unwrap_or("").to_string(),
220                    path,
221                    posix_user,
222                    creation_info,
223                    life_cycle_state: ap
224                        .life_cycle_state()
225                        .map(|s| format!("{:?}", s))
226                        .unwrap_or_default(),
227                });
228            }
229
230            next_token = resp.next_token().map(|s| s.to_string());
231            if next_token.is_none() {
232                break;
233            }
234        }
235        Ok(out)
236    }
237
238    /// List the mount targets (network interfaces) for a file system, including
239    /// their security groups. Sorted by availability zone name ascending.
240    pub async fn describe_mount_targets(
241        &self,
242        file_system_id: &str,
243    ) -> Result<Vec<EfsMountTarget>> {
244        let client = self.config.efs_client();
245        let mut out: Vec<EfsMountTarget> = Vec::new();
246        let mut marker: Option<String> = None;
247
248        loop {
249            let mut req = client
250                .describe_mount_targets()
251                .file_system_id(file_system_id);
252            if let Some(m) = marker {
253                req = req.marker(m);
254            }
255            let resp = req.send().await?;
256
257            for mt in resp.mount_targets() {
258                let mount_target_id = mt.mount_target_id().to_string();
259
260                // Security groups require a separate call and only succeed when
261                // the mount target is available; ignore errors (leave empty).
262                let security_groups = match client
263                    .describe_mount_target_security_groups()
264                    .mount_target_id(&mount_target_id)
265                    .send()
266                    .await
267                {
268                    Ok(sg_resp) => sg_resp.security_groups().join(", "),
269                    Err(_) => String::new(),
270                };
271
272                out.push(EfsMountTarget {
273                    mount_target_id,
274                    availability_zone_name: mt.availability_zone_name().unwrap_or("").to_string(),
275                    availability_zone_id: mt.availability_zone_id().unwrap_or("").to_string(),
276                    owner_id: mt.owner_id().unwrap_or("").to_string(),
277                    file_system_id: mt.file_system_id().to_string(),
278                    subnet_id: mt.subnet_id().to_string(),
279                    vpc_id: mt.vpc_id().unwrap_or("").to_string(),
280                    life_cycle_state: format!("{:?}", mt.life_cycle_state()),
281                    ip_address: mt.ip_address().unwrap_or("").to_string(),
282                    ipv6_address: mt.ipv6_address().unwrap_or("").to_string(),
283                    network_interface_id: mt.network_interface_id().unwrap_or("").to_string(),
284                    security_groups,
285                });
286            }
287
288            marker = resp.next_marker().map(|s| s.to_string());
289            if marker.is_none() {
290                break;
291            }
292        }
293
294        out.sort_by(|a, b| a.availability_zone_name.cmp(&b.availability_zone_name));
295        Ok(out)
296    }
297
298    /// Fetch the replication configuration for a file system. Returns
299    /// `Ok(None)` when the file system is not being replicated
300    /// (`ReplicationNotFound`).
301    pub async fn get_replication(&self, file_system_id: &str) -> Result<Option<String>> {
302        let client = self.config.efs_client();
303        let resp = client
304            .describe_replication_configurations()
305            .file_system_id(file_system_id)
306            .send()
307            .await;
308
309        match resp {
310            Ok(out) => {
311                let configs = out.replications();
312                if configs.is_empty() {
313                    return Ok(None);
314                }
315                Ok(Some(format_replication(configs)))
316            }
317            Err(err) => {
318                if err
319                    .as_service_error()
320                    .map(|e| e.is_replication_not_found())
321                    .unwrap_or(false)
322                {
323                    Ok(None)
324                } else {
325                    Err(err.into())
326                }
327            }
328        }
329    }
330
331    /// Fetch and pretty-print the file system's resource policy, if one exists.
332    /// Returns `Ok(None)` when no policy is attached (`PolicyNotFound`).
333    pub async fn get_file_system_policy(&self, file_system_id: &str) -> Result<Option<String>> {
334        let client = self.config.efs_client();
335        let resp = client
336            .describe_file_system_policy()
337            .file_system_id(file_system_id)
338            .send()
339            .await;
340
341        match resp {
342            Ok(out) => {
343                let Some(policy) = out.policy() else {
344                    return Ok(None);
345                };
346                // Pretty-print; fall back to the raw string if it isn't valid JSON.
347                let pretty = serde_json::from_str::<serde_json::Value>(policy)
348                    .ok()
349                    .and_then(|v| serde_json::to_string_pretty(&v).ok())
350                    .unwrap_or_else(|| policy.to_string());
351                Ok(Some(pretty))
352            }
353            Err(err) => {
354                if err
355                    .as_service_error()
356                    .map(|e| e.is_policy_not_found())
357                    .unwrap_or(false)
358                {
359                    Ok(None)
360                } else {
361                    Err(err.into())
362                }
363            }
364        }
365    }
366
367    /// Fetch the six EFS monitoring graphs for a file system via CloudWatch
368    /// GetMetricData (metric math). Mirrors the AWS console's EFS monitoring tab.
369    pub async fn get_monitoring_metrics(&self, file_system_id: &str) -> Result<EfsMonitoringData> {
370        let now = chrono::Utc::now();
371        let short_start = now - chrono::Duration::hours(3);
372        // StorageBytes is emitted with coarse (daily) granularity, so widen its window.
373        let long_start = now - chrono::Duration::hours(24);
374
375        let mut data = EfsMonitoringData::default();
376
377        // Graph 1 — Throughput utilization (%).
378        // e1 = (MeteredIOBytes/1MiB)/PERIOD, e2 = PermittedThroughput/1MiB, e4 = e1*100/e2.
379        {
380            let queries = vec![
381                efs_expr_query("e1", "(m1/1048576)/PERIOD(m1)", 60, false),
382                efs_expr_query("e2", "m2/1048576", 60, false),
383                efs_expr_query("e4", "((e1)*100)/(e2)", 60, true),
384                efs_metric_query(
385                    "m1",
386                    "MeteredIOBytes",
387                    "Sum",
388                    60,
389                    file_system_id,
390                    None,
391                    false,
392                ),
393                efs_metric_query(
394                    "m2",
395                    "PermittedThroughput",
396                    "Average",
397                    60,
398                    file_system_id,
399                    None,
400                    false,
401                ),
402            ];
403            let mut res = self.run_get_metric_data(queries, short_start, now).await?;
404            data.throughput_utilization = res.remove("e4").unwrap_or_default();
405        }
406
407        // Graph 2 — IOPS by type (stat SampleCount). % of TotalIOBytes samples.
408        {
409            let queries = iops_or_throughput_queries("SampleCount", file_system_id);
410            let mut res = self.run_get_metric_data(queries, short_start, now).await?;
411            data.iops_data_write = res.remove("e2").unwrap_or_default();
412            data.iops_data_read = res.remove("e3").unwrap_or_default();
413            data.iops_metadata = res.remove("e4").unwrap_or_default();
414        }
415
416        // Graph 3 — Throughput by type (stat Sum). % of TotalIOBytes.
417        {
418            let queries = iops_or_throughput_queries("Sum", file_system_id);
419            let mut res = self.run_get_metric_data(queries, short_start, now).await?;
420            data.throughput_data_write = res.remove("e2").unwrap_or_default();
421            data.throughput_data_read = res.remove("e3").unwrap_or_default();
422            data.throughput_metadata = res.remove("e4").unwrap_or_default();
423        }
424
425        // Graph 4 — IOPS utilization (%). PercentIOLimit, Average.
426        {
427            let queries = vec![efs_metric_query(
428                "m1",
429                "PercentIOLimit",
430                "Average",
431                60,
432                file_system_id,
433                None,
434                true,
435            )];
436            let mut res = self.run_get_metric_data(queries, short_start, now).await?;
437            data.iops_utilization = res.remove("m1").unwrap_or_default();
438        }
439
440        // Graph 5 — Client connections (Sum).
441        {
442            let queries = vec![efs_metric_query(
443                "m1",
444                "ClientConnections",
445                "Sum",
446                60,
447                file_system_id,
448                None,
449                true,
450            )];
451            let mut res = self.run_get_metric_data(queries, short_start, now).await?;
452            data.client_connections = res.remove("m1").unwrap_or_default();
453        }
454
455        // Graph 6 — Storage bytes by storage class (Average). Standard / IA / Archive.
456        {
457            let queries = vec![
458                efs_metric_query(
459                    "mStandard",
460                    "StorageBytes",
461                    "Average",
462                    3600,
463                    file_system_id,
464                    Some(("StorageClass", "Standard")),
465                    true,
466                ),
467                efs_metric_query(
468                    "mIA",
469                    "StorageBytes",
470                    "Average",
471                    3600,
472                    file_system_id,
473                    Some(("StorageClass", "IA")),
474                    true,
475                ),
476                efs_metric_query(
477                    "mArchive",
478                    "StorageBytes",
479                    "Average",
480                    3600,
481                    file_system_id,
482                    Some(("StorageClass", "Archive")),
483                    true,
484                ),
485            ];
486            let mut res = self.run_get_metric_data(queries, long_start, now).await?;
487            data.storage_standard = res.remove("mStandard").unwrap_or_default();
488            data.storage_ia = res.remove("mIA").unwrap_or_default();
489            data.storage_archive = res.remove("mArchive").unwrap_or_default();
490        }
491
492        Ok(data)
493    }
494
495    /// Run a GetMetricData request and collect every returned series by its query id.
496    async fn run_get_metric_data(
497        &self,
498        queries: Vec<aws_sdk_cloudwatch::types::MetricDataQuery>,
499        start_time: chrono::DateTime<chrono::Utc>,
500        end_time: chrono::DateTime<chrono::Utc>,
501    ) -> Result<std::collections::HashMap<String, Vec<(i64, f64)>>> {
502        let client = self.config.cloudwatch_client();
503
504        let mut req = client
505            .get_metric_data()
506            .start_time(aws_sdk_cloudwatch::primitives::DateTime::from_millis(
507                start_time.timestamp_millis(),
508            ))
509            .end_time(aws_sdk_cloudwatch::primitives::DateTime::from_millis(
510                end_time.timestamp_millis(),
511            ));
512        for q in queries {
513            req = req.metric_data_queries(q);
514        }
515
516        let resp = req.send().await?;
517
518        let mut out: std::collections::HashMap<String, Vec<(i64, f64)>> =
519            std::collections::HashMap::new();
520        for r in resp.metric_data_results() {
521            let Some(id) = r.id() else { continue };
522            let mut series: Vec<(i64, f64)> = r
523                .timestamps()
524                .iter()
525                .zip(r.values().iter())
526                .map(|(ts, val)| (ts.as_secs_f64() as i64, *val))
527                .collect();
528            series.sort_by_key(|(ts, _)| *ts);
529            out.insert(id.to_string(), series);
530        }
531        Ok(out)
532    }
533}
534
535/// An EFS access point.
536#[derive(Clone, Debug, Default)]
537pub struct EfsAccessPoint {
538    pub access_point_id: String,
539    pub name: String,
540    pub path: String,
541    /// POSIX user as `uid:gid`.
542    pub posix_user: String,
543    /// Root directory creation info as `owner_uid:owner_gid permissions`.
544    pub creation_info: String,
545    pub life_cycle_state: String,
546}
547
548/// An EFS mount target (network interface in a subnet).
549#[derive(Clone, Debug, Default)]
550pub struct EfsMountTarget {
551    pub mount_target_id: String,
552    pub availability_zone_name: String,
553    pub availability_zone_id: String,
554    pub owner_id: String,
555    pub file_system_id: String,
556    pub subnet_id: String,
557    pub vpc_id: String,
558    pub life_cycle_state: String,
559    pub ip_address: String,
560    pub ipv6_address: String,
561    pub network_interface_id: String,
562    /// Comma-separated security group IDs.
563    pub security_groups: String,
564}
565
566/// Format a replication configuration into a human-readable multi-line summary.
567fn format_replication(
568    configs: &[aws_sdk_efs::types::ReplicationConfigurationDescription],
569) -> String {
570    let mut lines: Vec<String> = Vec::new();
571    for cfg in configs {
572        lines.push(format!(
573            "Source file system: {}",
574            cfg.source_file_system_id()
575        ));
576        lines.push(format!(
577            "Source region: {}",
578            cfg.source_file_system_region()
579        ));
580        if let Ok(ts) = cfg
581            .creation_time()
582            .fmt(aws_smithy_types::date_time::Format::DateTime)
583        {
584            lines.push(format!("Created: {}", ts));
585        }
586        lines.push(String::new());
587        lines.push("Destinations:".to_string());
588        for d in cfg.destinations() {
589            lines.push(format!(
590                "  • {} ({}) — {:?}",
591                d.file_system_id(),
592                d.region(),
593                d.status()
594            ));
595            if let Some(ts) = d.last_replicated_timestamp() {
596                if let Ok(s) = ts.fmt(aws_smithy_types::date_time::Format::DateTime) {
597                    lines.push(format!("    last replicated: {}", s));
598                }
599            }
600        }
601    }
602    lines.join("\n")
603}
604
605/// Time series for each EFS monitoring graph. Empty vectors render as "no data".
606#[derive(Default, Clone, Debug)]
607pub struct EfsMonitoringData {
608    /// Graph 1 — Throughput utilization (%).
609    pub throughput_utilization: Vec<(i64, f64)>,
610    /// Graph 2 — IOPS by type (% of total IO operations).
611    pub iops_data_write: Vec<(i64, f64)>,
612    pub iops_data_read: Vec<(i64, f64)>,
613    pub iops_metadata: Vec<(i64, f64)>,
614    /// Graph 3 — Throughput by type (% of total IO bytes).
615    pub throughput_data_write: Vec<(i64, f64)>,
616    pub throughput_data_read: Vec<(i64, f64)>,
617    pub throughput_metadata: Vec<(i64, f64)>,
618    /// Graph 4 — IOPS utilization (%).
619    pub iops_utilization: Vec<(i64, f64)>,
620    /// Graph 5 — Client connections.
621    pub client_connections: Vec<(i64, f64)>,
622    /// Graph 6 — Storage bytes by storage class.
623    pub storage_standard: Vec<(i64, f64)>,
624    pub storage_ia: Vec<(i64, f64)>,
625    pub storage_archive: Vec<(i64, f64)>,
626}
627
628/// Build a raw metric `MetricDataQuery` for the `AWS/EFS` namespace.
629fn efs_metric_query(
630    id: &str,
631    metric_name: &str,
632    stat: &str,
633    period: i32,
634    file_system_id: &str,
635    extra_dimension: Option<(&str, &str)>,
636    return_data: bool,
637) -> aws_sdk_cloudwatch::types::MetricDataQuery {
638    use aws_sdk_cloudwatch::types::{Dimension, Metric, MetricDataQuery, MetricStat};
639
640    let mut metric_b = Metric::builder()
641        .namespace("AWS/EFS")
642        .metric_name(metric_name);
643    // StorageBytes uses StorageClass as the leading dimension in the console.
644    if let Some((k, v)) = extra_dimension {
645        metric_b = metric_b.dimensions(Dimension::builder().name(k).value(v).build());
646    }
647    metric_b = metric_b.dimensions(
648        Dimension::builder()
649            .name("FileSystemId")
650            .value(file_system_id)
651            .build(),
652    );
653
654    let stat = MetricStat::builder()
655        .metric(metric_b.build())
656        .period(period)
657        .stat(stat)
658        .build();
659
660    MetricDataQuery::builder()
661        .id(id)
662        .metric_stat(stat)
663        .return_data(return_data)
664        .build()
665}
666
667/// Build a metric-math expression `MetricDataQuery`.
668fn efs_expr_query(
669    id: &str,
670    expression: &str,
671    period: i32,
672    return_data: bool,
673) -> aws_sdk_cloudwatch::types::MetricDataQuery {
674    aws_sdk_cloudwatch::types::MetricDataQuery::builder()
675        .id(id)
676        .expression(expression)
677        .period(period)
678        .return_data(return_data)
679        .build()
680}
681
682/// Shared query set for graphs 2 (IOPS by type) and 3 (Throughput by type):
683/// Data write / read / metadata as a percentage of TotalIOBytes, differing only
684/// by the aggregation `stat` (SampleCount vs Sum).
685fn iops_or_throughput_queries(
686    stat: &str,
687    file_system_id: &str,
688) -> Vec<aws_sdk_cloudwatch::types::MetricDataQuery> {
689    vec![
690        efs_expr_query("e2", "(m2*100)/m1", 60, true),
691        efs_expr_query("e3", "(m3*100)/m1", 60, true),
692        efs_expr_query("e4", "(m4*100)/m1", 60, true),
693        efs_metric_query("m1", "TotalIOBytes", stat, 60, file_system_id, None, false),
694        efs_metric_query(
695            "m2",
696            "DataWriteIOBytes",
697            stat,
698            60,
699            file_system_id,
700            None,
701            false,
702        ),
703        efs_metric_query(
704            "m3",
705            "DataReadIOBytes",
706            stat,
707            60,
708            file_system_id,
709            None,
710            false,
711        ),
712        efs_metric_query(
713            "m4",
714            "MetadataIOBytes",
715            stat,
716            60,
717            file_system_id,
718            None,
719            false,
720        ),
721    ]
722}