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
818 let state = self.state.read();
819 let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
820 if let Some(acct) = state.get(&req.account_id) {
821 if let Some(map) = acct.metrics_in(&req.region) {
822 if let Some(data) = map.get(&namespace) {
823 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
824 for d in data.iter() {
825 if d.metric_name != metric_name {
826 continue;
827 }
828 if !dim_filter
829 .iter()
830 .all(|(k, v)| d.dimensions.get(k) == Some(v))
831 {
832 continue;
833 }
834 if d.timestamp < start_ts || d.timestamp >= end_ts {
835 continue;
836 }
837 let Some(stats) = datum_stats(d) else {
838 continue;
839 };
840 let secs = d.timestamp.timestamp();
841 let bucket_secs = secs - secs.rem_euclid(period);
842 let bucket_ts =
843 DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
844 buckets
845 .entry(bucket_ts)
846 .and_modify(|acc| merge_stats(acc, stats))
847 .or_insert(stats);
848 }
849 for (ts, agg) in buckets {
850 let mut stats = BTreeMap::new();
851 for stat in statistics.iter() {
852 if let Some(v) = stat_value(stat, agg) {
853 stats.insert(stat.clone(), v);
854 }
855 }
856 datapoints.push((ts, stats));
857 }
858 }
859 }
860 }
861
862 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
863 inner.push_str("<Datapoints>");
864 for (ts, stats) in datapoints {
865 inner.push_str("<member>");
866 inner.push_str(&format!(
867 "<Timestamp>{}</Timestamp>",
868 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
869 ));
870 for (name, value) in stats {
871 inner.push_str(&format!("<{name}>{value}</{name}>"));
872 }
873 inner.push_str("</member>");
874 }
875 inner.push_str("</Datapoints>");
876
877 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
878 }
879
880 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
881 validate_enum(
882 req,
883 "ScanBy",
884 &["TimestampDescending", "TimestampAscending"],
885 )?;
886 let start = required_query_param(req, "StartTime")?;
887 let end = required_query_param(req, "EndTime")?;
888 let start_ts = DateTime::parse_from_rfc3339(&start)
889 .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
890 .with_timezone(&Utc);
891 let end_ts = DateTime::parse_from_rfc3339(&end)
892 .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
893 .with_timezone(&Utc);
894
895 let descending = req
899 .query_params
900 .get("ScanBy")
901 .map(|s| s != "TimestampAscending")
902 .unwrap_or(true);
903
904 let queries = collect_indexed(req, "MetricDataQueries");
907
908 let state = self.state.read();
909 let mut inner = String::from("<MetricDataResults>");
910 for q in queries {
911 let id = q.get("Id").cloned().unwrap_or_default();
912 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
913 let stat = q
914 .get("MetricStat.Stat")
915 .cloned()
916 .unwrap_or_else(|| "Sum".to_string());
917 let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
918 let namespace = q.get("MetricStat.Metric.Namespace").cloned();
919 let period: i64 = q
920 .get("MetricStat.Period")
921 .and_then(|s| s.parse::<i64>().ok())
922 .filter(|p| *p > 0)
923 .unwrap_or(60);
924 let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
925
926 let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
927 if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
928 if let Some(acct) = state.get(&req.account_id) {
929 if let Some(map) = acct.metrics_in(&req.region) {
930 if let Some(data) = map.get(&namespace) {
931 let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
932 for d in data.iter() {
933 if d.metric_name != metric_name {
934 continue;
935 }
936 if !dim_filter
937 .iter()
938 .all(|(k, v)| d.dimensions.get(k) == Some(v))
939 {
940 continue;
941 }
942 if d.timestamp < start_ts || d.timestamp >= end_ts {
943 continue;
944 }
945 let Some(stats) = datum_stats(d) else {
946 continue;
947 };
948 let secs = d.timestamp.timestamp();
949 let bucket_secs = secs - secs.rem_euclid(period);
950 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
951 .unwrap_or(d.timestamp);
952 buckets
953 .entry(bucket_ts)
954 .and_modify(|acc| merge_stats(acc, stats))
955 .or_insert(stats);
956 }
957 for (ts, agg) in buckets {
958 let Some(v) = stat_value(&stat, agg) else {
959 continue;
960 };
961 timestamps
962 .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
963 values.push(v);
964 }
965 if descending {
966 timestamps.reverse();
967 values.reverse();
968 }
969 }
970 }
971 }
972 }
973
974 inner.push_str("<member>");
975 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
976 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
977 inner.push_str("<StatusCode>Complete</StatusCode>");
978 inner.push_str("<Timestamps>");
979 for ts in timestamps {
980 inner.push_str(&format!("<member>{ts}</member>"));
981 }
982 inner.push_str("</Timestamps>");
983 inner.push_str("<Values>");
984 for v in values {
985 inner.push_str(&format!("<member>{v}</member>"));
986 }
987 inner.push_str("</Values>");
988 inner.push_str("</member>");
989 }
990 inner.push_str("</MetricDataResults>");
991 inner.push_str("<Messages></Messages>");
992
993 Ok(xml_response("GetMetricData", &inner, &req.request_id))
994 }
995
996 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
997 validate_len(req, "AlarmName", 1, 255)?;
1003 validate_len(req, "AlarmDescription", 0, 1024)?;
1004 validate_len(req, "MetricName", 1, 255)?;
1005 validate_len(req, "Namespace", 1, 255)?;
1006 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1007 validate_len(req, "TreatMissingData", 1, 255)?;
1008 validate_len(req, "ThresholdMetricId", 1, 255)?;
1009 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1010 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1011 validate_range_i64(req, "Period", 1, i64::MAX)?;
1012 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1013 validate_enum(
1014 req,
1015 "ComparisonOperator",
1016 &[
1017 "GreaterThanOrEqualToThreshold",
1018 "GreaterThanThreshold",
1019 "GreaterThanUpperThreshold",
1020 "LessThanLowerOrGreaterThanUpperThreshold",
1021 "LessThanLowerThreshold",
1022 "LessThanOrEqualToThreshold",
1023 "LessThanThreshold",
1024 ],
1025 )?;
1026 validate_enum(
1027 req,
1028 "Statistic",
1029 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1030 )?;
1031 validate_enum(req, "Unit", STANDARD_UNITS)?;
1032 let alarm_name = required_query_param(req, "AlarmName")?;
1033 let comparison = optional_query_param(req, "ComparisonOperator")
1034 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1035 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1036 .and_then(|s| s.parse::<i64>().ok())
1037 .unwrap_or(1);
1038
1039 let alarm_description = optional_query_param(req, "AlarmDescription");
1040 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1041 .map(|s| s.eq_ignore_ascii_case("true"))
1042 .unwrap_or(true);
1043
1044 let metric_name = optional_query_param(req, "MetricName");
1045 let namespace = optional_query_param(req, "Namespace");
1046 let statistic = optional_query_param(req, "Statistic");
1047 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1048 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1049 let unit = optional_query_param(req, "Unit");
1050 let datapoints_to_alarm =
1051 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1052 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1053 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1054 let evaluate_low_sample_count_percentile =
1055 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1056 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1059 let dimensions = parse_dimensions_query(req, "Dimensions");
1060
1061 let mut ok_actions = Vec::new();
1062 let mut alarm_actions = Vec::new();
1063 let mut insufficient_data_actions = Vec::new();
1064 for (k, v) in req.query_params.iter() {
1065 if k.starts_with("OKActions.member.") {
1066 ok_actions.push(v.clone());
1067 } else if k.starts_with("AlarmActions.member.") {
1068 alarm_actions.push(v.clone());
1069 } else if k.starts_with("InsufficientDataActions.member.") {
1070 insufficient_data_actions.push(v.clone());
1071 }
1072 }
1073
1074 let arn = format!(
1075 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1076 req.region, req.account_id, alarm_name
1077 );
1078 let now = Utc::now();
1079
1080 let mut state = self.state.write();
1081 let acct = state.get_or_create(&req.account_id);
1082 let alarms = acct.alarms_in_mut(&req.region);
1083 let existing = alarms.get(&alarm_name).cloned();
1084 let alarm = MetricAlarm {
1085 alarm_name: alarm_name.clone(),
1086 alarm_arn: arn,
1087 alarm_description,
1088 actions_enabled,
1089 ok_actions,
1090 alarm_actions,
1091 insufficient_data_actions,
1092 state_value: existing
1093 .as_ref()
1094 .map(|a| a.state_value)
1095 .unwrap_or(AlarmState::InsufficientData),
1096 state_reason: existing
1097 .as_ref()
1098 .map(|a| a.state_reason.clone())
1099 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1100 state_updated_timestamp: existing
1101 .as_ref()
1102 .map(|a| a.state_updated_timestamp)
1103 .unwrap_or(now),
1104 metric_name,
1105 namespace,
1106 statistic,
1107 extended_statistic,
1108 dimensions,
1109 period,
1110 unit,
1111 evaluation_periods,
1112 datapoints_to_alarm,
1113 threshold,
1114 comparison_operator: comparison,
1115 treat_missing_data,
1116 evaluate_low_sample_count_percentile,
1117 threshold_metric_id,
1118 configuration_updated_timestamp: existing
1119 .as_ref()
1120 .map(|a| a.configuration_updated_timestamp)
1121 .unwrap_or(now),
1122 alarm_configuration_updated_timestamp: now,
1123 };
1124 alarms.insert(alarm_name, alarm);
1125
1126 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1127 }
1128
1129 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1130 let mut filter_names: Vec<String> = Vec::new();
1131 for (k, v) in req.query_params.iter() {
1132 if k.starts_with("AlarmNames.member.") {
1133 filter_names.push(v.clone());
1134 }
1135 }
1136 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1137 validate_len(req, "ActionPrefix", 1, 1024)?;
1138 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1139 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1140 validate_range_i64(req, "MaxRecords", 1, 100)?;
1141 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1142 let prefix = optional_query_param(req, "AlarmNamePrefix");
1143 let state_filter = optional_query_param(req, "StateValue");
1144 let action_prefix = optional_query_param(req, "ActionPrefix");
1145
1146 let state = self.state.read();
1147 let mut inner = String::from("<MetricAlarms>");
1148 if let Some(acct) = state.get(&req.account_id) {
1149 if let Some(alarms) = acct.alarms_in(&req.region) {
1150 for alarm in alarms.values() {
1151 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1152 continue;
1153 }
1154 if let Some(p) = prefix.as_ref() {
1155 if !alarm.alarm_name.starts_with(p) {
1156 continue;
1157 }
1158 }
1159 if let Some(sv) = state_filter.as_ref() {
1160 if alarm.state_value.as_str() != sv {
1161 continue;
1162 }
1163 }
1164 if let Some(ap) = action_prefix.as_ref() {
1165 let any = alarm
1166 .alarm_actions
1167 .iter()
1168 .chain(alarm.ok_actions.iter())
1169 .chain(alarm.insufficient_data_actions.iter())
1170 .any(|a| a.starts_with(ap));
1171 if !any {
1172 continue;
1173 }
1174 }
1175 inner.push_str(&render_alarm(alarm));
1176 }
1177 }
1178 }
1179 inner.push_str("</MetricAlarms>");
1180 inner.push_str("<CompositeAlarms>");
1181 if let Some(acct) = state.get(&req.account_id) {
1182 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1183 for alarm in composites.values() {
1184 if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1185 continue;
1186 }
1187 if let Some(p) = prefix.as_ref() {
1188 if !alarm.alarm_name.starts_with(p) {
1189 continue;
1190 }
1191 }
1192 if let Some(sv) = state_filter.as_ref() {
1193 if alarm.state_value.as_str() != sv {
1194 continue;
1195 }
1196 }
1197 if let Some(ap) = action_prefix.as_ref() {
1198 let any = alarm
1199 .alarm_actions
1200 .iter()
1201 .chain(alarm.ok_actions.iter())
1202 .chain(alarm.insufficient_data_actions.iter())
1203 .any(|a| a.starts_with(ap));
1204 if !any {
1205 continue;
1206 }
1207 }
1208 inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1209 }
1210 }
1211 }
1212 inner.push_str("</CompositeAlarms>");
1213
1214 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1215 }
1216
1217 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1218 validate_len(req, "MetricName", 1, 255)?;
1219 validate_len(req, "Namespace", 1, 255)?;
1220 validate_range_i64(req, "Period", 1, i64::MAX)?;
1221 validate_enum(
1222 req,
1223 "Statistic",
1224 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1225 )?;
1226 validate_enum(req, "Unit", STANDARD_UNITS)?;
1227 let metric_name = required_query_param(req, "MetricName")?;
1228 let namespace = required_query_param(req, "Namespace")?;
1229 let dim_filter = parse_dimensions_query(req, "Dimensions");
1230
1231 let state = self.state.read();
1232 let mut inner = String::from("<MetricAlarms>");
1233 if let Some(acct) = state.get(&req.account_id) {
1234 if let Some(alarms) = acct.alarms_in(&req.region) {
1235 for alarm in alarms.values() {
1236 if alarm.metric_name.as_deref() != Some(&metric_name) {
1237 continue;
1238 }
1239 if alarm.namespace.as_deref() != Some(&namespace) {
1240 continue;
1241 }
1242 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1243 continue;
1244 }
1245 inner.push_str(&render_alarm(alarm));
1246 }
1247 }
1248 }
1249 inner.push_str("</MetricAlarms>");
1250
1251 Ok(xml_response(
1252 "DescribeAlarmsForMetric",
1253 &inner,
1254 &req.request_id,
1255 ))
1256 }
1257
1258 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1259 let mut names: Vec<String> = Vec::new();
1263 for (k, v) in req.query_params.iter() {
1264 if k.starts_with("AlarmNames.member.") {
1265 names.push(v.clone());
1266 }
1267 }
1268
1269 let mut state = self.state.write();
1270 let acct = state.get_or_create(&req.account_id);
1271 for name in &names {
1272 acct.alarms_in_mut(&req.region).remove(name);
1273 acct.composite_alarms_in_mut(&req.region).remove(name);
1274 }
1275
1276 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1277 }
1278
1279 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1280 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1281 }
1282
1283 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1284 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1285 }
1286
1287 fn toggle_alarm_actions(
1288 &self,
1289 req: &AwsRequest,
1290 enabled: bool,
1291 action_name: &str,
1292 ) -> Result<AwsResponse, AwsServiceError> {
1293 let mut names: Vec<String> = Vec::new();
1294 for (k, v) in req.query_params.iter() {
1295 if k.starts_with("AlarmNames.member.") {
1296 names.push(v.clone());
1297 }
1298 }
1299 let mut state = self.state.write();
1300 let acct = state.get_or_create(&req.account_id);
1301 let alarms = acct.alarms_in_mut(&req.region);
1302 for name in names {
1303 if let Some(alarm) = alarms.get_mut(&name) {
1304 alarm.actions_enabled = enabled;
1305 alarm.alarm_configuration_updated_timestamp = Utc::now();
1306 }
1307 }
1308 Ok(empty_metadata_response(action_name, &req.request_id))
1309 }
1310
1311 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1312 validate_len(req, "AlarmName", 1, 255)?;
1313 validate_len(req, "StateReason", 0, 1023)?;
1314 validate_len(req, "StateReasonData", 0, 4000)?;
1315 let alarm_name = required_query_param(req, "AlarmName")?;
1316 let state_value = required_query_param(req, "StateValue")?;
1317 let state_reason = req
1321 .query_params
1322 .get("StateReason")
1323 .cloned()
1324 .ok_or_else(|| {
1325 AwsServiceError::aws_error(
1326 StatusCode::BAD_REQUEST,
1327 "MissingParameter",
1328 "The request must contain the parameter StateReason.",
1329 )
1330 })?;
1331 let new_state = AlarmState::parse(&state_value)
1332 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1333
1334 let mut state = self.state.write();
1335 let acct = state.get_or_create(&req.account_id);
1336 let alarms = acct.alarms_in_mut(&req.region);
1337 let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1338 AwsServiceError::aws_error(
1339 StatusCode::NOT_FOUND,
1340 "ResourceNotFound",
1341 format!("Alarm {alarm_name} not found"),
1342 )
1343 })?;
1344 alarm.state_value = new_state;
1345 alarm.state_reason = state_reason;
1346 alarm.state_updated_timestamp = Utc::now();
1347
1348 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1349 }
1350
1351 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1352 validate_len(req, "AlarmName", 1, 255)?;
1353 validate_len(req, "AlarmContributorId", 1, 16)?;
1354 validate_range_i64(req, "MaxRecords", 1, 100)?;
1355 validate_enum(
1356 req,
1357 "HistoryItemType",
1358 &[
1359 "ConfigurationUpdate",
1360 "StateUpdate",
1361 "Action",
1362 "AlarmContributorStateUpdate",
1363 "AlarmContributorAction",
1364 ],
1365 )?;
1366 validate_enum(
1367 req,
1368 "ScanBy",
1369 &["TimestampDescending", "TimestampAscending"],
1370 )?;
1371 let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1374 Ok(xml_response(
1375 "DescribeAlarmHistory",
1376 &inner,
1377 &req.request_id,
1378 ))
1379 }
1380
1381 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1382 let dashboard_name = req
1383 .query_params
1384 .get("DashboardName")
1385 .ok_or_else(|| invalid_param("DashboardName is required"))?
1386 .clone();
1387 let body = req
1388 .query_params
1389 .get("DashboardBody")
1390 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1391 .clone();
1392 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1395 return Err(AwsServiceError::aws_error(
1396 StatusCode::BAD_REQUEST,
1397 "InvalidParameterInput",
1398 "DashboardBody must be a valid JSON object",
1399 ));
1400 }
1401 let arn = format!(
1402 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1403 req.account_id
1404 );
1405 let dashboard = Dashboard {
1406 name: dashboard_name.clone(),
1407 arn,
1408 size_bytes: body.len() as i64,
1409 body,
1410 last_modified: Utc::now(),
1411 };
1412 let mut state = self.state.write();
1413 let acct = state.get_or_create(&req.account_id);
1414 acct.dashboards.insert(dashboard_name, dashboard);
1415 let inner = String::from("<DashboardValidationMessages/>");
1418 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1419 }
1420
1421 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1422 let name = req
1423 .query_params
1424 .get("DashboardName")
1425 .ok_or_else(|| invalid_param("DashboardName is required"))?
1426 .clone();
1427 let state = self.state.read();
1428 let dashboard = state
1429 .get(&req.account_id)
1430 .and_then(|a| a.dashboards.get(&name))
1431 .cloned()
1432 .ok_or_else(|| {
1433 AwsServiceError::aws_error(
1434 StatusCode::NOT_FOUND,
1435 "ResourceNotFound",
1436 format!("Dashboard {name} does not exist"),
1437 )
1438 })?;
1439 let inner = format!(
1440 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1441 xml_escape(&dashboard.arn),
1442 xml_escape(&dashboard.body),
1443 xml_escape(&dashboard.name),
1444 );
1445 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1446 }
1447
1448 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1449 let mut names: Vec<String> = Vec::new();
1450 for (k, v) in req.query_params.iter() {
1451 if k.starts_with("DashboardNames.member.") {
1452 names.push(v.clone());
1453 }
1454 }
1455 if names.is_empty() {
1456 return Err(invalid_param(
1457 "DashboardNames must contain at least one name",
1458 ));
1459 }
1460 let mut state = self.state.write();
1461 let acct = state.get_or_create(&req.account_id);
1462 for n in names {
1463 acct.dashboards.remove(&n);
1464 }
1465 let body = format!(
1469 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1470 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1471 <DeleteDashboardsResult/>\
1472 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1473 </DeleteDashboardsResponse>",
1474 req.request_id
1475 );
1476 Ok(AwsResponse::xml(StatusCode::OK, body))
1477 }
1478
1479 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1480 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1481 let state = self.state.read();
1482 let dashboards: Vec<Dashboard> = state
1483 .get(&req.account_id)
1484 .map(|a| {
1485 a.dashboards
1486 .values()
1487 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1488 .cloned()
1489 .collect()
1490 })
1491 .unwrap_or_default();
1492 let mut entries = String::new();
1493 for d in &dashboards {
1494 entries.push_str("<member>");
1495 entries.push_str(&format!(
1496 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1497 xml_escape(&d.arn),
1498 xml_escape(&d.name),
1499 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1500 d.size_bytes,
1501 ));
1502 entries.push_str("</member>");
1503 }
1504 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1505 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1506 }
1507}
1508
1509fn render_alarm(alarm: &MetricAlarm) -> String {
1510 let mut s = String::from("<member>");
1511 s.push_str(&format!(
1512 "<AlarmName>{}</AlarmName>",
1513 xml_escape(&alarm.alarm_name)
1514 ));
1515 s.push_str(&format!(
1516 "<AlarmArn>{}</AlarmArn>",
1517 xml_escape(&alarm.alarm_arn)
1518 ));
1519 if let Some(d) = &alarm.alarm_description {
1520 s.push_str(&format!(
1521 "<AlarmDescription>{}</AlarmDescription>",
1522 xml_escape(d)
1523 ));
1524 }
1525 s.push_str(&format!(
1526 "<ActionsEnabled>{}</ActionsEnabled>",
1527 alarm.actions_enabled
1528 ));
1529 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1530 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1531 push_action_list(
1532 &mut s,
1533 "InsufficientDataActions",
1534 &alarm.insufficient_data_actions,
1535 );
1536 s.push_str(&format!(
1537 "<StateValue>{}</StateValue>",
1538 alarm.state_value.as_str()
1539 ));
1540 s.push_str(&format!(
1541 "<StateReason>{}</StateReason>",
1542 xml_escape(&alarm.state_reason)
1543 ));
1544 s.push_str(&format!(
1545 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1546 alarm
1547 .state_updated_timestamp
1548 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1549 ));
1550 if let Some(m) = &alarm.metric_name {
1551 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1552 }
1553 if let Some(n) = &alarm.namespace {
1554 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1555 }
1556 if let Some(stat) = &alarm.statistic {
1557 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1558 }
1559 if let Some(ext) = &alarm.extended_statistic {
1560 s.push_str(&format!(
1561 "<ExtendedStatistic>{}</ExtendedStatistic>",
1562 xml_escape(ext)
1563 ));
1564 }
1565 s.push_str(&render_dimensions(&alarm.dimensions));
1566 if let Some(p) = alarm.period {
1567 s.push_str(&format!("<Period>{p}</Period>"));
1568 }
1569 if let Some(u) = &alarm.unit {
1570 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1571 }
1572 s.push_str(&format!(
1573 "<EvaluationPeriods>{}</EvaluationPeriods>",
1574 alarm.evaluation_periods
1575 ));
1576 if let Some(d) = alarm.datapoints_to_alarm {
1577 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1578 }
1579 if let Some(t) = alarm.threshold {
1580 s.push_str(&format!("<Threshold>{t}</Threshold>"));
1581 }
1582 if let Some(tid) = &alarm.threshold_metric_id {
1583 s.push_str(&format!(
1584 "<ThresholdMetricId>{}</ThresholdMetricId>",
1585 xml_escape(tid)
1586 ));
1587 }
1588 s.push_str(&format!(
1589 "<ComparisonOperator>{}</ComparisonOperator>",
1590 xml_escape(&alarm.comparison_operator)
1591 ));
1592 if let Some(t) = &alarm.treat_missing_data {
1593 s.push_str(&format!(
1594 "<TreatMissingData>{}</TreatMissingData>",
1595 xml_escape(t)
1596 ));
1597 }
1598 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1599 s.push_str(&format!(
1600 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1601 xml_escape(e)
1602 ));
1603 }
1604 s.push_str(&format!(
1605 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1606 alarm
1607 .alarm_configuration_updated_timestamp
1608 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1609 ));
1610 s.push_str("</member>");
1611 s
1612}
1613
1614fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1615 s.push_str(&format!("<{name}>"));
1616 for action in actions {
1617 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1618 }
1619 s.push_str(&format!("</{name}>"));
1620}