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 if !matches!(period, 1 | 5 | 10 | 30) && period % 60 != 0 {
1079 return Err(invalid_param(
1080 "The parameter Period must be a positive multiple of 60, or one of 1, 5, 10, 30.",
1081 ));
1082 }
1083 let start_ts = parse_input_timestamp(&start)
1084 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1085 let end_ts = parse_input_timestamp(&end)
1086 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1087 if start_ts >= end_ts {
1088 return Err(invalid_param(
1089 "The parameter StartTime must be less than the parameter EndTime.",
1090 ));
1091 }
1092
1093 let mut statistics: Vec<String> = Vec::new();
1094 let mut extended_statistics: Vec<String> = Vec::new();
1095 for (k, v) in req.query_params.iter() {
1096 if k.starts_with("Statistics.member.") {
1097 statistics.push(v.clone());
1098 } else if k.starts_with("ExtendedStatistics.member.") {
1099 extended_statistics.push(v.clone());
1100 }
1101 }
1102 if statistics.is_empty() && extended_statistics.is_empty() {
1103 return Err(invalid_param(
1104 "At least one of Statistics or ExtendedStatistics is required",
1105 ));
1106 }
1107
1108 let dim_filter = parse_dimensions_query(req, "Dimensions");
1109 let unit_filter = req.query_params.get("Unit").cloned();
1113
1114 let state = self.state.read();
1115 type StatPoint = (
1117 DateTime<Utc>,
1118 BTreeMap<String, f64>,
1119 Vec<(String, f64)>,
1120 Option<String>,
1121 );
1122 let mut datapoints: Vec<StatPoint> = Vec::new();
1123 if let Some(acct) = state.get(&req.account_id) {
1124 if let Some(map) = acct.metrics_in(&req.region) {
1125 if let Some(data) = map.get(&namespace) {
1126 let buckets = collect_metric_buckets(
1127 data,
1128 &metric_name,
1129 &dim_filter,
1130 unit_filter.as_deref(),
1131 period,
1132 start_ts,
1133 end_ts,
1134 );
1135 for (ts, bucket) in buckets {
1136 let mut sorted = bucket.samples.clone();
1137 sorted
1138 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1139 let mut simple = BTreeMap::new();
1140 for stat in statistics.iter() {
1141 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1142 simple.insert(stat.clone(), v);
1143 }
1144 }
1145 let mut extended = Vec::new();
1146 for stat in extended_statistics.iter() {
1147 if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1148 extended.push((stat.clone(), v));
1149 }
1150 }
1151 let unit = unit_filter.clone().or(bucket.unit);
1152 datapoints.push((ts, simple, extended, unit));
1153 }
1154 }
1155 }
1156 }
1157
1158 let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1159 inner.push_str("<Datapoints>");
1160 for (ts, simple, extended, unit) in datapoints {
1161 inner.push_str("<member>");
1162 inner.push_str(&format!(
1163 "<Timestamp>{}</Timestamp>",
1164 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1165 ));
1166 for (name, value) in simple {
1167 inner.push_str(&format!("<{name}>{value}</{name}>"));
1168 }
1169 if !extended.is_empty() {
1170 inner.push_str("<ExtendedStatistics>");
1171 for (name, value) in extended {
1172 inner.push_str(&format!(
1173 "<entry><key>{}</key><value>{}</value></entry>",
1174 xml_escape(&name),
1175 value
1176 ));
1177 }
1178 inner.push_str("</ExtendedStatistics>");
1179 }
1180 if let Some(u) = unit {
1181 inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1182 }
1183 inner.push_str("</member>");
1184 }
1185 inner.push_str("</Datapoints>");
1186
1187 Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1188 }
1189
1190 fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1191 validate_enum(
1192 req,
1193 "ScanBy",
1194 &["TimestampDescending", "TimestampAscending"],
1195 )?;
1196 let start = required_query_param(req, "StartTime")?;
1197 let end = required_query_param(req, "EndTime")?;
1198 let start_ts = parse_input_timestamp(&start)
1199 .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1200 let end_ts = parse_input_timestamp(&end)
1201 .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1202
1203 let descending = req
1207 .query_params
1208 .get("ScanBy")
1209 .map(|s| s != "TimestampAscending")
1210 .unwrap_or(true);
1211
1212 let queries = collect_indexed(req, "MetricDataQueries");
1215
1216 let state = self.state.read();
1217
1218 let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1221 for q in &queries {
1222 let id = q.get("Id").cloned().unwrap_or_default();
1223 let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1224 continue;
1225 };
1226 let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1227 continue;
1228 };
1229 let stat = q
1230 .get("MetricStat.Stat")
1231 .cloned()
1232 .unwrap_or_else(|| "Sum".to_string());
1233 let period: i64 = q
1234 .get("MetricStat.Period")
1235 .and_then(|s| s.parse::<i64>().ok())
1236 .filter(|p| *p > 0)
1237 .unwrap_or(60);
1238 let unit_filter = q.get("MetricStat.Unit").cloned();
1239 let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1240
1241 let mut series = crate::metric_math::Series::new();
1242 if let Some(acct) = state.get(&req.account_id) {
1243 if let Some(map) = acct.metrics_in(&req.region) {
1244 if let Some(data) = map.get(namespace) {
1245 let buckets = collect_metric_buckets(
1246 data,
1247 metric_name,
1248 &dim_filter,
1249 unit_filter.as_deref(),
1250 period,
1251 start_ts,
1252 end_ts,
1253 );
1254 for (ts, bucket) in buckets {
1255 let mut sorted = bucket.samples.clone();
1256 sorted.sort_by(|a, b| {
1257 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1258 });
1259 if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1260 series.insert(ts, v);
1261 }
1262 }
1263 }
1264 }
1265 }
1266 series_by_id.insert(id, series);
1267 }
1268
1269 let mut inner = String::from("<MetricDataResults>");
1272 for q in &queries {
1273 let id = q.get("Id").cloned().unwrap_or_default();
1274 let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1275 let return_data = q
1276 .get("ReturnData")
1277 .map(|s| !s.eq_ignore_ascii_case("false"))
1278 .unwrap_or(true);
1279 if !return_data {
1280 continue;
1281 }
1282
1283 let mut error_message: Option<String> = None;
1284 let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1285 match crate::metric_math::evaluate(expr, &series_by_id) {
1286 Ok(s) => s,
1287 Err(e) => {
1288 error_message = Some(e);
1289 crate::metric_math::Series::new()
1290 }
1291 }
1292 } else {
1293 series_by_id.get(&id).cloned().unwrap_or_default()
1294 };
1295
1296 let mut timestamps: Vec<String> = Vec::new();
1297 let mut values: Vec<f64> = Vec::new();
1298 for (ts, v) in series.iter() {
1299 if !v.is_finite() {
1303 continue;
1304 }
1305 timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1306 values.push(*v);
1307 }
1308 if descending {
1309 timestamps.reverse();
1310 values.reverse();
1311 }
1312
1313 inner.push_str("<member>");
1314 inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1315 inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1316 inner.push_str("<Timestamps>");
1317 for ts in ×tamps {
1318 inner.push_str(&format!("<member>{ts}</member>"));
1319 }
1320 inner.push_str("</Timestamps>");
1321 inner.push_str("<Values>");
1322 for v in &values {
1323 inner.push_str(&format!("<member>{v}</member>"));
1324 }
1325 inner.push_str("</Values>");
1326 if let Some(msg) = error_message {
1327 inner.push_str("<StatusCode>InternalError</StatusCode>");
1328 inner.push_str("<Messages><member>");
1329 inner.push_str("<Code>Error</Code>");
1330 inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1331 inner.push_str("</member></Messages>");
1332 } else {
1333 inner.push_str("<StatusCode>Complete</StatusCode>");
1334 }
1335 inner.push_str("</member>");
1336 }
1337 inner.push_str("</MetricDataResults>");
1338 inner.push_str("<Messages></Messages>");
1339
1340 Ok(xml_response("GetMetricData", &inner, &req.request_id))
1341 }
1342
1343 fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1344 validate_len(req, "AlarmName", 1, 255)?;
1350 validate_len(req, "AlarmDescription", 0, 1024)?;
1351 validate_len(req, "MetricName", 1, 255)?;
1352 validate_len(req, "Namespace", 1, 255)?;
1353 validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1354 validate_len(req, "TreatMissingData", 1, 255)?;
1355 validate_len(req, "ThresholdMetricId", 1, 255)?;
1356 validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1357 validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1358 validate_range_i64(req, "Period", 1, i64::MAX)?;
1359 validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1360 validate_enum(
1361 req,
1362 "ComparisonOperator",
1363 &[
1364 "GreaterThanOrEqualToThreshold",
1365 "GreaterThanThreshold",
1366 "GreaterThanUpperThreshold",
1367 "LessThanLowerOrGreaterThanUpperThreshold",
1368 "LessThanLowerThreshold",
1369 "LessThanOrEqualToThreshold",
1370 "LessThanThreshold",
1371 ],
1372 )?;
1373 validate_enum(
1374 req,
1375 "Statistic",
1376 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1377 )?;
1378 validate_enum(req, "Unit", STANDARD_UNITS)?;
1379 let alarm_name = required_query_param(req, "AlarmName")?;
1380 let comparison = optional_query_param(req, "ComparisonOperator")
1381 .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1382 let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1383 .and_then(|s| s.parse::<i64>().ok())
1384 .unwrap_or(1);
1385
1386 let alarm_description = optional_query_param(req, "AlarmDescription");
1387 let actions_enabled = optional_query_param(req, "ActionsEnabled")
1388 .map(|s| s.eq_ignore_ascii_case("true"))
1389 .unwrap_or(true);
1390
1391 let metric_name = optional_query_param(req, "MetricName");
1392 let namespace = optional_query_param(req, "Namespace");
1393 let statistic = optional_query_param(req, "Statistic");
1394 let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1395 let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1396 let unit = optional_query_param(req, "Unit");
1397 let datapoints_to_alarm =
1398 optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1399 let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1400 let treat_missing_data = optional_query_param(req, "TreatMissingData");
1401 let evaluate_low_sample_count_percentile =
1402 optional_query_param(req, "EvaluateLowSampleCountPercentile");
1403 let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1406 let dimensions = parse_dimensions_query(req, "Dimensions");
1407 let metrics = parse_alarm_metrics(req);
1411 let inline_tags = parse_tags(req, "Tags");
1414
1415 let mut ok_actions = Vec::new();
1416 let mut alarm_actions = Vec::new();
1417 let mut insufficient_data_actions = Vec::new();
1418 for (k, v) in req.query_params.iter() {
1419 if k.starts_with("OKActions.member.") {
1420 ok_actions.push(v.clone());
1421 } else if k.starts_with("AlarmActions.member.") {
1422 alarm_actions.push(v.clone());
1423 } else if k.starts_with("InsufficientDataActions.member.") {
1424 insufficient_data_actions.push(v.clone());
1425 }
1426 }
1427
1428 let arn = format!(
1429 "arn:aws:cloudwatch:{}:{}:alarm:{}",
1430 req.region, req.account_id, alarm_name
1431 );
1432 let now = Utc::now();
1433
1434 let mut state = self.state.write();
1435 let acct = state.get_or_create(&req.account_id);
1436 let alarms = acct.alarms_in_mut(&req.region);
1437 let existing = alarms.get(&alarm_name).cloned();
1438 let alarm = MetricAlarm {
1439 alarm_name: alarm_name.clone(),
1440 alarm_arn: arn,
1441 alarm_description,
1442 actions_enabled,
1443 ok_actions,
1444 alarm_actions,
1445 insufficient_data_actions,
1446 state_value: existing
1447 .as_ref()
1448 .map(|a| a.state_value)
1449 .unwrap_or(AlarmState::InsufficientData),
1450 state_reason: existing
1451 .as_ref()
1452 .map(|a| a.state_reason.clone())
1453 .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1454 state_updated_timestamp: existing
1455 .as_ref()
1456 .map(|a| a.state_updated_timestamp)
1457 .unwrap_or(now),
1458 metric_name,
1459 namespace,
1460 statistic,
1461 extended_statistic,
1462 dimensions,
1463 period,
1464 unit,
1465 evaluation_periods,
1466 datapoints_to_alarm,
1467 threshold,
1468 comparison_operator: comparison,
1469 treat_missing_data,
1470 evaluate_low_sample_count_percentile,
1471 threshold_metric_id,
1472 configuration_updated_timestamp: existing
1473 .as_ref()
1474 .map(|a| a.configuration_updated_timestamp)
1475 .unwrap_or(now),
1476 alarm_configuration_updated_timestamp: now,
1477 metrics,
1478 state_manually_set: existing
1481 .as_ref()
1482 .map(|a| a.state_manually_set)
1483 .unwrap_or(false),
1484 };
1485 let alarm_arn = alarm.alarm_arn.clone();
1486 let history_name = alarm_name.clone();
1487 let created = existing.is_none();
1488 alarms.insert(alarm_name, alarm);
1489
1490 if created && !inline_tags.is_empty() {
1495 let bucket = acct.tags.entry(alarm_arn).or_default();
1496 for (k, v) in inline_tags {
1497 bucket.insert(k, v);
1498 }
1499 }
1500
1501 let summary = if created {
1502 format!("Alarm \"{history_name}\" created")
1503 } else {
1504 format!("Alarm \"{history_name}\" updated")
1505 };
1506 let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1507 push_alarm_history(
1508 acct,
1509 &req.region,
1510 &history_name,
1511 "MetricAlarm",
1512 "ConfigurationUpdate",
1513 summary,
1514 history_data,
1515 );
1516
1517 Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1518 }
1519
1520 fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1521 let mut filter_names: Vec<String> = Vec::new();
1522 for (k, v) in req.query_params.iter() {
1523 if k.starts_with("AlarmNames.member.") {
1524 filter_names.push(v.clone());
1525 }
1526 }
1527 let alarm_types = collect_member_values(req, "AlarmTypes");
1531 for t in &alarm_types {
1532 if t != "MetricAlarm" && t != "CompositeAlarm" {
1533 return Err(invalid_param(format!(
1534 "AlarmTypes has an invalid value '{t}'"
1535 )));
1536 }
1537 }
1538 let want_metric = alarm_types.is_empty() || alarm_types.iter().any(|t| t == "MetricAlarm");
1539 let want_composite = alarm_types.iter().any(|t| t == "CompositeAlarm");
1540 validate_len(req, "AlarmNamePrefix", 1, 255)?;
1541 validate_len(req, "ActionPrefix", 1, 1024)?;
1542 validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1543 validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1544 validate_range_i64(req, "MaxRecords", 1, 100)?;
1545 validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1546 let prefix = optional_query_param(req, "AlarmNamePrefix");
1547 let state_filter = optional_query_param(req, "StateValue");
1548 let action_prefix = optional_query_param(req, "ActionPrefix");
1549 let max_records = optional_query_param(req, "MaxRecords")
1553 .and_then(|s| s.parse::<usize>().ok())
1554 .filter(|n| *n > 0)
1555 .unwrap_or(100);
1556 let offset = decode_offset_token(req.query_params.get("NextToken"));
1557
1558 let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1561 if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1562 return false;
1563 }
1564 if let Some(p) = prefix.as_ref() {
1565 if !name.starts_with(p) {
1566 return false;
1567 }
1568 }
1569 if let Some(want) = state_filter.as_ref() {
1570 if sv != want {
1571 return false;
1572 }
1573 }
1574 if let Some(ap) = action_prefix.as_ref() {
1575 let any = actions
1576 .iter()
1577 .flat_map(|a| a.iter())
1578 .any(|a| a.starts_with(ap));
1579 if !any {
1580 return false;
1581 }
1582 }
1583 true
1584 };
1585
1586 let mut state = self.state.write();
1590 if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1591 crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1592 }
1593 let mut combined: Vec<(bool, String)> = Vec::new();
1594 if let Some(acct) = state.get(&req.account_id) {
1595 if want_metric {
1596 if let Some(alarms) = acct.alarms_in(&req.region) {
1597 for alarm in alarms.values() {
1598 if matches(
1599 &alarm.alarm_name,
1600 alarm.state_value.as_str(),
1601 [
1602 &alarm.alarm_actions,
1603 &alarm.ok_actions,
1604 &alarm.insufficient_data_actions,
1605 ],
1606 ) {
1607 combined.push((false, render_alarm(alarm)));
1608 }
1609 }
1610 }
1611 }
1612 if want_composite {
1613 if let Some(composites) = acct.composite_alarms_in(&req.region) {
1614 for alarm in composites.values() {
1615 if matches(
1616 &alarm.alarm_name,
1617 alarm.state_value.as_str(),
1618 [
1619 &alarm.alarm_actions,
1620 &alarm.ok_actions,
1621 &alarm.insufficient_data_actions,
1622 ],
1623 ) {
1624 combined.push((
1625 true,
1626 crate::composite_alarms::render_composite_alarm(alarm),
1627 ));
1628 }
1629 }
1630 }
1631 }
1632 }
1633
1634 let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1635 let mut inner = String::from("<MetricAlarms>");
1636 for (is_composite, body) in &page {
1637 if !*is_composite {
1638 inner.push_str(body);
1639 }
1640 }
1641 inner.push_str("</MetricAlarms>");
1642 inner.push_str("<CompositeAlarms>");
1643 for (is_composite, body) in &page {
1644 if *is_composite {
1645 inner.push_str(body);
1646 }
1647 }
1648 inner.push_str("</CompositeAlarms>");
1649 if offset + max_records < combined.len() {
1650 inner.push_str(&format!(
1651 "<NextToken>{}</NextToken>",
1652 encode_offset_token(offset + max_records)
1653 ));
1654 }
1655
1656 Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1657 }
1658
1659 fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1660 validate_len(req, "MetricName", 1, 255)?;
1661 validate_len(req, "Namespace", 1, 255)?;
1662 validate_range_i64(req, "Period", 1, i64::MAX)?;
1663 validate_enum(
1664 req,
1665 "Statistic",
1666 &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1667 )?;
1668 validate_enum(req, "Unit", STANDARD_UNITS)?;
1669 let metric_name = required_query_param(req, "MetricName")?;
1670 let namespace = required_query_param(req, "Namespace")?;
1671 let dim_filter = parse_dimensions_query(req, "Dimensions");
1672
1673 {
1677 let mut state = self.state.write();
1678 if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1679 crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1680 }
1681 }
1682
1683 let state = self.state.read();
1684 let mut inner = String::from("<MetricAlarms>");
1685 if let Some(acct) = state.get(&req.account_id) {
1686 if let Some(alarms) = acct.alarms_in(&req.region) {
1687 for alarm in alarms.values() {
1688 if alarm.metric_name.as_deref() != Some(&metric_name) {
1689 continue;
1690 }
1691 if alarm.namespace.as_deref() != Some(&namespace) {
1692 continue;
1693 }
1694 if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1695 continue;
1696 }
1697 inner.push_str(&render_alarm(alarm));
1698 }
1699 }
1700 }
1701 inner.push_str("</MetricAlarms>");
1702
1703 Ok(xml_response(
1704 "DescribeAlarmsForMetric",
1705 &inner,
1706 &req.request_id,
1707 ))
1708 }
1709
1710 fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1711 let mut names: Vec<String> = Vec::new();
1715 for (k, v) in req.query_params.iter() {
1716 if k.starts_with("AlarmNames.member.") {
1717 names.push(v.clone());
1718 }
1719 }
1720
1721 let mut state = self.state.write();
1722 let acct = state.get_or_create(&req.account_id);
1723 for name in &names {
1724 acct.alarms_in_mut(&req.region).remove(name);
1725 acct.composite_alarms_in_mut(&req.region).remove(name);
1726 acct.alarm_history_in_mut(&req.region).remove(name);
1729 }
1730
1731 Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1732 }
1733
1734 fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1735 self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1736 }
1737
1738 fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1739 self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1740 }
1741
1742 fn toggle_alarm_actions(
1743 &self,
1744 req: &AwsRequest,
1745 enabled: bool,
1746 action_name: &str,
1747 ) -> Result<AwsResponse, AwsServiceError> {
1748 let mut names: Vec<String> = Vec::new();
1749 for (k, v) in req.query_params.iter() {
1750 if k.starts_with("AlarmNames.member.") {
1751 names.push(v.clone());
1752 }
1753 }
1754 let mut state = self.state.write();
1755 let acct = state.get_or_create(&req.account_id);
1756 let alarms = acct.alarms_in_mut(&req.region);
1757 for name in names {
1758 if let Some(alarm) = alarms.get_mut(&name) {
1759 alarm.actions_enabled = enabled;
1760 alarm.alarm_configuration_updated_timestamp = Utc::now();
1761 }
1762 }
1763 Ok(empty_metadata_response(action_name, &req.request_id))
1764 }
1765
1766 fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1767 validate_len(req, "AlarmName", 1, 255)?;
1768 validate_len(req, "StateReason", 0, 1023)?;
1769 validate_len(req, "StateReasonData", 0, 4000)?;
1770 let alarm_name = required_query_param(req, "AlarmName")?;
1771 let state_value = required_query_param(req, "StateValue")?;
1772 let state_reason = req
1776 .query_params
1777 .get("StateReason")
1778 .cloned()
1779 .ok_or_else(|| {
1780 AwsServiceError::aws_error(
1781 StatusCode::BAD_REQUEST,
1782 "MissingParameter",
1783 "The request must contain the parameter StateReason.",
1784 )
1785 })?;
1786 let new_state = AlarmState::parse(&state_value)
1787 .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1788
1789 let now = Utc::now();
1790 let mut state = self.state.write();
1791 let acct = state.get_or_create(&req.account_id);
1792 let (old_state, alarm_type) =
1795 if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1796 let old = alarm.state_value.as_str().to_string();
1797 alarm.state_value = new_state;
1798 alarm.state_reason = state_reason.clone();
1799 alarm.state_updated_timestamp = now;
1800 alarm.state_manually_set = true;
1803 (old, "MetricAlarm")
1804 } else if let Some(composite) = acct
1805 .composite_alarms_in_mut(&req.region)
1806 .get_mut(&alarm_name)
1807 {
1808 let old = composite.state_value.as_str().to_string();
1809 composite.state_value = new_state;
1810 composite.state_reason = state_reason.clone();
1811 composite.state_updated_timestamp = now;
1812 (old, "CompositeAlarm")
1813 } else {
1814 return Err(AwsServiceError::aws_error(
1815 StatusCode::NOT_FOUND,
1816 "ResourceNotFound",
1817 format!("Alarm {alarm_name} not found"),
1818 ));
1819 };
1820
1821 let new_state_str = new_state.as_str().to_string();
1822 let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1823 let history_data = format!(
1824 "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1825 state_reason.replace('"', "\\\"")
1826 );
1827 push_alarm_history(
1828 acct,
1829 &req.region,
1830 &alarm_name,
1831 alarm_type,
1832 "StateUpdate",
1833 summary,
1834 history_data,
1835 );
1836
1837 Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1838 }
1839
1840 fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1841 validate_len(req, "AlarmName", 1, 255)?;
1842 validate_len(req, "AlarmContributorId", 1, 16)?;
1843 validate_range_i64(req, "MaxRecords", 1, 100)?;
1844 validate_enum(
1845 req,
1846 "HistoryItemType",
1847 &[
1848 "ConfigurationUpdate",
1849 "StateUpdate",
1850 "Action",
1851 "AlarmContributorStateUpdate",
1852 "AlarmContributorAction",
1853 ],
1854 )?;
1855 validate_enum(
1856 req,
1857 "ScanBy",
1858 &["TimestampDescending", "TimestampAscending"],
1859 )?;
1860 let alarm_filter = optional_query_param(req, "AlarmName");
1861 let type_filter = optional_query_param(req, "HistoryItemType");
1862 let start_date =
1863 optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1864 let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1865 let descending = req
1867 .query_params
1868 .get("ScanBy")
1869 .map(|s| s != "TimestampAscending")
1870 .unwrap_or(true);
1871 let max_records = optional_query_param(req, "MaxRecords")
1872 .and_then(|s| s.parse::<usize>().ok())
1873 .filter(|n| *n > 0)
1874 .unwrap_or(100);
1875 let offset = decode_offset_token(req.query_params.get("NextToken"));
1876
1877 let state = self.state.read();
1878 let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1879 if let Some(acct) = state.get(&req.account_id) {
1880 if let Some(history) = acct.alarm_history_in(&req.region) {
1881 for (name, list) in history.iter() {
1882 if let Some(f) = alarm_filter.as_ref() {
1883 if name != f {
1884 continue;
1885 }
1886 }
1887 for item in list.iter() {
1888 if let Some(t) = type_filter.as_ref() {
1889 if &item.history_item_type != t {
1890 continue;
1891 }
1892 }
1893 if let Some(sd) = start_date {
1894 if item.timestamp < sd {
1895 continue;
1896 }
1897 }
1898 if let Some(ed) = end_date {
1899 if item.timestamp > ed {
1900 continue;
1901 }
1902 }
1903 items.push(item);
1904 }
1905 }
1906 }
1907 }
1908 items.sort_by_key(|i| i.timestamp);
1909 if descending {
1910 items.reverse();
1911 }
1912 let total = items.len();
1913 let page: Vec<&AlarmHistoryItem> =
1914 items.into_iter().skip(offset).take(max_records).collect();
1915
1916 let mut inner = String::from("<AlarmHistoryItems>");
1917 for item in page {
1918 inner.push_str("<member>");
1919 inner.push_str(&format!(
1920 "<AlarmName>{}</AlarmName>",
1921 xml_escape(&item.alarm_name)
1922 ));
1923 inner.push_str(&format!(
1924 "<AlarmType>{}</AlarmType>",
1925 xml_escape(&item.alarm_type)
1926 ));
1927 inner.push_str(&format!(
1928 "<Timestamp>{}</Timestamp>",
1929 item.timestamp
1930 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1931 ));
1932 inner.push_str(&format!(
1933 "<HistoryItemType>{}</HistoryItemType>",
1934 xml_escape(&item.history_item_type)
1935 ));
1936 inner.push_str(&format!(
1937 "<HistorySummary>{}</HistorySummary>",
1938 xml_escape(&item.history_summary)
1939 ));
1940 inner.push_str(&format!(
1941 "<HistoryData>{}</HistoryData>",
1942 xml_escape(&item.history_data)
1943 ));
1944 inner.push_str("</member>");
1945 }
1946 inner.push_str("</AlarmHistoryItems>");
1947 if offset + max_records < total {
1948 inner.push_str(&format!(
1949 "<NextToken>{}</NextToken>",
1950 encode_offset_token(offset + max_records)
1951 ));
1952 }
1953 Ok(xml_response(
1954 "DescribeAlarmHistory",
1955 &inner,
1956 &req.request_id,
1957 ))
1958 }
1959
1960 fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1961 let dashboard_name = req
1962 .query_params
1963 .get("DashboardName")
1964 .ok_or_else(|| invalid_param("DashboardName is required"))?
1965 .clone();
1966 let body = req
1967 .query_params
1968 .get("DashboardBody")
1969 .ok_or_else(|| invalid_param("DashboardBody is required"))?
1970 .clone();
1971 if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1974 return Err(AwsServiceError::aws_error(
1975 StatusCode::BAD_REQUEST,
1976 "InvalidParameterInput",
1977 "DashboardBody must be a valid JSON object",
1978 ));
1979 }
1980 let arn = format!(
1981 "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1982 req.account_id
1983 );
1984 let dashboard = Dashboard {
1985 name: dashboard_name.clone(),
1986 arn,
1987 size_bytes: body.len() as i64,
1988 body,
1989 last_modified: Utc::now(),
1990 };
1991 let mut state = self.state.write();
1992 let acct = state.get_or_create(&req.account_id);
1993 acct.dashboards.insert(dashboard_name, dashboard);
1994 let inner = String::from("<DashboardValidationMessages/>");
1997 Ok(xml_response("PutDashboard", &inner, &req.request_id))
1998 }
1999
2000 fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2001 let name = req
2002 .query_params
2003 .get("DashboardName")
2004 .ok_or_else(|| invalid_param("DashboardName is required"))?
2005 .clone();
2006 let state = self.state.read();
2007 let dashboard = state
2008 .get(&req.account_id)
2009 .and_then(|a| a.dashboards.get(&name))
2010 .cloned()
2011 .ok_or_else(|| {
2012 AwsServiceError::aws_error(
2013 StatusCode::NOT_FOUND,
2014 "ResourceNotFound",
2015 format!("Dashboard {name} does not exist"),
2016 )
2017 })?;
2018 let inner = format!(
2019 "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
2020 xml_escape(&dashboard.arn),
2021 xml_escape(&dashboard.body),
2022 xml_escape(&dashboard.name),
2023 );
2024 Ok(xml_response("GetDashboard", &inner, &req.request_id))
2025 }
2026
2027 fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2028 let mut names: Vec<String> = Vec::new();
2029 for (k, v) in req.query_params.iter() {
2030 if k.starts_with("DashboardNames.member.") {
2031 names.push(v.clone());
2032 }
2033 }
2034 if names.is_empty() {
2035 return Err(invalid_param(
2036 "DashboardNames must contain at least one name",
2037 ));
2038 }
2039 let mut state = self.state.write();
2040 let acct = state.get_or_create(&req.account_id);
2041 for n in names {
2042 acct.dashboards.remove(&n);
2043 }
2044 let body = format!(
2048 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
2049 <DeleteDashboardsResponse xmlns=\"{NS}\">\
2050 <DeleteDashboardsResult/>\
2051 <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
2052 </DeleteDashboardsResponse>",
2053 req.request_id
2054 );
2055 Ok(AwsResponse::xml(StatusCode::OK, body))
2056 }
2057
2058 fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2059 let prefix = req.query_params.get("DashboardNamePrefix").cloned();
2060 let state = self.state.read();
2061 let dashboards: Vec<Dashboard> = state
2062 .get(&req.account_id)
2063 .map(|a| {
2064 a.dashboards
2065 .values()
2066 .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
2067 .cloned()
2068 .collect()
2069 })
2070 .unwrap_or_default();
2071 let mut entries = String::new();
2072 for d in &dashboards {
2073 entries.push_str("<member>");
2074 entries.push_str(&format!(
2075 "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
2076 xml_escape(&d.arn),
2077 xml_escape(&d.name),
2078 d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2079 d.size_bytes,
2080 ));
2081 entries.push_str("</member>");
2082 }
2083 let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
2084 Ok(xml_response("ListDashboards", &inner, &req.request_id))
2085 }
2086}
2087
2088pub(crate) fn push_alarm_history(
2092 acct: &mut crate::state::CloudWatchState,
2093 region: &str,
2094 alarm_name: &str,
2095 alarm_type: &str,
2096 history_item_type: &str,
2097 history_summary: String,
2098 history_data: String,
2099) {
2100 acct.alarm_history_in_mut(region)
2101 .entry(alarm_name.to_string())
2102 .or_default()
2103 .push(AlarmHistoryItem {
2104 alarm_name: alarm_name.to_string(),
2105 alarm_type: alarm_type.to_string(),
2106 timestamp: Utc::now(),
2107 history_item_type: history_item_type.to_string(),
2108 history_summary,
2109 history_data,
2110 });
2111}
2112
2113fn render_alarm(alarm: &MetricAlarm) -> String {
2114 let mut s = String::from("<member>");
2115 s.push_str(&format!(
2116 "<AlarmName>{}</AlarmName>",
2117 xml_escape(&alarm.alarm_name)
2118 ));
2119 s.push_str(&format!(
2120 "<AlarmArn>{}</AlarmArn>",
2121 xml_escape(&alarm.alarm_arn)
2122 ));
2123 if let Some(d) = &alarm.alarm_description {
2124 s.push_str(&format!(
2125 "<AlarmDescription>{}</AlarmDescription>",
2126 xml_escape(d)
2127 ));
2128 }
2129 s.push_str(&format!(
2130 "<ActionsEnabled>{}</ActionsEnabled>",
2131 alarm.actions_enabled
2132 ));
2133 push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2134 push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2135 push_action_list(
2136 &mut s,
2137 "InsufficientDataActions",
2138 &alarm.insufficient_data_actions,
2139 );
2140 s.push_str(&format!(
2141 "<StateValue>{}</StateValue>",
2142 alarm.state_value.as_str()
2143 ));
2144 s.push_str(&format!(
2145 "<StateReason>{}</StateReason>",
2146 xml_escape(&alarm.state_reason)
2147 ));
2148 s.push_str(&format!(
2149 "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2150 alarm
2151 .state_updated_timestamp
2152 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2153 ));
2154 if let Some(m) = &alarm.metric_name {
2155 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2156 }
2157 if let Some(n) = &alarm.namespace {
2158 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2159 }
2160 if let Some(stat) = &alarm.statistic {
2161 s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2162 }
2163 if let Some(ext) = &alarm.extended_statistic {
2164 s.push_str(&format!(
2165 "<ExtendedStatistic>{}</ExtendedStatistic>",
2166 xml_escape(ext)
2167 ));
2168 }
2169 s.push_str(&render_dimensions(&alarm.dimensions));
2170 if let Some(p) = alarm.period {
2171 s.push_str(&format!("<Period>{p}</Period>"));
2172 }
2173 if let Some(u) = &alarm.unit {
2174 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2175 }
2176 s.push_str(&format!(
2177 "<EvaluationPeriods>{}</EvaluationPeriods>",
2178 alarm.evaluation_periods
2179 ));
2180 if let Some(d) = alarm.datapoints_to_alarm {
2181 s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2182 }
2183 if let Some(t) = alarm.threshold {
2184 s.push_str(&format!("<Threshold>{t}</Threshold>"));
2185 }
2186 if let Some(tid) = &alarm.threshold_metric_id {
2187 s.push_str(&format!(
2188 "<ThresholdMetricId>{}</ThresholdMetricId>",
2189 xml_escape(tid)
2190 ));
2191 }
2192 s.push_str(&format!(
2193 "<ComparisonOperator>{}</ComparisonOperator>",
2194 xml_escape(&alarm.comparison_operator)
2195 ));
2196 if let Some(t) = &alarm.treat_missing_data {
2197 s.push_str(&format!(
2198 "<TreatMissingData>{}</TreatMissingData>",
2199 xml_escape(t)
2200 ));
2201 }
2202 if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2203 s.push_str(&format!(
2204 "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2205 xml_escape(e)
2206 ));
2207 }
2208 s.push_str(&format!(
2209 "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2210 alarm
2211 .alarm_configuration_updated_timestamp
2212 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2213 ));
2214 render_alarm_metrics(&mut s, &alarm.metrics);
2215 s.push_str("</member>");
2216 s
2217}
2218
2219fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2221 if metrics.is_empty() {
2222 return;
2223 }
2224 s.push_str("<Metrics>");
2225 for q in metrics {
2226 s.push_str("<member>");
2227 s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2228 if let Some(stat) = &q.metric_stat {
2229 s.push_str("<MetricStat>");
2230 s.push_str("<Metric>");
2231 if let Some(ns) = &stat.namespace {
2232 s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2233 }
2234 if let Some(mn) = &stat.metric_name {
2235 s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2236 }
2237 s.push_str(&render_dimensions(&stat.dimensions));
2238 s.push_str("</Metric>");
2239 if let Some(p) = stat.period {
2240 s.push_str(&format!("<Period>{p}</Period>"));
2241 }
2242 if let Some(st) = &stat.stat {
2243 s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2244 }
2245 if let Some(u) = &stat.unit {
2246 s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2247 }
2248 s.push_str("</MetricStat>");
2249 }
2250 if let Some(e) = &q.expression {
2251 s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2252 }
2253 if let Some(l) = &q.label {
2254 s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2255 }
2256 if let Some(rd) = q.return_data {
2257 s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2258 }
2259 if let Some(p) = q.period {
2260 s.push_str(&format!("<Period>{p}</Period>"));
2261 }
2262 if let Some(acct) = &q.account_id {
2263 s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2264 }
2265 s.push_str("</member>");
2266 }
2267 s.push_str("</Metrics>");
2268}
2269
2270fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2271 s.push_str(&format!("<{name}>"));
2272 for action in actions {
2273 s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2274 }
2275 s.push_str(&format!("</{name}>"));
2276}