1use std::collections::{BTreeMap, HashMap};
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use http::StatusCode;
6
7use fakecloud_core::query::{
8 optional_query_param, query_metadata_only_xml, query_response_xml, required_query_param,
9};
10use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
11
12use std::sync::Arc;
13
14use fakecloud_persistence::SnapshotStore;
15use tokio::sync::Mutex;
16
17use crate::state::{
18 AlarmState, CloudWatchSnapshot, Dashboard, MetricAlarm, MetricDatum, SharedCloudWatchState,
19 StatisticSet, CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
20};
21
22pub(crate) const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
23
24pub(crate) const STANDARD_UNITS: &[&str] = &[
26 "Seconds",
27 "Microseconds",
28 "Milliseconds",
29 "Bytes",
30 "Kilobytes",
31 "Megabytes",
32 "Gigabytes",
33 "Terabytes",
34 "Bits",
35 "Kilobits",
36 "Megabits",
37 "Gigabits",
38 "Terabits",
39 "Percent",
40 "Count",
41 "Bytes/Second",
42 "Kilobytes/Second",
43 "Megabytes/Second",
44 "Gigabytes/Second",
45 "Terabytes/Second",
46 "Bits/Second",
47 "Kilobits/Second",
48 "Megabits/Second",
49 "Gigabits/Second",
50 "Terabits/Second",
51 "Count/Second",
52 "None",
53];
54
55const SUPPORTED_ACTIONS: &[&str] = &[
56 "PutMetricData",
58 "GetMetricStatistics",
59 "GetMetricData",
60 "ListMetrics",
61 "PutMetricAlarm",
62 "DescribeAlarms",
63 "DescribeAlarmsForMetric",
64 "DeleteAlarms",
65 "EnableAlarmActions",
66 "DisableAlarmActions",
67 "SetAlarmState",
68 "DescribeAlarmHistory",
69 "PutDashboard",
71 "GetDashboard",
72 "DeleteDashboards",
73 "ListDashboards",
74 "PutAnomalyDetector",
76 "DescribeAnomalyDetectors",
77 "DeleteAnomalyDetector",
78 "PutInsightRule",
80 "DescribeInsightRules",
81 "DeleteInsightRules",
82 "EnableInsightRules",
83 "DisableInsightRules",
84 "GetInsightRuleReport",
85 "PutManagedInsightRules",
86 "ListManagedInsightRules",
87 "PutMetricStream",
89 "GetMetricStream",
90 "ListMetricStreams",
91 "DeleteMetricStream",
92 "StartMetricStreams",
93 "StopMetricStreams",
94 "PutCompositeAlarm",
96 "PutAlarmMuteRule",
98 "GetAlarmMuteRule",
99 "ListAlarmMuteRules",
100 "DeleteAlarmMuteRule",
101 "GetOTelEnrichment",
103 "StartOTelEnrichment",
104 "StopOTelEnrichment",
105 "AssociateDatasetKmsKey",
107 "DisassociateDatasetKmsKey",
108 "GetDataset",
109 "DescribeAlarmContributors",
111 "GetMetricWidgetImage",
112 "TagResource",
114 "UntagResource",
115 "ListTagsForResource",
116];
117
118pub struct CloudWatchService {
119 pub(crate) state: SharedCloudWatchState,
120 snapshot_store: Option<Arc<dyn SnapshotStore>>,
121 snapshot_lock: Arc<Mutex<()>>,
122}
123
124impl CloudWatchService {
125 pub fn new(state: SharedCloudWatchState) -> Self {
126 Self {
127 state,
128 snapshot_store: None,
129 snapshot_lock: Arc::new(Mutex::new(())),
130 }
131 }
132
133 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
137 self.snapshot_store = Some(store);
138 self
139 }
140
141 pub(crate) async fn save_snapshot(&self) {
145 save_cloudwatch_snapshot(
146 &self.state,
147 self.snapshot_store.clone(),
148 &self.snapshot_lock,
149 )
150 .await;
151 }
152
153 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
158 let store = self.snapshot_store.clone()?;
159 let state = self.state.clone();
160 let lock = self.snapshot_lock.clone();
161 Some(Arc::new(move || {
162 let state = state.clone();
163 let store = store.clone();
164 let lock = lock.clone();
165 Box::pin(async move {
166 save_cloudwatch_snapshot(&state, Some(store), &lock).await;
167 })
168 }))
169 }
170}
171
172pub async fn save_cloudwatch_snapshot(
179 state: &SharedCloudWatchState,
180 store: Option<Arc<dyn SnapshotStore>>,
181 lock: &Mutex<()>,
182) {
183 let Some(store) = store else {
184 return;
185 };
186 let _guard = lock.lock().await;
187 let snapshot = CloudWatchSnapshot {
188 schema_version: CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
189 accounts: state.read().clone_for_snapshot(),
190 };
191 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
192 let bytes = serde_json::to_vec(&snapshot)
193 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
194 store.save(&bytes)
195 })
196 .await;
197 match join {
198 Ok(Ok(())) => {}
199 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudwatch snapshot"),
200 Err(err) => tracing::error!(%err, "cloudwatch snapshot task panicked"),
201 }
202}
203
204#[async_trait]
205impl AwsService for CloudWatchService {
206 fn service_name(&self) -> &str {
207 "monitoring"
208 }
209
210 fn supported_actions(&self) -> &[&str] {
211 SUPPORTED_ACTIONS
212 }
213
214 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
215 let mutates = matches!(
216 req.action.as_str(),
217 "PutMetricData"
218 | "PutMetricAlarm"
219 | "DeleteAlarms"
220 | "EnableAlarmActions"
221 | "DisableAlarmActions"
222 | "SetAlarmState"
223 | "PutDashboard"
224 | "DeleteDashboards"
225 | "PutAnomalyDetector"
226 | "DeleteAnomalyDetector"
227 | "PutInsightRule"
228 | "DeleteInsightRules"
229 | "EnableInsightRules"
230 | "DisableInsightRules"
231 | "PutManagedInsightRules"
232 | "PutMetricStream"
233 | "DeleteMetricStream"
234 | "StartMetricStreams"
235 | "StopMetricStreams"
236 | "PutCompositeAlarm"
237 | "PutAlarmMuteRule"
238 | "DeleteAlarmMuteRule"
239 | "StartOTelEnrichment"
240 | "StopOTelEnrichment"
241 | "AssociateDatasetKmsKey"
242 | "DisassociateDatasetKmsKey"
243 | "TagResource"
244 | "UntagResource"
245 );
246 let result = match req.action.as_str() {
247 "PutMetricData" => self.put_metric_data(&req),
248 "GetMetricStatistics" => self.get_metric_statistics(&req),
249 "GetMetricData" => self.get_metric_data(&req),
250 "ListMetrics" => self.list_metrics(&req),
251 "PutMetricAlarm" => self.put_metric_alarm(&req),
252 "DescribeAlarms" => self.describe_alarms(&req),
253 "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
254 "DeleteAlarms" => self.delete_alarms(&req),
255 "EnableAlarmActions" => self.enable_alarm_actions(&req),
256 "DisableAlarmActions" => self.disable_alarm_actions(&req),
257 "SetAlarmState" => self.set_alarm_state(&req),
258 "DescribeAlarmHistory" => self.describe_alarm_history(&req),
259 "PutDashboard" => self.put_dashboard(&req),
260 "GetDashboard" => self.get_dashboard(&req),
261 "DeleteDashboards" => self.delete_dashboards(&req),
262 "ListDashboards" => self.list_dashboards(&req),
263 "PutAnomalyDetector" => self.put_anomaly_detector(&req),
265 "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
266 "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
267 "PutInsightRule" => self.put_insight_rule(&req),
269 "DescribeInsightRules" => self.describe_insight_rules(&req),
270 "DeleteInsightRules" => self.delete_insight_rules(&req),
271 "EnableInsightRules" => self.enable_insight_rules(&req),
272 "DisableInsightRules" => self.disable_insight_rules(&req),
273 "GetInsightRuleReport" => self.get_insight_rule_report(&req),
274 "PutManagedInsightRules" => self.put_managed_insight_rules(&req),
275 "ListManagedInsightRules" => self.list_managed_insight_rules(&req),
276 "PutMetricStream" => self.put_metric_stream(&req),
278 "GetMetricStream" => self.get_metric_stream(&req),
279 "ListMetricStreams" => self.list_metric_streams(&req),
280 "DeleteMetricStream" => self.delete_metric_stream(&req),
281 "StartMetricStreams" => self.start_metric_streams(&req),
282 "StopMetricStreams" => self.stop_metric_streams(&req),
283 "PutCompositeAlarm" => self.put_composite_alarm(&req),
285 "PutAlarmMuteRule" => self.put_alarm_mute_rule(&req),
287 "GetAlarmMuteRule" => self.get_alarm_mute_rule(&req),
288 "ListAlarmMuteRules" => self.list_alarm_mute_rules(&req),
289 "DeleteAlarmMuteRule" => self.delete_alarm_mute_rule(&req),
290 "GetOTelEnrichment" => self.get_otel_enrichment(&req),
292 "StartOTelEnrichment" => self.start_otel_enrichment(&req),
293 "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
294 "AssociateDatasetKmsKey" => self.associate_dataset_kms_key(&req),
296 "DisassociateDatasetKmsKey" => self.disassociate_dataset_kms_key(&req),
297 "GetDataset" => self.get_dataset(&req),
298 "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
300 "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
301 "TagResource" => self.tag_resource(&req),
303 "UntagResource" => self.untag_resource(&req),
304 "ListTagsForResource" => self.list_tags_for_resource(&req),
305 _ => Err(AwsServiceError::action_not_implemented(
306 "monitoring",
307 &req.action,
308 )),
309 };
310 if mutates && result.is_ok() {
311 self.save_snapshot().await;
312 }
313 result
314 }
315}
316
317pub(crate) fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
318 AwsResponse::xml(
319 StatusCode::OK,
320 query_response_xml(action, NS, inner, request_id),
321 )
322}
323
324pub(crate) fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
325 AwsResponse::xml(
326 StatusCode::OK,
327 query_metadata_only_xml(action, NS, request_id),
328 )
329}
330
331pub(crate) fn invalid_param(message: impl Into<String>) -> AwsServiceError {
332 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
333}
334
335pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
337 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
338}
339
340pub(crate) fn missing_param(name: &str) -> AwsServiceError {
343 AwsServiceError::aws_error(
344 StatusCode::BAD_REQUEST,
345 "MissingParameter",
346 format!("The request must contain the parameter {name}."),
347 )
348}
349
350pub(crate) fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
351 let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
352 let needle = format!("{prefix}.member.");
353 for (k, v) in req.query_params.iter() {
354 let Some(rest) = k.strip_prefix(&needle) else {
355 continue;
356 };
357 let mut parts = rest.splitn(2, '.');
358 let Some(idx_str) = parts.next() else {
359 continue;
360 };
361 let Ok(idx) = idx_str.parse::<u32>() else {
362 continue;
363 };
364 let field = parts.next().unwrap_or("").to_string();
365 by_index.entry(idx).or_default().insert(field, v.clone());
366 }
367 by_index.into_values().collect()
368}
369
370fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
371 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
372 let needle = format!("{prefix}.member.");
373 for (k, v) in member.iter() {
374 let Some(rest) = k.strip_prefix(&needle) else {
375 continue;
376 };
377 let mut parts = rest.splitn(2, '.');
378 let Some(idx_str) = parts.next() else {
379 continue;
380 };
381 let Ok(idx) = idx_str.parse::<u32>() else {
382 continue;
383 };
384 let field = parts.next().unwrap_or("");
385 let entry = dims.entry(idx).or_default();
386 match field {
387 "Name" => entry.0 = Some(v.clone()),
388 "Value" => entry.1 = Some(v.clone()),
389 _ => {}
390 }
391 }
392 let mut out = BTreeMap::new();
393 for (_, (name, value)) in dims {
394 if let (Some(n), Some(v)) = (name, value) {
395 out.insert(n, v);
396 }
397 }
398 out
399}
400
401pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
402 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
403 let needle = format!("{prefix}.member.");
404 for (k, v) in req.query_params.iter() {
405 let Some(rest) = k.strip_prefix(&needle) else {
406 continue;
407 };
408 let mut parts = rest.splitn(2, '.');
409 let Some(idx_str) = parts.next() else {
410 continue;
411 };
412 let Ok(idx) = idx_str.parse::<u32>() else {
413 continue;
414 };
415 let field = parts.next().unwrap_or("");
416 let entry = dims.entry(idx).or_default();
417 match field {
418 "Name" => entry.0 = Some(v.clone()),
419 "Value" => entry.1 = Some(v.clone()),
420 _ => {}
421 }
422 }
423 let mut out = BTreeMap::new();
424 for (_, (name, value)) in dims {
425 if let (Some(n), Some(v)) = (name, value) {
426 out.insert(n, v);
427 }
428 }
429 out
430}
431
432pub(crate) fn validate_len(
436 req: &AwsRequest,
437 param: &str,
438 min: usize,
439 max: usize,
440) -> Result<(), AwsServiceError> {
441 if let Some(v) = req.query_params.get(param) {
442 let len = v.chars().count();
443 if len < min || len > max {
444 return Err(invalid_param(format!(
445 "{param} length {len} is outside [{min}, {max}]"
446 )));
447 }
448 }
449 Ok(())
450}
451
452pub(crate) fn validate_range_i64(
454 req: &AwsRequest,
455 param: &str,
456 min: i64,
457 max: i64,
458) -> Result<(), AwsServiceError> {
459 if let Some(v) = req.query_params.get(param) {
460 if v.is_empty() {
461 return Ok(());
462 }
463 let n = v
464 .parse::<i64>()
465 .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
466 if n < min || n > max {
467 return Err(invalid_param(format!(
468 "{param} value {n} is outside [{min}, {max}]"
469 )));
470 }
471 }
472 Ok(())
473}
474
475pub(crate) fn validate_enum(
477 req: &AwsRequest,
478 param: &str,
479 allowed: &[&str],
480) -> Result<(), AwsServiceError> {
481 if let Some(v) = req.query_params.get(param) {
482 if !v.is_empty() && !allowed.contains(&v.as_str()) {
483 return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
484 }
485 }
486 Ok(())
487}
488
489pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
491 let needle = format!("{prefix}.member.");
492 let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
493 for (k, v) in req.query_params.iter() {
494 let Some(rest) = k.strip_prefix(&needle) else {
495 continue;
496 };
497 if let Ok(idx) = rest.parse::<u32>() {
498 by_index.insert(idx, v.clone());
499 }
500 }
501 by_index.into_values().collect()
502}
503
504pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
506 let members = collect_indexed(req, prefix);
507 let mut out = BTreeMap::new();
508 for m in members {
509 if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
510 out.insert(k.clone(), v.clone());
511 }
512 }
513 out
514}
515
516pub(crate) fn xml_escape(s: &str) -> String {
517 s.replace('&', "&")
518 .replace('<', "<")
519 .replace('>', ">")
520 .replace('"', """)
521 .replace('\'', "'")
522}
523
524#[derive(Clone, Copy)]
528struct DatumStats {
529 sum: f64,
530 min: f64,
531 max: f64,
532 count: f64,
533}
534
535fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
536 if let Some(v) = d.value {
537 return Some(DatumStats {
538 sum: v,
539 min: v,
540 max: v,
541 count: 1.0,
542 });
543 }
544 if let Some(s) = &d.statistic_values {
545 return Some(DatumStats {
546 sum: s.sum,
547 min: s.minimum,
548 max: s.maximum,
549 count: s.sample_count,
550 });
551 }
552 None
553}
554
555fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
556 acc.sum += other.sum;
557 acc.count += other.count;
558 if other.min < acc.min {
559 acc.min = other.min;
560 }
561 if other.max > acc.max {
562 acc.max = other.max;
563 }
564}
565
566fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
567 match stat {
568 "Sum" => Some(agg.sum),
569 "Average" => {
570 if agg.count > 0.0 {
571 Some(agg.sum / agg.count)
572 } else {
573 None
574 }
575 }
576 "Minimum" => Some(agg.min),
577 "Maximum" => Some(agg.max),
578 "SampleCount" => Some(agg.count),
579 _ => None,
580 }
581}
582
583pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
584 let mut s = String::from("<Dimensions>");
585 for (name, value) in dims.iter() {
586 s.push_str(&format!(
587 "<member><Name>{}</Name><Value>{}</Value></member>",
588 xml_escape(name),
589 xml_escape(value),
590 ));
591 }
592 s.push_str("</Dimensions>");
593 s
594}
595
596impl CloudWatchService {
597 fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
598 let namespace = required_query_param(req, "Namespace")?;
599 let members = collect_indexed(req, "MetricData");
600 if members.is_empty() {
601 return Err(invalid_param(
602 "PutMetricData requires at least one MetricData entry",
603 ));
604 }
605
606 let now = Utc::now();
607 let mut state = self.state.write();
608 let acct = state.get_or_create(&req.account_id);
609 let metrics_map = acct.metrics_in_mut(&req.region);
610 let bucket = metrics_map.entry(namespace.clone()).or_default();
611
612 for member in members {
613 let metric_name = member
614 .get("MetricName")
615 .cloned()
616 .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
617 let value = member
618 .get("Value")
619 .map(|s| s.parse::<f64>())
620 .transpose()
621 .map_err(|_| invalid_param("Value must be a valid number"))?;
622 let timestamp = member
623 .get("Timestamp")
624 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
625 .map(|d| d.with_timezone(&Utc))
626 .unwrap_or(now);
627 let unit = member.get("Unit").cloned();
628 let storage_resolution = member
629 .get("StorageResolution")
630 .and_then(|s| s.parse::<i64>().ok());
631 let dimensions = parse_dimensions(&member, "Dimensions");
632
633 let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
634 member.get("StatisticValues.SampleCount"),
635 member.get("StatisticValues.Sum"),
636 member.get("StatisticValues.Minimum"),
637 member.get("StatisticValues.Maximum"),
638 ) {
639 Some(StatisticSet {
640 sample_count: sc.parse::<f64>().map_err(|_| {
641 invalid_param("StatisticValues.SampleCount must be a number")
642 })?,
643 sum: sum
644 .parse::<f64>()
645 .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
646 minimum: min
647 .parse::<f64>()
648 .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
649 maximum: max
650 .parse::<f64>()
651 .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
652 })
653 } else {
654 None
655 };
656
657 if value.is_none() && statistic_values.is_none() {
658 return Err(invalid_param(
659 "MetricData entry must supply either Value or StatisticValues",
660 ));
661 }
662
663 bucket.push(MetricDatum {
664 metric_name,
665 dimensions,
666 timestamp,
667 value,
668 statistic_values,
669 unit,
670 storage_resolution,
671 });
672 }
673
674 Ok(empty_metadata_response("PutMetricData", &req.request_id))
675 }
676
677 fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
678 validate_len(req, "Namespace", 1, 255)?;
679 validate_len(req, "MetricName", 1, 255)?;
680 validate_len(req, "OwningAccount", 1, 255)?;
681 validate_enum(req, "RecentlyActive", &["PT3H"])?;
682 let namespace = optional_query_param(req, "Namespace");
683 let metric_name = optional_query_param(req, "MetricName");
684 let dim_filter = parse_dimensions_query(req, "Dimensions");
685
686 let state = self.state.read();
687 let mut out = String::from("<Metrics>");
688 if let Some(acct) = state.get(&req.account_id) {
689 if let Some(map) = acct.metrics_in(&req.region) {
690 for (ns, data) in map.iter() {
691 if let Some(filter_ns) = namespace.as_ref() {
692 if ns != filter_ns {
693 continue;
694 }
695 }
696 let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
697 BTreeMap::new();
698 for d in data.iter() {
699 if let Some(filter_name) = metric_name.as_ref() {
700 if &d.metric_name != filter_name {
701 continue;
702 }
703 }
704 if !dim_filter.is_empty()
705 && !dim_filter
706 .iter()
707 .all(|(k, v)| d.dimensions.get(k) == Some(v))
708 {
709 continue;
710 }
711 seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
712 }
713 for ((name, dims), _) in seen {
714 out.push_str("<member>");
715 out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
716 out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
717 out.push_str(&render_dimensions(&dims));
718 out.push_str("</member>");
719 }
720 }
721 }
722 }
723 out.push_str("</Metrics>");
724
725 Ok(xml_response("ListMetrics", &out, &req.request_id))
726 }
727
728 fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
729 let namespace = required_query_param(req, "Namespace")?;
730 let metric_name = required_query_param(req, "MetricName")?;
731 let start = required_query_param(req, "StartTime")?;
732 let end = required_query_param(req, "EndTime")?;
733 let period = required_query_param(req, "Period")?
734 .parse::<i64>()
735 .map_err(|_| invalid_param("Period must be an integer"))?;
736 if period <= 0 {
737 return Err(invalid_param("Period must be positive"));
738 }
739 let start_ts = DateTime::parse_from_rfc3339(&start)
740 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
741 .with_timezone(&Utc);
742 let end_ts = DateTime::parse_from_rfc3339(&end)
743 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
744 .with_timezone(&Utc);
745
746 let mut statistics: Vec<String> = Vec::new();
747 for (k, v) in req.query_params.iter() {
748 if k.starts_with("Statistics.member.") {
749 statistics.push(v.clone());
750 }
751 }
752 if statistics.is_empty() {
753 return Err(invalid_param("At least one Statistic is required"));
754 }
755
756 let dim_filter = parse_dimensions_query(req, "Dimensions");
757
758 let state = self.state.read();
759 let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
760 if let Some(acct) = state.get(&req.account_id) {
761 if let Some(map) = acct.metrics_in(&req.region) {
762 if let Some(data) = map.get(&namespace) {
763 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
764 for d in data.iter() {
765 if d.metric_name != metric_name {
766 continue;
767 }
768 if !dim_filter
769 .iter()
770 .all(|(k, v)| d.dimensions.get(k) == Some(v))
771 {
772 continue;
773 }
774 if d.timestamp < start_ts || d.timestamp >= end_ts {
775 continue;
776 }
777 let Some(stats) = datum_stats(d) else {
778 continue;
779 };
780 let secs = d.timestamp.timestamp();
781 let bucket_secs = secs - secs.rem_euclid(period);
782 let bucket_ts =
783 DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
784 buckets
785 .entry(bucket_ts)
786 .and_modify(|acc| merge_stats(acc, stats))
787 .or_insert(stats);
788 }
789 for (ts, agg) in buckets {
790 let mut stats = BTreeMap::new();
791 for stat in statistics.iter() {
792 if let Some(v) = stat_value(stat, agg) {
793 stats.insert(stat.clone(), v);
794 }
795 }
796 datapoints.push((ts, stats));
797 }
798 }
799 }
800 }
801
802 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
803 inner.push_str("<Datapoints>");
804 for (ts, stats) in datapoints {
805 inner.push_str("<member>");
806 inner.push_str(&format!(
807 "<Timestamp>{}</Timestamp>",
808 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
809 ));
810 for (name, value) in stats {
811 inner.push_str(&format!("<{name}>{value}</{name}>"));
812 }
813 inner.push_str("</member>");
814 }
815 inner.push_str("</Datapoints>");
816
817 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
818 }
819
820 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
821 validate_enum(
822 req,
823 "ScanBy",
824 &["TimestampDescending", "TimestampAscending"],
825 )?;
826 let start = required_query_param(req, "StartTime")?;
827 let end = required_query_param(req, "EndTime")?;
828 let start_ts = DateTime::parse_from_rfc3339(&start)
829 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
830 .with_timezone(&Utc);
831 let end_ts = DateTime::parse_from_rfc3339(&end)
832 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
833 .with_timezone(&Utc);
834
835 let queries = collect_indexed(req, "MetricDataQueries");
838
839 let state = self.state.read();
840 let mut inner = String::from("<MetricDataResults>");
841 for q in queries {
842 let id = q.get("Id").cloned().unwrap_or_default();
843 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
844 let stat = q
845 .get("MetricStat.Stat")
846 .cloned()
847 .unwrap_or_else(|| "Sum".to_string());
848 let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
849 let namespace = q.get("MetricStat.Metric.Namespace").cloned();
850 let period: i64 = q
851 .get("MetricStat.Period")
852 .and_then(|s| s.parse::<i64>().ok())
853 .filter(|p| *p > 0)
854 .unwrap_or(60);
855 let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
856
857 let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
858 if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
859 if let Some(acct) = state.get(&req.account_id) {
860 if let Some(map) = acct.metrics_in(&req.region) {
861 if let Some(data) = map.get(&namespace) {
862 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
863 for d in data.iter() {
864 if d.metric_name != metric_name {
865 continue;
866 }
867 if !dim_filter
868 .iter()
869 .all(|(k, v)| d.dimensions.get(k) == Some(v))
870 {
871 continue;
872 }
873 if d.timestamp < start_ts || d.timestamp >= end_ts {
874 continue;
875 }
876 let Some(stats) = datum_stats(d) else {
877 continue;
878 };
879 let secs = d.timestamp.timestamp();
880 let bucket_secs = secs - secs.rem_euclid(period);
881 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
882 .unwrap_or(d.timestamp);
883 buckets
884 .entry(bucket_ts)
885 .and_modify(|acc| merge_stats(acc, stats))
886 .or_insert(stats);
887 }
888 for (ts, agg) in buckets {
889 let Some(v) = stat_value(&stat, agg) else {
890 continue;
891 };
892 timestamps
893 .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
894 values.push(v);
895 }
896 }
897 }
898 }
899 }
900
901 inner.push_str("<member>");
902 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
903 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
904 inner.push_str("<StatusCode>Complete</StatusCode>");
905 inner.push_str("<Timestamps>");
906 for ts in timestamps {
907 inner.push_str(&format!("<member>{ts}</member>"));
908 }
909 inner.push_str("</Timestamps>");
910 inner.push_str("<Values>");
911 for v in values {
912 inner.push_str(&format!("<member>{v}</member>"));
913 }
914 inner.push_str("</Values>");
915 inner.push_str("</member>");
916 }
917 inner.push_str("</MetricDataResults>");
918 inner.push_str("<Messages></Messages>");
919
920 Ok(xml_response("GetMetricData", &inner, &req.request_id))
921 }
922
923 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
924 validate_len(req, "AlarmName", 1, 255)?;
930 validate_len(req, "AlarmDescription", 0, 1024)?;
931 validate_len(req, "MetricName", 1, 255)?;
932 validate_len(req, "Namespace", 1, 255)?;
933 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
934 validate_len(req, "TreatMissingData", 1, 255)?;
935 validate_len(req, "ThresholdMetricId", 1, 255)?;
936 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
937 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
938 validate_range_i64(req, "Period", 1, i64::MAX)?;
939 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
940 validate_enum(
941 req,
942 "ComparisonOperator",
943 &[
944 "GreaterThanOrEqualToThreshold",
945 "GreaterThanThreshold",
946 "GreaterThanUpperThreshold",
947 "LessThanLowerOrGreaterThanUpperThreshold",
948 "LessThanLowerThreshold",
949 "LessThanOrEqualToThreshold",
950 "LessThanThreshold",
951 ],
952 )?;
953 validate_enum(
954 req,
955 "Statistic",
956 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
957 )?;
958 validate_enum(req, "Unit", STANDARD_UNITS)?;
959 let alarm_name = required_query_param(req, "AlarmName")?;
960 let comparison = optional_query_param(req, "ComparisonOperator")
961 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
962 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
963 .and_then(|s| s.parse::<i64>().ok())
964 .unwrap_or(1);
965
966 let alarm_description = optional_query_param(req, "AlarmDescription");
967 let actions_enabled = optional_query_param(req, "ActionsEnabled")
968 .map(|s| s.eq_ignore_ascii_case("true"))
969 .unwrap_or(true);
970
971 let metric_name = optional_query_param(req, "MetricName");
972 let namespace = optional_query_param(req, "Namespace");
973 let statistic = optional_query_param(req, "Statistic");
974 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
975 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
976 let unit = optional_query_param(req, "Unit");
977 let datapoints_to_alarm =
978 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
979 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
980 let treat_missing_data = optional_query_param(req, "TreatMissingData");
981 let evaluate_low_sample_count_percentile =
982 optional_query_param(req, "EvaluateLowSampleCountPercentile");
983 let dimensions = parse_dimensions_query(req, "Dimensions");
984
985 let mut ok_actions = Vec::new();
986 let mut alarm_actions = Vec::new();
987 let mut insufficient_data_actions = Vec::new();
988 for (k, v) in req.query_params.iter() {
989 if k.starts_with("OKActions.member.") {
990 ok_actions.push(v.clone());
991 } else if k.starts_with("AlarmActions.member.") {
992 alarm_actions.push(v.clone());
993 } else if k.starts_with("InsufficientDataActions.member.") {
994 insufficient_data_actions.push(v.clone());
995 }
996 }
997
998 let arn = format!(
999 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1000 req.region, req.account_id, alarm_name
1001 );
1002 let now = Utc::now();
1003
1004 let mut state = self.state.write();
1005 let acct = state.get_or_create(&req.account_id);
1006 let alarms = acct.alarms_in_mut(&req.region);
1007 let existing = alarms.get(&alarm_name).cloned();
1008 let alarm = MetricAlarm {
1009 alarm_name: alarm_name.clone(),
1010 alarm_arn: arn,
1011 alarm_description,
1012 actions_enabled,
1013 ok_actions,
1014 alarm_actions,
1015 insufficient_data_actions,
1016 state_value: existing
1017 .as_ref()
1018 .map(|a| a.state_value)
1019 .unwrap_or(AlarmState::InsufficientData),
1020 state_reason: existing
1021 .as_ref()
1022 .map(|a| a.state_reason.clone())
1023 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1024 state_updated_timestamp: existing
1025 .as_ref()
1026 .map(|a| a.state_updated_timestamp)
1027 .unwrap_or(now),
1028 metric_name,
1029 namespace,
1030 statistic,
1031 extended_statistic,
1032 dimensions,
1033 period,
1034 unit,
1035 evaluation_periods,
1036 datapoints_to_alarm,
1037 threshold,
1038 comparison_operator: comparison,
1039 treat_missing_data,
1040 evaluate_low_sample_count_percentile,
1041 configuration_updated_timestamp: existing
1042 .as_ref()
1043 .map(|a| a.configuration_updated_timestamp)
1044 .unwrap_or(now),
1045 alarm_configuration_updated_timestamp: now,
1046 };
1047 alarms.insert(alarm_name, alarm);
1048
1049 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1050 }
1051
1052 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1053 let mut filter_names: Vec<String> = Vec::new();
1054 for (k, v) in req.query_params.iter() {
1055 if k.starts_with("AlarmNames.member.") {
1056 filter_names.push(v.clone());
1057 }
1058 }
1059 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1060 validate_len(req, "ActionPrefix", 1, 1024)?;
1061 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1062 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1063 validate_range_i64(req, "MaxRecords", 1, 100)?;
1064 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1065 let prefix = optional_query_param(req, "AlarmNamePrefix");
1066 let state_filter = optional_query_param(req, "StateValue");
1067 let action_prefix = optional_query_param(req, "ActionPrefix");
1068
1069 let state = self.state.read();
1070 let mut inner = String::from("<MetricAlarms>");
1071 if let Some(acct) = state.get(&req.account_id) {
1072 if let Some(alarms) = acct.alarms_in(&req.region) {
1073 for alarm in alarms.values() {
1074 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1075 continue;
1076 }
1077 if let Some(p) = prefix.as_ref() {
1078 if !alarm.alarm_name.starts_with(p) {
1079 continue;
1080 }
1081 }
1082 if let Some(sv) = state_filter.as_ref() {
1083 if alarm.state_value.as_str() != sv {
1084 continue;
1085 }
1086 }
1087 if let Some(ap) = action_prefix.as_ref() {
1088 let any = alarm
1089 .alarm_actions
1090 .iter()
1091 .chain(alarm.ok_actions.iter())
1092 .chain(alarm.insufficient_data_actions.iter())
1093 .any(|a| a.starts_with(ap));
1094 if !any {
1095 continue;
1096 }
1097 }
1098 inner.push_str(&render_alarm(alarm));
1099 }
1100 }
1101 }
1102 inner.push_str("</MetricAlarms>");
1103 inner.push_str("<CompositeAlarms>");
1104 if let Some(acct) = state.get(&req.account_id) {
1105 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1106 for alarm in composites.values() {
1107 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1108 continue;
1109 }
1110 if let Some(p) = prefix.as_ref() {
1111 if !alarm.alarm_name.starts_with(p) {
1112 continue;
1113 }
1114 }
1115 if let Some(sv) = state_filter.as_ref() {
1116 if alarm.state_value.as_str() != sv {
1117 continue;
1118 }
1119 }
1120 if let Some(ap) = action_prefix.as_ref() {
1121 let any = alarm
1122 .alarm_actions
1123 .iter()
1124 .chain(alarm.ok_actions.iter())
1125 .chain(alarm.insufficient_data_actions.iter())
1126 .any(|a| a.starts_with(ap));
1127 if !any {
1128 continue;
1129 }
1130 }
1131 inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1132 }
1133 }
1134 }
1135 inner.push_str("</CompositeAlarms>");
1136
1137 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1138 }
1139
1140 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1141 validate_len(req, "MetricName", 1, 255)?;
1142 validate_len(req, "Namespace", 1, 255)?;
1143 validate_range_i64(req, "Period", 1, i64::MAX)?;
1144 validate_enum(
1145 req,
1146 "Statistic",
1147 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1148 )?;
1149 validate_enum(req, "Unit", STANDARD_UNITS)?;
1150 let metric_name = required_query_param(req, "MetricName")?;
1151 let namespace = required_query_param(req, "Namespace")?;
1152 let dim_filter = parse_dimensions_query(req, "Dimensions");
1153
1154 let state = self.state.read();
1155 let mut inner = String::from("<MetricAlarms>");
1156 if let Some(acct) = state.get(&req.account_id) {
1157 if let Some(alarms) = acct.alarms_in(&req.region) {
1158 for alarm in alarms.values() {
1159 if alarm.metric_name.as_deref() != Some(&metric_name) {
1160 continue;
1161 }
1162 if alarm.namespace.as_deref() != Some(&namespace) {
1163 continue;
1164 }
1165 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1166 continue;
1167 }
1168 inner.push_str(&render_alarm(alarm));
1169 }
1170 }
1171 }
1172 inner.push_str("</MetricAlarms>");
1173
1174 Ok(xml_response(
1175 "DescribeAlarmsForMetric",
1176 &inner,
1177 &req.request_id,
1178 ))
1179 }
1180
1181 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1182 let mut names: Vec<String> = Vec::new();
1186 for (k, v) in req.query_params.iter() {
1187 if k.starts_with("AlarmNames.member.") {
1188 names.push(v.clone());
1189 }
1190 }
1191
1192 let mut state = self.state.write();
1193 let acct = state.get_or_create(&req.account_id);
1194 for name in &names {
1195 acct.alarms_in_mut(&req.region).remove(name);
1196 acct.composite_alarms_in_mut(&req.region).remove(name);
1197 }
1198
1199 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1200 }
1201
1202 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1203 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1204 }
1205
1206 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1207 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1208 }
1209
1210 fn toggle_alarm_actions(
1211 &self,
1212 req: &AwsRequest,
1213 enabled: bool,
1214 action_name: &str,
1215 ) -> Result<AwsResponse, AwsServiceError> {
1216 let mut names: Vec<String> = Vec::new();
1217 for (k, v) in req.query_params.iter() {
1218 if k.starts_with("AlarmNames.member.") {
1219 names.push(v.clone());
1220 }
1221 }
1222 let mut state = self.state.write();
1223 let acct = state.get_or_create(&req.account_id);
1224 let alarms = acct.alarms_in_mut(&req.region);
1225 for name in names {
1226 if let Some(alarm) = alarms.get_mut(&name) {
1227 alarm.actions_enabled = enabled;
1228 alarm.alarm_configuration_updated_timestamp = Utc::now();
1229 }
1230 }
1231 Ok(empty_metadata_response(action_name, &req.request_id))
1232 }
1233
1234 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1235 validate_len(req, "AlarmName", 1, 255)?;
1236 validate_len(req, "StateReason", 0, 1023)?;
1237 validate_len(req, "StateReasonData", 0, 4000)?;
1238 let alarm_name = required_query_param(req, "AlarmName")?;
1239 let state_value = required_query_param(req, "StateValue")?;
1240 let state_reason = req
1244 .query_params
1245 .get("StateReason")
1246 .cloned()
1247 .ok_or_else(|| {
1248 AwsServiceError::aws_error(
1249 StatusCode::BAD_REQUEST,
1250 "MissingParameter",
1251 "The request must contain the parameter StateReason.",
1252 )
1253 })?;
1254 let new_state = AlarmState::parse(&state_value)
1255 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1256
1257 let mut state = self.state.write();
1258 let acct = state.get_or_create(&req.account_id);
1259 let alarms = acct.alarms_in_mut(&req.region);
1260 let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1261 AwsServiceError::aws_error(
1262 StatusCode::NOT_FOUND,
1263 "ResourceNotFound",
1264 format!("Alarm {alarm_name} not found"),
1265 )
1266 })?;
1267 alarm.state_value = new_state;
1268 alarm.state_reason = state_reason;
1269 alarm.state_updated_timestamp = Utc::now();
1270
1271 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1272 }
1273
1274 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1275 validate_len(req, "AlarmName", 1, 255)?;
1276 validate_len(req, "AlarmContributorId", 1, 16)?;
1277 validate_range_i64(req, "MaxRecords", 1, 100)?;
1278 validate_enum(
1279 req,
1280 "HistoryItemType",
1281 &[
1282 "ConfigurationUpdate",
1283 "StateUpdate",
1284 "Action",
1285 "AlarmContributorStateUpdate",
1286 "AlarmContributorAction",
1287 ],
1288 )?;
1289 validate_enum(
1290 req,
1291 "ScanBy",
1292 &["TimestampDescending", "TimestampAscending"],
1293 )?;
1294 let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1297 Ok(xml_response(
1298 "DescribeAlarmHistory",
1299 &inner,
1300 &req.request_id,
1301 ))
1302 }
1303
1304 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1305 let dashboard_name = req
1306 .query_params
1307 .get("DashboardName")
1308 .ok_or_else(|| invalid_param("DashboardName is required"))?
1309 .clone();
1310 let body = req
1311 .query_params
1312 .get("DashboardBody")
1313 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1314 .clone();
1315 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1318 return Err(AwsServiceError::aws_error(
1319 StatusCode::BAD_REQUEST,
1320 "InvalidParameterInput",
1321 "DashboardBody must be a valid JSON object",
1322 ));
1323 }
1324 let arn = format!(
1325 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1326 req.account_id
1327 );
1328 let dashboard = Dashboard {
1329 name: dashboard_name.clone(),
1330 arn,
1331 size_bytes: body.len() as i64,
1332 body,
1333 last_modified: Utc::now(),
1334 };
1335 let mut state = self.state.write();
1336 let acct = state.get_or_create(&req.account_id);
1337 acct.dashboards.insert(dashboard_name, dashboard);
1338 let inner = String::from("<DashboardValidationMessages/>");
1341 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1342 }
1343
1344 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1345 let name = req
1346 .query_params
1347 .get("DashboardName")
1348 .ok_or_else(|| invalid_param("DashboardName is required"))?
1349 .clone();
1350 let state = self.state.read();
1351 let dashboard = state
1352 .get(&req.account_id)
1353 .and_then(|a| a.dashboards.get(&name))
1354 .cloned()
1355 .ok_or_else(|| {
1356 AwsServiceError::aws_error(
1357 StatusCode::NOT_FOUND,
1358 "ResourceNotFound",
1359 format!("Dashboard {name} does not exist"),
1360 )
1361 })?;
1362 let inner = format!(
1363 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1364 xml_escape(&dashboard.arn),
1365 xml_escape(&dashboard.body),
1366 xml_escape(&dashboard.name),
1367 );
1368 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1369 }
1370
1371 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1372 let mut names: Vec<String> = Vec::new();
1373 for (k, v) in req.query_params.iter() {
1374 if k.starts_with("DashboardNames.member.") {
1375 names.push(v.clone());
1376 }
1377 }
1378 if names.is_empty() {
1379 return Err(invalid_param(
1380 "DashboardNames must contain at least one name",
1381 ));
1382 }
1383 let mut state = self.state.write();
1384 let acct = state.get_or_create(&req.account_id);
1385 for n in names {
1386 acct.dashboards.remove(&n);
1387 }
1388 Ok(empty_metadata_response("DeleteDashboards", &req.request_id))
1389 }
1390
1391 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1392 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1393 let state = self.state.read();
1394 let dashboards: Vec<Dashboard> = state
1395 .get(&req.account_id)
1396 .map(|a| {
1397 a.dashboards
1398 .values()
1399 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1400 .cloned()
1401 .collect()
1402 })
1403 .unwrap_or_default();
1404 let mut entries = String::new();
1405 for d in &dashboards {
1406 entries.push_str("<member>");
1407 entries.push_str(&format!(
1408 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1409 xml_escape(&d.arn),
1410 xml_escape(&d.name),
1411 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1412 d.size_bytes,
1413 ));
1414 entries.push_str("</member>");
1415 }
1416 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1417 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1418 }
1419}
1420
1421fn render_alarm(alarm: &MetricAlarm) -> String {
1422 let mut s = String::from("<member>");
1423 s.push_str(&format!(
1424 "<AlarmName>{}</AlarmName>",
1425 xml_escape(&alarm.alarm_name)
1426 ));
1427 s.push_str(&format!(
1428 "<AlarmArn>{}</AlarmArn>",
1429 xml_escape(&alarm.alarm_arn)
1430 ));
1431 if let Some(d) = &alarm.alarm_description {
1432 s.push_str(&format!(
1433 "<AlarmDescription>{}</AlarmDescription>",
1434 xml_escape(d)
1435 ));
1436 }
1437 s.push_str(&format!(
1438 "<ActionsEnabled>{}</ActionsEnabled>",
1439 alarm.actions_enabled
1440 ));
1441 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1442 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1443 push_action_list(
1444 &mut s,
1445 "InsufficientDataActions",
1446 &alarm.insufficient_data_actions,
1447 );
1448 s.push_str(&format!(
1449 "<StateValue>{}</StateValue>",
1450 alarm.state_value.as_str()
1451 ));
1452 s.push_str(&format!(
1453 "<StateReason>{}</StateReason>",
1454 xml_escape(&alarm.state_reason)
1455 ));
1456 s.push_str(&format!(
1457 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1458 alarm
1459 .state_updated_timestamp
1460 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1461 ));
1462 if let Some(m) = &alarm.metric_name {
1463 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1464 }
1465 if let Some(n) = &alarm.namespace {
1466 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1467 }
1468 if let Some(stat) = &alarm.statistic {
1469 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1470 }
1471 if let Some(ext) = &alarm.extended_statistic {
1472 s.push_str(&format!(
1473 "<ExtendedStatistic>{}</ExtendedStatistic>",
1474 xml_escape(ext)
1475 ));
1476 }
1477 s.push_str(&render_dimensions(&alarm.dimensions));
1478 if let Some(p) = alarm.period {
1479 s.push_str(&format!("<Period>{p}</Period>"));
1480 }
1481 if let Some(u) = &alarm.unit {
1482 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1483 }
1484 s.push_str(&format!(
1485 "<EvaluationPeriods>{}</EvaluationPeriods>",
1486 alarm.evaluation_periods
1487 ));
1488 if let Some(d) = alarm.datapoints_to_alarm {
1489 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1490 }
1491 if let Some(t) = alarm.threshold {
1492 s.push_str(&format!("<Threshold>{t}</Threshold>"));
1493 }
1494 s.push_str(&format!(
1495 "<ComparisonOperator>{}</ComparisonOperator>",
1496 xml_escape(&alarm.comparison_operator)
1497 ));
1498 if let Some(t) = &alarm.treat_missing_data {
1499 s.push_str(&format!(
1500 "<TreatMissingData>{}</TreatMissingData>",
1501 xml_escape(t)
1502 ));
1503 }
1504 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1505 s.push_str(&format!(
1506 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1507 xml_escape(e)
1508 ));
1509 }
1510 s.push_str(&format!(
1511 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1512 alarm
1513 .alarm_configuration_updated_timestamp
1514 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1515 ));
1516 s.push_str("</member>");
1517 s
1518}
1519
1520fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1521 s.push_str(&format!("<{name}>"));
1522 for action in actions {
1523 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1524 }
1525 s.push_str(&format!("</{name}>"));
1526}