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