Skip to main content

fakecloud_cloudwatch/
service.rs

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    AlarmState, CloudWatchSnapshot, Dashboard, MetricAlarm, MetricDatum, SharedCloudWatchState,
19    StatisticSet, CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
20};
21
22pub(crate) const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
23
24/// Valid `StandardUnit` wire values, per the Smithy enum.
25pub(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    // Metrics & alarms (original surface).
57    "PutMetricData",
58    "GetMetricStatistics",
59    "GetMetricData",
60    "ListMetrics",
61    "PutMetricAlarm",
62    "DescribeAlarms",
63    "DescribeAlarmsForMetric",
64    "DeleteAlarms",
65    "EnableAlarmActions",
66    "DisableAlarmActions",
67    "SetAlarmState",
68    "DescribeAlarmHistory",
69    // Dashboards.
70    "PutDashboard",
71    "GetDashboard",
72    "DeleteDashboards",
73    "ListDashboards",
74    // Anomaly detectors.
75    "PutAnomalyDetector",
76    "DescribeAnomalyDetectors",
77    "DeleteAnomalyDetector",
78    // Insight rules.
79    "PutInsightRule",
80    "DescribeInsightRules",
81    "DeleteInsightRules",
82    "EnableInsightRules",
83    "DisableInsightRules",
84    "GetInsightRuleReport",
85    "PutManagedInsightRules",
86    "ListManagedInsightRules",
87    // Metric streams.
88    "PutMetricStream",
89    "GetMetricStream",
90    "ListMetricStreams",
91    "DeleteMetricStream",
92    "StartMetricStreams",
93    "StopMetricStreams",
94    // Composite alarms.
95    "PutCompositeAlarm",
96    // Mute rules.
97    "PutAlarmMuteRule",
98    "GetAlarmMuteRule",
99    "ListAlarmMuteRules",
100    "DeleteAlarmMuteRule",
101    // OTel enrichment.
102    "GetOTelEnrichment",
103    "StartOTelEnrichment",
104    "StopOTelEnrichment",
105    // Dataset KMS key management.
106    "AssociateDatasetKmsKey",
107    "DisassociateDatasetKmsKey",
108    "GetDataset",
109    // Misc.
110    "DescribeAlarmContributors",
111    "GetMetricWidgetImage",
112    // Tagging.
113    "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    /// Attach a `SnapshotStore` so alarms / dashboards / metrics survive
134    /// restarts. Without this, all CloudWatch state is in-memory only —
135    /// alarms wired to actions fire on a freshly-started process.
136    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
137        self.snapshot_store = Some(store);
138        self
139    }
140
141    /// Persist current state as a snapshot. Cloned + serialized under
142    /// the snapshot lock so concurrent mutators can't race a stale-last
143    /// write.
144    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    /// Build a hook that persists the current CloudWatch state when invoked, or
154    /// `None` in memory mode (no snapshot store). The CloudFormation provisioner
155    /// mutates `state` directly and uses this to write a CFN-provisioned
156    /// resource through to disk, the same way a direct mutating API call would.
157    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
172/// Persist the current CloudWatch state as a snapshot. Cloned + serialized
173/// under the snapshot lock so concurrent mutators can't race a stale-last
174/// write. Noop when `store` is `None` (memory mode). Shared by
175/// `CloudWatchService::save_snapshot` and the CloudFormation provisioner's
176/// post-provision persist hook so both route through the same
177/// serialize-and-write path.
178pub 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            // Anomaly detectors.
264            "PutAnomalyDetector" => self.put_anomaly_detector(&req),
265            "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
266            "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
267            // Insight rules.
268            "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            // Metric streams.
277            "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            // Composite alarms.
284            "PutCompositeAlarm" => self.put_composite_alarm(&req),
285            // Mute rules.
286            "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            // OTel enrichment.
291            "GetOTelEnrichment" => self.get_otel_enrichment(&req),
292            "StartOTelEnrichment" => self.start_otel_enrichment(&req),
293            "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
294            // Dataset KMS key management.
295            "AssociateDatasetKmsKey" => self.associate_dataset_kms_key(&req),
296            "DisassociateDatasetKmsKey" => self.disassociate_dataset_kms_key(&req),
297            "GetDataset" => self.get_dataset(&req),
298            // Misc.
299            "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
300            "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
301            // Tagging.
302            "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
335/// `ResourceNotFoundException` — wire code matches the awsQueryError trait.
336pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
337    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
338}
339
340/// `MissingRequiredParameterException` — awsQueryError wire code is
341/// `MissingParameter`.
342pub(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
370/// Collect an indexed `<prefix>.member.N` numeric array out of a flattened
371/// MetricData member (e.g. `Values.member.1`, `Counts.member.1`).
372fn 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
393/// Build a [`StatisticSet`] from a MetricDatum's `Values`/`Counts` distribution.
394/// Returns `Ok(None)` when no `Values` array is present.
395fn 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
484/// Validate the length of an optional string param against `[min, max]`.
485/// Returns a 4xx on violation. AWS measures length in characters; the
486/// conformance probe only sends ASCII so byte length is equivalent here.
487pub(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
504/// Validate an optional integer param against `[min, max]` (inclusive).
505pub(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
527/// Validate that an optional param, when present, is one of `allowed`.
528pub(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
541/// Collect repeated `<Prefix>.member.N` scalar values, ordered by index.
542pub(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
556/// Parse a `Tags.member.N.Key` / `Tags.member.N.Value` list into a map.
557pub(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('&', "&amp;")
570        .replace('<', "&lt;")
571        .replace('>', "&gt;")
572        .replace('"', "&quot;")
573        .replace('\'', "&apos;")
574}
575
576/// Per-datapoint aggregation summary covering both the simple `Value` form
577/// and the `StatisticValues` form so callers don't lose the count or
578/// min/max baked into a `StatisticSet`.
579#[derive(Clone, Copy)]
580struct DatumStats {
581    sum: f64,
582    min: f64,
583    max: f64,
584    count: f64,
585}
586
587fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
588    if let Some(v) = d.value {
589        return Some(DatumStats {
590            sum: v,
591            min: v,
592            max: v,
593            count: 1.0,
594        });
595    }
596    if let Some(s) = &d.statistic_values {
597        return Some(DatumStats {
598            sum: s.sum,
599            min: s.minimum,
600            max: s.maximum,
601            count: s.sample_count,
602        });
603    }
604    None
605}
606
607fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
608    acc.sum += other.sum;
609    acc.count += other.count;
610    if other.min < acc.min {
611        acc.min = other.min;
612    }
613    if other.max > acc.max {
614        acc.max = other.max;
615    }
616}
617
618fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
619    match stat {
620        "Sum" => Some(agg.sum),
621        "Average" => {
622            if agg.count > 0.0 {
623                Some(agg.sum / agg.count)
624            } else {
625                None
626            }
627        }
628        "Minimum" => Some(agg.min),
629        "Maximum" => Some(agg.max),
630        "SampleCount" => Some(agg.count),
631        _ => None,
632    }
633}
634
635pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
636    let mut s = String::from("<Dimensions>");
637    for (name, value) in dims.iter() {
638        s.push_str(&format!(
639            "<member><Name>{}</Name><Value>{}</Value></member>",
640            xml_escape(name),
641            xml_escape(value),
642        ));
643    }
644    s.push_str("</Dimensions>");
645    s
646}
647
648impl CloudWatchService {
649    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
650        let namespace = required_query_param(req, "Namespace")?;
651        let members = collect_indexed(req, "MetricData");
652        if members.is_empty() {
653            return Err(invalid_param(
654                "PutMetricData requires at least one MetricData entry",
655            ));
656        }
657
658        let now = Utc::now();
659        let mut state = self.state.write();
660        let acct = state.get_or_create(&req.account_id);
661        let metrics_map = acct.metrics_in_mut(&req.region);
662        let bucket = metrics_map.entry(namespace.clone()).or_default();
663
664        for member in members {
665            let metric_name = member
666                .get("MetricName")
667                .cloned()
668                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
669            let value = member
670                .get("Value")
671                .map(|s| s.parse::<f64>())
672                .transpose()
673                .map_err(|_| invalid_param("Value must be a valid number"))?;
674            let timestamp = member
675                .get("Timestamp")
676                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
677                .map(|d| d.with_timezone(&Utc))
678                .unwrap_or(now);
679            let unit = member.get("Unit").cloned();
680            let storage_resolution = member
681                .get("StorageResolution")
682                .and_then(|s| s.parse::<i64>().ok());
683            let dimensions = parse_dimensions(&member, "Dimensions");
684
685            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
686                member.get("StatisticValues.SampleCount"),
687                member.get("StatisticValues.Sum"),
688                member.get("StatisticValues.Minimum"),
689                member.get("StatisticValues.Maximum"),
690            ) {
691                Some(StatisticSet {
692                    sample_count: sc.parse::<f64>().map_err(|_| {
693                        invalid_param("StatisticValues.SampleCount must be a number")
694                    })?,
695                    sum: sum
696                        .parse::<f64>()
697                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
698                    minimum: min
699                        .parse::<f64>()
700                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
701                    maximum: max
702                        .parse::<f64>()
703                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
704                })
705            } else {
706                None
707            };
708
709            // A `Values`/`Counts` value-distribution is collapsed into a
710            // StatisticSet (which the statistics path already aggregates), so
711            // the common histogram publish path stops 400-ing.
712            let statistic_values = match statistic_values {
713                Some(s) => Some(s),
714                None => values_counts_statistic(&member)?,
715            };
716
717            if value.is_none() && statistic_values.is_none() {
718                return Err(invalid_param(
719                    "MetricData entry must supply either Value, StatisticValues, or Values",
720                ));
721            }
722
723            bucket.push(MetricDatum {
724                metric_name,
725                dimensions,
726                timestamp,
727                value,
728                statistic_values,
729                unit,
730                storage_resolution,
731            });
732        }
733
734        Ok(empty_metadata_response("PutMetricData", &req.request_id))
735    }
736
737    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
738        validate_len(req, "Namespace", 1, 255)?;
739        validate_len(req, "MetricName", 1, 255)?;
740        validate_len(req, "OwningAccount", 1, 255)?;
741        validate_enum(req, "RecentlyActive", &["PT3H"])?;
742        let namespace = optional_query_param(req, "Namespace");
743        let metric_name = optional_query_param(req, "MetricName");
744        let dim_filter = parse_dimensions_query(req, "Dimensions");
745
746        let state = self.state.read();
747        let mut out = String::from("<Metrics>");
748        if let Some(acct) = state.get(&req.account_id) {
749            if let Some(map) = acct.metrics_in(&req.region) {
750                for (ns, data) in map.iter() {
751                    if let Some(filter_ns) = namespace.as_ref() {
752                        if ns != filter_ns {
753                            continue;
754                        }
755                    }
756                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
757                        BTreeMap::new();
758                    for d in data.iter() {
759                        if let Some(filter_name) = metric_name.as_ref() {
760                            if &d.metric_name != filter_name {
761                                continue;
762                            }
763                        }
764                        if !dim_filter.is_empty()
765                            && !dim_filter
766                                .iter()
767                                .all(|(k, v)| d.dimensions.get(k) == Some(v))
768                        {
769                            continue;
770                        }
771                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
772                    }
773                    for ((name, dims), _) in seen {
774                        out.push_str("<member>");
775                        out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
776                        out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
777                        out.push_str(&render_dimensions(&dims));
778                        out.push_str("</member>");
779                    }
780                }
781            }
782        }
783        out.push_str("</Metrics>");
784
785        Ok(xml_response("ListMetrics", &out, &req.request_id))
786    }
787
788    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
789        let namespace = required_query_param(req, "Namespace")?;
790        let metric_name = required_query_param(req, "MetricName")?;
791        let start = required_query_param(req, "StartTime")?;
792        let end = required_query_param(req, "EndTime")?;
793        let period = required_query_param(req, "Period")?
794            .parse::<i64>()
795            .map_err(|_| invalid_param("Period must be an integer"))?;
796        if period <= 0 {
797            return Err(invalid_param("Period must be positive"));
798        }
799        let start_ts = DateTime::parse_from_rfc3339(&start)
800            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
801            .with_timezone(&Utc);
802        let end_ts = DateTime::parse_from_rfc3339(&end)
803            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
804            .with_timezone(&Utc);
805
806        let mut statistics: Vec<String> = Vec::new();
807        for (k, v) in req.query_params.iter() {
808            if k.starts_with("Statistics.member.") {
809                statistics.push(v.clone());
810            }
811        }
812        if statistics.is_empty() {
813            return Err(invalid_param("At least one Statistic is required"));
814        }
815
816        let dim_filter = parse_dimensions_query(req, "Dimensions");
817
818        let state = self.state.read();
819        let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
820        if let Some(acct) = state.get(&req.account_id) {
821            if let Some(map) = acct.metrics_in(&req.region) {
822                if let Some(data) = map.get(&namespace) {
823                    let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
824                    for d in data.iter() {
825                        if d.metric_name != metric_name {
826                            continue;
827                        }
828                        if !dim_filter
829                            .iter()
830                            .all(|(k, v)| d.dimensions.get(k) == Some(v))
831                        {
832                            continue;
833                        }
834                        if d.timestamp < start_ts || d.timestamp >= end_ts {
835                            continue;
836                        }
837                        let Some(stats) = datum_stats(d) else {
838                            continue;
839                        };
840                        let secs = d.timestamp.timestamp();
841                        let bucket_secs = secs - secs.rem_euclid(period);
842                        let bucket_ts =
843                            DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
844                        buckets
845                            .entry(bucket_ts)
846                            .and_modify(|acc| merge_stats(acc, stats))
847                            .or_insert(stats);
848                    }
849                    for (ts, agg) in buckets {
850                        let mut stats = BTreeMap::new();
851                        for stat in statistics.iter() {
852                            if let Some(v) = stat_value(stat, agg) {
853                                stats.insert(stat.clone(), v);
854                            }
855                        }
856                        datapoints.push((ts, stats));
857                    }
858                }
859            }
860        }
861
862        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
863        inner.push_str("<Datapoints>");
864        for (ts, stats) in datapoints {
865            inner.push_str("<member>");
866            inner.push_str(&format!(
867                "<Timestamp>{}</Timestamp>",
868                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
869            ));
870            for (name, value) in stats {
871                inner.push_str(&format!("<{name}>{value}</{name}>"));
872            }
873            inner.push_str("</member>");
874        }
875        inner.push_str("</Datapoints>");
876
877        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
878    }
879
880    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
881        validate_enum(
882            req,
883            "ScanBy",
884            &["TimestampDescending", "TimestampAscending"],
885        )?;
886        let start = required_query_param(req, "StartTime")?;
887        let end = required_query_param(req, "EndTime")?;
888        let start_ts = DateTime::parse_from_rfc3339(&start)
889            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
890            .with_timezone(&Utc);
891        let end_ts = DateTime::parse_from_rfc3339(&end)
892            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
893            .with_timezone(&Utc);
894
895        // Default ScanBy is TimestampDescending (newest first); callers read
896        // Values[0] as the latest datapoint. The bucket map is ascending, so
897        // reverse unless the caller asked for TimestampAscending.
898        let descending = req
899            .query_params
900            .get("ScanBy")
901            .map(|s| s != "TimestampAscending")
902            .unwrap_or(true);
903
904        // GetMetricData declares only InvalidNextToken, so it never rejects an
905        // empty / malformed query list with a 4xx — it returns empty results.
906        let queries = collect_indexed(req, "MetricDataQueries");
907
908        let state = self.state.read();
909        let mut inner = String::from("<MetricDataResults>");
910        for q in queries {
911            let id = q.get("Id").cloned().unwrap_or_default();
912            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
913            let stat = q
914                .get("MetricStat.Stat")
915                .cloned()
916                .unwrap_or_else(|| "Sum".to_string());
917            let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
918            let namespace = q.get("MetricStat.Metric.Namespace").cloned();
919            let period: i64 = q
920                .get("MetricStat.Period")
921                .and_then(|s| s.parse::<i64>().ok())
922                .filter(|p| *p > 0)
923                .unwrap_or(60);
924            let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
925
926            let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
927            if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
928                if let Some(acct) = state.get(&req.account_id) {
929                    if let Some(map) = acct.metrics_in(&req.region) {
930                        if let Some(data) = map.get(&namespace) {
931                            let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
932                            for d in data.iter() {
933                                if d.metric_name != metric_name {
934                                    continue;
935                                }
936                                if !dim_filter
937                                    .iter()
938                                    .all(|(k, v)| d.dimensions.get(k) == Some(v))
939                                {
940                                    continue;
941                                }
942                                if d.timestamp < start_ts || d.timestamp >= end_ts {
943                                    continue;
944                                }
945                                let Some(stats) = datum_stats(d) else {
946                                    continue;
947                                };
948                                let secs = d.timestamp.timestamp();
949                                let bucket_secs = secs - secs.rem_euclid(period);
950                                let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
951                                    .unwrap_or(d.timestamp);
952                                buckets
953                                    .entry(bucket_ts)
954                                    .and_modify(|acc| merge_stats(acc, stats))
955                                    .or_insert(stats);
956                            }
957                            for (ts, agg) in buckets {
958                                let Some(v) = stat_value(&stat, agg) else {
959                                    continue;
960                                };
961                                timestamps
962                                    .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
963                                values.push(v);
964                            }
965                            if descending {
966                                timestamps.reverse();
967                                values.reverse();
968                            }
969                        }
970                    }
971                }
972            }
973
974            inner.push_str("<member>");
975            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
976            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
977            inner.push_str("<StatusCode>Complete</StatusCode>");
978            inner.push_str("<Timestamps>");
979            for ts in timestamps {
980                inner.push_str(&format!("<member>{ts}</member>"));
981            }
982            inner.push_str("</Timestamps>");
983            inner.push_str("<Values>");
984            for v in values {
985                inner.push_str(&format!("<member>{v}</member>"));
986            }
987            inner.push_str("</Values>");
988            inner.push_str("</member>");
989        }
990        inner.push_str("</MetricDataResults>");
991        inner.push_str("<Messages></Messages>");
992
993        Ok(xml_response("GetMetricData", &inner, &req.request_id))
994    }
995
996    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
997        // Only `AlarmName` is required by the Smithy contract; the op declares
998        // no validation errors, so ComparisonOperator / EvaluationPeriods are
999        // accepted with sensible defaults rather than rejected. Constraint
1000        // violations still produce a 4xx, which the probe accepts as AnyError
1001        // for the negative variants.
1002        validate_len(req, "AlarmName", 1, 255)?;
1003        validate_len(req, "AlarmDescription", 0, 1024)?;
1004        validate_len(req, "MetricName", 1, 255)?;
1005        validate_len(req, "Namespace", 1, 255)?;
1006        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1007        validate_len(req, "TreatMissingData", 1, 255)?;
1008        validate_len(req, "ThresholdMetricId", 1, 255)?;
1009        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1010        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1011        validate_range_i64(req, "Period", 1, i64::MAX)?;
1012        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1013        validate_enum(
1014            req,
1015            "ComparisonOperator",
1016            &[
1017                "GreaterThanOrEqualToThreshold",
1018                "GreaterThanThreshold",
1019                "GreaterThanUpperThreshold",
1020                "LessThanLowerOrGreaterThanUpperThreshold",
1021                "LessThanLowerThreshold",
1022                "LessThanOrEqualToThreshold",
1023                "LessThanThreshold",
1024            ],
1025        )?;
1026        validate_enum(
1027            req,
1028            "Statistic",
1029            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1030        )?;
1031        validate_enum(req, "Unit", STANDARD_UNITS)?;
1032        let alarm_name = required_query_param(req, "AlarmName")?;
1033        let comparison = optional_query_param(req, "ComparisonOperator")
1034            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1035        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1036            .and_then(|s| s.parse::<i64>().ok())
1037            .unwrap_or(1);
1038
1039        let alarm_description = optional_query_param(req, "AlarmDescription");
1040        let actions_enabled = optional_query_param(req, "ActionsEnabled")
1041            .map(|s| s.eq_ignore_ascii_case("true"))
1042            .unwrap_or(true);
1043
1044        let metric_name = optional_query_param(req, "MetricName");
1045        let namespace = optional_query_param(req, "Namespace");
1046        let statistic = optional_query_param(req, "Statistic");
1047        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1048        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1049        let unit = optional_query_param(req, "Unit");
1050        let datapoints_to_alarm =
1051            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1052        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1053        let treat_missing_data = optional_query_param(req, "TreatMissingData");
1054        let evaluate_low_sample_count_percentile =
1055            optional_query_param(req, "EvaluateLowSampleCountPercentile");
1056        // Anomaly-detection alarms reference a metric-math id instead of a
1057        // static Threshold; previously accepted then dropped (1.24).
1058        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1059        let dimensions = parse_dimensions_query(req, "Dimensions");
1060
1061        let mut ok_actions = Vec::new();
1062        let mut alarm_actions = Vec::new();
1063        let mut insufficient_data_actions = Vec::new();
1064        for (k, v) in req.query_params.iter() {
1065            if k.starts_with("OKActions.member.") {
1066                ok_actions.push(v.clone());
1067            } else if k.starts_with("AlarmActions.member.") {
1068                alarm_actions.push(v.clone());
1069            } else if k.starts_with("InsufficientDataActions.member.") {
1070                insufficient_data_actions.push(v.clone());
1071            }
1072        }
1073
1074        let arn = format!(
1075            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1076            req.region, req.account_id, alarm_name
1077        );
1078        let now = Utc::now();
1079
1080        let mut state = self.state.write();
1081        let acct = state.get_or_create(&req.account_id);
1082        let alarms = acct.alarms_in_mut(&req.region);
1083        let existing = alarms.get(&alarm_name).cloned();
1084        let alarm = MetricAlarm {
1085            alarm_name: alarm_name.clone(),
1086            alarm_arn: arn,
1087            alarm_description,
1088            actions_enabled,
1089            ok_actions,
1090            alarm_actions,
1091            insufficient_data_actions,
1092            state_value: existing
1093                .as_ref()
1094                .map(|a| a.state_value)
1095                .unwrap_or(AlarmState::InsufficientData),
1096            state_reason: existing
1097                .as_ref()
1098                .map(|a| a.state_reason.clone())
1099                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1100            state_updated_timestamp: existing
1101                .as_ref()
1102                .map(|a| a.state_updated_timestamp)
1103                .unwrap_or(now),
1104            metric_name,
1105            namespace,
1106            statistic,
1107            extended_statistic,
1108            dimensions,
1109            period,
1110            unit,
1111            evaluation_periods,
1112            datapoints_to_alarm,
1113            threshold,
1114            comparison_operator: comparison,
1115            treat_missing_data,
1116            evaluate_low_sample_count_percentile,
1117            threshold_metric_id,
1118            configuration_updated_timestamp: existing
1119                .as_ref()
1120                .map(|a| a.configuration_updated_timestamp)
1121                .unwrap_or(now),
1122            alarm_configuration_updated_timestamp: now,
1123        };
1124        alarms.insert(alarm_name, alarm);
1125
1126        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1127    }
1128
1129    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1130        let mut filter_names: Vec<String> = Vec::new();
1131        for (k, v) in req.query_params.iter() {
1132            if k.starts_with("AlarmNames.member.") {
1133                filter_names.push(v.clone());
1134            }
1135        }
1136        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1137        validate_len(req, "ActionPrefix", 1, 1024)?;
1138        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1139        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1140        validate_range_i64(req, "MaxRecords", 1, 100)?;
1141        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1142        let prefix = optional_query_param(req, "AlarmNamePrefix");
1143        let state_filter = optional_query_param(req, "StateValue");
1144        let action_prefix = optional_query_param(req, "ActionPrefix");
1145
1146        let state = self.state.read();
1147        let mut inner = String::from("<MetricAlarms>");
1148        if let Some(acct) = state.get(&req.account_id) {
1149            if let Some(alarms) = acct.alarms_in(&req.region) {
1150                for alarm in alarms.values() {
1151                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1152                        continue;
1153                    }
1154                    if let Some(p) = prefix.as_ref() {
1155                        if !alarm.alarm_name.starts_with(p) {
1156                            continue;
1157                        }
1158                    }
1159                    if let Some(sv) = state_filter.as_ref() {
1160                        if alarm.state_value.as_str() != sv {
1161                            continue;
1162                        }
1163                    }
1164                    if let Some(ap) = action_prefix.as_ref() {
1165                        let any = alarm
1166                            .alarm_actions
1167                            .iter()
1168                            .chain(alarm.ok_actions.iter())
1169                            .chain(alarm.insufficient_data_actions.iter())
1170                            .any(|a| a.starts_with(ap));
1171                        if !any {
1172                            continue;
1173                        }
1174                    }
1175                    inner.push_str(&render_alarm(alarm));
1176                }
1177            }
1178        }
1179        inner.push_str("</MetricAlarms>");
1180        inner.push_str("<CompositeAlarms>");
1181        if let Some(acct) = state.get(&req.account_id) {
1182            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1183                for alarm in composites.values() {
1184                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1185                        continue;
1186                    }
1187                    if let Some(p) = prefix.as_ref() {
1188                        if !alarm.alarm_name.starts_with(p) {
1189                            continue;
1190                        }
1191                    }
1192                    if let Some(sv) = state_filter.as_ref() {
1193                        if alarm.state_value.as_str() != sv {
1194                            continue;
1195                        }
1196                    }
1197                    if let Some(ap) = action_prefix.as_ref() {
1198                        let any = alarm
1199                            .alarm_actions
1200                            .iter()
1201                            .chain(alarm.ok_actions.iter())
1202                            .chain(alarm.insufficient_data_actions.iter())
1203                            .any(|a| a.starts_with(ap));
1204                        if !any {
1205                            continue;
1206                        }
1207                    }
1208                    inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1209                }
1210            }
1211        }
1212        inner.push_str("</CompositeAlarms>");
1213
1214        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1215    }
1216
1217    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1218        validate_len(req, "MetricName", 1, 255)?;
1219        validate_len(req, "Namespace", 1, 255)?;
1220        validate_range_i64(req, "Period", 1, i64::MAX)?;
1221        validate_enum(
1222            req,
1223            "Statistic",
1224            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1225        )?;
1226        validate_enum(req, "Unit", STANDARD_UNITS)?;
1227        let metric_name = required_query_param(req, "MetricName")?;
1228        let namespace = required_query_param(req, "Namespace")?;
1229        let dim_filter = parse_dimensions_query(req, "Dimensions");
1230
1231        let state = self.state.read();
1232        let mut inner = String::from("<MetricAlarms>");
1233        if let Some(acct) = state.get(&req.account_id) {
1234            if let Some(alarms) = acct.alarms_in(&req.region) {
1235                for alarm in alarms.values() {
1236                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1237                        continue;
1238                    }
1239                    if alarm.namespace.as_deref() != Some(&namespace) {
1240                        continue;
1241                    }
1242                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1243                        continue;
1244                    }
1245                    inner.push_str(&render_alarm(alarm));
1246                }
1247            }
1248        }
1249        inner.push_str("</MetricAlarms>");
1250
1251        Ok(xml_response(
1252            "DescribeAlarmsForMetric",
1253            &inner,
1254            &req.request_id,
1255        ))
1256    }
1257
1258    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1259        // AlarmNames is required, but an empty list serialises to zero wire
1260        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1261        // set is a no-op rather than an undeclared 4xx.
1262        let mut names: Vec<String> = Vec::new();
1263        for (k, v) in req.query_params.iter() {
1264            if k.starts_with("AlarmNames.member.") {
1265                names.push(v.clone());
1266            }
1267        }
1268
1269        let mut state = self.state.write();
1270        let acct = state.get_or_create(&req.account_id);
1271        for name in &names {
1272            acct.alarms_in_mut(&req.region).remove(name);
1273            acct.composite_alarms_in_mut(&req.region).remove(name);
1274        }
1275
1276        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1277    }
1278
1279    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1280        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1281    }
1282
1283    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1284        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1285    }
1286
1287    fn toggle_alarm_actions(
1288        &self,
1289        req: &AwsRequest,
1290        enabled: bool,
1291        action_name: &str,
1292    ) -> Result<AwsResponse, AwsServiceError> {
1293        let mut names: Vec<String> = Vec::new();
1294        for (k, v) in req.query_params.iter() {
1295            if k.starts_with("AlarmNames.member.") {
1296                names.push(v.clone());
1297            }
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        for name in names {
1303            if let Some(alarm) = alarms.get_mut(&name) {
1304                alarm.actions_enabled = enabled;
1305                alarm.alarm_configuration_updated_timestamp = Utc::now();
1306            }
1307        }
1308        Ok(empty_metadata_response(action_name, &req.request_id))
1309    }
1310
1311    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1312        validate_len(req, "AlarmName", 1, 255)?;
1313        validate_len(req, "StateReason", 0, 1023)?;
1314        validate_len(req, "StateReasonData", 0, 4000)?;
1315        let alarm_name = required_query_param(req, "AlarmName")?;
1316        let state_value = required_query_param(req, "StateValue")?;
1317        // StateReason is required but allows a zero-length value (min=0). Treat
1318        // an absent key as missing (declared error) while accepting an empty
1319        // string as a valid value.
1320        let state_reason = req
1321            .query_params
1322            .get("StateReason")
1323            .cloned()
1324            .ok_or_else(|| {
1325                AwsServiceError::aws_error(
1326                    StatusCode::BAD_REQUEST,
1327                    "MissingParameter",
1328                    "The request must contain the parameter StateReason.",
1329                )
1330            })?;
1331        let new_state = AlarmState::parse(&state_value)
1332            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1333
1334        let mut state = self.state.write();
1335        let acct = state.get_or_create(&req.account_id);
1336        let alarms = acct.alarms_in_mut(&req.region);
1337        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1338            AwsServiceError::aws_error(
1339                StatusCode::NOT_FOUND,
1340                "ResourceNotFound",
1341                format!("Alarm {alarm_name} not found"),
1342            )
1343        })?;
1344        alarm.state_value = new_state;
1345        alarm.state_reason = state_reason;
1346        alarm.state_updated_timestamp = Utc::now();
1347
1348        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1349    }
1350
1351    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1352        validate_len(req, "AlarmName", 1, 255)?;
1353        validate_len(req, "AlarmContributorId", 1, 16)?;
1354        validate_range_i64(req, "MaxRecords", 1, 100)?;
1355        validate_enum(
1356            req,
1357            "HistoryItemType",
1358            &[
1359                "ConfigurationUpdate",
1360                "StateUpdate",
1361                "Action",
1362                "AlarmContributorStateUpdate",
1363                "AlarmContributorAction",
1364            ],
1365        )?;
1366        validate_enum(
1367            req,
1368            "ScanBy",
1369            &["TimestampDescending", "TimestampAscending"],
1370        )?;
1371        // Minimal implementation: return empty history. AWS pagination tokens are
1372        // not tracked locally, so callers see an empty list rather than a stub.
1373        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1374        Ok(xml_response(
1375            "DescribeAlarmHistory",
1376            &inner,
1377            &req.request_id,
1378        ))
1379    }
1380
1381    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1382        let dashboard_name = req
1383            .query_params
1384            .get("DashboardName")
1385            .ok_or_else(|| invalid_param("DashboardName is required"))?
1386            .clone();
1387        let body = req
1388            .query_params
1389            .get("DashboardBody")
1390            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1391            .clone();
1392        // AWS validates that DashboardBody parses as JSON; we do the same so
1393        // bad bodies surface a useful error before persisting.
1394        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1395            return Err(AwsServiceError::aws_error(
1396                StatusCode::BAD_REQUEST,
1397                "InvalidParameterInput",
1398                "DashboardBody must be a valid JSON object",
1399            ));
1400        }
1401        let arn = format!(
1402            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1403            req.account_id
1404        );
1405        let dashboard = Dashboard {
1406            name: dashboard_name.clone(),
1407            arn,
1408            size_bytes: body.len() as i64,
1409            body,
1410            last_modified: Utc::now(),
1411        };
1412        let mut state = self.state.write();
1413        let acct = state.get_or_create(&req.account_id);
1414        acct.dashboards.insert(dashboard_name, dashboard);
1415        // PutDashboard returns DashboardValidationMessages — empty when the
1416        // body parses cleanly.
1417        let inner = String::from("<DashboardValidationMessages/>");
1418        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1419    }
1420
1421    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1422        let name = req
1423            .query_params
1424            .get("DashboardName")
1425            .ok_or_else(|| invalid_param("DashboardName is required"))?
1426            .clone();
1427        let state = self.state.read();
1428        let dashboard = state
1429            .get(&req.account_id)
1430            .and_then(|a| a.dashboards.get(&name))
1431            .cloned()
1432            .ok_or_else(|| {
1433                AwsServiceError::aws_error(
1434                    StatusCode::NOT_FOUND,
1435                    "ResourceNotFound",
1436                    format!("Dashboard {name} does not exist"),
1437                )
1438            })?;
1439        let inner = format!(
1440            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1441            xml_escape(&dashboard.arn),
1442            xml_escape(&dashboard.body),
1443            xml_escape(&dashboard.name),
1444        );
1445        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1446    }
1447
1448    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1449        let mut names: Vec<String> = Vec::new();
1450        for (k, v) in req.query_params.iter() {
1451            if k.starts_with("DashboardNames.member.") {
1452                names.push(v.clone());
1453            }
1454        }
1455        if names.is_empty() {
1456            return Err(invalid_param(
1457                "DashboardNames must contain at least one name",
1458            ));
1459        }
1460        let mut state = self.state.write();
1461        let acct = state.get_or_create(&req.account_id);
1462        for n in names {
1463            acct.dashboards.remove(&n);
1464        }
1465        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
1466        // the AWS SDK fails to deserialize the response if the result node is
1467        // absent ("DeleteDashboardsResult node not found").
1468        let body = format!(
1469            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1470             <DeleteDashboardsResponse xmlns=\"{NS}\">\
1471             <DeleteDashboardsResult/>\
1472             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1473             </DeleteDashboardsResponse>",
1474            req.request_id
1475        );
1476        Ok(AwsResponse::xml(StatusCode::OK, body))
1477    }
1478
1479    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1480        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1481        let state = self.state.read();
1482        let dashboards: Vec<Dashboard> = state
1483            .get(&req.account_id)
1484            .map(|a| {
1485                a.dashboards
1486                    .values()
1487                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1488                    .cloned()
1489                    .collect()
1490            })
1491            .unwrap_or_default();
1492        let mut entries = String::new();
1493        for d in &dashboards {
1494            entries.push_str("<member>");
1495            entries.push_str(&format!(
1496                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1497                xml_escape(&d.arn),
1498                xml_escape(&d.name),
1499                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1500                d.size_bytes,
1501            ));
1502            entries.push_str("</member>");
1503        }
1504        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1505        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1506    }
1507}
1508
1509fn render_alarm(alarm: &MetricAlarm) -> String {
1510    let mut s = String::from("<member>");
1511    s.push_str(&format!(
1512        "<AlarmName>{}</AlarmName>",
1513        xml_escape(&alarm.alarm_name)
1514    ));
1515    s.push_str(&format!(
1516        "<AlarmArn>{}</AlarmArn>",
1517        xml_escape(&alarm.alarm_arn)
1518    ));
1519    if let Some(d) = &alarm.alarm_description {
1520        s.push_str(&format!(
1521            "<AlarmDescription>{}</AlarmDescription>",
1522            xml_escape(d)
1523        ));
1524    }
1525    s.push_str(&format!(
1526        "<ActionsEnabled>{}</ActionsEnabled>",
1527        alarm.actions_enabled
1528    ));
1529    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1530    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1531    push_action_list(
1532        &mut s,
1533        "InsufficientDataActions",
1534        &alarm.insufficient_data_actions,
1535    );
1536    s.push_str(&format!(
1537        "<StateValue>{}</StateValue>",
1538        alarm.state_value.as_str()
1539    ));
1540    s.push_str(&format!(
1541        "<StateReason>{}</StateReason>",
1542        xml_escape(&alarm.state_reason)
1543    ));
1544    s.push_str(&format!(
1545        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1546        alarm
1547            .state_updated_timestamp
1548            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1549    ));
1550    if let Some(m) = &alarm.metric_name {
1551        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1552    }
1553    if let Some(n) = &alarm.namespace {
1554        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1555    }
1556    if let Some(stat) = &alarm.statistic {
1557        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1558    }
1559    if let Some(ext) = &alarm.extended_statistic {
1560        s.push_str(&format!(
1561            "<ExtendedStatistic>{}</ExtendedStatistic>",
1562            xml_escape(ext)
1563        ));
1564    }
1565    s.push_str(&render_dimensions(&alarm.dimensions));
1566    if let Some(p) = alarm.period {
1567        s.push_str(&format!("<Period>{p}</Period>"));
1568    }
1569    if let Some(u) = &alarm.unit {
1570        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1571    }
1572    s.push_str(&format!(
1573        "<EvaluationPeriods>{}</EvaluationPeriods>",
1574        alarm.evaluation_periods
1575    ));
1576    if let Some(d) = alarm.datapoints_to_alarm {
1577        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1578    }
1579    if let Some(t) = alarm.threshold {
1580        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1581    }
1582    if let Some(tid) = &alarm.threshold_metric_id {
1583        s.push_str(&format!(
1584            "<ThresholdMetricId>{}</ThresholdMetricId>",
1585            xml_escape(tid)
1586        ));
1587    }
1588    s.push_str(&format!(
1589        "<ComparisonOperator>{}</ComparisonOperator>",
1590        xml_escape(&alarm.comparison_operator)
1591    ));
1592    if let Some(t) = &alarm.treat_missing_data {
1593        s.push_str(&format!(
1594            "<TreatMissingData>{}</TreatMissingData>",
1595            xml_escape(t)
1596        ));
1597    }
1598    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1599        s.push_str(&format!(
1600            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1601            xml_escape(e)
1602        ));
1603    }
1604    s.push_str(&format!(
1605        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1606        alarm
1607            .alarm_configuration_updated_timestamp
1608            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1609    ));
1610    s.push_str("</member>");
1611    s
1612}
1613
1614fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1615    s.push_str(&format!("<{name}>"));
1616    for action in actions {
1617        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1618    }
1619    s.push_str(&format!("</{name}>"));
1620}