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 parse_dimension_filters(
545 req: &AwsRequest,
546 prefix: &str,
547) -> Vec<(String, Option<String>)> {
548 let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
549 let needle = format!("{prefix}.member.");
550 for (k, v) in req.query_params.iter() {
551 let Some(rest) = k.strip_prefix(&needle) else {
552 continue;
553 };
554 let mut parts = rest.splitn(2, '.');
555 let Some(idx_str) = parts.next() else {
556 continue;
557 };
558 let Ok(idx) = idx_str.parse::<u32>() else {
559 continue;
560 };
561 let field = parts.next().unwrap_or("");
562 let entry = dims.entry(idx).or_default();
563 match field {
564 "Name" => entry.0 = Some(v.clone()),
565 "Value" => entry.1 = Some(v.clone()),
566 _ => {}
567 }
568 }
569 dims.into_values()
570 .filter_map(|(name, value)| name.map(|n| (n, value)))
571 .collect()
572}
573
574pub(crate) fn validate_len(
578 req: &AwsRequest,
579 param: &str,
580 min: usize,
581 max: usize,
582) -> Result<(), AwsServiceError> {
583 if let Some(v) = req.query_params.get(param) {
584 let len = v.chars().count();
585 if len < min || len > max {
586 return Err(invalid_param(format!(
587 "{param} length {len} is outside [{min}, {max}]"
588 )));
589 }
590 }
591 Ok(())
592}
593
594pub(crate) fn validate_range_i64(
596 req: &AwsRequest,
597 param: &str,
598 min: i64,
599 max: i64,
600) -> Result<(), AwsServiceError> {
601 if let Some(v) = req.query_params.get(param) {
602 if v.is_empty() {
603 return Ok(());
604 }
605 let n = v
606 .parse::<i64>()
607 .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
608 if n < min || n > max {
609 return Err(invalid_param(format!(
610 "{param} value {n} is outside [{min}, {max}]"
611 )));
612 }
613 }
614 Ok(())
615}
616
617pub(crate) fn validate_enum(
619 req: &AwsRequest,
620 param: &str,
621 allowed: &[&str],
622) -> Result<(), AwsServiceError> {
623 if let Some(v) = req.query_params.get(param) {
624 if !v.is_empty() && !allowed.contains(&v.as_str()) {
625 return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
626 }
627 }
628 Ok(())
629}
630
631pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
633 let needle = format!("{prefix}.member.");
634 let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
635 for (k, v) in req.query_params.iter() {
636 let Some(rest) = k.strip_prefix(&needle) else {
637 continue;
638 };
639 if let Ok(idx) = rest.parse::<u32>() {
640 by_index.insert(idx, v.clone());
641 }
642 }
643 by_index.into_values().collect()
644}
645
646pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
648 let members = collect_indexed(req, prefix);
649 let mut out = BTreeMap::new();
650 for m in members {
651 if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
652 out.insert(k.clone(), v.clone());
653 }
654 }
655 out
656}
657
658pub(crate) fn xml_escape(s: &str) -> String {
659 s.replace('&', "&")
660 .replace('<', "<")
661 .replace('>', ">")
662 .replace('"', """)
663 .replace('\'', "'")
664}
665
666pub(crate) fn encode_offset_token(offset: usize) -> String {
668 use base64::Engine;
669 base64::engine::general_purpose::STANDARD.encode(format!("offset:{offset}"))
670}
671
672pub(crate) fn decode_offset_token(token: Option<&String>) -> usize {
676 use base64::Engine;
677 let Some(token) = token else {
678 return 0;
679 };
680 base64::engine::general_purpose::STANDARD
681 .decode(token)
682 .ok()
683 .and_then(|b| String::from_utf8(b).ok())
684 .and_then(|s| s.strip_prefix("offset:").map(|n| n.to_string()))
685 .and_then(|n| n.parse::<usize>().ok())
686 .unwrap_or(0)
687}
688
689pub(crate) fn parse_input_timestamp(s: &str) -> Option<DateTime<Utc>> {
694 let s = s.trim();
695 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
696 return Some(dt.with_timezone(&Utc));
697 }
698 if let Ok(secs) = s.parse::<f64>() {
700 if secs.is_finite() {
701 let whole = secs.trunc() as i64;
702 let nanos = (secs.fract().abs() * 1_000_000_000.0).round() as u32;
703 return DateTime::<Utc>::from_timestamp(whole, nanos);
704 }
705 }
706 None
707}
708
709#[derive(Clone, Copy)]
713struct DatumStats {
714 sum: f64,
715 min: f64,
716 max: f64,
717 count: f64,
718}
719
720fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
721 if let Some(v) = d.value {
722 return Some(DatumStats {
723 sum: v,
724 min: v,
725 max: v,
726 count: 1.0,
727 });
728 }
729 if let Some(s) = &d.statistic_values {
730 return Some(DatumStats {
731 sum: s.sum,
732 min: s.minimum,
733 max: s.maximum,
734 count: s.sample_count,
735 });
736 }
737 None
738}
739
740fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
741 acc.sum += other.sum;
742 acc.count += other.count;
743 if other.min < acc.min {
744 acc.min = other.min;
745 }
746 if other.max > acc.max {
747 acc.max = other.max;
748 }
749}
750
751fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
752 match stat {
753 "Sum" => Some(agg.sum),
754 "Average" => {
755 if agg.count > 0.0 {
756 Some(agg.sum / agg.count)
757 } else {
758 None
759 }
760 }
761 "Minimum" => Some(agg.min),
762 "Maximum" => Some(agg.max),
763 "SampleCount" => Some(agg.count),
764 _ => None,
765 }
766}
767
768pub(crate) fn parse_percentile(stat: &str) -> Option<f64> {
772 let rest = stat.strip_prefix('p').or_else(|| stat.strip_prefix('P'))?;
773 let p = rest.parse::<f64>().ok()?;
774 if (0.0..=100.0).contains(&p) {
775 Some(p)
776 } else {
777 None
778 }
779}
780
781pub(crate) fn percentile(sorted: &[f64], p: f64) -> Option<f64> {
785 if sorted.is_empty() {
786 return None;
787 }
788 if sorted.len() == 1 {
789 return Some(sorted[0]);
790 }
791 let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
792 let lo = rank.floor() as usize;
793 let hi = rank.ceil() as usize;
794 if lo == hi {
795 return Some(sorted[lo]);
796 }
797 let frac = rank - lo as f64;
798 Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
799}
800
801pub(crate) struct MetricBucket {
806 agg: DatumStats,
807 pub(crate) samples: Vec<f64>,
808 unit: Option<String>,
809}
810
811pub(crate) fn resolve_stat(
814 stat: &str,
815 bucket: &MetricBucket,
816 samples_sorted: &[f64],
817) -> Option<f64> {
818 if let Some(p) = parse_percentile(stat) {
819 return percentile(samples_sorted, p);
820 }
821 stat_value(stat, bucket.agg)
822}
823
824#[allow(clippy::too_many_arguments)]
829pub(crate) fn collect_metric_buckets(
830 data: &[MetricDatum],
831 metric_name: &str,
832 dim_filter: &BTreeMap<String, String>,
833 unit_filter: Option<&str>,
834 period: i64,
835 start_ts: DateTime<Utc>,
836 end_ts: DateTime<Utc>,
837) -> BTreeMap<DateTime<Utc>, MetricBucket> {
838 let mut buckets: BTreeMap<DateTime<Utc>, MetricBucket> = BTreeMap::new();
839 for d in data.iter() {
840 if d.metric_name != metric_name {
841 continue;
842 }
843 if let Some(uf) = unit_filter {
844 if d.unit.as_deref().unwrap_or("None") != uf {
845 continue;
846 }
847 }
848 if &d.dimensions != dim_filter {
852 continue;
853 }
854 if d.timestamp < start_ts || d.timestamp >= end_ts {
855 continue;
856 }
857 let Some(stats) = datum_stats(d) else {
858 continue;
859 };
860 let secs = d.timestamp.timestamp();
861 let bucket_secs = secs - secs.rem_euclid(period);
862 let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
863 match buckets.get_mut(&bucket_ts) {
864 Some(bucket) => {
865 merge_stats(&mut bucket.agg, stats);
866 if bucket.unit != d.unit {
867 bucket.unit = None;
868 }
869 if let Some(v) = d.value {
870 bucket.samples.push(v);
871 }
872 }
873 None => {
874 buckets.insert(
875 bucket_ts,
876 MetricBucket {
877 agg: stats,
878 samples: d.value.map(|v| vec![v]).unwrap_or_default(),
879 unit: d.unit.clone(),
880 },
881 );
882 }
883 }
884 }
885 buckets
886}
887
888pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
889 let mut s = String::from("<Dimensions>");
890 for (name, value) in dims.iter() {
891 s.push_str(&format!(
892 "<member><Name>{}</Name><Value>{}</Value></member>",
893 xml_escape(name),
894 xml_escape(value),
895 ));
896 }
897 s.push_str("</Dimensions>");
898 s
899}
900
901impl CloudWatchService {
902 fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
903 let namespace = required_query_param(req, "Namespace")?;
904 let members = collect_indexed(req, "MetricData");
905 if members.is_empty() {
906 return Err(invalid_param(
907 "PutMetricData requires at least one MetricData entry",
908 ));
909 }
910
911 let now = Utc::now();
912 let mut state = self.state.write();
913 let acct = state.get_or_create(&req.account_id);
914 let metrics_map = acct.metrics_in_mut(&req.region);
915 let bucket = metrics_map.entry(namespace.clone()).or_default();
916
917 for member in members {
918 let metric_name = member
919 .get("MetricName")
920 .cloned()
921 .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
922 let value = member
923 .get("Value")
924 .map(|s| s.parse::<f64>())
925 .transpose()
926 .map_err(|_| invalid_param("Value must be a valid number"))?;
927 let timestamp = member
928 .get("Timestamp")
929 .and_then(|s| parse_input_timestamp(s))
930 .unwrap_or(now);
931 let unit = member.get("Unit").cloned();
932 let storage_resolution = member
933 .get("StorageResolution")
934 .and_then(|s| s.parse::<i64>().ok());
935 let dimensions = parse_dimensions(&member, "Dimensions");
936
937 let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
938 member.get("StatisticValues.SampleCount"),
939 member.get("StatisticValues.Sum"),
940 member.get("StatisticValues.Minimum"),
941 member.get("StatisticValues.Maximum"),
942 ) {
943 Some(StatisticSet {
944 sample_count: sc.parse::<f64>().map_err(|_| {
945 invalid_param("StatisticValues.SampleCount must be a number")
946 })?,
947 sum: sum
948 .parse::<f64>()
949 .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
950 minimum: min
951 .parse::<f64>()
952 .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
953 maximum: max
954 .parse::<f64>()
955 .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
956 })
957 } else {
958 None
959 };
960
961 let statistic_values = match statistic_values {
965 Some(s) => Some(s),
966 None => values_counts_statistic(&member)?,
967 };
968
969 if value.is_none() && statistic_values.is_none() {
970 return Err(invalid_param(
971 "MetricData entry must supply either Value, StatisticValues, or Values",
972 ));
973 }
974
975 bucket.push(MetricDatum {
976 metric_name,
977 dimensions,
978 timestamp,
979 value,
980 statistic_values,
981 unit,
982 storage_resolution,
983 });
984 }
985
986 Ok(empty_metadata_response("PutMetricData", &req.request_id))
987 }
988
989 fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
990 validate_len(req, "Namespace", 1, 255)?;
991 validate_len(req, "MetricName", 1, 255)?;
992 validate_len(req, "OwningAccount", 1, 255)?;
993 validate_enum(req, "RecentlyActive", &["PT3H"])?;
994 let namespace = optional_query_param(req, "Namespace");
995 let metric_name = optional_query_param(req, "MetricName");
996 let dim_filter = parse_dimension_filters(req, "Dimensions");
997 const LIST_METRICS_PAGE: usize = 500;
1000 let offset = decode_offset_token(req.query_params.get("NextToken"));
1001
1002 let state = self.state.read();
1003 let mut all: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
1006 if let Some(acct) = state.get(&req.account_id) {
1007 if let Some(map) = acct.metrics_in(&req.region) {
1008 for (ns, data) in map.iter() {
1009 if let Some(filter_ns) = namespace.as_ref() {
1010 if ns != filter_ns {
1011 continue;
1012 }
1013 }
1014 let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
1015 BTreeMap::new();
1016 for d in data.iter() {
1017 if let Some(filter_name) = metric_name.as_ref() {
1018 if &d.metric_name != filter_name {
1019 continue;
1020 }
1021 }
1022 if !dim_filter.is_empty()
1027 && !dim_filter.iter().all(|(k, v)| match v {
1028 Some(val) => d.dimensions.get(k) == Some(val),
1029 None => d.dimensions.contains_key(k),
1030 })
1031 {
1032 continue;
1033 }
1034 seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
1035 }
1036 for ((name, dims), _) in seen {
1037 all.push((ns.clone(), name, dims));
1038 }
1039 }
1040 }
1041 }
1042
1043 let page = all.iter().skip(offset).take(LIST_METRICS_PAGE);
1044 let mut out = String::from("<Metrics>");
1045 {
1046 for (ns, name, dims) in page {
1047 out.push_str("<member>");
1048 out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
1049 out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(name)));
1050 out.push_str(&render_dimensions(dims));
1051 out.push_str("</member>");
1052 }
1053 }
1054 out.push_str("</Metrics>");
1055 if offset + LIST_METRICS_PAGE < all.len() {
1056 out.push_str(&format!(
1057 "<NextToken>{}</NextToken>",
1058 encode_offset_token(offset + LIST_METRICS_PAGE)
1059 ));
1060 }
1061
1062 Ok(xml_response("ListMetrics", &out, &req.request_id))
1063 }
1064
1065 fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1066 let namespace = required_query_param(req, "Namespace")?;
1067 let metric_name = required_query_param(req, "MetricName")?;
1068 let start = required_query_param(req, "StartTime")?;
1069 let end = required_query_param(req, "EndTime")?;
1070 let period = required_query_param(req, "Period")?
1071 .parse::<i64>()
1072 .map_err(|_| invalid_param("Period must be an integer"))?;
1073 if period <= 0 {
1074 return Err(invalid_param("Period must be positive"));
1075 }
1076 let start_ts = parse_input_timestamp(&start)
1077 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1078 let end_ts = parse_input_timestamp(&end)
1079 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1080
1081 let mut statistics: Vec<String> = Vec::new();
1082 let mut extended_statistics: Vec<String> = Vec::new();
1083 for (k, v) in req.query_params.iter() {
1084 if k.starts_with("Statistics.member.") {
1085 statistics.push(v.clone());
1086 } else if k.starts_with("ExtendedStatistics.member.") {
1087 extended_statistics.push(v.clone());
1088 }
1089 }
1090 if statistics.is_empty() && extended_statistics.is_empty() {
1091 return Err(invalid_param(
1092 "At least one of Statistics or ExtendedStatistics is required",
1093 ));
1094 }
1095
1096 let dim_filter = parse_dimensions_query(req, "Dimensions");
1097 let unit_filter = req.query_params.get("Unit").cloned();
1101
1102 let state = self.state.read();
1103 type StatPoint = (
1105 DateTime<Utc>,
1106 BTreeMap<String, f64>,
1107 Vec<(String, f64)>,
1108 Option<String>,
1109 );
1110 let mut datapoints: Vec<StatPoint> = Vec::new();
1111 if let Some(acct) = state.get(&req.account_id) {
1112 if let Some(map) = acct.metrics_in(&req.region) {
1113 if let Some(data) = map.get(&namespace) {
1114 let buckets = collect_metric_buckets(
1115 data,
1116 &metric_name,
1117 &dim_filter,
1118 unit_filter.as_deref(),
1119 period,
1120 start_ts,
1121 end_ts,
1122 );
1123 for (ts, bucket) in buckets {
1124 let mut sorted = bucket.samples.clone();
1125 sorted
1126 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1127 let mut simple = BTreeMap::new();
1128 for stat in statistics.iter() {
1129 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1130 simple.insert(stat.clone(), v);
1131 }
1132 }
1133 let mut extended = Vec::new();
1134 for stat in extended_statistics.iter() {
1135 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1136 extended.push((stat.clone(), v));
1137 }
1138 }
1139 let unit = unit_filter.clone().or(bucket.unit);
1140 datapoints.push((ts, simple, extended, unit));
1141 }
1142 }
1143 }
1144 }
1145
1146 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1147 inner.push_str("<Datapoints>");
1148 for (ts, simple, extended, unit) in datapoints {
1149 inner.push_str("<member>");
1150 inner.push_str(&format!(
1151 "<Timestamp>{}</Timestamp>",
1152 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1153 ));
1154 for (name, value) in simple {
1155 inner.push_str(&format!("<{name}>{value}</{name}>"));
1156 }
1157 if !extended.is_empty() {
1158 inner.push_str("<ExtendedStatistics>");
1159 for (name, value) in extended {
1160 inner.push_str(&format!(
1161 "<entry><key>{}</key><value>{}</value></entry>",
1162 xml_escape(&name),
1163 value
1164 ));
1165 }
1166 inner.push_str("</ExtendedStatistics>");
1167 }
1168 if let Some(u) = unit {
1169 inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1170 }
1171 inner.push_str("</member>");
1172 }
1173 inner.push_str("</Datapoints>");
1174
1175 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1176 }
1177
1178 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1179 validate_enum(
1180 req,
1181 "ScanBy",
1182 &["TimestampDescending", "TimestampAscending"],
1183 )?;
1184 let start = required_query_param(req, "StartTime")?;
1185 let end = required_query_param(req, "EndTime")?;
1186 let start_ts = parse_input_timestamp(&start)
1187 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1188 let end_ts = parse_input_timestamp(&end)
1189 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1190
1191 let descending = req
1195 .query_params
1196 .get("ScanBy")
1197 .map(|s| s != "TimestampAscending")
1198 .unwrap_or(true);
1199
1200 let queries = collect_indexed(req, "MetricDataQueries");
1203
1204 let state = self.state.read();
1205
1206 let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1209 for q in &queries {
1210 let id = q.get("Id").cloned().unwrap_or_default();
1211 let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1212 continue;
1213 };
1214 let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1215 continue;
1216 };
1217 let stat = q
1218 .get("MetricStat.Stat")
1219 .cloned()
1220 .unwrap_or_else(|| "Sum".to_string());
1221 let period: i64 = q
1222 .get("MetricStat.Period")
1223 .and_then(|s| s.parse::<i64>().ok())
1224 .filter(|p| *p > 0)
1225 .unwrap_or(60);
1226 let unit_filter = q.get("MetricStat.Unit").cloned();
1227 let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1228
1229 let mut series = crate::metric_math::Series::new();
1230 if let Some(acct) = state.get(&req.account_id) {
1231 if let Some(map) = acct.metrics_in(&req.region) {
1232 if let Some(data) = map.get(namespace) {
1233 let buckets = collect_metric_buckets(
1234 data,
1235 metric_name,
1236 &dim_filter,
1237 unit_filter.as_deref(),
1238 period,
1239 start_ts,
1240 end_ts,
1241 );
1242 for (ts, bucket) in buckets {
1243 let mut sorted = bucket.samples.clone();
1244 sorted.sort_by(|a, b| {
1245 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1246 });
1247 if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1248 series.insert(ts, v);
1249 }
1250 }
1251 }
1252 }
1253 }
1254 series_by_id.insert(id, series);
1255 }
1256
1257 let mut inner = String::from("<MetricDataResults>");
1260 for q in &queries {
1261 let id = q.get("Id").cloned().unwrap_or_default();
1262 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1263 let return_data = q
1264 .get("ReturnData")
1265 .map(|s| !s.eq_ignore_ascii_case("false"))
1266 .unwrap_or(true);
1267 if !return_data {
1268 continue;
1269 }
1270
1271 let mut error_message: Option<String> = None;
1272 let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1273 match crate::metric_math::evaluate(expr, &series_by_id) {
1274 Ok(s) => s,
1275 Err(e) => {
1276 error_message = Some(e);
1277 crate::metric_math::Series::new()
1278 }
1279 }
1280 } else {
1281 series_by_id.get(&id).cloned().unwrap_or_default()
1282 };
1283
1284 let mut timestamps: Vec<String> = Vec::new();
1285 let mut values: Vec<f64> = Vec::new();
1286 for (ts, v) in series.iter() {
1287 timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1288 values.push(*v);
1289 }
1290 if descending {
1291 timestamps.reverse();
1292 values.reverse();
1293 }
1294
1295 inner.push_str("<member>");
1296 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1297 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1298 inner.push_str("<Timestamps>");
1299 for ts in ×tamps {
1300 inner.push_str(&format!("<member>{ts}</member>"));
1301 }
1302 inner.push_str("</Timestamps>");
1303 inner.push_str("<Values>");
1304 for v in &values {
1305 inner.push_str(&format!("<member>{v}</member>"));
1306 }
1307 inner.push_str("</Values>");
1308 if let Some(msg) = error_message {
1309 inner.push_str("<StatusCode>InternalError</StatusCode>");
1310 inner.push_str("<Messages><member>");
1311 inner.push_str("<Code>Error</Code>");
1312 inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1313 inner.push_str("</member></Messages>");
1314 } else {
1315 inner.push_str("<StatusCode>Complete</StatusCode>");
1316 }
1317 inner.push_str("</member>");
1318 }
1319 inner.push_str("</MetricDataResults>");
1320 inner.push_str("<Messages></Messages>");
1321
1322 Ok(xml_response("GetMetricData", &inner, &req.request_id))
1323 }
1324
1325 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1326 validate_len(req, "AlarmName", 1, 255)?;
1332 validate_len(req, "AlarmDescription", 0, 1024)?;
1333 validate_len(req, "MetricName", 1, 255)?;
1334 validate_len(req, "Namespace", 1, 255)?;
1335 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1336 validate_len(req, "TreatMissingData", 1, 255)?;
1337 validate_len(req, "ThresholdMetricId", 1, 255)?;
1338 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1339 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1340 validate_range_i64(req, "Period", 1, i64::MAX)?;
1341 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1342 validate_enum(
1343 req,
1344 "ComparisonOperator",
1345 &[
1346 "GreaterThanOrEqualToThreshold",
1347 "GreaterThanThreshold",
1348 "GreaterThanUpperThreshold",
1349 "LessThanLowerOrGreaterThanUpperThreshold",
1350 "LessThanLowerThreshold",
1351 "LessThanOrEqualToThreshold",
1352 "LessThanThreshold",
1353 ],
1354 )?;
1355 validate_enum(
1356 req,
1357 "Statistic",
1358 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1359 )?;
1360 validate_enum(req, "Unit", STANDARD_UNITS)?;
1361 let alarm_name = required_query_param(req, "AlarmName")?;
1362 let comparison = optional_query_param(req, "ComparisonOperator")
1363 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1364 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1365 .and_then(|s| s.parse::<i64>().ok())
1366 .unwrap_or(1);
1367
1368 let alarm_description = optional_query_param(req, "AlarmDescription");
1369 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1370 .map(|s| s.eq_ignore_ascii_case("true"))
1371 .unwrap_or(true);
1372
1373 let metric_name = optional_query_param(req, "MetricName");
1374 let namespace = optional_query_param(req, "Namespace");
1375 let statistic = optional_query_param(req, "Statistic");
1376 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1377 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1378 let unit = optional_query_param(req, "Unit");
1379 let datapoints_to_alarm =
1380 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1381 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1382 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1383 let evaluate_low_sample_count_percentile =
1384 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1385 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1388 let dimensions = parse_dimensions_query(req, "Dimensions");
1389 let metrics = parse_alarm_metrics(req);
1393 let inline_tags = parse_tags(req, "Tags");
1396
1397 let mut ok_actions = Vec::new();
1398 let mut alarm_actions = Vec::new();
1399 let mut insufficient_data_actions = Vec::new();
1400 for (k, v) in req.query_params.iter() {
1401 if k.starts_with("OKActions.member.") {
1402 ok_actions.push(v.clone());
1403 } else if k.starts_with("AlarmActions.member.") {
1404 alarm_actions.push(v.clone());
1405 } else if k.starts_with("InsufficientDataActions.member.") {
1406 insufficient_data_actions.push(v.clone());
1407 }
1408 }
1409
1410 let arn = format!(
1411 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1412 req.region, req.account_id, alarm_name
1413 );
1414 let now = Utc::now();
1415
1416 let mut state = self.state.write();
1417 let acct = state.get_or_create(&req.account_id);
1418 let alarms = acct.alarms_in_mut(&req.region);
1419 let existing = alarms.get(&alarm_name).cloned();
1420 let alarm = MetricAlarm {
1421 alarm_name: alarm_name.clone(),
1422 alarm_arn: arn,
1423 alarm_description,
1424 actions_enabled,
1425 ok_actions,
1426 alarm_actions,
1427 insufficient_data_actions,
1428 state_value: existing
1429 .as_ref()
1430 .map(|a| a.state_value)
1431 .unwrap_or(AlarmState::InsufficientData),
1432 state_reason: existing
1433 .as_ref()
1434 .map(|a| a.state_reason.clone())
1435 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1436 state_updated_timestamp: existing
1437 .as_ref()
1438 .map(|a| a.state_updated_timestamp)
1439 .unwrap_or(now),
1440 metric_name,
1441 namespace,
1442 statistic,
1443 extended_statistic,
1444 dimensions,
1445 period,
1446 unit,
1447 evaluation_periods,
1448 datapoints_to_alarm,
1449 threshold,
1450 comparison_operator: comparison,
1451 treat_missing_data,
1452 evaluate_low_sample_count_percentile,
1453 threshold_metric_id,
1454 configuration_updated_timestamp: existing
1455 .as_ref()
1456 .map(|a| a.configuration_updated_timestamp)
1457 .unwrap_or(now),
1458 alarm_configuration_updated_timestamp: now,
1459 metrics,
1460 };
1461 let alarm_arn = alarm.alarm_arn.clone();
1462 let history_name = alarm_name.clone();
1463 let created = existing.is_none();
1464 alarms.insert(alarm_name, alarm);
1465
1466 if created && !inline_tags.is_empty() {
1471 let bucket = acct.tags.entry(alarm_arn).or_default();
1472 for (k, v) in inline_tags {
1473 bucket.insert(k, v);
1474 }
1475 }
1476
1477 let summary = if created {
1478 format!("Alarm \"{history_name}\" created")
1479 } else {
1480 format!("Alarm \"{history_name}\" updated")
1481 };
1482 let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1483 push_alarm_history(
1484 acct,
1485 &req.region,
1486 &history_name,
1487 "MetricAlarm",
1488 "ConfigurationUpdate",
1489 summary,
1490 history_data,
1491 );
1492
1493 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1494 }
1495
1496 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1497 let mut filter_names: Vec<String> = Vec::new();
1498 for (k, v) in req.query_params.iter() {
1499 if k.starts_with("AlarmNames.member.") {
1500 filter_names.push(v.clone());
1501 }
1502 }
1503 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1504 validate_len(req, "ActionPrefix", 1, 1024)?;
1505 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1506 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1507 validate_range_i64(req, "MaxRecords", 1, 100)?;
1508 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1509 let prefix = optional_query_param(req, "AlarmNamePrefix");
1510 let state_filter = optional_query_param(req, "StateValue");
1511 let action_prefix = optional_query_param(req, "ActionPrefix");
1512 let max_records = optional_query_param(req, "MaxRecords")
1516 .and_then(|s| s.parse::<usize>().ok())
1517 .filter(|n| *n > 0)
1518 .unwrap_or(100);
1519 let offset = decode_offset_token(req.query_params.get("NextToken"));
1520
1521 let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1524 if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1525 return false;
1526 }
1527 if let Some(p) = prefix.as_ref() {
1528 if !name.starts_with(p) {
1529 return false;
1530 }
1531 }
1532 if let Some(want) = state_filter.as_ref() {
1533 if sv != want {
1534 return false;
1535 }
1536 }
1537 if let Some(ap) = action_prefix.as_ref() {
1538 let any = actions
1539 .iter()
1540 .flat_map(|a| a.iter())
1541 .any(|a| a.starts_with(ap));
1542 if !any {
1543 return false;
1544 }
1545 }
1546 true
1547 };
1548
1549 let mut state = self.state.write();
1553 if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1554 crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1555 }
1556 let mut combined: Vec<(bool, String)> = Vec::new();
1557 if let Some(acct) = state.get(&req.account_id) {
1558 if let Some(alarms) = acct.alarms_in(&req.region) {
1559 for alarm in alarms.values() {
1560 if matches(
1561 &alarm.alarm_name,
1562 alarm.state_value.as_str(),
1563 [
1564 &alarm.alarm_actions,
1565 &alarm.ok_actions,
1566 &alarm.insufficient_data_actions,
1567 ],
1568 ) {
1569 combined.push((false, render_alarm(alarm)));
1570 }
1571 }
1572 }
1573 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1574 for alarm in composites.values() {
1575 if matches(
1576 &alarm.alarm_name,
1577 alarm.state_value.as_str(),
1578 [
1579 &alarm.alarm_actions,
1580 &alarm.ok_actions,
1581 &alarm.insufficient_data_actions,
1582 ],
1583 ) {
1584 combined
1585 .push((true, crate::composite_alarms::render_composite_alarm(alarm)));
1586 }
1587 }
1588 }
1589 }
1590
1591 let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1592 let mut inner = String::from("<MetricAlarms>");
1593 for (is_composite, body) in &page {
1594 if !*is_composite {
1595 inner.push_str(body);
1596 }
1597 }
1598 inner.push_str("</MetricAlarms>");
1599 inner.push_str("<CompositeAlarms>");
1600 for (is_composite, body) in &page {
1601 if *is_composite {
1602 inner.push_str(body);
1603 }
1604 }
1605 inner.push_str("</CompositeAlarms>");
1606 if offset + max_records < combined.len() {
1607 inner.push_str(&format!(
1608 "<NextToken>{}</NextToken>",
1609 encode_offset_token(offset + max_records)
1610 ));
1611 }
1612
1613 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1614 }
1615
1616 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1617 validate_len(req, "MetricName", 1, 255)?;
1618 validate_len(req, "Namespace", 1, 255)?;
1619 validate_range_i64(req, "Period", 1, i64::MAX)?;
1620 validate_enum(
1621 req,
1622 "Statistic",
1623 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1624 )?;
1625 validate_enum(req, "Unit", STANDARD_UNITS)?;
1626 let metric_name = required_query_param(req, "MetricName")?;
1627 let namespace = required_query_param(req, "Namespace")?;
1628 let dim_filter = parse_dimensions_query(req, "Dimensions");
1629
1630 let state = self.state.read();
1631 let mut inner = String::from("<MetricAlarms>");
1632 if let Some(acct) = state.get(&req.account_id) {
1633 if let Some(alarms) = acct.alarms_in(&req.region) {
1634 for alarm in alarms.values() {
1635 if alarm.metric_name.as_deref() != Some(&metric_name) {
1636 continue;
1637 }
1638 if alarm.namespace.as_deref() != Some(&namespace) {
1639 continue;
1640 }
1641 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1642 continue;
1643 }
1644 inner.push_str(&render_alarm(alarm));
1645 }
1646 }
1647 }
1648 inner.push_str("</MetricAlarms>");
1649
1650 Ok(xml_response(
1651 "DescribeAlarmsForMetric",
1652 &inner,
1653 &req.request_id,
1654 ))
1655 }
1656
1657 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1658 let mut names: Vec<String> = Vec::new();
1662 for (k, v) in req.query_params.iter() {
1663 if k.starts_with("AlarmNames.member.") {
1664 names.push(v.clone());
1665 }
1666 }
1667
1668 let mut state = self.state.write();
1669 let acct = state.get_or_create(&req.account_id);
1670 for name in &names {
1671 acct.alarms_in_mut(&req.region).remove(name);
1672 acct.composite_alarms_in_mut(&req.region).remove(name);
1673 acct.alarm_history_in_mut(&req.region).remove(name);
1676 }
1677
1678 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1679 }
1680
1681 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1682 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1683 }
1684
1685 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1686 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1687 }
1688
1689 fn toggle_alarm_actions(
1690 &self,
1691 req: &AwsRequest,
1692 enabled: bool,
1693 action_name: &str,
1694 ) -> Result<AwsResponse, AwsServiceError> {
1695 let mut names: Vec<String> = Vec::new();
1696 for (k, v) in req.query_params.iter() {
1697 if k.starts_with("AlarmNames.member.") {
1698 names.push(v.clone());
1699 }
1700 }
1701 let mut state = self.state.write();
1702 let acct = state.get_or_create(&req.account_id);
1703 let alarms = acct.alarms_in_mut(&req.region);
1704 for name in names {
1705 if let Some(alarm) = alarms.get_mut(&name) {
1706 alarm.actions_enabled = enabled;
1707 alarm.alarm_configuration_updated_timestamp = Utc::now();
1708 }
1709 }
1710 Ok(empty_metadata_response(action_name, &req.request_id))
1711 }
1712
1713 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1714 validate_len(req, "AlarmName", 1, 255)?;
1715 validate_len(req, "StateReason", 0, 1023)?;
1716 validate_len(req, "StateReasonData", 0, 4000)?;
1717 let alarm_name = required_query_param(req, "AlarmName")?;
1718 let state_value = required_query_param(req, "StateValue")?;
1719 let state_reason = req
1723 .query_params
1724 .get("StateReason")
1725 .cloned()
1726 .ok_or_else(|| {
1727 AwsServiceError::aws_error(
1728 StatusCode::BAD_REQUEST,
1729 "MissingParameter",
1730 "The request must contain the parameter StateReason.",
1731 )
1732 })?;
1733 let new_state = AlarmState::parse(&state_value)
1734 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1735
1736 let now = Utc::now();
1737 let mut state = self.state.write();
1738 let acct = state.get_or_create(&req.account_id);
1739 let (old_state, alarm_type) =
1742 if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1743 let old = alarm.state_value.as_str().to_string();
1744 alarm.state_value = new_state;
1745 alarm.state_reason = state_reason.clone();
1746 alarm.state_updated_timestamp = now;
1747 (old, "MetricAlarm")
1748 } else if let Some(composite) = acct
1749 .composite_alarms_in_mut(&req.region)
1750 .get_mut(&alarm_name)
1751 {
1752 let old = composite.state_value.as_str().to_string();
1753 composite.state_value = new_state;
1754 composite.state_reason = state_reason.clone();
1755 composite.state_updated_timestamp = now;
1756 (old, "CompositeAlarm")
1757 } else {
1758 return Err(AwsServiceError::aws_error(
1759 StatusCode::NOT_FOUND,
1760 "ResourceNotFound",
1761 format!("Alarm {alarm_name} not found"),
1762 ));
1763 };
1764
1765 let new_state_str = new_state.as_str().to_string();
1766 let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1767 let history_data = format!(
1768 "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1769 state_reason.replace('"', "\\\"")
1770 );
1771 push_alarm_history(
1772 acct,
1773 &req.region,
1774 &alarm_name,
1775 alarm_type,
1776 "StateUpdate",
1777 summary,
1778 history_data,
1779 );
1780
1781 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1782 }
1783
1784 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1785 validate_len(req, "AlarmName", 1, 255)?;
1786 validate_len(req, "AlarmContributorId", 1, 16)?;
1787 validate_range_i64(req, "MaxRecords", 1, 100)?;
1788 validate_enum(
1789 req,
1790 "HistoryItemType",
1791 &[
1792 "ConfigurationUpdate",
1793 "StateUpdate",
1794 "Action",
1795 "AlarmContributorStateUpdate",
1796 "AlarmContributorAction",
1797 ],
1798 )?;
1799 validate_enum(
1800 req,
1801 "ScanBy",
1802 &["TimestampDescending", "TimestampAscending"],
1803 )?;
1804 let alarm_filter = optional_query_param(req, "AlarmName");
1805 let type_filter = optional_query_param(req, "HistoryItemType");
1806 let start_date =
1807 optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1808 let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1809 let descending = req
1811 .query_params
1812 .get("ScanBy")
1813 .map(|s| s != "TimestampAscending")
1814 .unwrap_or(true);
1815 let max_records = optional_query_param(req, "MaxRecords")
1816 .and_then(|s| s.parse::<usize>().ok())
1817 .filter(|n| *n > 0)
1818 .unwrap_or(100);
1819 let offset = decode_offset_token(req.query_params.get("NextToken"));
1820
1821 let state = self.state.read();
1822 let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1823 if let Some(acct) = state.get(&req.account_id) {
1824 if let Some(history) = acct.alarm_history_in(&req.region) {
1825 for (name, list) in history.iter() {
1826 if let Some(f) = alarm_filter.as_ref() {
1827 if name != f {
1828 continue;
1829 }
1830 }
1831 for item in list.iter() {
1832 if let Some(t) = type_filter.as_ref() {
1833 if &item.history_item_type != t {
1834 continue;
1835 }
1836 }
1837 if let Some(sd) = start_date {
1838 if item.timestamp < sd {
1839 continue;
1840 }
1841 }
1842 if let Some(ed) = end_date {
1843 if item.timestamp > ed {
1844 continue;
1845 }
1846 }
1847 items.push(item);
1848 }
1849 }
1850 }
1851 }
1852 items.sort_by_key(|i| i.timestamp);
1853 if descending {
1854 items.reverse();
1855 }
1856 let total = items.len();
1857 let page: Vec<&AlarmHistoryItem> =
1858 items.into_iter().skip(offset).take(max_records).collect();
1859
1860 let mut inner = String::from("<AlarmHistoryItems>");
1861 for item in page {
1862 inner.push_str("<member>");
1863 inner.push_str(&format!(
1864 "<AlarmName>{}</AlarmName>",
1865 xml_escape(&item.alarm_name)
1866 ));
1867 inner.push_str(&format!(
1868 "<AlarmType>{}</AlarmType>",
1869 xml_escape(&item.alarm_type)
1870 ));
1871 inner.push_str(&format!(
1872 "<Timestamp>{}</Timestamp>",
1873 item.timestamp
1874 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1875 ));
1876 inner.push_str(&format!(
1877 "<HistoryItemType>{}</HistoryItemType>",
1878 xml_escape(&item.history_item_type)
1879 ));
1880 inner.push_str(&format!(
1881 "<HistorySummary>{}</HistorySummary>",
1882 xml_escape(&item.history_summary)
1883 ));
1884 inner.push_str(&format!(
1885 "<HistoryData>{}</HistoryData>",
1886 xml_escape(&item.history_data)
1887 ));
1888 inner.push_str("</member>");
1889 }
1890 inner.push_str("</AlarmHistoryItems>");
1891 if offset + max_records < total {
1892 inner.push_str(&format!(
1893 "<NextToken>{}</NextToken>",
1894 encode_offset_token(offset + max_records)
1895 ));
1896 }
1897 Ok(xml_response(
1898 "DescribeAlarmHistory",
1899 &inner,
1900 &req.request_id,
1901 ))
1902 }
1903
1904 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1905 let dashboard_name = req
1906 .query_params
1907 .get("DashboardName")
1908 .ok_or_else(|| invalid_param("DashboardName is required"))?
1909 .clone();
1910 let body = req
1911 .query_params
1912 .get("DashboardBody")
1913 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1914 .clone();
1915 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1918 return Err(AwsServiceError::aws_error(
1919 StatusCode::BAD_REQUEST,
1920 "InvalidParameterInput",
1921 "DashboardBody must be a valid JSON object",
1922 ));
1923 }
1924 let arn = format!(
1925 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1926 req.account_id
1927 );
1928 let dashboard = Dashboard {
1929 name: dashboard_name.clone(),
1930 arn,
1931 size_bytes: body.len() as i64,
1932 body,
1933 last_modified: Utc::now(),
1934 };
1935 let mut state = self.state.write();
1936 let acct = state.get_or_create(&req.account_id);
1937 acct.dashboards.insert(dashboard_name, dashboard);
1938 let inner = String::from("<DashboardValidationMessages/>");
1941 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1942 }
1943
1944 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1945 let name = req
1946 .query_params
1947 .get("DashboardName")
1948 .ok_or_else(|| invalid_param("DashboardName is required"))?
1949 .clone();
1950 let state = self.state.read();
1951 let dashboard = state
1952 .get(&req.account_id)
1953 .and_then(|a| a.dashboards.get(&name))
1954 .cloned()
1955 .ok_or_else(|| {
1956 AwsServiceError::aws_error(
1957 StatusCode::NOT_FOUND,
1958 "ResourceNotFound",
1959 format!("Dashboard {name} does not exist"),
1960 )
1961 })?;
1962 let inner = format!(
1963 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1964 xml_escape(&dashboard.arn),
1965 xml_escape(&dashboard.body),
1966 xml_escape(&dashboard.name),
1967 );
1968 Ok(xml_response("GetDashboard", &inner, &req.request_id))
1969 }
1970
1971 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1972 let mut names: Vec<String> = Vec::new();
1973 for (k, v) in req.query_params.iter() {
1974 if k.starts_with("DashboardNames.member.") {
1975 names.push(v.clone());
1976 }
1977 }
1978 if names.is_empty() {
1979 return Err(invalid_param(
1980 "DashboardNames must contain at least one name",
1981 ));
1982 }
1983 let mut state = self.state.write();
1984 let acct = state.get_or_create(&req.account_id);
1985 for n in names {
1986 acct.dashboards.remove(&n);
1987 }
1988 let body = format!(
1992 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1993 <DeleteDashboardsResponse xmlns=\"{NS}\">\
1994 <DeleteDashboardsResult/>\
1995 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1996 </DeleteDashboardsResponse>",
1997 req.request_id
1998 );
1999 Ok(AwsResponse::xml(StatusCode::OK, body))
2000 }
2001
2002 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2003 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
2004 let state = self.state.read();
2005 let dashboards: Vec<Dashboard> = state
2006 .get(&req.account_id)
2007 .map(|a| {
2008 a.dashboards
2009 .values()
2010 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
2011 .cloned()
2012 .collect()
2013 })
2014 .unwrap_or_default();
2015 let mut entries = String::new();
2016 for d in &dashboards {
2017 entries.push_str("<member>");
2018 entries.push_str(&format!(
2019 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
2020 xml_escape(&d.arn),
2021 xml_escape(&d.name),
2022 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2023 d.size_bytes,
2024 ));
2025 entries.push_str("</member>");
2026 }
2027 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
2028 Ok(xml_response("ListDashboards", &inner, &req.request_id))
2029 }
2030}
2031
2032pub(crate) fn push_alarm_history(
2036 acct: &mut crate::state::CloudWatchState,
2037 region: &str,
2038 alarm_name: &str,
2039 alarm_type: &str,
2040 history_item_type: &str,
2041 history_summary: String,
2042 history_data: String,
2043) {
2044 acct.alarm_history_in_mut(region)
2045 .entry(alarm_name.to_string())
2046 .or_default()
2047 .push(AlarmHistoryItem {
2048 alarm_name: alarm_name.to_string(),
2049 alarm_type: alarm_type.to_string(),
2050 timestamp: Utc::now(),
2051 history_item_type: history_item_type.to_string(),
2052 history_summary,
2053 history_data,
2054 });
2055}
2056
2057fn render_alarm(alarm: &MetricAlarm) -> String {
2058 let mut s = String::from("<member>");
2059 s.push_str(&format!(
2060 "<AlarmName>{}</AlarmName>",
2061 xml_escape(&alarm.alarm_name)
2062 ));
2063 s.push_str(&format!(
2064 "<AlarmArn>{}</AlarmArn>",
2065 xml_escape(&alarm.alarm_arn)
2066 ));
2067 if let Some(d) = &alarm.alarm_description {
2068 s.push_str(&format!(
2069 "<AlarmDescription>{}</AlarmDescription>",
2070 xml_escape(d)
2071 ));
2072 }
2073 s.push_str(&format!(
2074 "<ActionsEnabled>{}</ActionsEnabled>",
2075 alarm.actions_enabled
2076 ));
2077 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2078 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2079 push_action_list(
2080 &mut s,
2081 "InsufficientDataActions",
2082 &alarm.insufficient_data_actions,
2083 );
2084 s.push_str(&format!(
2085 "<StateValue>{}</StateValue>",
2086 alarm.state_value.as_str()
2087 ));
2088 s.push_str(&format!(
2089 "<StateReason>{}</StateReason>",
2090 xml_escape(&alarm.state_reason)
2091 ));
2092 s.push_str(&format!(
2093 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2094 alarm
2095 .state_updated_timestamp
2096 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2097 ));
2098 if let Some(m) = &alarm.metric_name {
2099 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2100 }
2101 if let Some(n) = &alarm.namespace {
2102 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2103 }
2104 if let Some(stat) = &alarm.statistic {
2105 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2106 }
2107 if let Some(ext) = &alarm.extended_statistic {
2108 s.push_str(&format!(
2109 "<ExtendedStatistic>{}</ExtendedStatistic>",
2110 xml_escape(ext)
2111 ));
2112 }
2113 s.push_str(&render_dimensions(&alarm.dimensions));
2114 if let Some(p) = alarm.period {
2115 s.push_str(&format!("<Period>{p}</Period>"));
2116 }
2117 if let Some(u) = &alarm.unit {
2118 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2119 }
2120 s.push_str(&format!(
2121 "<EvaluationPeriods>{}</EvaluationPeriods>",
2122 alarm.evaluation_periods
2123 ));
2124 if let Some(d) = alarm.datapoints_to_alarm {
2125 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2126 }
2127 if let Some(t) = alarm.threshold {
2128 s.push_str(&format!("<Threshold>{t}</Threshold>"));
2129 }
2130 if let Some(tid) = &alarm.threshold_metric_id {
2131 s.push_str(&format!(
2132 "<ThresholdMetricId>{}</ThresholdMetricId>",
2133 xml_escape(tid)
2134 ));
2135 }
2136 s.push_str(&format!(
2137 "<ComparisonOperator>{}</ComparisonOperator>",
2138 xml_escape(&alarm.comparison_operator)
2139 ));
2140 if let Some(t) = &alarm.treat_missing_data {
2141 s.push_str(&format!(
2142 "<TreatMissingData>{}</TreatMissingData>",
2143 xml_escape(t)
2144 ));
2145 }
2146 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2147 s.push_str(&format!(
2148 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2149 xml_escape(e)
2150 ));
2151 }
2152 s.push_str(&format!(
2153 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2154 alarm
2155 .alarm_configuration_updated_timestamp
2156 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2157 ));
2158 render_alarm_metrics(&mut s, &alarm.metrics);
2159 s.push_str("</member>");
2160 s
2161}
2162
2163fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2165 if metrics.is_empty() {
2166 return;
2167 }
2168 s.push_str("<Metrics>");
2169 for q in metrics {
2170 s.push_str("<member>");
2171 s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2172 if let Some(stat) = &q.metric_stat {
2173 s.push_str("<MetricStat>");
2174 s.push_str("<Metric>");
2175 if let Some(ns) = &stat.namespace {
2176 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2177 }
2178 if let Some(mn) = &stat.metric_name {
2179 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2180 }
2181 s.push_str(&render_dimensions(&stat.dimensions));
2182 s.push_str("</Metric>");
2183 if let Some(p) = stat.period {
2184 s.push_str(&format!("<Period>{p}</Period>"));
2185 }
2186 if let Some(st) = &stat.stat {
2187 s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2188 }
2189 if let Some(u) = &stat.unit {
2190 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2191 }
2192 s.push_str("</MetricStat>");
2193 }
2194 if let Some(e) = &q.expression {
2195 s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2196 }
2197 if let Some(l) = &q.label {
2198 s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2199 }
2200 if let Some(rd) = q.return_data {
2201 s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2202 }
2203 if let Some(p) = q.period {
2204 s.push_str(&format!("<Period>{p}</Period>"));
2205 }
2206 if let Some(acct) = &q.account_id {
2207 s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2208 }
2209 s.push_str("</member>");
2210 }
2211 s.push_str("</Metrics>");
2212}
2213
2214fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2215 s.push_str(&format!("<{name}>"));
2216 for action in actions {
2217 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2218 }
2219 s.push_str(&format!("</{name}>"));
2220}