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 threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
986 let dimensions = parse_dimensions_query(req, "Dimensions");
987
988 let mut ok_actions = Vec::new();
989 let mut alarm_actions = Vec::new();
990 let mut insufficient_data_actions = Vec::new();
991 for (k, v) in req.query_params.iter() {
992 if k.starts_with("OKActions.member.") {
993 ok_actions.push(v.clone());
994 } else if k.starts_with("AlarmActions.member.") {
995 alarm_actions.push(v.clone());
996 } else if k.starts_with("InsufficientDataActions.member.") {
997 insufficient_data_actions.push(v.clone());
998 }
999 }
1000
1001 let arn = format!(
1002 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1003 req.region, req.account_id, alarm_name
1004 );
1005 let now = Utc::now();
1006
1007 let mut state = self.state.write();
1008 let acct = state.get_or_create(&req.account_id);
1009 let alarms = acct.alarms_in_mut(&req.region);
1010 let existing = alarms.get(&alarm_name).cloned();
1011 let alarm = MetricAlarm {
1012 alarm_name: alarm_name.clone(),
1013 alarm_arn: arn,
1014 alarm_description,
1015 actions_enabled,
1016 ok_actions,
1017 alarm_actions,
1018 insufficient_data_actions,
1019 state_value: existing
1020 .as_ref()
1021 .map(|a| a.state_value)
1022 .unwrap_or(AlarmState::InsufficientData),
1023 state_reason: existing
1024 .as_ref()
1025 .map(|a| a.state_reason.clone())
1026 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1027 state_updated_timestamp: existing
1028 .as_ref()
1029 .map(|a| a.state_updated_timestamp)
1030 .unwrap_or(now),
1031 metric_name,
1032 namespace,
1033 statistic,
1034 extended_statistic,
1035 dimensions,
1036 period,
1037 unit,
1038 evaluation_periods,
1039 datapoints_to_alarm,
1040 threshold,
1041 comparison_operator: comparison,
1042 treat_missing_data,
1043 evaluate_low_sample_count_percentile,
1044 threshold_metric_id,
1045 configuration_updated_timestamp: existing
1046 .as_ref()
1047 .map(|a| a.configuration_updated_timestamp)
1048 .unwrap_or(now),
1049 alarm_configuration_updated_timestamp: now,
1050 };
1051 alarms.insert(alarm_name, alarm);
1052
1053 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1054 }
1055
1056 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1057 let mut filter_names: Vec<String> = Vec::new();
1058 for (k, v) in req.query_params.iter() {
1059 if k.starts_with("AlarmNames.member.") {
1060 filter_names.push(v.clone());
1061 }
1062 }
1063 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1064 validate_len(req, "ActionPrefix", 1, 1024)?;
1065 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1066 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1067 validate_range_i64(req, "MaxRecords", 1, 100)?;
1068 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1069 let prefix = optional_query_param(req, "AlarmNamePrefix");
1070 let state_filter = optional_query_param(req, "StateValue");
1071 let action_prefix = optional_query_param(req, "ActionPrefix");
1072
1073 let state = self.state.read();
1074 let mut inner = String::from("<MetricAlarms>");
1075 if let Some(acct) = state.get(&req.account_id) {
1076 if let Some(alarms) = acct.alarms_in(&req.region) {
1077 for alarm in alarms.values() {
1078 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1079 continue;
1080 }
1081 if let Some(p) = prefix.as_ref() {
1082 if !alarm.alarm_name.starts_with(p) {
1083 continue;
1084 }
1085 }
1086 if let Some(sv) = state_filter.as_ref() {
1087 if alarm.state_value.as_str() != sv {
1088 continue;
1089 }
1090 }
1091 if let Some(ap) = action_prefix.as_ref() {
1092 let any = alarm
1093 .alarm_actions
1094 .iter()
1095 .chain(alarm.ok_actions.iter())
1096 .chain(alarm.insufficient_data_actions.iter())
1097 .any(|a| a.starts_with(ap));
1098 if !any {
1099 continue;
1100 }
1101 }
1102 inner.push_str(&render_alarm(alarm));
1103 }
1104 }
1105 }
1106 inner.push_str("</MetricAlarms>");
1107 inner.push_str("<CompositeAlarms>");
1108 if let Some(acct) = state.get(&req.account_id) {
1109 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1110 for alarm in composites.values() {
1111 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1112 continue;
1113 }
1114 if let Some(p) = prefix.as_ref() {
1115 if !alarm.alarm_name.starts_with(p) {
1116 continue;
1117 }
1118 }
1119 if let Some(sv) = state_filter.as_ref() {
1120 if alarm.state_value.as_str() != sv {
1121 continue;
1122 }
1123 }
1124 if let Some(ap) = action_prefix.as_ref() {
1125 let any = alarm
1126 .alarm_actions
1127 .iter()
1128 .chain(alarm.ok_actions.iter())
1129 .chain(alarm.insufficient_data_actions.iter())
1130 .any(|a| a.starts_with(ap));
1131 if !any {
1132 continue;
1133 }
1134 }
1135 inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1136 }
1137 }
1138 }
1139 inner.push_str("</CompositeAlarms>");
1140
1141 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1142 }
1143
1144 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1145 validate_len(req, "MetricName", 1, 255)?;
1146 validate_len(req, "Namespace", 1, 255)?;
1147 validate_range_i64(req, "Period", 1, i64::MAX)?;
1148 validate_enum(
1149 req,
1150 "Statistic",
1151 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1152 )?;
1153 validate_enum(req, "Unit", STANDARD_UNITS)?;
1154 let metric_name = required_query_param(req, "MetricName")?;
1155 let namespace = required_query_param(req, "Namespace")?;
1156 let dim_filter = parse_dimensions_query(req, "Dimensions");
1157
1158 let state = self.state.read();
1159 let mut inner = String::from("<MetricAlarms>");
1160 if let Some(acct) = state.get(&req.account_id) {
1161 if let Some(alarms) = acct.alarms_in(&req.region) {
1162 for alarm in alarms.values() {
1163 if alarm.metric_name.as_deref() != Some(&metric_name) {
1164 continue;
1165 }
1166 if alarm.namespace.as_deref() != Some(&namespace) {
1167 continue;
1168 }
1169 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1170 continue;
1171 }
1172 inner.push_str(&render_alarm(alarm));
1173 }
1174 }
1175 }
1176 inner.push_str("</MetricAlarms>");
1177
1178 Ok(xml_response(
1179 "DescribeAlarmsForMetric",
1180 &inner,
1181 &req.request_id,
1182 ))
1183 }
1184
1185 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1186 let mut names: Vec<String> = Vec::new();
1190 for (k, v) in req.query_params.iter() {
1191 if k.starts_with("AlarmNames.member.") {
1192 names.push(v.clone());
1193 }
1194 }
1195
1196 let mut state = self.state.write();
1197 let acct = state.get_or_create(&req.account_id);
1198 for name in &names {
1199 acct.alarms_in_mut(&req.region).remove(name);
1200 acct.composite_alarms_in_mut(&req.region).remove(name);
1201 }
1202
1203 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1204 }
1205
1206 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1207 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1208 }
1209
1210 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1211 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1212 }
1213
1214 fn toggle_alarm_actions(
1215 &self,
1216 req: &AwsRequest,
1217 enabled: bool,
1218 action_name: &str,
1219 ) -> Result<AwsResponse, AwsServiceError> {
1220 let mut names: Vec<String> = Vec::new();
1221 for (k, v) in req.query_params.iter() {
1222 if k.starts_with("AlarmNames.member.") {
1223 names.push(v.clone());
1224 }
1225 }
1226 let mut state = self.state.write();
1227 let acct = state.get_or_create(&req.account_id);
1228 let alarms = acct.alarms_in_mut(&req.region);
1229 for name in names {
1230 if let Some(alarm) = alarms.get_mut(&name) {
1231 alarm.actions_enabled = enabled;
1232 alarm.alarm_configuration_updated_timestamp = Utc::now();
1233 }
1234 }
1235 Ok(empty_metadata_response(action_name, &req.request_id))
1236 }
1237
1238 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1239 validate_len(req, "AlarmName", 1, 255)?;
1240 validate_len(req, "StateReason", 0, 1023)?;
1241 validate_len(req, "StateReasonData", 0, 4000)?;
1242 let alarm_name = required_query_param(req, "AlarmName")?;
1243 let state_value = required_query_param(req, "StateValue")?;
1244 let state_reason = req
1248 .query_params
1249 .get("StateReason")
1250 .cloned()
1251 .ok_or_else(|| {
1252 AwsServiceError::aws_error(
1253 StatusCode::BAD_REQUEST,
1254 "MissingParameter",
1255 "The request must contain the parameter StateReason.",
1256 )
1257 })?;
1258 let new_state = AlarmState::parse(&state_value)
1259 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1260
1261 let mut state = self.state.write();
1262 let acct = state.get_or_create(&req.account_id);
1263 let alarms = acct.alarms_in_mut(&req.region);
1264 let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1265 AwsServiceError::aws_error(
1266 StatusCode::NOT_FOUND,
1267 "ResourceNotFound",
1268 format!("Alarm {alarm_name} not found"),
1269 )
1270 })?;
1271 alarm.state_value = new_state;
1272 alarm.state_reason = state_reason;
1273 alarm.state_updated_timestamp = Utc::now();
1274
1275 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1276 }
1277
1278 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1279 validate_len(req, "AlarmName", 1, 255)?;
1280 validate_len(req, "AlarmContributorId", 1, 16)?;
1281 validate_range_i64(req, "MaxRecords", 1, 100)?;
1282 validate_enum(
1283 req,
1284 "HistoryItemType",
1285 &[
1286 "ConfigurationUpdate",
1287 "StateUpdate",
1288 "Action",
1289 "AlarmContributorStateUpdate",
1290 "AlarmContributorAction",
1291 ],
1292 )?;
1293 validate_enum(
1294 req,
1295 "ScanBy",
1296 &["TimestampDescending", "TimestampAscending"],
1297 )?;
1298 let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1301 Ok(xml_response(
1302 "DescribeAlarmHistory",
1303 &inner,
1304 &req.request_id,
1305 ))
1306 }
1307
1308 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1309 let dashboard_name = req
1310 .query_params
1311 .get("DashboardName")
1312 .ok_or_else(|| invalid_param("DashboardName is required"))?
1313 .clone();
1314 let body = req
1315 .query_params
1316 .get("DashboardBody")
1317 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1318 .clone();
1319 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1322 return Err(AwsServiceError::aws_error(
1323 StatusCode::BAD_REQUEST,
1324 "InvalidParameterInput",
1325 "DashboardBody must be a valid JSON object",
1326 ));
1327 }
1328 let arn = format!(
1329 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1330 req.account_id
1331 );
1332 let dashboard = Dashboard {
1333 name: dashboard_name.clone(),
1334 arn,
1335 size_bytes: body.len() as i64,
1336 body,
1337 last_modified: Utc::now(),
1338 };
1339 let mut state = self.state.write();
1340 let acct = state.get_or_create(&req.account_id);
1341 acct.dashboards.insert(dashboard_name, dashboard);
1342 let inner = String::from("<DashboardValidationMessages/>");
1345 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1346 }
1347
1348 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1349 let name = req
1350 .query_params
1351 .get("DashboardName")
1352 .ok_or_else(|| invalid_param("DashboardName is required"))?
1353 .clone();
1354 let state = self.state.read();
1355 let dashboard = state
1356 .get(&req.account_id)
1357 .and_then(|a| a.dashboards.get(&name))
1358 .cloned()
1359 .ok_or_else(|| {
1360 AwsServiceError::aws_error(
1361 StatusCode::NOT_FOUND,
1362 "ResourceNotFound",
1363 format!("Dashboard {name} does not exist"),
1364 )
1365 })?;
1366 let inner = format!(
1367 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1368 xml_escape(&dashboard.arn),
1369 xml_escape(&dashboard.body),
1370 xml_escape(&dashboard.name),
1371 );
1372 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1373 }
1374
1375 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1376 let mut names: Vec<String> = Vec::new();
1377 for (k, v) in req.query_params.iter() {
1378 if k.starts_with("DashboardNames.member.") {
1379 names.push(v.clone());
1380 }
1381 }
1382 if names.is_empty() {
1383 return Err(invalid_param(
1384 "DashboardNames must contain at least one name",
1385 ));
1386 }
1387 let mut state = self.state.write();
1388 let acct = state.get_or_create(&req.account_id);
1389 for n in names {
1390 acct.dashboards.remove(&n);
1391 }
1392 let body = format!(
1396 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1397 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1398 <DeleteDashboardsResult/>\
1399 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1400 </DeleteDashboardsResponse>",
1401 req.request_id
1402 );
1403 Ok(AwsResponse::xml(StatusCode::OK, body))
1404 }
1405
1406 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1407 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1408 let state = self.state.read();
1409 let dashboards: Vec<Dashboard> = state
1410 .get(&req.account_id)
1411 .map(|a| {
1412 a.dashboards
1413 .values()
1414 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1415 .cloned()
1416 .collect()
1417 })
1418 .unwrap_or_default();
1419 let mut entries = String::new();
1420 for d in &dashboards {
1421 entries.push_str("<member>");
1422 entries.push_str(&format!(
1423 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1424 xml_escape(&d.arn),
1425 xml_escape(&d.name),
1426 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1427 d.size_bytes,
1428 ));
1429 entries.push_str("</member>");
1430 }
1431 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1432 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1433 }
1434}
1435
1436fn render_alarm(alarm: &MetricAlarm) -> String {
1437 let mut s = String::from("<member>");
1438 s.push_str(&format!(
1439 "<AlarmName>{}</AlarmName>",
1440 xml_escape(&alarm.alarm_name)
1441 ));
1442 s.push_str(&format!(
1443 "<AlarmArn>{}</AlarmArn>",
1444 xml_escape(&alarm.alarm_arn)
1445 ));
1446 if let Some(d) = &alarm.alarm_description {
1447 s.push_str(&format!(
1448 "<AlarmDescription>{}</AlarmDescription>",
1449 xml_escape(d)
1450 ));
1451 }
1452 s.push_str(&format!(
1453 "<ActionsEnabled>{}</ActionsEnabled>",
1454 alarm.actions_enabled
1455 ));
1456 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1457 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1458 push_action_list(
1459 &mut s,
1460 "InsufficientDataActions",
1461 &alarm.insufficient_data_actions,
1462 );
1463 s.push_str(&format!(
1464 "<StateValue>{}</StateValue>",
1465 alarm.state_value.as_str()
1466 ));
1467 s.push_str(&format!(
1468 "<StateReason>{}</StateReason>",
1469 xml_escape(&alarm.state_reason)
1470 ));
1471 s.push_str(&format!(
1472 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1473 alarm
1474 .state_updated_timestamp
1475 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1476 ));
1477 if let Some(m) = &alarm.metric_name {
1478 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1479 }
1480 if let Some(n) = &alarm.namespace {
1481 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1482 }
1483 if let Some(stat) = &alarm.statistic {
1484 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1485 }
1486 if let Some(ext) = &alarm.extended_statistic {
1487 s.push_str(&format!(
1488 "<ExtendedStatistic>{}</ExtendedStatistic>",
1489 xml_escape(ext)
1490 ));
1491 }
1492 s.push_str(&render_dimensions(&alarm.dimensions));
1493 if let Some(p) = alarm.period {
1494 s.push_str(&format!("<Period>{p}</Period>"));
1495 }
1496 if let Some(u) = &alarm.unit {
1497 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1498 }
1499 s.push_str(&format!(
1500 "<EvaluationPeriods>{}</EvaluationPeriods>",
1501 alarm.evaluation_periods
1502 ));
1503 if let Some(d) = alarm.datapoints_to_alarm {
1504 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1505 }
1506 if let Some(t) = alarm.threshold {
1507 s.push_str(&format!("<Threshold>{t}</Threshold>"));
1508 }
1509 if let Some(tid) = &alarm.threshold_metric_id {
1510 s.push_str(&format!(
1511 "<ThresholdMetricId>{}</ThresholdMetricId>",
1512 xml_escape(tid)
1513 ));
1514 }
1515 s.push_str(&format!(
1516 "<ComparisonOperator>{}</ComparisonOperator>",
1517 xml_escape(&alarm.comparison_operator)
1518 ));
1519 if let Some(t) = &alarm.treat_missing_data {
1520 s.push_str(&format!(
1521 "<TreatMissingData>{}</TreatMissingData>",
1522 xml_escape(t)
1523 ));
1524 }
1525 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1526 s.push_str(&format!(
1527 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1528 xml_escape(e)
1529 ));
1530 }
1531 s.push_str(&format!(
1532 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1533 alarm
1534 .alarm_configuration_updated_timestamp
1535 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1536 ));
1537 s.push_str("</member>");
1538 s
1539}
1540
1541fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1542 s.push_str(&format!("<{name}>"));
1543 for action in actions {
1544 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1545 }
1546 s.push_str(&format!("</{name}>"));
1547}