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