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 AlarmHistoryItem, AlarmMetricQuery, AlarmMetricStat, AlarmState, CloudWatchSnapshot, Dashboard,
19 MetricAlarm, MetricDatum, SharedCloudWatchState, StatisticSet,
20 CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
21};
22
23pub(crate) const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
24
25pub(crate) const STANDARD_UNITS: &[&str] = &[
27 "Seconds",
28 "Microseconds",
29 "Milliseconds",
30 "Bytes",
31 "Kilobytes",
32 "Megabytes",
33 "Gigabytes",
34 "Terabytes",
35 "Bits",
36 "Kilobits",
37 "Megabits",
38 "Gigabits",
39 "Terabits",
40 "Percent",
41 "Count",
42 "Bytes/Second",
43 "Kilobytes/Second",
44 "Megabytes/Second",
45 "Gigabytes/Second",
46 "Terabytes/Second",
47 "Bits/Second",
48 "Kilobits/Second",
49 "Megabits/Second",
50 "Gigabits/Second",
51 "Terabits/Second",
52 "Count/Second",
53 "None",
54];
55
56const SUPPORTED_ACTIONS: &[&str] = &[
57 "PutMetricData",
59 "GetMetricStatistics",
60 "GetMetricData",
61 "ListMetrics",
62 "PutMetricAlarm",
63 "DescribeAlarms",
64 "DescribeAlarmsForMetric",
65 "DeleteAlarms",
66 "EnableAlarmActions",
67 "DisableAlarmActions",
68 "SetAlarmState",
69 "DescribeAlarmHistory",
70 "PutDashboard",
72 "GetDashboard",
73 "DeleteDashboards",
74 "ListDashboards",
75 "PutAnomalyDetector",
77 "DescribeAnomalyDetectors",
78 "DeleteAnomalyDetector",
79 "PutInsightRule",
81 "DescribeInsightRules",
82 "DeleteInsightRules",
83 "EnableInsightRules",
84 "DisableInsightRules",
85 "GetInsightRuleReport",
86 "PutManagedInsightRules",
87 "ListManagedInsightRules",
88 "PutMetricStream",
90 "GetMetricStream",
91 "ListMetricStreams",
92 "DeleteMetricStream",
93 "StartMetricStreams",
94 "StopMetricStreams",
95 "PutCompositeAlarm",
97 "PutAlarmMuteRule",
99 "GetAlarmMuteRule",
100 "ListAlarmMuteRules",
101 "DeleteAlarmMuteRule",
102 "GetOTelEnrichment",
104 "StartOTelEnrichment",
105 "StopOTelEnrichment",
106 "AssociateDatasetKmsKey",
108 "DisassociateDatasetKmsKey",
109 "GetDataset",
110 "DescribeAlarmContributors",
112 "GetMetricWidgetImage",
113 "TagResource",
115 "UntagResource",
116 "ListTagsForResource",
117];
118
119pub struct CloudWatchService {
120 pub(crate) state: SharedCloudWatchState,
121 snapshot_store: Option<Arc<dyn SnapshotStore>>,
122 snapshot_lock: Arc<Mutex<()>>,
123}
124
125impl CloudWatchService {
126 pub fn new(state: SharedCloudWatchState) -> Self {
127 Self {
128 state,
129 snapshot_store: None,
130 snapshot_lock: Arc::new(Mutex::new(())),
131 }
132 }
133
134 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
138 self.snapshot_store = Some(store);
139 self
140 }
141
142 pub(crate) async fn save_snapshot(&self) {
146 save_cloudwatch_snapshot(
147 &self.state,
148 self.snapshot_store.clone(),
149 &self.snapshot_lock,
150 )
151 .await;
152 }
153
154 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
159 let store = self.snapshot_store.clone()?;
160 let state = self.state.clone();
161 let lock = self.snapshot_lock.clone();
162 Some(Arc::new(move || {
163 let state = state.clone();
164 let store = store.clone();
165 let lock = lock.clone();
166 Box::pin(async move {
167 save_cloudwatch_snapshot(&state, Some(store), &lock).await;
168 })
169 }))
170 }
171}
172
173pub async fn save_cloudwatch_snapshot(
180 state: &SharedCloudWatchState,
181 store: Option<Arc<dyn SnapshotStore>>,
182 lock: &Mutex<()>,
183) {
184 let Some(store) = store else {
185 return;
186 };
187 let _guard = lock.lock().await;
188 let snapshot = CloudWatchSnapshot {
189 schema_version: CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
190 accounts: state.read().clone_for_snapshot(),
191 };
192 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
193 let bytes = serde_json::to_vec(&snapshot)
194 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
195 store.save(&bytes)
196 })
197 .await;
198 match join {
199 Ok(Ok(())) => {}
200 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudwatch snapshot"),
201 Err(err) => tracing::error!(%err, "cloudwatch snapshot task panicked"),
202 }
203}
204
205#[async_trait]
206impl AwsService for CloudWatchService {
207 fn service_name(&self) -> &str {
208 "monitoring"
209 }
210
211 fn supported_actions(&self) -> &[&str] {
212 SUPPORTED_ACTIONS
213 }
214
215 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
216 let mutates = matches!(
217 req.action.as_str(),
218 "PutMetricData"
219 | "PutMetricAlarm"
220 | "DeleteAlarms"
221 | "EnableAlarmActions"
222 | "DisableAlarmActions"
223 | "SetAlarmState"
224 | "PutDashboard"
225 | "DeleteDashboards"
226 | "PutAnomalyDetector"
227 | "DeleteAnomalyDetector"
228 | "PutInsightRule"
229 | "DeleteInsightRules"
230 | "EnableInsightRules"
231 | "DisableInsightRules"
232 | "PutManagedInsightRules"
233 | "PutMetricStream"
234 | "DeleteMetricStream"
235 | "StartMetricStreams"
236 | "StopMetricStreams"
237 | "PutCompositeAlarm"
238 | "PutAlarmMuteRule"
239 | "DeleteAlarmMuteRule"
240 | "StartOTelEnrichment"
241 | "StopOTelEnrichment"
242 | "AssociateDatasetKmsKey"
243 | "DisassociateDatasetKmsKey"
244 | "TagResource"
245 | "UntagResource"
246 );
247 let result = match req.action.as_str() {
248 "PutMetricData" => self.put_metric_data(&req),
249 "GetMetricStatistics" => self.get_metric_statistics(&req),
250 "GetMetricData" => self.get_metric_data(&req),
251 "ListMetrics" => self.list_metrics(&req),
252 "PutMetricAlarm" => self.put_metric_alarm(&req),
253 "DescribeAlarms" => self.describe_alarms(&req),
254 "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
255 "DeleteAlarms" => self.delete_alarms(&req),
256 "EnableAlarmActions" => self.enable_alarm_actions(&req),
257 "DisableAlarmActions" => self.disable_alarm_actions(&req),
258 "SetAlarmState" => self.set_alarm_state(&req),
259 "DescribeAlarmHistory" => self.describe_alarm_history(&req),
260 "PutDashboard" => self.put_dashboard(&req),
261 "GetDashboard" => self.get_dashboard(&req),
262 "DeleteDashboards" => self.delete_dashboards(&req),
263 "ListDashboards" => self.list_dashboards(&req),
264 "PutAnomalyDetector" => self.put_anomaly_detector(&req),
266 "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
267 "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
268 "PutInsightRule" => self.put_insight_rule(&req),
270 "DescribeInsightRules" => self.describe_insight_rules(&req),
271 "DeleteInsightRules" => self.delete_insight_rules(&req),
272 "EnableInsightRules" => self.enable_insight_rules(&req),
273 "DisableInsightRules" => self.disable_insight_rules(&req),
274 "GetInsightRuleReport" => self.get_insight_rule_report(&req),
275 "PutManagedInsightRules" => self.put_managed_insight_rules(&req),
276 "ListManagedInsightRules" => self.list_managed_insight_rules(&req),
277 "PutMetricStream" => self.put_metric_stream(&req),
279 "GetMetricStream" => self.get_metric_stream(&req),
280 "ListMetricStreams" => self.list_metric_streams(&req),
281 "DeleteMetricStream" => self.delete_metric_stream(&req),
282 "StartMetricStreams" => self.start_metric_streams(&req),
283 "StopMetricStreams" => self.stop_metric_streams(&req),
284 "PutCompositeAlarm" => self.put_composite_alarm(&req),
286 "PutAlarmMuteRule" => self.put_alarm_mute_rule(&req),
288 "GetAlarmMuteRule" => self.get_alarm_mute_rule(&req),
289 "ListAlarmMuteRules" => self.list_alarm_mute_rules(&req),
290 "DeleteAlarmMuteRule" => self.delete_alarm_mute_rule(&req),
291 "GetOTelEnrichment" => self.get_otel_enrichment(&req),
293 "StartOTelEnrichment" => self.start_otel_enrichment(&req),
294 "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
295 "AssociateDatasetKmsKey" => self.associate_dataset_kms_key(&req),
297 "DisassociateDatasetKmsKey" => self.disassociate_dataset_kms_key(&req),
298 "GetDataset" => self.get_dataset(&req),
299 "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
301 "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
302 "TagResource" => self.tag_resource(&req),
304 "UntagResource" => self.untag_resource(&req),
305 "ListTagsForResource" => self.list_tags_for_resource(&req),
306 _ => Err(AwsServiceError::action_not_implemented(
307 "monitoring",
308 &req.action,
309 )),
310 };
311 if mutates && result.is_ok() {
312 self.save_snapshot().await;
313 }
314 if request_is_json(&req) {
318 return result.map(crate::json_protocol::xml_response_to_json);
319 }
320 result
321 }
322}
323
324fn request_is_json(req: &AwsRequest) -> bool {
327 req.headers.contains_key("x-amz-target")
328}
329
330pub(crate) fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
331 AwsResponse::xml(
332 StatusCode::OK,
333 query_response_xml(action, NS, inner, request_id),
334 )
335}
336
337pub(crate) fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
338 AwsResponse::xml(
339 StatusCode::OK,
340 query_metadata_only_xml(action, NS, request_id),
341 )
342}
343
344pub(crate) fn invalid_param(message: impl Into<String>) -> AwsServiceError {
345 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
346}
347
348pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
350 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
351}
352
353pub(crate) fn missing_param(name: &str) -> AwsServiceError {
356 AwsServiceError::aws_error(
357 StatusCode::BAD_REQUEST,
358 "MissingParameter",
359 format!("The request must contain the parameter {name}."),
360 )
361}
362
363pub(crate) fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
364 let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
365 let needle = format!("{prefix}.member.");
366 for (k, v) in req.query_params.iter() {
367 let Some(rest) = k.strip_prefix(&needle) else {
368 continue;
369 };
370 let mut parts = rest.splitn(2, '.');
371 let Some(idx_str) = parts.next() else {
372 continue;
373 };
374 let Ok(idx) = idx_str.parse::<u32>() else {
375 continue;
376 };
377 let field = parts.next().unwrap_or("").to_string();
378 by_index.entry(idx).or_default().insert(field, v.clone());
379 }
380 by_index.into_values().collect()
381}
382
383fn collect_member_numbers(
386 member: &HashMap<String, String>,
387 prefix: &str,
388) -> Result<Vec<f64>, AwsServiceError> {
389 let needle = format!("{prefix}.member.");
390 let mut by_index: BTreeMap<u32, f64> = BTreeMap::new();
391 for (k, v) in member.iter() {
392 let Some(idx_str) = k.strip_prefix(&needle) else {
393 continue;
394 };
395 let Ok(idx) = idx_str.parse::<u32>() else {
396 continue;
397 };
398 let n = v
399 .parse::<f64>()
400 .map_err(|_| invalid_param(format!("{prefix} entries must be numbers")))?;
401 by_index.insert(idx, n);
402 }
403 Ok(by_index.into_values().collect())
404}
405
406fn values_counts_statistic(
409 member: &HashMap<String, String>,
410) -> Result<Option<StatisticSet>, AwsServiceError> {
411 let values = collect_member_numbers(member, "Values")?;
412 if values.is_empty() {
413 return Ok(None);
414 }
415 let counts = collect_member_numbers(member, "Counts")?;
416 let mut sample_count = 0.0;
417 let mut sum = 0.0;
418 let mut minimum = f64::INFINITY;
419 let mut maximum = f64::NEG_INFINITY;
420 for (i, v) in values.iter().enumerate() {
421 let c = counts.get(i).copied().unwrap_or(1.0);
422 sample_count += c;
423 sum += v * c;
424 minimum = minimum.min(*v);
425 maximum = maximum.max(*v);
426 }
427 Ok(Some(StatisticSet {
428 sample_count,
429 sum,
430 minimum,
431 maximum,
432 }))
433}
434
435fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
436 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
437 let needle = format!("{prefix}.member.");
438 for (k, v) in member.iter() {
439 let Some(rest) = k.strip_prefix(&needle) else {
440 continue;
441 };
442 let mut parts = rest.splitn(2, '.');
443 let Some(idx_str) = parts.next() else {
444 continue;
445 };
446 let Ok(idx) = idx_str.parse::<u32>() else {
447 continue;
448 };
449 let field = parts.next().unwrap_or("");
450 let entry = dims.entry(idx).or_default();
451 match field {
452 "Name" => entry.0 = Some(v.clone()),
453 "Value" => entry.1 = Some(v.clone()),
454 _ => {}
455 }
456 }
457 let mut out = BTreeMap::new();
458 for (_, (name, value)) in dims {
459 if let (Some(n), Some(v)) = (name, value) {
460 out.insert(n, v);
461 }
462 }
463 out
464}
465
466fn parse_alarm_metrics(req: &AwsRequest) -> Vec<AlarmMetricQuery> {
469 let mut out = Vec::new();
470 for member in collect_indexed(req, "Metrics") {
471 let Some(id) = member.get("Id").cloned() else {
472 continue;
473 };
474 let metric_stat = if member.keys().any(|k| k.starts_with("MetricStat.")) {
475 let dimensions = parse_dimensions(&member, "MetricStat.Metric.Dimensions");
476 Some(AlarmMetricStat {
477 namespace: member.get("MetricStat.Metric.Namespace").cloned(),
478 metric_name: member.get("MetricStat.Metric.MetricName").cloned(),
479 dimensions,
480 period: member
481 .get("MetricStat.Period")
482 .and_then(|s| s.parse::<i64>().ok()),
483 stat: member.get("MetricStat.Stat").cloned(),
484 unit: member.get("MetricStat.Unit").cloned(),
485 })
486 } else {
487 None
488 };
489 out.push(AlarmMetricQuery {
490 id,
491 metric_stat,
492 expression: member.get("Expression").cloned(),
493 label: member.get("Label").cloned(),
494 return_data: member
495 .get("ReturnData")
496 .map(|s| s.eq_ignore_ascii_case("true")),
497 account_id: member.get("AccountId").cloned(),
498 period: member.get("Period").and_then(|s| s.parse::<i64>().ok()),
499 });
500 }
501 out
502}
503
504pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
505 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
506 let needle = format!("{prefix}.member.");
507 for (k, v) in req.query_params.iter() {
508 let Some(rest) = k.strip_prefix(&needle) else {
509 continue;
510 };
511 let mut parts = rest.splitn(2, '.');
512 let Some(idx_str) = parts.next() else {
513 continue;
514 };
515 let Ok(idx) = idx_str.parse::<u32>() else {
516 continue;
517 };
518 let field = parts.next().unwrap_or("");
519 let entry = dims.entry(idx).or_default();
520 match field {
521 "Name" => entry.0 = Some(v.clone()),
522 "Value" => entry.1 = Some(v.clone()),
523 _ => {}
524 }
525 }
526 let mut out = BTreeMap::new();
527 for (_, (name, value)) in dims {
528 if let (Some(n), Some(v)) = (name, value) {
529 out.insert(n, v);
530 }
531 }
532 out
533}
534
535pub(crate) fn validate_len(
539 req: &AwsRequest,
540 param: &str,
541 min: usize,
542 max: usize,
543) -> Result<(), AwsServiceError> {
544 if let Some(v) = req.query_params.get(param) {
545 let len = v.chars().count();
546 if len < min || len > max {
547 return Err(invalid_param(format!(
548 "{param} length {len} is outside [{min}, {max}]"
549 )));
550 }
551 }
552 Ok(())
553}
554
555pub(crate) fn validate_range_i64(
557 req: &AwsRequest,
558 param: &str,
559 min: i64,
560 max: i64,
561) -> Result<(), AwsServiceError> {
562 if let Some(v) = req.query_params.get(param) {
563 if v.is_empty() {
564 return Ok(());
565 }
566 let n = v
567 .parse::<i64>()
568 .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
569 if n < min || n > max {
570 return Err(invalid_param(format!(
571 "{param} value {n} is outside [{min}, {max}]"
572 )));
573 }
574 }
575 Ok(())
576}
577
578pub(crate) fn validate_enum(
580 req: &AwsRequest,
581 param: &str,
582 allowed: &[&str],
583) -> Result<(), AwsServiceError> {
584 if let Some(v) = req.query_params.get(param) {
585 if !v.is_empty() && !allowed.contains(&v.as_str()) {
586 return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
587 }
588 }
589 Ok(())
590}
591
592pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
594 let needle = format!("{prefix}.member.");
595 let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
596 for (k, v) in req.query_params.iter() {
597 let Some(rest) = k.strip_prefix(&needle) else {
598 continue;
599 };
600 if let Ok(idx) = rest.parse::<u32>() {
601 by_index.insert(idx, v.clone());
602 }
603 }
604 by_index.into_values().collect()
605}
606
607pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
609 let members = collect_indexed(req, prefix);
610 let mut out = BTreeMap::new();
611 for m in members {
612 if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
613 out.insert(k.clone(), v.clone());
614 }
615 }
616 out
617}
618
619pub(crate) fn xml_escape(s: &str) -> String {
620 s.replace('&', "&")
621 .replace('<', "<")
622 .replace('>', ">")
623 .replace('"', """)
624 .replace('\'', "'")
625}
626
627pub(crate) fn encode_offset_token(offset: usize) -> String {
629 use base64::Engine;
630 base64::engine::general_purpose::STANDARD.encode(format!("offset:{offset}"))
631}
632
633pub(crate) fn decode_offset_token(token: Option<&String>) -> usize {
637 use base64::Engine;
638 let Some(token) = token else {
639 return 0;
640 };
641 base64::engine::general_purpose::STANDARD
642 .decode(token)
643 .ok()
644 .and_then(|b| String::from_utf8(b).ok())
645 .and_then(|s| s.strip_prefix("offset:").map(|n| n.to_string()))
646 .and_then(|n| n.parse::<usize>().ok())
647 .unwrap_or(0)
648}
649
650pub(crate) fn parse_input_timestamp(s: &str) -> Option<DateTime<Utc>> {
655 let s = s.trim();
656 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
657 return Some(dt.with_timezone(&Utc));
658 }
659 if let Ok(secs) = s.parse::<f64>() {
661 if secs.is_finite() {
662 let whole = secs.trunc() as i64;
663 let nanos = (secs.fract().abs() * 1_000_000_000.0).round() as u32;
664 return DateTime::<Utc>::from_timestamp(whole, nanos);
665 }
666 }
667 None
668}
669
670#[derive(Clone, Copy)]
674struct DatumStats {
675 sum: f64,
676 min: f64,
677 max: f64,
678 count: f64,
679}
680
681fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
682 if let Some(v) = d.value {
683 return Some(DatumStats {
684 sum: v,
685 min: v,
686 max: v,
687 count: 1.0,
688 });
689 }
690 if let Some(s) = &d.statistic_values {
691 return Some(DatumStats {
692 sum: s.sum,
693 min: s.minimum,
694 max: s.maximum,
695 count: s.sample_count,
696 });
697 }
698 None
699}
700
701fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
702 acc.sum += other.sum;
703 acc.count += other.count;
704 if other.min < acc.min {
705 acc.min = other.min;
706 }
707 if other.max > acc.max {
708 acc.max = other.max;
709 }
710}
711
712fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
713 match stat {
714 "Sum" => Some(agg.sum),
715 "Average" => {
716 if agg.count > 0.0 {
717 Some(agg.sum / agg.count)
718 } else {
719 None
720 }
721 }
722 "Minimum" => Some(agg.min),
723 "Maximum" => Some(agg.max),
724 "SampleCount" => Some(agg.count),
725 _ => None,
726 }
727}
728
729pub(crate) fn parse_percentile(stat: &str) -> Option<f64> {
733 let rest = stat.strip_prefix('p').or_else(|| stat.strip_prefix('P'))?;
734 let p = rest.parse::<f64>().ok()?;
735 if (0.0..=100.0).contains(&p) {
736 Some(p)
737 } else {
738 None
739 }
740}
741
742pub(crate) fn percentile(sorted: &[f64], p: f64) -> Option<f64> {
746 if sorted.is_empty() {
747 return None;
748 }
749 if sorted.len() == 1 {
750 return Some(sorted[0]);
751 }
752 let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
753 let lo = rank.floor() as usize;
754 let hi = rank.ceil() as usize;
755 if lo == hi {
756 return Some(sorted[lo]);
757 }
758 let frac = rank - lo as f64;
759 Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
760}
761
762pub(crate) struct MetricBucket {
767 agg: DatumStats,
768 pub(crate) samples: Vec<f64>,
769 unit: Option<String>,
770}
771
772pub(crate) fn resolve_stat(
775 stat: &str,
776 bucket: &MetricBucket,
777 samples_sorted: &[f64],
778) -> Option<f64> {
779 if let Some(p) = parse_percentile(stat) {
780 return percentile(samples_sorted, p);
781 }
782 stat_value(stat, bucket.agg)
783}
784
785#[allow(clippy::too_many_arguments)]
790pub(crate) fn collect_metric_buckets(
791 data: &[MetricDatum],
792 metric_name: &str,
793 dim_filter: &BTreeMap<String, String>,
794 unit_filter: Option<&str>,
795 period: i64,
796 start_ts: DateTime<Utc>,
797 end_ts: DateTime<Utc>,
798) -> BTreeMap<DateTime<Utc>, MetricBucket> {
799 let mut buckets: BTreeMap<DateTime<Utc>, MetricBucket> = BTreeMap::new();
800 for d in data.iter() {
801 if d.metric_name != metric_name {
802 continue;
803 }
804 if let Some(uf) = unit_filter {
805 if d.unit.as_deref().unwrap_or("None") != uf {
806 continue;
807 }
808 }
809 if &d.dimensions != dim_filter {
813 continue;
814 }
815 if d.timestamp < start_ts || d.timestamp >= end_ts {
816 continue;
817 }
818 let Some(stats) = datum_stats(d) else {
819 continue;
820 };
821 let secs = d.timestamp.timestamp();
822 let bucket_secs = secs - secs.rem_euclid(period);
823 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
824 match buckets.get_mut(&bucket_ts) {
825 Some(bucket) => {
826 merge_stats(&mut bucket.agg, stats);
827 if bucket.unit != d.unit {
828 bucket.unit = None;
829 }
830 if let Some(v) = d.value {
831 bucket.samples.push(v);
832 }
833 }
834 None => {
835 buckets.insert(
836 bucket_ts,
837 MetricBucket {
838 agg: stats,
839 samples: d.value.map(|v| vec![v]).unwrap_or_default(),
840 unit: d.unit.clone(),
841 },
842 );
843 }
844 }
845 }
846 buckets
847}
848
849pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
850 let mut s = String::from("<Dimensions>");
851 for (name, value) in dims.iter() {
852 s.push_str(&format!(
853 "<member><Name>{}</Name><Value>{}</Value></member>",
854 xml_escape(name),
855 xml_escape(value),
856 ));
857 }
858 s.push_str("</Dimensions>");
859 s
860}
861
862impl CloudWatchService {
863 fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
864 let namespace = required_query_param(req, "Namespace")?;
865 let members = collect_indexed(req, "MetricData");
866 if members.is_empty() {
867 return Err(invalid_param(
868 "PutMetricData requires at least one MetricData entry",
869 ));
870 }
871
872 let now = Utc::now();
873 let mut state = self.state.write();
874 let acct = state.get_or_create(&req.account_id);
875 let metrics_map = acct.metrics_in_mut(&req.region);
876 let bucket = metrics_map.entry(namespace.clone()).or_default();
877
878 for member in members {
879 let metric_name = member
880 .get("MetricName")
881 .cloned()
882 .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
883 let value = member
884 .get("Value")
885 .map(|s| s.parse::<f64>())
886 .transpose()
887 .map_err(|_| invalid_param("Value must be a valid number"))?;
888 let timestamp = member
889 .get("Timestamp")
890 .and_then(|s| parse_input_timestamp(s))
891 .unwrap_or(now);
892 let unit = member.get("Unit").cloned();
893 let storage_resolution = member
894 .get("StorageResolution")
895 .and_then(|s| s.parse::<i64>().ok());
896 let dimensions = parse_dimensions(&member, "Dimensions");
897
898 let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
899 member.get("StatisticValues.SampleCount"),
900 member.get("StatisticValues.Sum"),
901 member.get("StatisticValues.Minimum"),
902 member.get("StatisticValues.Maximum"),
903 ) {
904 Some(StatisticSet {
905 sample_count: sc.parse::<f64>().map_err(|_| {
906 invalid_param("StatisticValues.SampleCount must be a number")
907 })?,
908 sum: sum
909 .parse::<f64>()
910 .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
911 minimum: min
912 .parse::<f64>()
913 .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
914 maximum: max
915 .parse::<f64>()
916 .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
917 })
918 } else {
919 None
920 };
921
922 let statistic_values = match statistic_values {
926 Some(s) => Some(s),
927 None => values_counts_statistic(&member)?,
928 };
929
930 if value.is_none() && statistic_values.is_none() {
931 return Err(invalid_param(
932 "MetricData entry must supply either Value, StatisticValues, or Values",
933 ));
934 }
935
936 bucket.push(MetricDatum {
937 metric_name,
938 dimensions,
939 timestamp,
940 value,
941 statistic_values,
942 unit,
943 storage_resolution,
944 });
945 }
946
947 Ok(empty_metadata_response("PutMetricData", &req.request_id))
948 }
949
950 fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
951 validate_len(req, "Namespace", 1, 255)?;
952 validate_len(req, "MetricName", 1, 255)?;
953 validate_len(req, "OwningAccount", 1, 255)?;
954 validate_enum(req, "RecentlyActive", &["PT3H"])?;
955 let namespace = optional_query_param(req, "Namespace");
956 let metric_name = optional_query_param(req, "MetricName");
957 let dim_filter = parse_dimensions_query(req, "Dimensions");
958 const LIST_METRICS_PAGE: usize = 500;
961 let offset = decode_offset_token(req.query_params.get("NextToken"));
962
963 let state = self.state.read();
964 let mut all: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
967 if let Some(acct) = state.get(&req.account_id) {
968 if let Some(map) = acct.metrics_in(&req.region) {
969 for (ns, data) in map.iter() {
970 if let Some(filter_ns) = namespace.as_ref() {
971 if ns != filter_ns {
972 continue;
973 }
974 }
975 let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
976 BTreeMap::new();
977 for d in data.iter() {
978 if let Some(filter_name) = metric_name.as_ref() {
979 if &d.metric_name != filter_name {
980 continue;
981 }
982 }
983 if !dim_filter.is_empty()
988 && !dim_filter
989 .iter()
990 .all(|(k, v)| d.dimensions.get(k) == Some(v))
991 {
992 continue;
993 }
994 seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
995 }
996 for ((name, dims), _) in seen {
997 all.push((ns.clone(), name, dims));
998 }
999 }
1000 }
1001 }
1002
1003 let page = all.iter().skip(offset).take(LIST_METRICS_PAGE);
1004 let mut out = String::from("<Metrics>");
1005 {
1006 for (ns, name, dims) in page {
1007 out.push_str("<member>");
1008 out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
1009 out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(name)));
1010 out.push_str(&render_dimensions(dims));
1011 out.push_str("</member>");
1012 }
1013 }
1014 out.push_str("</Metrics>");
1015 if offset + LIST_METRICS_PAGE < all.len() {
1016 out.push_str(&format!(
1017 "<NextToken>{}</NextToken>",
1018 encode_offset_token(offset + LIST_METRICS_PAGE)
1019 ));
1020 }
1021
1022 Ok(xml_response("ListMetrics", &out, &req.request_id))
1023 }
1024
1025 fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1026 let namespace = required_query_param(req, "Namespace")?;
1027 let metric_name = required_query_param(req, "MetricName")?;
1028 let start = required_query_param(req, "StartTime")?;
1029 let end = required_query_param(req, "EndTime")?;
1030 let period = required_query_param(req, "Period")?
1031 .parse::<i64>()
1032 .map_err(|_| invalid_param("Period must be an integer"))?;
1033 if period <= 0 {
1034 return Err(invalid_param("Period must be positive"));
1035 }
1036 let start_ts = parse_input_timestamp(&start)
1037 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1038 let end_ts = parse_input_timestamp(&end)
1039 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1040
1041 let mut statistics: Vec<String> = Vec::new();
1042 let mut extended_statistics: Vec<String> = Vec::new();
1043 for (k, v) in req.query_params.iter() {
1044 if k.starts_with("Statistics.member.") {
1045 statistics.push(v.clone());
1046 } else if k.starts_with("ExtendedStatistics.member.") {
1047 extended_statistics.push(v.clone());
1048 }
1049 }
1050 if statistics.is_empty() && extended_statistics.is_empty() {
1051 return Err(invalid_param(
1052 "At least one of Statistics or ExtendedStatistics is required",
1053 ));
1054 }
1055
1056 let dim_filter = parse_dimensions_query(req, "Dimensions");
1057 let unit_filter = req.query_params.get("Unit").cloned();
1061
1062 let state = self.state.read();
1063 type StatPoint = (
1065 DateTime<Utc>,
1066 BTreeMap<String, f64>,
1067 Vec<(String, f64)>,
1068 Option<String>,
1069 );
1070 let mut datapoints: Vec<StatPoint> = Vec::new();
1071 if let Some(acct) = state.get(&req.account_id) {
1072 if let Some(map) = acct.metrics_in(&req.region) {
1073 if let Some(data) = map.get(&namespace) {
1074 let buckets = collect_metric_buckets(
1075 data,
1076 &metric_name,
1077 &dim_filter,
1078 unit_filter.as_deref(),
1079 period,
1080 start_ts,
1081 end_ts,
1082 );
1083 for (ts, bucket) in buckets {
1084 let mut sorted = bucket.samples.clone();
1085 sorted
1086 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1087 let mut simple = BTreeMap::new();
1088 for stat in statistics.iter() {
1089 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1090 simple.insert(stat.clone(), v);
1091 }
1092 }
1093 let mut extended = Vec::new();
1094 for stat in extended_statistics.iter() {
1095 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1096 extended.push((stat.clone(), v));
1097 }
1098 }
1099 let unit = unit_filter.clone().or(bucket.unit);
1100 datapoints.push((ts, simple, extended, unit));
1101 }
1102 }
1103 }
1104 }
1105
1106 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1107 inner.push_str("<Datapoints>");
1108 for (ts, simple, extended, unit) in datapoints {
1109 inner.push_str("<member>");
1110 inner.push_str(&format!(
1111 "<Timestamp>{}</Timestamp>",
1112 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1113 ));
1114 for (name, value) in simple {
1115 inner.push_str(&format!("<{name}>{value}</{name}>"));
1116 }
1117 if !extended.is_empty() {
1118 inner.push_str("<ExtendedStatistics>");
1119 for (name, value) in extended {
1120 inner.push_str(&format!(
1121 "<entry><key>{}</key><value>{}</value></entry>",
1122 xml_escape(&name),
1123 value
1124 ));
1125 }
1126 inner.push_str("</ExtendedStatistics>");
1127 }
1128 if let Some(u) = unit {
1129 inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1130 }
1131 inner.push_str("</member>");
1132 }
1133 inner.push_str("</Datapoints>");
1134
1135 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1136 }
1137
1138 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1139 validate_enum(
1140 req,
1141 "ScanBy",
1142 &["TimestampDescending", "TimestampAscending"],
1143 )?;
1144 let start = required_query_param(req, "StartTime")?;
1145 let end = required_query_param(req, "EndTime")?;
1146 let start_ts = parse_input_timestamp(&start)
1147 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1148 let end_ts = parse_input_timestamp(&end)
1149 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1150
1151 let descending = req
1155 .query_params
1156 .get("ScanBy")
1157 .map(|s| s != "TimestampAscending")
1158 .unwrap_or(true);
1159
1160 let queries = collect_indexed(req, "MetricDataQueries");
1163
1164 let state = self.state.read();
1165
1166 let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1169 for q in &queries {
1170 let id = q.get("Id").cloned().unwrap_or_default();
1171 let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1172 continue;
1173 };
1174 let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1175 continue;
1176 };
1177 let stat = q
1178 .get("MetricStat.Stat")
1179 .cloned()
1180 .unwrap_or_else(|| "Sum".to_string());
1181 let period: i64 = q
1182 .get("MetricStat.Period")
1183 .and_then(|s| s.parse::<i64>().ok())
1184 .filter(|p| *p > 0)
1185 .unwrap_or(60);
1186 let unit_filter = q.get("MetricStat.Unit").cloned();
1187 let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1188
1189 let mut series = crate::metric_math::Series::new();
1190 if let Some(acct) = state.get(&req.account_id) {
1191 if let Some(map) = acct.metrics_in(&req.region) {
1192 if let Some(data) = map.get(namespace) {
1193 let buckets = collect_metric_buckets(
1194 data,
1195 metric_name,
1196 &dim_filter,
1197 unit_filter.as_deref(),
1198 period,
1199 start_ts,
1200 end_ts,
1201 );
1202 for (ts, bucket) in buckets {
1203 let mut sorted = bucket.samples.clone();
1204 sorted.sort_by(|a, b| {
1205 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1206 });
1207 if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1208 series.insert(ts, v);
1209 }
1210 }
1211 }
1212 }
1213 }
1214 series_by_id.insert(id, series);
1215 }
1216
1217 let mut inner = String::from("<MetricDataResults>");
1220 for q in &queries {
1221 let id = q.get("Id").cloned().unwrap_or_default();
1222 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1223 let return_data = q
1224 .get("ReturnData")
1225 .map(|s| !s.eq_ignore_ascii_case("false"))
1226 .unwrap_or(true);
1227 if !return_data {
1228 continue;
1229 }
1230
1231 let mut error_message: Option<String> = None;
1232 let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1233 match crate::metric_math::evaluate(expr, &series_by_id) {
1234 Ok(s) => s,
1235 Err(e) => {
1236 error_message = Some(e);
1237 crate::metric_math::Series::new()
1238 }
1239 }
1240 } else {
1241 series_by_id.get(&id).cloned().unwrap_or_default()
1242 };
1243
1244 let mut timestamps: Vec<String> = Vec::new();
1245 let mut values: Vec<f64> = Vec::new();
1246 for (ts, v) in series.iter() {
1247 timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1248 values.push(*v);
1249 }
1250 if descending {
1251 timestamps.reverse();
1252 values.reverse();
1253 }
1254
1255 inner.push_str("<member>");
1256 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1257 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1258 inner.push_str("<Timestamps>");
1259 for ts in ×tamps {
1260 inner.push_str(&format!("<member>{ts}</member>"));
1261 }
1262 inner.push_str("</Timestamps>");
1263 inner.push_str("<Values>");
1264 for v in &values {
1265 inner.push_str(&format!("<member>{v}</member>"));
1266 }
1267 inner.push_str("</Values>");
1268 if let Some(msg) = error_message {
1269 inner.push_str("<StatusCode>InternalError</StatusCode>");
1270 inner.push_str("<Messages><member>");
1271 inner.push_str("<Code>Error</Code>");
1272 inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1273 inner.push_str("</member></Messages>");
1274 } else {
1275 inner.push_str("<StatusCode>Complete</StatusCode>");
1276 }
1277 inner.push_str("</member>");
1278 }
1279 inner.push_str("</MetricDataResults>");
1280 inner.push_str("<Messages></Messages>");
1281
1282 Ok(xml_response("GetMetricData", &inner, &req.request_id))
1283 }
1284
1285 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1286 validate_len(req, "AlarmName", 1, 255)?;
1292 validate_len(req, "AlarmDescription", 0, 1024)?;
1293 validate_len(req, "MetricName", 1, 255)?;
1294 validate_len(req, "Namespace", 1, 255)?;
1295 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1296 validate_len(req, "TreatMissingData", 1, 255)?;
1297 validate_len(req, "ThresholdMetricId", 1, 255)?;
1298 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1299 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1300 validate_range_i64(req, "Period", 1, i64::MAX)?;
1301 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1302 validate_enum(
1303 req,
1304 "ComparisonOperator",
1305 &[
1306 "GreaterThanOrEqualToThreshold",
1307 "GreaterThanThreshold",
1308 "GreaterThanUpperThreshold",
1309 "LessThanLowerOrGreaterThanUpperThreshold",
1310 "LessThanLowerThreshold",
1311 "LessThanOrEqualToThreshold",
1312 "LessThanThreshold",
1313 ],
1314 )?;
1315 validate_enum(
1316 req,
1317 "Statistic",
1318 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1319 )?;
1320 validate_enum(req, "Unit", STANDARD_UNITS)?;
1321 let alarm_name = required_query_param(req, "AlarmName")?;
1322 let comparison = optional_query_param(req, "ComparisonOperator")
1323 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1324 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1325 .and_then(|s| s.parse::<i64>().ok())
1326 .unwrap_or(1);
1327
1328 let alarm_description = optional_query_param(req, "AlarmDescription");
1329 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1330 .map(|s| s.eq_ignore_ascii_case("true"))
1331 .unwrap_or(true);
1332
1333 let metric_name = optional_query_param(req, "MetricName");
1334 let namespace = optional_query_param(req, "Namespace");
1335 let statistic = optional_query_param(req, "Statistic");
1336 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1337 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1338 let unit = optional_query_param(req, "Unit");
1339 let datapoints_to_alarm =
1340 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1341 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1342 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1343 let evaluate_low_sample_count_percentile =
1344 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1345 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1348 let dimensions = parse_dimensions_query(req, "Dimensions");
1349 let metrics = parse_alarm_metrics(req);
1353 let inline_tags = parse_tags(req, "Tags");
1356
1357 let mut ok_actions = Vec::new();
1358 let mut alarm_actions = Vec::new();
1359 let mut insufficient_data_actions = Vec::new();
1360 for (k, v) in req.query_params.iter() {
1361 if k.starts_with("OKActions.member.") {
1362 ok_actions.push(v.clone());
1363 } else if k.starts_with("AlarmActions.member.") {
1364 alarm_actions.push(v.clone());
1365 } else if k.starts_with("InsufficientDataActions.member.") {
1366 insufficient_data_actions.push(v.clone());
1367 }
1368 }
1369
1370 let arn = format!(
1371 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1372 req.region, req.account_id, alarm_name
1373 );
1374 let now = Utc::now();
1375
1376 let mut state = self.state.write();
1377 let acct = state.get_or_create(&req.account_id);
1378 let alarms = acct.alarms_in_mut(&req.region);
1379 let existing = alarms.get(&alarm_name).cloned();
1380 let alarm = MetricAlarm {
1381 alarm_name: alarm_name.clone(),
1382 alarm_arn: arn,
1383 alarm_description,
1384 actions_enabled,
1385 ok_actions,
1386 alarm_actions,
1387 insufficient_data_actions,
1388 state_value: existing
1389 .as_ref()
1390 .map(|a| a.state_value)
1391 .unwrap_or(AlarmState::InsufficientData),
1392 state_reason: existing
1393 .as_ref()
1394 .map(|a| a.state_reason.clone())
1395 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1396 state_updated_timestamp: existing
1397 .as_ref()
1398 .map(|a| a.state_updated_timestamp)
1399 .unwrap_or(now),
1400 metric_name,
1401 namespace,
1402 statistic,
1403 extended_statistic,
1404 dimensions,
1405 period,
1406 unit,
1407 evaluation_periods,
1408 datapoints_to_alarm,
1409 threshold,
1410 comparison_operator: comparison,
1411 treat_missing_data,
1412 evaluate_low_sample_count_percentile,
1413 threshold_metric_id,
1414 configuration_updated_timestamp: existing
1415 .as_ref()
1416 .map(|a| a.configuration_updated_timestamp)
1417 .unwrap_or(now),
1418 alarm_configuration_updated_timestamp: now,
1419 metrics,
1420 };
1421 let alarm_arn = alarm.alarm_arn.clone();
1422 let history_name = alarm_name.clone();
1423 let created = existing.is_none();
1424 alarms.insert(alarm_name, alarm);
1425
1426 if created && !inline_tags.is_empty() {
1431 let bucket = acct.tags.entry(alarm_arn).or_default();
1432 for (k, v) in inline_tags {
1433 bucket.insert(k, v);
1434 }
1435 }
1436
1437 let summary = if created {
1438 format!("Alarm \"{history_name}\" created")
1439 } else {
1440 format!("Alarm \"{history_name}\" updated")
1441 };
1442 let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1443 push_alarm_history(
1444 acct,
1445 &req.region,
1446 &history_name,
1447 "MetricAlarm",
1448 "ConfigurationUpdate",
1449 summary,
1450 history_data,
1451 );
1452
1453 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1454 }
1455
1456 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1457 let mut filter_names: Vec<String> = Vec::new();
1458 for (k, v) in req.query_params.iter() {
1459 if k.starts_with("AlarmNames.member.") {
1460 filter_names.push(v.clone());
1461 }
1462 }
1463 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1464 validate_len(req, "ActionPrefix", 1, 1024)?;
1465 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1466 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1467 validate_range_i64(req, "MaxRecords", 1, 100)?;
1468 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1469 let prefix = optional_query_param(req, "AlarmNamePrefix");
1470 let state_filter = optional_query_param(req, "StateValue");
1471 let action_prefix = optional_query_param(req, "ActionPrefix");
1472 let max_records = optional_query_param(req, "MaxRecords")
1476 .and_then(|s| s.parse::<usize>().ok())
1477 .filter(|n| *n > 0)
1478 .unwrap_or(100);
1479 let offset = decode_offset_token(req.query_params.get("NextToken"));
1480
1481 let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1484 if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1485 return false;
1486 }
1487 if let Some(p) = prefix.as_ref() {
1488 if !name.starts_with(p) {
1489 return false;
1490 }
1491 }
1492 if let Some(want) = state_filter.as_ref() {
1493 if sv != want {
1494 return false;
1495 }
1496 }
1497 if let Some(ap) = action_prefix.as_ref() {
1498 let any = actions
1499 .iter()
1500 .flat_map(|a| a.iter())
1501 .any(|a| a.starts_with(ap));
1502 if !any {
1503 return false;
1504 }
1505 }
1506 true
1507 };
1508
1509 let mut state = self.state.write();
1513 if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1514 crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1515 }
1516 let mut combined: Vec<(bool, String)> = Vec::new();
1517 if let Some(acct) = state.get(&req.account_id) {
1518 if let Some(alarms) = acct.alarms_in(&req.region) {
1519 for alarm in alarms.values() {
1520 if matches(
1521 &alarm.alarm_name,
1522 alarm.state_value.as_str(),
1523 [
1524 &alarm.alarm_actions,
1525 &alarm.ok_actions,
1526 &alarm.insufficient_data_actions,
1527 ],
1528 ) {
1529 combined.push((false, render_alarm(alarm)));
1530 }
1531 }
1532 }
1533 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1534 for alarm in composites.values() {
1535 if matches(
1536 &alarm.alarm_name,
1537 alarm.state_value.as_str(),
1538 [
1539 &alarm.alarm_actions,
1540 &alarm.ok_actions,
1541 &alarm.insufficient_data_actions,
1542 ],
1543 ) {
1544 combined
1545 .push((true, crate::composite_alarms::render_composite_alarm(alarm)));
1546 }
1547 }
1548 }
1549 }
1550
1551 let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1552 let mut inner = String::from("<MetricAlarms>");
1553 for (is_composite, body) in &page {
1554 if !*is_composite {
1555 inner.push_str(body);
1556 }
1557 }
1558 inner.push_str("</MetricAlarms>");
1559 inner.push_str("<CompositeAlarms>");
1560 for (is_composite, body) in &page {
1561 if *is_composite {
1562 inner.push_str(body);
1563 }
1564 }
1565 inner.push_str("</CompositeAlarms>");
1566 if offset + max_records < combined.len() {
1567 inner.push_str(&format!(
1568 "<NextToken>{}</NextToken>",
1569 encode_offset_token(offset + max_records)
1570 ));
1571 }
1572
1573 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1574 }
1575
1576 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1577 validate_len(req, "MetricName", 1, 255)?;
1578 validate_len(req, "Namespace", 1, 255)?;
1579 validate_range_i64(req, "Period", 1, i64::MAX)?;
1580 validate_enum(
1581 req,
1582 "Statistic",
1583 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1584 )?;
1585 validate_enum(req, "Unit", STANDARD_UNITS)?;
1586 let metric_name = required_query_param(req, "MetricName")?;
1587 let namespace = required_query_param(req, "Namespace")?;
1588 let dim_filter = parse_dimensions_query(req, "Dimensions");
1589
1590 let state = self.state.read();
1591 let mut inner = String::from("<MetricAlarms>");
1592 if let Some(acct) = state.get(&req.account_id) {
1593 if let Some(alarms) = acct.alarms_in(&req.region) {
1594 for alarm in alarms.values() {
1595 if alarm.metric_name.as_deref() != Some(&metric_name) {
1596 continue;
1597 }
1598 if alarm.namespace.as_deref() != Some(&namespace) {
1599 continue;
1600 }
1601 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1602 continue;
1603 }
1604 inner.push_str(&render_alarm(alarm));
1605 }
1606 }
1607 }
1608 inner.push_str("</MetricAlarms>");
1609
1610 Ok(xml_response(
1611 "DescribeAlarmsForMetric",
1612 &inner,
1613 &req.request_id,
1614 ))
1615 }
1616
1617 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1618 let mut names: Vec<String> = Vec::new();
1622 for (k, v) in req.query_params.iter() {
1623 if k.starts_with("AlarmNames.member.") {
1624 names.push(v.clone());
1625 }
1626 }
1627
1628 let mut state = self.state.write();
1629 let acct = state.get_or_create(&req.account_id);
1630 for name in &names {
1631 acct.alarms_in_mut(&req.region).remove(name);
1632 acct.composite_alarms_in_mut(&req.region).remove(name);
1633 acct.alarm_history_in_mut(&req.region).remove(name);
1636 }
1637
1638 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1639 }
1640
1641 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1642 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1643 }
1644
1645 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1646 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1647 }
1648
1649 fn toggle_alarm_actions(
1650 &self,
1651 req: &AwsRequest,
1652 enabled: bool,
1653 action_name: &str,
1654 ) -> Result<AwsResponse, AwsServiceError> {
1655 let mut names: Vec<String> = Vec::new();
1656 for (k, v) in req.query_params.iter() {
1657 if k.starts_with("AlarmNames.member.") {
1658 names.push(v.clone());
1659 }
1660 }
1661 let mut state = self.state.write();
1662 let acct = state.get_or_create(&req.account_id);
1663 let alarms = acct.alarms_in_mut(&req.region);
1664 for name in names {
1665 if let Some(alarm) = alarms.get_mut(&name) {
1666 alarm.actions_enabled = enabled;
1667 alarm.alarm_configuration_updated_timestamp = Utc::now();
1668 }
1669 }
1670 Ok(empty_metadata_response(action_name, &req.request_id))
1671 }
1672
1673 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1674 validate_len(req, "AlarmName", 1, 255)?;
1675 validate_len(req, "StateReason", 0, 1023)?;
1676 validate_len(req, "StateReasonData", 0, 4000)?;
1677 let alarm_name = required_query_param(req, "AlarmName")?;
1678 let state_value = required_query_param(req, "StateValue")?;
1679 let state_reason = req
1683 .query_params
1684 .get("StateReason")
1685 .cloned()
1686 .ok_or_else(|| {
1687 AwsServiceError::aws_error(
1688 StatusCode::BAD_REQUEST,
1689 "MissingParameter",
1690 "The request must contain the parameter StateReason.",
1691 )
1692 })?;
1693 let new_state = AlarmState::parse(&state_value)
1694 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1695
1696 let now = Utc::now();
1697 let mut state = self.state.write();
1698 let acct = state.get_or_create(&req.account_id);
1699 let (old_state, alarm_type) =
1702 if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1703 let old = alarm.state_value.as_str().to_string();
1704 alarm.state_value = new_state;
1705 alarm.state_reason = state_reason.clone();
1706 alarm.state_updated_timestamp = now;
1707 (old, "MetricAlarm")
1708 } else if let Some(composite) = acct
1709 .composite_alarms_in_mut(&req.region)
1710 .get_mut(&alarm_name)
1711 {
1712 let old = composite.state_value.as_str().to_string();
1713 composite.state_value = new_state;
1714 composite.state_reason = state_reason.clone();
1715 composite.state_updated_timestamp = now;
1716 (old, "CompositeAlarm")
1717 } else {
1718 return Err(AwsServiceError::aws_error(
1719 StatusCode::NOT_FOUND,
1720 "ResourceNotFound",
1721 format!("Alarm {alarm_name} not found"),
1722 ));
1723 };
1724
1725 let new_state_str = new_state.as_str().to_string();
1726 let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1727 let history_data = format!(
1728 "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1729 state_reason.replace('"', "\\\"")
1730 );
1731 push_alarm_history(
1732 acct,
1733 &req.region,
1734 &alarm_name,
1735 alarm_type,
1736 "StateUpdate",
1737 summary,
1738 history_data,
1739 );
1740
1741 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1742 }
1743
1744 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1745 validate_len(req, "AlarmName", 1, 255)?;
1746 validate_len(req, "AlarmContributorId", 1, 16)?;
1747 validate_range_i64(req, "MaxRecords", 1, 100)?;
1748 validate_enum(
1749 req,
1750 "HistoryItemType",
1751 &[
1752 "ConfigurationUpdate",
1753 "StateUpdate",
1754 "Action",
1755 "AlarmContributorStateUpdate",
1756 "AlarmContributorAction",
1757 ],
1758 )?;
1759 validate_enum(
1760 req,
1761 "ScanBy",
1762 &["TimestampDescending", "TimestampAscending"],
1763 )?;
1764 let alarm_filter = optional_query_param(req, "AlarmName");
1765 let type_filter = optional_query_param(req, "HistoryItemType");
1766 let start_date =
1767 optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1768 let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1769 let descending = req
1771 .query_params
1772 .get("ScanBy")
1773 .map(|s| s != "TimestampAscending")
1774 .unwrap_or(true);
1775 let max_records = optional_query_param(req, "MaxRecords")
1776 .and_then(|s| s.parse::<usize>().ok())
1777 .filter(|n| *n > 0)
1778 .unwrap_or(100);
1779 let offset = decode_offset_token(req.query_params.get("NextToken"));
1780
1781 let state = self.state.read();
1782 let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1783 if let Some(acct) = state.get(&req.account_id) {
1784 if let Some(history) = acct.alarm_history_in(&req.region) {
1785 for (name, list) in history.iter() {
1786 if let Some(f) = alarm_filter.as_ref() {
1787 if name != f {
1788 continue;
1789 }
1790 }
1791 for item in list.iter() {
1792 if let Some(t) = type_filter.as_ref() {
1793 if &item.history_item_type != t {
1794 continue;
1795 }
1796 }
1797 if let Some(sd) = start_date {
1798 if item.timestamp < sd {
1799 continue;
1800 }
1801 }
1802 if let Some(ed) = end_date {
1803 if item.timestamp > ed {
1804 continue;
1805 }
1806 }
1807 items.push(item);
1808 }
1809 }
1810 }
1811 }
1812 items.sort_by_key(|i| i.timestamp);
1813 if descending {
1814 items.reverse();
1815 }
1816 let total = items.len();
1817 let page: Vec<&AlarmHistoryItem> =
1818 items.into_iter().skip(offset).take(max_records).collect();
1819
1820 let mut inner = String::from("<AlarmHistoryItems>");
1821 for item in page {
1822 inner.push_str("<member>");
1823 inner.push_str(&format!(
1824 "<AlarmName>{}</AlarmName>",
1825 xml_escape(&item.alarm_name)
1826 ));
1827 inner.push_str(&format!(
1828 "<AlarmType>{}</AlarmType>",
1829 xml_escape(&item.alarm_type)
1830 ));
1831 inner.push_str(&format!(
1832 "<Timestamp>{}</Timestamp>",
1833 item.timestamp
1834 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1835 ));
1836 inner.push_str(&format!(
1837 "<HistoryItemType>{}</HistoryItemType>",
1838 xml_escape(&item.history_item_type)
1839 ));
1840 inner.push_str(&format!(
1841 "<HistorySummary>{}</HistorySummary>",
1842 xml_escape(&item.history_summary)
1843 ));
1844 inner.push_str(&format!(
1845 "<HistoryData>{}</HistoryData>",
1846 xml_escape(&item.history_data)
1847 ));
1848 inner.push_str("</member>");
1849 }
1850 inner.push_str("</AlarmHistoryItems>");
1851 if offset + max_records < total {
1852 inner.push_str(&format!(
1853 "<NextToken>{}</NextToken>",
1854 encode_offset_token(offset + max_records)
1855 ));
1856 }
1857 Ok(xml_response(
1858 "DescribeAlarmHistory",
1859 &inner,
1860 &req.request_id,
1861 ))
1862 }
1863
1864 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1865 let dashboard_name = req
1866 .query_params
1867 .get("DashboardName")
1868 .ok_or_else(|| invalid_param("DashboardName is required"))?
1869 .clone();
1870 let body = req
1871 .query_params
1872 .get("DashboardBody")
1873 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1874 .clone();
1875 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1878 return Err(AwsServiceError::aws_error(
1879 StatusCode::BAD_REQUEST,
1880 "InvalidParameterInput",
1881 "DashboardBody must be a valid JSON object",
1882 ));
1883 }
1884 let arn = format!(
1885 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1886 req.account_id
1887 );
1888 let dashboard = Dashboard {
1889 name: dashboard_name.clone(),
1890 arn,
1891 size_bytes: body.len() as i64,
1892 body,
1893 last_modified: Utc::now(),
1894 };
1895 let mut state = self.state.write();
1896 let acct = state.get_or_create(&req.account_id);
1897 acct.dashboards.insert(dashboard_name, dashboard);
1898 let inner = String::from("<DashboardValidationMessages/>");
1901 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1902 }
1903
1904 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1905 let name = req
1906 .query_params
1907 .get("DashboardName")
1908 .ok_or_else(|| invalid_param("DashboardName is required"))?
1909 .clone();
1910 let state = self.state.read();
1911 let dashboard = state
1912 .get(&req.account_id)
1913 .and_then(|a| a.dashboards.get(&name))
1914 .cloned()
1915 .ok_or_else(|| {
1916 AwsServiceError::aws_error(
1917 StatusCode::NOT_FOUND,
1918 "ResourceNotFound",
1919 format!("Dashboard {name} does not exist"),
1920 )
1921 })?;
1922 let inner = format!(
1923 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1924 xml_escape(&dashboard.arn),
1925 xml_escape(&dashboard.body),
1926 xml_escape(&dashboard.name),
1927 );
1928 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1929 }
1930
1931 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1932 let mut names: Vec<String> = Vec::new();
1933 for (k, v) in req.query_params.iter() {
1934 if k.starts_with("DashboardNames.member.") {
1935 names.push(v.clone());
1936 }
1937 }
1938 if names.is_empty() {
1939 return Err(invalid_param(
1940 "DashboardNames must contain at least one name",
1941 ));
1942 }
1943 let mut state = self.state.write();
1944 let acct = state.get_or_create(&req.account_id);
1945 for n in names {
1946 acct.dashboards.remove(&n);
1947 }
1948 let body = format!(
1952 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1953 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1954 <DeleteDashboardsResult/>\
1955 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1956 </DeleteDashboardsResponse>",
1957 req.request_id
1958 );
1959 Ok(AwsResponse::xml(StatusCode::OK, body))
1960 }
1961
1962 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1963 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1964 let state = self.state.read();
1965 let dashboards: Vec<Dashboard> = state
1966 .get(&req.account_id)
1967 .map(|a| {
1968 a.dashboards
1969 .values()
1970 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1971 .cloned()
1972 .collect()
1973 })
1974 .unwrap_or_default();
1975 let mut entries = String::new();
1976 for d in &dashboards {
1977 entries.push_str("<member>");
1978 entries.push_str(&format!(
1979 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1980 xml_escape(&d.arn),
1981 xml_escape(&d.name),
1982 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1983 d.size_bytes,
1984 ));
1985 entries.push_str("</member>");
1986 }
1987 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1988 Ok(xml_response("ListDashboards", &inner, &req.request_id))
1989 }
1990}
1991
1992pub(crate) fn push_alarm_history(
1996 acct: &mut crate::state::CloudWatchState,
1997 region: &str,
1998 alarm_name: &str,
1999 alarm_type: &str,
2000 history_item_type: &str,
2001 history_summary: String,
2002 history_data: String,
2003) {
2004 acct.alarm_history_in_mut(region)
2005 .entry(alarm_name.to_string())
2006 .or_default()
2007 .push(AlarmHistoryItem {
2008 alarm_name: alarm_name.to_string(),
2009 alarm_type: alarm_type.to_string(),
2010 timestamp: Utc::now(),
2011 history_item_type: history_item_type.to_string(),
2012 history_summary,
2013 history_data,
2014 });
2015}
2016
2017fn render_alarm(alarm: &MetricAlarm) -> String {
2018 let mut s = String::from("<member>");
2019 s.push_str(&format!(
2020 "<AlarmName>{}</AlarmName>",
2021 xml_escape(&alarm.alarm_name)
2022 ));
2023 s.push_str(&format!(
2024 "<AlarmArn>{}</AlarmArn>",
2025 xml_escape(&alarm.alarm_arn)
2026 ));
2027 if let Some(d) = &alarm.alarm_description {
2028 s.push_str(&format!(
2029 "<AlarmDescription>{}</AlarmDescription>",
2030 xml_escape(d)
2031 ));
2032 }
2033 s.push_str(&format!(
2034 "<ActionsEnabled>{}</ActionsEnabled>",
2035 alarm.actions_enabled
2036 ));
2037 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2038 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2039 push_action_list(
2040 &mut s,
2041 "InsufficientDataActions",
2042 &alarm.insufficient_data_actions,
2043 );
2044 s.push_str(&format!(
2045 "<StateValue>{}</StateValue>",
2046 alarm.state_value.as_str()
2047 ));
2048 s.push_str(&format!(
2049 "<StateReason>{}</StateReason>",
2050 xml_escape(&alarm.state_reason)
2051 ));
2052 s.push_str(&format!(
2053 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2054 alarm
2055 .state_updated_timestamp
2056 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2057 ));
2058 if let Some(m) = &alarm.metric_name {
2059 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2060 }
2061 if let Some(n) = &alarm.namespace {
2062 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2063 }
2064 if let Some(stat) = &alarm.statistic {
2065 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2066 }
2067 if let Some(ext) = &alarm.extended_statistic {
2068 s.push_str(&format!(
2069 "<ExtendedStatistic>{}</ExtendedStatistic>",
2070 xml_escape(ext)
2071 ));
2072 }
2073 s.push_str(&render_dimensions(&alarm.dimensions));
2074 if let Some(p) = alarm.period {
2075 s.push_str(&format!("<Period>{p}</Period>"));
2076 }
2077 if let Some(u) = &alarm.unit {
2078 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2079 }
2080 s.push_str(&format!(
2081 "<EvaluationPeriods>{}</EvaluationPeriods>",
2082 alarm.evaluation_periods
2083 ));
2084 if let Some(d) = alarm.datapoints_to_alarm {
2085 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2086 }
2087 if let Some(t) = alarm.threshold {
2088 s.push_str(&format!("<Threshold>{t}</Threshold>"));
2089 }
2090 if let Some(tid) = &alarm.threshold_metric_id {
2091 s.push_str(&format!(
2092 "<ThresholdMetricId>{}</ThresholdMetricId>",
2093 xml_escape(tid)
2094 ));
2095 }
2096 s.push_str(&format!(
2097 "<ComparisonOperator>{}</ComparisonOperator>",
2098 xml_escape(&alarm.comparison_operator)
2099 ));
2100 if let Some(t) = &alarm.treat_missing_data {
2101 s.push_str(&format!(
2102 "<TreatMissingData>{}</TreatMissingData>",
2103 xml_escape(t)
2104 ));
2105 }
2106 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2107 s.push_str(&format!(
2108 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2109 xml_escape(e)
2110 ));
2111 }
2112 s.push_str(&format!(
2113 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2114 alarm
2115 .alarm_configuration_updated_timestamp
2116 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2117 ));
2118 render_alarm_metrics(&mut s, &alarm.metrics);
2119 s.push_str("</member>");
2120 s
2121}
2122
2123fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2125 if metrics.is_empty() {
2126 return;
2127 }
2128 s.push_str("<Metrics>");
2129 for q in metrics {
2130 s.push_str("<member>");
2131 s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2132 if let Some(stat) = &q.metric_stat {
2133 s.push_str("<MetricStat>");
2134 s.push_str("<Metric>");
2135 if let Some(ns) = &stat.namespace {
2136 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2137 }
2138 if let Some(mn) = &stat.metric_name {
2139 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2140 }
2141 s.push_str(&render_dimensions(&stat.dimensions));
2142 s.push_str("</Metric>");
2143 if let Some(p) = stat.period {
2144 s.push_str(&format!("<Period>{p}</Period>"));
2145 }
2146 if let Some(st) = &stat.stat {
2147 s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2148 }
2149 if let Some(u) = &stat.unit {
2150 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2151 }
2152 s.push_str("</MetricStat>");
2153 }
2154 if let Some(e) = &q.expression {
2155 s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2156 }
2157 if let Some(l) = &q.label {
2158 s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2159 }
2160 if let Some(rd) = q.return_data {
2161 s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2162 }
2163 if let Some(p) = q.period {
2164 s.push_str(&format!("<Period>{p}</Period>"));
2165 }
2166 if let Some(acct) = &q.account_id {
2167 s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2168 }
2169 s.push_str("</member>");
2170 }
2171 s.push_str("</Metrics>");
2172}
2173
2174fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2175 s.push_str(&format!("<{name}>"));
2176 for action in actions {
2177 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2178 }
2179 s.push_str(&format!("</{name}>"));
2180}