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