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