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 collect_member_numbers(
373 member: &HashMap<String, String>,
374 prefix: &str,
375) -> Result<Vec<f64>, AwsServiceError> {
376 let needle = format!("{prefix}.member.");
377 let mut by_index: BTreeMap<u32, f64> = BTreeMap::new();
378 for (k, v) in member.iter() {
379 let Some(idx_str) = k.strip_prefix(&needle) else {
380 continue;
381 };
382 let Ok(idx) = idx_str.parse::<u32>() else {
383 continue;
384 };
385 let n = v
386 .parse::<f64>()
387 .map_err(|_| invalid_param(format!("{prefix} entries must be numbers")))?;
388 by_index.insert(idx, n);
389 }
390 Ok(by_index.into_values().collect())
391}
392
393fn values_counts_statistic(
396 member: &HashMap<String, String>,
397) -> Result<Option<StatisticSet>, AwsServiceError> {
398 let values = collect_member_numbers(member, "Values")?;
399 if values.is_empty() {
400 return Ok(None);
401 }
402 let counts = collect_member_numbers(member, "Counts")?;
403 let mut sample_count = 0.0;
404 let mut sum = 0.0;
405 let mut minimum = f64::INFINITY;
406 let mut maximum = f64::NEG_INFINITY;
407 for (i, v) in values.iter().enumerate() {
408 let c = counts.get(i).copied().unwrap_or(1.0);
409 sample_count += c;
410 sum += v * c;
411 minimum = minimum.min(*v);
412 maximum = maximum.max(*v);
413 }
414 Ok(Some(StatisticSet {
415 sample_count,
416 sum,
417 minimum,
418 maximum,
419 }))
420}
421
422fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
423 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
424 let needle = format!("{prefix}.member.");
425 for (k, v) in member.iter() {
426 let Some(rest) = k.strip_prefix(&needle) else {
427 continue;
428 };
429 let mut parts = rest.splitn(2, '.');
430 let Some(idx_str) = parts.next() else {
431 continue;
432 };
433 let Ok(idx) = idx_str.parse::<u32>() else {
434 continue;
435 };
436 let field = parts.next().unwrap_or("");
437 let entry = dims.entry(idx).or_default();
438 match field {
439 "Name" => entry.0 = Some(v.clone()),
440 "Value" => entry.1 = Some(v.clone()),
441 _ => {}
442 }
443 }
444 let mut out = BTreeMap::new();
445 for (_, (name, value)) in dims {
446 if let (Some(n), Some(v)) = (name, value) {
447 out.insert(n, v);
448 }
449 }
450 out
451}
452
453pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
454 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
455 let needle = format!("{prefix}.member.");
456 for (k, v) in req.query_params.iter() {
457 let Some(rest) = k.strip_prefix(&needle) else {
458 continue;
459 };
460 let mut parts = rest.splitn(2, '.');
461 let Some(idx_str) = parts.next() else {
462 continue;
463 };
464 let Ok(idx) = idx_str.parse::<u32>() else {
465 continue;
466 };
467 let field = parts.next().unwrap_or("");
468 let entry = dims.entry(idx).or_default();
469 match field {
470 "Name" => entry.0 = Some(v.clone()),
471 "Value" => entry.1 = Some(v.clone()),
472 _ => {}
473 }
474 }
475 let mut out = BTreeMap::new();
476 for (_, (name, value)) in dims {
477 if let (Some(n), Some(v)) = (name, value) {
478 out.insert(n, v);
479 }
480 }
481 out
482}
483
484pub(crate) fn validate_len(
488 req: &AwsRequest,
489 param: &str,
490 min: usize,
491 max: usize,
492) -> Result<(), AwsServiceError> {
493 if let Some(v) = req.query_params.get(param) {
494 let len = v.chars().count();
495 if len < min || len > max {
496 return Err(invalid_param(format!(
497 "{param} length {len} is outside [{min}, {max}]"
498 )));
499 }
500 }
501 Ok(())
502}
503
504pub(crate) fn validate_range_i64(
506 req: &AwsRequest,
507 param: &str,
508 min: i64,
509 max: i64,
510) -> Result<(), AwsServiceError> {
511 if let Some(v) = req.query_params.get(param) {
512 if v.is_empty() {
513 return Ok(());
514 }
515 let n = v
516 .parse::<i64>()
517 .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
518 if n < min || n > max {
519 return Err(invalid_param(format!(
520 "{param} value {n} is outside [{min}, {max}]"
521 )));
522 }
523 }
524 Ok(())
525}
526
527pub(crate) fn validate_enum(
529 req: &AwsRequest,
530 param: &str,
531 allowed: &[&str],
532) -> Result<(), AwsServiceError> {
533 if let Some(v) = req.query_params.get(param) {
534 if !v.is_empty() && !allowed.contains(&v.as_str()) {
535 return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
536 }
537 }
538 Ok(())
539}
540
541pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
543 let needle = format!("{prefix}.member.");
544 let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
545 for (k, v) in req.query_params.iter() {
546 let Some(rest) = k.strip_prefix(&needle) else {
547 continue;
548 };
549 if let Ok(idx) = rest.parse::<u32>() {
550 by_index.insert(idx, v.clone());
551 }
552 }
553 by_index.into_values().collect()
554}
555
556pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
558 let members = collect_indexed(req, prefix);
559 let mut out = BTreeMap::new();
560 for m in members {
561 if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
562 out.insert(k.clone(), v.clone());
563 }
564 }
565 out
566}
567
568pub(crate) fn xml_escape(s: &str) -> String {
569 s.replace('&', "&")
570 .replace('<', "<")
571 .replace('>', ">")
572 .replace('"', """)
573 .replace('\'', "'")
574}
575
576#[derive(Clone, Copy)]
580struct DatumStats {
581 sum: f64,
582 min: f64,
583 max: f64,
584 count: f64,
585}
586
587fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
588 if let Some(v) = d.value {
589 return Some(DatumStats {
590 sum: v,
591 min: v,
592 max: v,
593 count: 1.0,
594 });
595 }
596 if let Some(s) = &d.statistic_values {
597 return Some(DatumStats {
598 sum: s.sum,
599 min: s.minimum,
600 max: s.maximum,
601 count: s.sample_count,
602 });
603 }
604 None
605}
606
607fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
608 acc.sum += other.sum;
609 acc.count += other.count;
610 if other.min < acc.min {
611 acc.min = other.min;
612 }
613 if other.max > acc.max {
614 acc.max = other.max;
615 }
616}
617
618fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
619 match stat {
620 "Sum" => Some(agg.sum),
621 "Average" => {
622 if agg.count > 0.0 {
623 Some(agg.sum / agg.count)
624 } else {
625 None
626 }
627 }
628 "Minimum" => Some(agg.min),
629 "Maximum" => Some(agg.max),
630 "SampleCount" => Some(agg.count),
631 _ => None,
632 }
633}
634
635pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
636 let mut s = String::from("<Dimensions>");
637 for (name, value) in dims.iter() {
638 s.push_str(&format!(
639 "<member><Name>{}</Name><Value>{}</Value></member>",
640 xml_escape(name),
641 xml_escape(value),
642 ));
643 }
644 s.push_str("</Dimensions>");
645 s
646}
647
648impl CloudWatchService {
649 fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
650 let namespace = required_query_param(req, "Namespace")?;
651 let members = collect_indexed(req, "MetricData");
652 if members.is_empty() {
653 return Err(invalid_param(
654 "PutMetricData requires at least one MetricData entry",
655 ));
656 }
657
658 let now = Utc::now();
659 let mut state = self.state.write();
660 let acct = state.get_or_create(&req.account_id);
661 let metrics_map = acct.metrics_in_mut(&req.region);
662 let bucket = metrics_map.entry(namespace.clone()).or_default();
663
664 for member in members {
665 let metric_name = member
666 .get("MetricName")
667 .cloned()
668 .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
669 let value = member
670 .get("Value")
671 .map(|s| s.parse::<f64>())
672 .transpose()
673 .map_err(|_| invalid_param("Value must be a valid number"))?;
674 let timestamp = member
675 .get("Timestamp")
676 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
677 .map(|d| d.with_timezone(&Utc))
678 .unwrap_or(now);
679 let unit = member.get("Unit").cloned();
680 let storage_resolution = member
681 .get("StorageResolution")
682 .and_then(|s| s.parse::<i64>().ok());
683 let dimensions = parse_dimensions(&member, "Dimensions");
684
685 let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
686 member.get("StatisticValues.SampleCount"),
687 member.get("StatisticValues.Sum"),
688 member.get("StatisticValues.Minimum"),
689 member.get("StatisticValues.Maximum"),
690 ) {
691 Some(StatisticSet {
692 sample_count: sc.parse::<f64>().map_err(|_| {
693 invalid_param("StatisticValues.SampleCount must be a number")
694 })?,
695 sum: sum
696 .parse::<f64>()
697 .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
698 minimum: min
699 .parse::<f64>()
700 .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
701 maximum: max
702 .parse::<f64>()
703 .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
704 })
705 } else {
706 None
707 };
708
709 let statistic_values = match statistic_values {
713 Some(s) => Some(s),
714 None => values_counts_statistic(&member)?,
715 };
716
717 if value.is_none() && statistic_values.is_none() {
718 return Err(invalid_param(
719 "MetricData entry must supply either Value, StatisticValues, or Values",
720 ));
721 }
722
723 bucket.push(MetricDatum {
724 metric_name,
725 dimensions,
726 timestamp,
727 value,
728 statistic_values,
729 unit,
730 storage_resolution,
731 });
732 }
733
734 Ok(empty_metadata_response("PutMetricData", &req.request_id))
735 }
736
737 fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
738 validate_len(req, "Namespace", 1, 255)?;
739 validate_len(req, "MetricName", 1, 255)?;
740 validate_len(req, "OwningAccount", 1, 255)?;
741 validate_enum(req, "RecentlyActive", &["PT3H"])?;
742 let namespace = optional_query_param(req, "Namespace");
743 let metric_name = optional_query_param(req, "MetricName");
744 let dim_filter = parse_dimensions_query(req, "Dimensions");
745
746 let state = self.state.read();
747 let mut out = String::from("<Metrics>");
748 if let Some(acct) = state.get(&req.account_id) {
749 if let Some(map) = acct.metrics_in(&req.region) {
750 for (ns, data) in map.iter() {
751 if let Some(filter_ns) = namespace.as_ref() {
752 if ns != filter_ns {
753 continue;
754 }
755 }
756 let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
757 BTreeMap::new();
758 for d in data.iter() {
759 if let Some(filter_name) = metric_name.as_ref() {
760 if &d.metric_name != filter_name {
761 continue;
762 }
763 }
764 if !dim_filter.is_empty()
765 && !dim_filter
766 .iter()
767 .all(|(k, v)| d.dimensions.get(k) == Some(v))
768 {
769 continue;
770 }
771 seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
772 }
773 for ((name, dims), _) in seen {
774 out.push_str("<member>");
775 out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
776 out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
777 out.push_str(&render_dimensions(&dims));
778 out.push_str("</member>");
779 }
780 }
781 }
782 }
783 out.push_str("</Metrics>");
784
785 Ok(xml_response("ListMetrics", &out, &req.request_id))
786 }
787
788 fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
789 let namespace = required_query_param(req, "Namespace")?;
790 let metric_name = required_query_param(req, "MetricName")?;
791 let start = required_query_param(req, "StartTime")?;
792 let end = required_query_param(req, "EndTime")?;
793 let period = required_query_param(req, "Period")?
794 .parse::<i64>()
795 .map_err(|_| invalid_param("Period must be an integer"))?;
796 if period <= 0 {
797 return Err(invalid_param("Period must be positive"));
798 }
799 let start_ts = DateTime::parse_from_rfc3339(&start)
800 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
801 .with_timezone(&Utc);
802 let end_ts = DateTime::parse_from_rfc3339(&end)
803 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
804 .with_timezone(&Utc);
805
806 let mut statistics: Vec<String> = Vec::new();
807 for (k, v) in req.query_params.iter() {
808 if k.starts_with("Statistics.member.") {
809 statistics.push(v.clone());
810 }
811 }
812 if statistics.is_empty() {
813 return Err(invalid_param("At least one Statistic is required"));
814 }
815
816 let dim_filter = parse_dimensions_query(req, "Dimensions");
817 let unit_filter = req.query_params.get("Unit").cloned();
821
822 let state = self.state.read();
823 let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
824 if let Some(acct) = state.get(&req.account_id) {
825 if let Some(map) = acct.metrics_in(&req.region) {
826 if let Some(data) = map.get(&namespace) {
827 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
828 for d in data.iter() {
829 if d.metric_name != metric_name {
830 continue;
831 }
832 if let Some(uf) = &unit_filter {
833 if d.unit.as_deref().unwrap_or("None") != uf {
834 continue;
835 }
836 }
837 if !dim_filter
838 .iter()
839 .all(|(k, v)| d.dimensions.get(k) == Some(v))
840 {
841 continue;
842 }
843 if d.timestamp < start_ts || d.timestamp >= end_ts {
844 continue;
845 }
846 let Some(stats) = datum_stats(d) else {
847 continue;
848 };
849 let secs = d.timestamp.timestamp();
850 let bucket_secs = secs - secs.rem_euclid(period);
851 let bucket_ts =
852 DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
853 buckets
854 .entry(bucket_ts)
855 .and_modify(|acc| merge_stats(acc, stats))
856 .or_insert(stats);
857 }
858 for (ts, agg) in buckets {
859 let mut stats = BTreeMap::new();
860 for stat in statistics.iter() {
861 if let Some(v) = stat_value(stat, agg) {
862 stats.insert(stat.clone(), v);
863 }
864 }
865 datapoints.push((ts, stats));
866 }
867 }
868 }
869 }
870
871 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
872 inner.push_str("<Datapoints>");
873 for (ts, stats) in datapoints {
874 inner.push_str("<member>");
875 inner.push_str(&format!(
876 "<Timestamp>{}</Timestamp>",
877 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
878 ));
879 for (name, value) in stats {
880 inner.push_str(&format!("<{name}>{value}</{name}>"));
881 }
882 inner.push_str("</member>");
883 }
884 inner.push_str("</Datapoints>");
885
886 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
887 }
888
889 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
890 validate_enum(
891 req,
892 "ScanBy",
893 &["TimestampDescending", "TimestampAscending"],
894 )?;
895 let start = required_query_param(req, "StartTime")?;
896 let end = required_query_param(req, "EndTime")?;
897 let start_ts = DateTime::parse_from_rfc3339(&start)
898 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
899 .with_timezone(&Utc);
900 let end_ts = DateTime::parse_from_rfc3339(&end)
901 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
902 .with_timezone(&Utc);
903
904 let descending = req
908 .query_params
909 .get("ScanBy")
910 .map(|s| s != "TimestampAscending")
911 .unwrap_or(true);
912
913 let queries = collect_indexed(req, "MetricDataQueries");
916
917 let state = self.state.read();
918 let mut inner = String::from("<MetricDataResults>");
919 for q in queries {
920 let id = q.get("Id").cloned().unwrap_or_default();
921 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
922 let stat = q
923 .get("MetricStat.Stat")
924 .cloned()
925 .unwrap_or_else(|| "Sum".to_string());
926 let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
927 let namespace = q.get("MetricStat.Metric.Namespace").cloned();
928 let period: i64 = q
929 .get("MetricStat.Period")
930 .and_then(|s| s.parse::<i64>().ok())
931 .filter(|p| *p > 0)
932 .unwrap_or(60);
933 let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
934
935 let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
936 if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
937 if let Some(acct) = state.get(&req.account_id) {
938 if let Some(map) = acct.metrics_in(&req.region) {
939 if let Some(data) = map.get(&namespace) {
940 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
941 for d in data.iter() {
942 if d.metric_name != metric_name {
943 continue;
944 }
945 if !dim_filter
946 .iter()
947 .all(|(k, v)| d.dimensions.get(k) == Some(v))
948 {
949 continue;
950 }
951 if d.timestamp < start_ts || d.timestamp >= end_ts {
952 continue;
953 }
954 let Some(stats) = datum_stats(d) else {
955 continue;
956 };
957 let secs = d.timestamp.timestamp();
958 let bucket_secs = secs - secs.rem_euclid(period);
959 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
960 .unwrap_or(d.timestamp);
961 buckets
962 .entry(bucket_ts)
963 .and_modify(|acc| merge_stats(acc, stats))
964 .or_insert(stats);
965 }
966 for (ts, agg) in buckets {
967 let Some(v) = stat_value(&stat, agg) else {
968 continue;
969 };
970 timestamps
971 .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
972 values.push(v);
973 }
974 if descending {
975 timestamps.reverse();
976 values.reverse();
977 }
978 }
979 }
980 }
981 }
982
983 inner.push_str("<member>");
984 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
985 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
986 inner.push_str("<StatusCode>Complete</StatusCode>");
987 inner.push_str("<Timestamps>");
988 for ts in timestamps {
989 inner.push_str(&format!("<member>{ts}</member>"));
990 }
991 inner.push_str("</Timestamps>");
992 inner.push_str("<Values>");
993 for v in values {
994 inner.push_str(&format!("<member>{v}</member>"));
995 }
996 inner.push_str("</Values>");
997 inner.push_str("</member>");
998 }
999 inner.push_str("</MetricDataResults>");
1000 inner.push_str("<Messages></Messages>");
1001
1002 Ok(xml_response("GetMetricData", &inner, &req.request_id))
1003 }
1004
1005 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1006 validate_len(req, "AlarmName", 1, 255)?;
1012 validate_len(req, "AlarmDescription", 0, 1024)?;
1013 validate_len(req, "MetricName", 1, 255)?;
1014 validate_len(req, "Namespace", 1, 255)?;
1015 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1016 validate_len(req, "TreatMissingData", 1, 255)?;
1017 validate_len(req, "ThresholdMetricId", 1, 255)?;
1018 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1019 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1020 validate_range_i64(req, "Period", 1, i64::MAX)?;
1021 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1022 validate_enum(
1023 req,
1024 "ComparisonOperator",
1025 &[
1026 "GreaterThanOrEqualToThreshold",
1027 "GreaterThanThreshold",
1028 "GreaterThanUpperThreshold",
1029 "LessThanLowerOrGreaterThanUpperThreshold",
1030 "LessThanLowerThreshold",
1031 "LessThanOrEqualToThreshold",
1032 "LessThanThreshold",
1033 ],
1034 )?;
1035 validate_enum(
1036 req,
1037 "Statistic",
1038 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1039 )?;
1040 validate_enum(req, "Unit", STANDARD_UNITS)?;
1041 let alarm_name = required_query_param(req, "AlarmName")?;
1042 let comparison = optional_query_param(req, "ComparisonOperator")
1043 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1044 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1045 .and_then(|s| s.parse::<i64>().ok())
1046 .unwrap_or(1);
1047
1048 let alarm_description = optional_query_param(req, "AlarmDescription");
1049 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1050 .map(|s| s.eq_ignore_ascii_case("true"))
1051 .unwrap_or(true);
1052
1053 let metric_name = optional_query_param(req, "MetricName");
1054 let namespace = optional_query_param(req, "Namespace");
1055 let statistic = optional_query_param(req, "Statistic");
1056 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1057 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1058 let unit = optional_query_param(req, "Unit");
1059 let datapoints_to_alarm =
1060 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1061 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1062 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1063 let evaluate_low_sample_count_percentile =
1064 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1065 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1068 let dimensions = parse_dimensions_query(req, "Dimensions");
1069
1070 let mut ok_actions = Vec::new();
1071 let mut alarm_actions = Vec::new();
1072 let mut insufficient_data_actions = Vec::new();
1073 for (k, v) in req.query_params.iter() {
1074 if k.starts_with("OKActions.member.") {
1075 ok_actions.push(v.clone());
1076 } else if k.starts_with("AlarmActions.member.") {
1077 alarm_actions.push(v.clone());
1078 } else if k.starts_with("InsufficientDataActions.member.") {
1079 insufficient_data_actions.push(v.clone());
1080 }
1081 }
1082
1083 let arn = format!(
1084 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1085 req.region, req.account_id, alarm_name
1086 );
1087 let now = Utc::now();
1088
1089 let mut state = self.state.write();
1090 let acct = state.get_or_create(&req.account_id);
1091 let alarms = acct.alarms_in_mut(&req.region);
1092 let existing = alarms.get(&alarm_name).cloned();
1093 let alarm = MetricAlarm {
1094 alarm_name: alarm_name.clone(),
1095 alarm_arn: arn,
1096 alarm_description,
1097 actions_enabled,
1098 ok_actions,
1099 alarm_actions,
1100 insufficient_data_actions,
1101 state_value: existing
1102 .as_ref()
1103 .map(|a| a.state_value)
1104 .unwrap_or(AlarmState::InsufficientData),
1105 state_reason: existing
1106 .as_ref()
1107 .map(|a| a.state_reason.clone())
1108 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1109 state_updated_timestamp: existing
1110 .as_ref()
1111 .map(|a| a.state_updated_timestamp)
1112 .unwrap_or(now),
1113 metric_name,
1114 namespace,
1115 statistic,
1116 extended_statistic,
1117 dimensions,
1118 period,
1119 unit,
1120 evaluation_periods,
1121 datapoints_to_alarm,
1122 threshold,
1123 comparison_operator: comparison,
1124 treat_missing_data,
1125 evaluate_low_sample_count_percentile,
1126 threshold_metric_id,
1127 configuration_updated_timestamp: existing
1128 .as_ref()
1129 .map(|a| a.configuration_updated_timestamp)
1130 .unwrap_or(now),
1131 alarm_configuration_updated_timestamp: now,
1132 };
1133 alarms.insert(alarm_name, alarm);
1134
1135 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1136 }
1137
1138 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1139 let mut filter_names: Vec<String> = Vec::new();
1140 for (k, v) in req.query_params.iter() {
1141 if k.starts_with("AlarmNames.member.") {
1142 filter_names.push(v.clone());
1143 }
1144 }
1145 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1146 validate_len(req, "ActionPrefix", 1, 1024)?;
1147 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1148 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1149 validate_range_i64(req, "MaxRecords", 1, 100)?;
1150 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1151 let prefix = optional_query_param(req, "AlarmNamePrefix");
1152 let state_filter = optional_query_param(req, "StateValue");
1153 let action_prefix = optional_query_param(req, "ActionPrefix");
1154
1155 let state = self.state.read();
1156 let mut inner = String::from("<MetricAlarms>");
1157 if let Some(acct) = state.get(&req.account_id) {
1158 if let Some(alarms) = acct.alarms_in(&req.region) {
1159 for alarm in alarms.values() {
1160 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1161 continue;
1162 }
1163 if let Some(p) = prefix.as_ref() {
1164 if !alarm.alarm_name.starts_with(p) {
1165 continue;
1166 }
1167 }
1168 if let Some(sv) = state_filter.as_ref() {
1169 if alarm.state_value.as_str() != sv {
1170 continue;
1171 }
1172 }
1173 if let Some(ap) = action_prefix.as_ref() {
1174 let any = alarm
1175 .alarm_actions
1176 .iter()
1177 .chain(alarm.ok_actions.iter())
1178 .chain(alarm.insufficient_data_actions.iter())
1179 .any(|a| a.starts_with(ap));
1180 if !any {
1181 continue;
1182 }
1183 }
1184 inner.push_str(&render_alarm(alarm));
1185 }
1186 }
1187 }
1188 inner.push_str("</MetricAlarms>");
1189 inner.push_str("<CompositeAlarms>");
1190 if let Some(acct) = state.get(&req.account_id) {
1191 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1192 for alarm in composites.values() {
1193 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1194 continue;
1195 }
1196 if let Some(p) = prefix.as_ref() {
1197 if !alarm.alarm_name.starts_with(p) {
1198 continue;
1199 }
1200 }
1201 if let Some(sv) = state_filter.as_ref() {
1202 if alarm.state_value.as_str() != sv {
1203 continue;
1204 }
1205 }
1206 if let Some(ap) = action_prefix.as_ref() {
1207 let any = alarm
1208 .alarm_actions
1209 .iter()
1210 .chain(alarm.ok_actions.iter())
1211 .chain(alarm.insufficient_data_actions.iter())
1212 .any(|a| a.starts_with(ap));
1213 if !any {
1214 continue;
1215 }
1216 }
1217 inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1218 }
1219 }
1220 }
1221 inner.push_str("</CompositeAlarms>");
1222
1223 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1224 }
1225
1226 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1227 validate_len(req, "MetricName", 1, 255)?;
1228 validate_len(req, "Namespace", 1, 255)?;
1229 validate_range_i64(req, "Period", 1, i64::MAX)?;
1230 validate_enum(
1231 req,
1232 "Statistic",
1233 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1234 )?;
1235 validate_enum(req, "Unit", STANDARD_UNITS)?;
1236 let metric_name = required_query_param(req, "MetricName")?;
1237 let namespace = required_query_param(req, "Namespace")?;
1238 let dim_filter = parse_dimensions_query(req, "Dimensions");
1239
1240 let state = self.state.read();
1241 let mut inner = String::from("<MetricAlarms>");
1242 if let Some(acct) = state.get(&req.account_id) {
1243 if let Some(alarms) = acct.alarms_in(&req.region) {
1244 for alarm in alarms.values() {
1245 if alarm.metric_name.as_deref() != Some(&metric_name) {
1246 continue;
1247 }
1248 if alarm.namespace.as_deref() != Some(&namespace) {
1249 continue;
1250 }
1251 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1252 continue;
1253 }
1254 inner.push_str(&render_alarm(alarm));
1255 }
1256 }
1257 }
1258 inner.push_str("</MetricAlarms>");
1259
1260 Ok(xml_response(
1261 "DescribeAlarmsForMetric",
1262 &inner,
1263 &req.request_id,
1264 ))
1265 }
1266
1267 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1268 let mut names: Vec<String> = Vec::new();
1272 for (k, v) in req.query_params.iter() {
1273 if k.starts_with("AlarmNames.member.") {
1274 names.push(v.clone());
1275 }
1276 }
1277
1278 let mut state = self.state.write();
1279 let acct = state.get_or_create(&req.account_id);
1280 for name in &names {
1281 acct.alarms_in_mut(&req.region).remove(name);
1282 acct.composite_alarms_in_mut(&req.region).remove(name);
1283 }
1284
1285 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1286 }
1287
1288 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1289 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1290 }
1291
1292 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1293 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1294 }
1295
1296 fn toggle_alarm_actions(
1297 &self,
1298 req: &AwsRequest,
1299 enabled: bool,
1300 action_name: &str,
1301 ) -> Result<AwsResponse, AwsServiceError> {
1302 let mut names: Vec<String> = Vec::new();
1303 for (k, v) in req.query_params.iter() {
1304 if k.starts_with("AlarmNames.member.") {
1305 names.push(v.clone());
1306 }
1307 }
1308 let mut state = self.state.write();
1309 let acct = state.get_or_create(&req.account_id);
1310 let alarms = acct.alarms_in_mut(&req.region);
1311 for name in names {
1312 if let Some(alarm) = alarms.get_mut(&name) {
1313 alarm.actions_enabled = enabled;
1314 alarm.alarm_configuration_updated_timestamp = Utc::now();
1315 }
1316 }
1317 Ok(empty_metadata_response(action_name, &req.request_id))
1318 }
1319
1320 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1321 validate_len(req, "AlarmName", 1, 255)?;
1322 validate_len(req, "StateReason", 0, 1023)?;
1323 validate_len(req, "StateReasonData", 0, 4000)?;
1324 let alarm_name = required_query_param(req, "AlarmName")?;
1325 let state_value = required_query_param(req, "StateValue")?;
1326 let state_reason = req
1330 .query_params
1331 .get("StateReason")
1332 .cloned()
1333 .ok_or_else(|| {
1334 AwsServiceError::aws_error(
1335 StatusCode::BAD_REQUEST,
1336 "MissingParameter",
1337 "The request must contain the parameter StateReason.",
1338 )
1339 })?;
1340 let new_state = AlarmState::parse(&state_value)
1341 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1342
1343 let mut state = self.state.write();
1344 let acct = state.get_or_create(&req.account_id);
1345 let alarms = acct.alarms_in_mut(&req.region);
1346 let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1347 AwsServiceError::aws_error(
1348 StatusCode::NOT_FOUND,
1349 "ResourceNotFound",
1350 format!("Alarm {alarm_name} not found"),
1351 )
1352 })?;
1353 alarm.state_value = new_state;
1354 alarm.state_reason = state_reason;
1355 alarm.state_updated_timestamp = Utc::now();
1356
1357 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1358 }
1359
1360 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1361 validate_len(req, "AlarmName", 1, 255)?;
1362 validate_len(req, "AlarmContributorId", 1, 16)?;
1363 validate_range_i64(req, "MaxRecords", 1, 100)?;
1364 validate_enum(
1365 req,
1366 "HistoryItemType",
1367 &[
1368 "ConfigurationUpdate",
1369 "StateUpdate",
1370 "Action",
1371 "AlarmContributorStateUpdate",
1372 "AlarmContributorAction",
1373 ],
1374 )?;
1375 validate_enum(
1376 req,
1377 "ScanBy",
1378 &["TimestampDescending", "TimestampAscending"],
1379 )?;
1380 let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1383 Ok(xml_response(
1384 "DescribeAlarmHistory",
1385 &inner,
1386 &req.request_id,
1387 ))
1388 }
1389
1390 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1391 let dashboard_name = req
1392 .query_params
1393 .get("DashboardName")
1394 .ok_or_else(|| invalid_param("DashboardName is required"))?
1395 .clone();
1396 let body = req
1397 .query_params
1398 .get("DashboardBody")
1399 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1400 .clone();
1401 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1404 return Err(AwsServiceError::aws_error(
1405 StatusCode::BAD_REQUEST,
1406 "InvalidParameterInput",
1407 "DashboardBody must be a valid JSON object",
1408 ));
1409 }
1410 let arn = format!(
1411 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1412 req.account_id
1413 );
1414 let dashboard = Dashboard {
1415 name: dashboard_name.clone(),
1416 arn,
1417 size_bytes: body.len() as i64,
1418 body,
1419 last_modified: Utc::now(),
1420 };
1421 let mut state = self.state.write();
1422 let acct = state.get_or_create(&req.account_id);
1423 acct.dashboards.insert(dashboard_name, dashboard);
1424 let inner = String::from("<DashboardValidationMessages/>");
1427 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1428 }
1429
1430 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1431 let name = req
1432 .query_params
1433 .get("DashboardName")
1434 .ok_or_else(|| invalid_param("DashboardName is required"))?
1435 .clone();
1436 let state = self.state.read();
1437 let dashboard = state
1438 .get(&req.account_id)
1439 .and_then(|a| a.dashboards.get(&name))
1440 .cloned()
1441 .ok_or_else(|| {
1442 AwsServiceError::aws_error(
1443 StatusCode::NOT_FOUND,
1444 "ResourceNotFound",
1445 format!("Dashboard {name} does not exist"),
1446 )
1447 })?;
1448 let inner = format!(
1449 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1450 xml_escape(&dashboard.arn),
1451 xml_escape(&dashboard.body),
1452 xml_escape(&dashboard.name),
1453 );
1454 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1455 }
1456
1457 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1458 let mut names: Vec<String> = Vec::new();
1459 for (k, v) in req.query_params.iter() {
1460 if k.starts_with("DashboardNames.member.") {
1461 names.push(v.clone());
1462 }
1463 }
1464 if names.is_empty() {
1465 return Err(invalid_param(
1466 "DashboardNames must contain at least one name",
1467 ));
1468 }
1469 let mut state = self.state.write();
1470 let acct = state.get_or_create(&req.account_id);
1471 for n in names {
1472 acct.dashboards.remove(&n);
1473 }
1474 let body = format!(
1478 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1479 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1480 <DeleteDashboardsResult/>\
1481 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1482 </DeleteDashboardsResponse>",
1483 req.request_id
1484 );
1485 Ok(AwsResponse::xml(StatusCode::OK, body))
1486 }
1487
1488 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1489 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1490 let state = self.state.read();
1491 let dashboards: Vec<Dashboard> = state
1492 .get(&req.account_id)
1493 .map(|a| {
1494 a.dashboards
1495 .values()
1496 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1497 .cloned()
1498 .collect()
1499 })
1500 .unwrap_or_default();
1501 let mut entries = String::new();
1502 for d in &dashboards {
1503 entries.push_str("<member>");
1504 entries.push_str(&format!(
1505 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1506 xml_escape(&d.arn),
1507 xml_escape(&d.name),
1508 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1509 d.size_bytes,
1510 ));
1511 entries.push_str("</member>");
1512 }
1513 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1514 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1515 }
1516}
1517
1518fn render_alarm(alarm: &MetricAlarm) -> String {
1519 let mut s = String::from("<member>");
1520 s.push_str(&format!(
1521 "<AlarmName>{}</AlarmName>",
1522 xml_escape(&alarm.alarm_name)
1523 ));
1524 s.push_str(&format!(
1525 "<AlarmArn>{}</AlarmArn>",
1526 xml_escape(&alarm.alarm_arn)
1527 ));
1528 if let Some(d) = &alarm.alarm_description {
1529 s.push_str(&format!(
1530 "<AlarmDescription>{}</AlarmDescription>",
1531 xml_escape(d)
1532 ));
1533 }
1534 s.push_str(&format!(
1535 "<ActionsEnabled>{}</ActionsEnabled>",
1536 alarm.actions_enabled
1537 ));
1538 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1539 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1540 push_action_list(
1541 &mut s,
1542 "InsufficientDataActions",
1543 &alarm.insufficient_data_actions,
1544 );
1545 s.push_str(&format!(
1546 "<StateValue>{}</StateValue>",
1547 alarm.state_value.as_str()
1548 ));
1549 s.push_str(&format!(
1550 "<StateReason>{}</StateReason>",
1551 xml_escape(&alarm.state_reason)
1552 ));
1553 s.push_str(&format!(
1554 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1555 alarm
1556 .state_updated_timestamp
1557 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1558 ));
1559 if let Some(m) = &alarm.metric_name {
1560 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1561 }
1562 if let Some(n) = &alarm.namespace {
1563 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1564 }
1565 if let Some(stat) = &alarm.statistic {
1566 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1567 }
1568 if let Some(ext) = &alarm.extended_statistic {
1569 s.push_str(&format!(
1570 "<ExtendedStatistic>{}</ExtendedStatistic>",
1571 xml_escape(ext)
1572 ));
1573 }
1574 s.push_str(&render_dimensions(&alarm.dimensions));
1575 if let Some(p) = alarm.period {
1576 s.push_str(&format!("<Period>{p}</Period>"));
1577 }
1578 if let Some(u) = &alarm.unit {
1579 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1580 }
1581 s.push_str(&format!(
1582 "<EvaluationPeriods>{}</EvaluationPeriods>",
1583 alarm.evaluation_periods
1584 ));
1585 if let Some(d) = alarm.datapoints_to_alarm {
1586 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1587 }
1588 if let Some(t) = alarm.threshold {
1589 s.push_str(&format!("<Threshold>{t}</Threshold>"));
1590 }
1591 if let Some(tid) = &alarm.threshold_metric_id {
1592 s.push_str(&format!(
1593 "<ThresholdMetricId>{}</ThresholdMetricId>",
1594 xml_escape(tid)
1595 ));
1596 }
1597 s.push_str(&format!(
1598 "<ComparisonOperator>{}</ComparisonOperator>",
1599 xml_escape(&alarm.comparison_operator)
1600 ));
1601 if let Some(t) = &alarm.treat_missing_data {
1602 s.push_str(&format!(
1603 "<TreatMissingData>{}</TreatMissingData>",
1604 xml_escape(t)
1605 ));
1606 }
1607 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1608 s.push_str(&format!(
1609 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1610 xml_escape(e)
1611 ));
1612 }
1613 s.push_str(&format!(
1614 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1615 alarm
1616 .alarm_configuration_updated_timestamp
1617 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1618 ));
1619 s.push_str("</member>");
1620 s
1621}
1622
1623fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1624 s.push_str(&format!("<{name}>"));
1625 for action in actions {
1626 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1627 }
1628 s.push_str(&format!("</{name}>"));
1629}