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