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