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        // When a Unit is given, only datapoints published with that exact unit
818        // are aggregated (AWS treats an unspecified unit as "None"); otherwise
819        // mixing units gives a meaningless statistic.
820        let unit_filter = req.query_params.get("Unit").cloned();
821
822        let state = self.state.read();
823        let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
824        if let Some(acct) = state.get(&req.account_id) {
825            if let Some(map) = acct.metrics_in(&req.region) {
826                if let Some(data) = map.get(&namespace) {
827                    let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
828                    for d in data.iter() {
829                        if d.metric_name != metric_name {
830                            continue;
831                        }
832                        if let Some(uf) = &unit_filter {
833                            if d.unit.as_deref().unwrap_or("None") != uf {
834                                continue;
835                            }
836                        }
837                        if !dim_filter
838                            .iter()
839                            .all(|(k, v)| d.dimensions.get(k) == Some(v))
840                        {
841                            continue;
842                        }
843                        if d.timestamp < start_ts || d.timestamp >= end_ts {
844                            continue;
845                        }
846                        let Some(stats) = datum_stats(d) else {
847                            continue;
848                        };
849                        let secs = d.timestamp.timestamp();
850                        let bucket_secs = secs - secs.rem_euclid(period);
851                        let bucket_ts =
852                            DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
853                        buckets
854                            .entry(bucket_ts)
855                            .and_modify(|acc| merge_stats(acc, stats))
856                            .or_insert(stats);
857                    }
858                    for (ts, agg) in buckets {
859                        let mut stats = BTreeMap::new();
860                        for stat in statistics.iter() {
861                            if let Some(v) = stat_value(stat, agg) {
862                                stats.insert(stat.clone(), v);
863                            }
864                        }
865                        datapoints.push((ts, stats));
866                    }
867                }
868            }
869        }
870
871        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
872        inner.push_str("<Datapoints>");
873        for (ts, stats) in datapoints {
874            inner.push_str("<member>");
875            inner.push_str(&format!(
876                "<Timestamp>{}</Timestamp>",
877                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
878            ));
879            for (name, value) in stats {
880                inner.push_str(&format!("<{name}>{value}</{name}>"));
881            }
882            inner.push_str("</member>");
883        }
884        inner.push_str("</Datapoints>");
885
886        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
887    }
888
889    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
890        validate_enum(
891            req,
892            "ScanBy",
893            &["TimestampDescending", "TimestampAscending"],
894        )?;
895        let start = required_query_param(req, "StartTime")?;
896        let end = required_query_param(req, "EndTime")?;
897        let start_ts = DateTime::parse_from_rfc3339(&start)
898            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
899            .with_timezone(&Utc);
900        let end_ts = DateTime::parse_from_rfc3339(&end)
901            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
902            .with_timezone(&Utc);
903
904        // Default ScanBy is TimestampDescending (newest first); callers read
905        // Values[0] as the latest datapoint. The bucket map is ascending, so
906        // reverse unless the caller asked for TimestampAscending.
907        let descending = req
908            .query_params
909            .get("ScanBy")
910            .map(|s| s != "TimestampAscending")
911            .unwrap_or(true);
912
913        // GetMetricData declares only InvalidNextToken, so it never rejects an
914        // empty / malformed query list with a 4xx — it returns empty results.
915        let queries = collect_indexed(req, "MetricDataQueries");
916
917        let state = self.state.read();
918        let mut inner = String::from("<MetricDataResults>");
919        for q in queries {
920            let id = q.get("Id").cloned().unwrap_or_default();
921            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
922            let stat = q
923                .get("MetricStat.Stat")
924                .cloned()
925                .unwrap_or_else(|| "Sum".to_string());
926            let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
927            let namespace = q.get("MetricStat.Metric.Namespace").cloned();
928            let period: i64 = q
929                .get("MetricStat.Period")
930                .and_then(|s| s.parse::<i64>().ok())
931                .filter(|p| *p > 0)
932                .unwrap_or(60);
933            let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
934
935            let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
936            if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
937                if let Some(acct) = state.get(&req.account_id) {
938                    if let Some(map) = acct.metrics_in(&req.region) {
939                        if let Some(data) = map.get(&namespace) {
940                            let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
941                            for d in data.iter() {
942                                if d.metric_name != metric_name {
943                                    continue;
944                                }
945                                if !dim_filter
946                                    .iter()
947                                    .all(|(k, v)| d.dimensions.get(k) == Some(v))
948                                {
949                                    continue;
950                                }
951                                if d.timestamp < start_ts || d.timestamp >= end_ts {
952                                    continue;
953                                }
954                                let Some(stats) = datum_stats(d) else {
955                                    continue;
956                                };
957                                let secs = d.timestamp.timestamp();
958                                let bucket_secs = secs - secs.rem_euclid(period);
959                                let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
960                                    .unwrap_or(d.timestamp);
961                                buckets
962                                    .entry(bucket_ts)
963                                    .and_modify(|acc| merge_stats(acc, stats))
964                                    .or_insert(stats);
965                            }
966                            for (ts, agg) in buckets {
967                                let Some(v) = stat_value(&stat, agg) else {
968                                    continue;
969                                };
970                                timestamps
971                                    .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
972                                values.push(v);
973                            }
974                            if descending {
975                                timestamps.reverse();
976                                values.reverse();
977                            }
978                        }
979                    }
980                }
981            }
982
983            inner.push_str("<member>");
984            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
985            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
986            inner.push_str("<StatusCode>Complete</StatusCode>");
987            inner.push_str("<Timestamps>");
988            for ts in timestamps {
989                inner.push_str(&format!("<member>{ts}</member>"));
990            }
991            inner.push_str("</Timestamps>");
992            inner.push_str("<Values>");
993            for v in values {
994                inner.push_str(&format!("<member>{v}</member>"));
995            }
996            inner.push_str("</Values>");
997            inner.push_str("</member>");
998        }
999        inner.push_str("</MetricDataResults>");
1000        inner.push_str("<Messages></Messages>");
1001
1002        Ok(xml_response("GetMetricData", &inner, &req.request_id))
1003    }
1004
1005    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1006        // Only `AlarmName` is required by the Smithy contract; the op declares
1007        // no validation errors, so ComparisonOperator / EvaluationPeriods are
1008        // accepted with sensible defaults rather than rejected. Constraint
1009        // violations still produce a 4xx, which the probe accepts as AnyError
1010        // for the negative variants.
1011        validate_len(req, "AlarmName", 1, 255)?;
1012        validate_len(req, "AlarmDescription", 0, 1024)?;
1013        validate_len(req, "MetricName", 1, 255)?;
1014        validate_len(req, "Namespace", 1, 255)?;
1015        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1016        validate_len(req, "TreatMissingData", 1, 255)?;
1017        validate_len(req, "ThresholdMetricId", 1, 255)?;
1018        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1019        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1020        validate_range_i64(req, "Period", 1, i64::MAX)?;
1021        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1022        validate_enum(
1023            req,
1024            "ComparisonOperator",
1025            &[
1026                "GreaterThanOrEqualToThreshold",
1027                "GreaterThanThreshold",
1028                "GreaterThanUpperThreshold",
1029                "LessThanLowerOrGreaterThanUpperThreshold",
1030                "LessThanLowerThreshold",
1031                "LessThanOrEqualToThreshold",
1032                "LessThanThreshold",
1033            ],
1034        )?;
1035        validate_enum(
1036            req,
1037            "Statistic",
1038            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1039        )?;
1040        validate_enum(req, "Unit", STANDARD_UNITS)?;
1041        let alarm_name = required_query_param(req, "AlarmName")?;
1042        let comparison = optional_query_param(req, "ComparisonOperator")
1043            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1044        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1045            .and_then(|s| s.parse::<i64>().ok())
1046            .unwrap_or(1);
1047
1048        let alarm_description = optional_query_param(req, "AlarmDescription");
1049        let actions_enabled = optional_query_param(req, "ActionsEnabled")
1050            .map(|s| s.eq_ignore_ascii_case("true"))
1051            .unwrap_or(true);
1052
1053        let metric_name = optional_query_param(req, "MetricName");
1054        let namespace = optional_query_param(req, "Namespace");
1055        let statistic = optional_query_param(req, "Statistic");
1056        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1057        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1058        let unit = optional_query_param(req, "Unit");
1059        let datapoints_to_alarm =
1060            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1061        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1062        let treat_missing_data = optional_query_param(req, "TreatMissingData");
1063        let evaluate_low_sample_count_percentile =
1064            optional_query_param(req, "EvaluateLowSampleCountPercentile");
1065        // Anomaly-detection alarms reference a metric-math id instead of a
1066        // static Threshold; previously accepted then dropped (1.24).
1067        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1068        let dimensions = parse_dimensions_query(req, "Dimensions");
1069
1070        let mut ok_actions = Vec::new();
1071        let mut alarm_actions = Vec::new();
1072        let mut insufficient_data_actions = Vec::new();
1073        for (k, v) in req.query_params.iter() {
1074            if k.starts_with("OKActions.member.") {
1075                ok_actions.push(v.clone());
1076            } else if k.starts_with("AlarmActions.member.") {
1077                alarm_actions.push(v.clone());
1078            } else if k.starts_with("InsufficientDataActions.member.") {
1079                insufficient_data_actions.push(v.clone());
1080            }
1081        }
1082
1083        let arn = format!(
1084            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1085            req.region, req.account_id, alarm_name
1086        );
1087        let now = Utc::now();
1088
1089        let mut state = self.state.write();
1090        let acct = state.get_or_create(&req.account_id);
1091        let alarms = acct.alarms_in_mut(&req.region);
1092        let existing = alarms.get(&alarm_name).cloned();
1093        let alarm = MetricAlarm {
1094            alarm_name: alarm_name.clone(),
1095            alarm_arn: arn,
1096            alarm_description,
1097            actions_enabled,
1098            ok_actions,
1099            alarm_actions,
1100            insufficient_data_actions,
1101            state_value: existing
1102                .as_ref()
1103                .map(|a| a.state_value)
1104                .unwrap_or(AlarmState::InsufficientData),
1105            state_reason: existing
1106                .as_ref()
1107                .map(|a| a.state_reason.clone())
1108                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1109            state_updated_timestamp: existing
1110                .as_ref()
1111                .map(|a| a.state_updated_timestamp)
1112                .unwrap_or(now),
1113            metric_name,
1114            namespace,
1115            statistic,
1116            extended_statistic,
1117            dimensions,
1118            period,
1119            unit,
1120            evaluation_periods,
1121            datapoints_to_alarm,
1122            threshold,
1123            comparison_operator: comparison,
1124            treat_missing_data,
1125            evaluate_low_sample_count_percentile,
1126            threshold_metric_id,
1127            configuration_updated_timestamp: existing
1128                .as_ref()
1129                .map(|a| a.configuration_updated_timestamp)
1130                .unwrap_or(now),
1131            alarm_configuration_updated_timestamp: now,
1132        };
1133        alarms.insert(alarm_name, alarm);
1134
1135        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1136    }
1137
1138    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1139        let mut filter_names: Vec<String> = Vec::new();
1140        for (k, v) in req.query_params.iter() {
1141            if k.starts_with("AlarmNames.member.") {
1142                filter_names.push(v.clone());
1143            }
1144        }
1145        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1146        validate_len(req, "ActionPrefix", 1, 1024)?;
1147        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1148        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1149        validate_range_i64(req, "MaxRecords", 1, 100)?;
1150        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1151        let prefix = optional_query_param(req, "AlarmNamePrefix");
1152        let state_filter = optional_query_param(req, "StateValue");
1153        let action_prefix = optional_query_param(req, "ActionPrefix");
1154
1155        let state = self.state.read();
1156        let mut inner = String::from("<MetricAlarms>");
1157        if let Some(acct) = state.get(&req.account_id) {
1158            if let Some(alarms) = acct.alarms_in(&req.region) {
1159                for alarm in alarms.values() {
1160                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1161                        continue;
1162                    }
1163                    if let Some(p) = prefix.as_ref() {
1164                        if !alarm.alarm_name.starts_with(p) {
1165                            continue;
1166                        }
1167                    }
1168                    if let Some(sv) = state_filter.as_ref() {
1169                        if alarm.state_value.as_str() != sv {
1170                            continue;
1171                        }
1172                    }
1173                    if let Some(ap) = action_prefix.as_ref() {
1174                        let any = alarm
1175                            .alarm_actions
1176                            .iter()
1177                            .chain(alarm.ok_actions.iter())
1178                            .chain(alarm.insufficient_data_actions.iter())
1179                            .any(|a| a.starts_with(ap));
1180                        if !any {
1181                            continue;
1182                        }
1183                    }
1184                    inner.push_str(&render_alarm(alarm));
1185                }
1186            }
1187        }
1188        inner.push_str("</MetricAlarms>");
1189        inner.push_str("<CompositeAlarms>");
1190        if let Some(acct) = state.get(&req.account_id) {
1191            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1192                for alarm in composites.values() {
1193                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1194                        continue;
1195                    }
1196                    if let Some(p) = prefix.as_ref() {
1197                        if !alarm.alarm_name.starts_with(p) {
1198                            continue;
1199                        }
1200                    }
1201                    if let Some(sv) = state_filter.as_ref() {
1202                        if alarm.state_value.as_str() != sv {
1203                            continue;
1204                        }
1205                    }
1206                    if let Some(ap) = action_prefix.as_ref() {
1207                        let any = alarm
1208                            .alarm_actions
1209                            .iter()
1210                            .chain(alarm.ok_actions.iter())
1211                            .chain(alarm.insufficient_data_actions.iter())
1212                            .any(|a| a.starts_with(ap));
1213                        if !any {
1214                            continue;
1215                        }
1216                    }
1217                    inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1218                }
1219            }
1220        }
1221        inner.push_str("</CompositeAlarms>");
1222
1223        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1224    }
1225
1226    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1227        validate_len(req, "MetricName", 1, 255)?;
1228        validate_len(req, "Namespace", 1, 255)?;
1229        validate_range_i64(req, "Period", 1, i64::MAX)?;
1230        validate_enum(
1231            req,
1232            "Statistic",
1233            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1234        )?;
1235        validate_enum(req, "Unit", STANDARD_UNITS)?;
1236        let metric_name = required_query_param(req, "MetricName")?;
1237        let namespace = required_query_param(req, "Namespace")?;
1238        let dim_filter = parse_dimensions_query(req, "Dimensions");
1239
1240        let state = self.state.read();
1241        let mut inner = String::from("<MetricAlarms>");
1242        if let Some(acct) = state.get(&req.account_id) {
1243            if let Some(alarms) = acct.alarms_in(&req.region) {
1244                for alarm in alarms.values() {
1245                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1246                        continue;
1247                    }
1248                    if alarm.namespace.as_deref() != Some(&namespace) {
1249                        continue;
1250                    }
1251                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1252                        continue;
1253                    }
1254                    inner.push_str(&render_alarm(alarm));
1255                }
1256            }
1257        }
1258        inner.push_str("</MetricAlarms>");
1259
1260        Ok(xml_response(
1261            "DescribeAlarmsForMetric",
1262            &inner,
1263            &req.request_id,
1264        ))
1265    }
1266
1267    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1268        // AlarmNames is required, but an empty list serialises to zero wire
1269        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1270        // set is a no-op rather than an undeclared 4xx.
1271        let mut names: Vec<String> = Vec::new();
1272        for (k, v) in req.query_params.iter() {
1273            if k.starts_with("AlarmNames.member.") {
1274                names.push(v.clone());
1275            }
1276        }
1277
1278        let mut state = self.state.write();
1279        let acct = state.get_or_create(&req.account_id);
1280        for name in &names {
1281            acct.alarms_in_mut(&req.region).remove(name);
1282            acct.composite_alarms_in_mut(&req.region).remove(name);
1283        }
1284
1285        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1286    }
1287
1288    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1289        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1290    }
1291
1292    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1293        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1294    }
1295
1296    fn toggle_alarm_actions(
1297        &self,
1298        req: &AwsRequest,
1299        enabled: bool,
1300        action_name: &str,
1301    ) -> Result<AwsResponse, AwsServiceError> {
1302        let mut names: Vec<String> = Vec::new();
1303        for (k, v) in req.query_params.iter() {
1304            if k.starts_with("AlarmNames.member.") {
1305                names.push(v.clone());
1306            }
1307        }
1308        let mut state = self.state.write();
1309        let acct = state.get_or_create(&req.account_id);
1310        let alarms = acct.alarms_in_mut(&req.region);
1311        for name in names {
1312            if let Some(alarm) = alarms.get_mut(&name) {
1313                alarm.actions_enabled = enabled;
1314                alarm.alarm_configuration_updated_timestamp = Utc::now();
1315            }
1316        }
1317        Ok(empty_metadata_response(action_name, &req.request_id))
1318    }
1319
1320    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1321        validate_len(req, "AlarmName", 1, 255)?;
1322        validate_len(req, "StateReason", 0, 1023)?;
1323        validate_len(req, "StateReasonData", 0, 4000)?;
1324        let alarm_name = required_query_param(req, "AlarmName")?;
1325        let state_value = required_query_param(req, "StateValue")?;
1326        // StateReason is required but allows a zero-length value (min=0). Treat
1327        // an absent key as missing (declared error) while accepting an empty
1328        // string as a valid value.
1329        let state_reason = req
1330            .query_params
1331            .get("StateReason")
1332            .cloned()
1333            .ok_or_else(|| {
1334                AwsServiceError::aws_error(
1335                    StatusCode::BAD_REQUEST,
1336                    "MissingParameter",
1337                    "The request must contain the parameter StateReason.",
1338                )
1339            })?;
1340        let new_state = AlarmState::parse(&state_value)
1341            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1342
1343        let mut state = self.state.write();
1344        let acct = state.get_or_create(&req.account_id);
1345        let alarms = acct.alarms_in_mut(&req.region);
1346        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1347            AwsServiceError::aws_error(
1348                StatusCode::NOT_FOUND,
1349                "ResourceNotFound",
1350                format!("Alarm {alarm_name} not found"),
1351            )
1352        })?;
1353        alarm.state_value = new_state;
1354        alarm.state_reason = state_reason;
1355        alarm.state_updated_timestamp = Utc::now();
1356
1357        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1358    }
1359
1360    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1361        validate_len(req, "AlarmName", 1, 255)?;
1362        validate_len(req, "AlarmContributorId", 1, 16)?;
1363        validate_range_i64(req, "MaxRecords", 1, 100)?;
1364        validate_enum(
1365            req,
1366            "HistoryItemType",
1367            &[
1368                "ConfigurationUpdate",
1369                "StateUpdate",
1370                "Action",
1371                "AlarmContributorStateUpdate",
1372                "AlarmContributorAction",
1373            ],
1374        )?;
1375        validate_enum(
1376            req,
1377            "ScanBy",
1378            &["TimestampDescending", "TimestampAscending"],
1379        )?;
1380        // Minimal implementation: return empty history. AWS pagination tokens are
1381        // not tracked locally, so callers see an empty list rather than a stub.
1382        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1383        Ok(xml_response(
1384            "DescribeAlarmHistory",
1385            &inner,
1386            &req.request_id,
1387        ))
1388    }
1389
1390    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1391        let dashboard_name = req
1392            .query_params
1393            .get("DashboardName")
1394            .ok_or_else(|| invalid_param("DashboardName is required"))?
1395            .clone();
1396        let body = req
1397            .query_params
1398            .get("DashboardBody")
1399            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1400            .clone();
1401        // AWS validates that DashboardBody parses as JSON; we do the same so
1402        // bad bodies surface a useful error before persisting.
1403        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1404            return Err(AwsServiceError::aws_error(
1405                StatusCode::BAD_REQUEST,
1406                "InvalidParameterInput",
1407                "DashboardBody must be a valid JSON object",
1408            ));
1409        }
1410        let arn = format!(
1411            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1412            req.account_id
1413        );
1414        let dashboard = Dashboard {
1415            name: dashboard_name.clone(),
1416            arn,
1417            size_bytes: body.len() as i64,
1418            body,
1419            last_modified: Utc::now(),
1420        };
1421        let mut state = self.state.write();
1422        let acct = state.get_or_create(&req.account_id);
1423        acct.dashboards.insert(dashboard_name, dashboard);
1424        // PutDashboard returns DashboardValidationMessages — empty when the
1425        // body parses cleanly.
1426        let inner = String::from("<DashboardValidationMessages/>");
1427        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1428    }
1429
1430    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1431        let name = req
1432            .query_params
1433            .get("DashboardName")
1434            .ok_or_else(|| invalid_param("DashboardName is required"))?
1435            .clone();
1436        let state = self.state.read();
1437        let dashboard = state
1438            .get(&req.account_id)
1439            .and_then(|a| a.dashboards.get(&name))
1440            .cloned()
1441            .ok_or_else(|| {
1442                AwsServiceError::aws_error(
1443                    StatusCode::NOT_FOUND,
1444                    "ResourceNotFound",
1445                    format!("Dashboard {name} does not exist"),
1446                )
1447            })?;
1448        let inner = format!(
1449            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1450            xml_escape(&dashboard.arn),
1451            xml_escape(&dashboard.body),
1452            xml_escape(&dashboard.name),
1453        );
1454        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1455    }
1456
1457    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1458        let mut names: Vec<String> = Vec::new();
1459        for (k, v) in req.query_params.iter() {
1460            if k.starts_with("DashboardNames.member.") {
1461                names.push(v.clone());
1462            }
1463        }
1464        if names.is_empty() {
1465            return Err(invalid_param(
1466                "DashboardNames must contain at least one name",
1467            ));
1468        }
1469        let mut state = self.state.write();
1470        let acct = state.get_or_create(&req.account_id);
1471        for n in names {
1472            acct.dashboards.remove(&n);
1473        }
1474        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
1475        // the AWS SDK fails to deserialize the response if the result node is
1476        // absent ("DeleteDashboardsResult node not found").
1477        let body = format!(
1478            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1479             <DeleteDashboardsResponse xmlns=\"{NS}\">\
1480             <DeleteDashboardsResult/>\
1481             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1482             </DeleteDashboardsResponse>",
1483            req.request_id
1484        );
1485        Ok(AwsResponse::xml(StatusCode::OK, body))
1486    }
1487
1488    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1489        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1490        let state = self.state.read();
1491        let dashboards: Vec<Dashboard> = state
1492            .get(&req.account_id)
1493            .map(|a| {
1494                a.dashboards
1495                    .values()
1496                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1497                    .cloned()
1498                    .collect()
1499            })
1500            .unwrap_or_default();
1501        let mut entries = String::new();
1502        for d in &dashboards {
1503            entries.push_str("<member>");
1504            entries.push_str(&format!(
1505                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1506                xml_escape(&d.arn),
1507                xml_escape(&d.name),
1508                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1509                d.size_bytes,
1510            ));
1511            entries.push_str("</member>");
1512        }
1513        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1514        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1515    }
1516}
1517
1518fn render_alarm(alarm: &MetricAlarm) -> String {
1519    let mut s = String::from("<member>");
1520    s.push_str(&format!(
1521        "<AlarmName>{}</AlarmName>",
1522        xml_escape(&alarm.alarm_name)
1523    ));
1524    s.push_str(&format!(
1525        "<AlarmArn>{}</AlarmArn>",
1526        xml_escape(&alarm.alarm_arn)
1527    ));
1528    if let Some(d) = &alarm.alarm_description {
1529        s.push_str(&format!(
1530            "<AlarmDescription>{}</AlarmDescription>",
1531            xml_escape(d)
1532        ));
1533    }
1534    s.push_str(&format!(
1535        "<ActionsEnabled>{}</ActionsEnabled>",
1536        alarm.actions_enabled
1537    ));
1538    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1539    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1540    push_action_list(
1541        &mut s,
1542        "InsufficientDataActions",
1543        &alarm.insufficient_data_actions,
1544    );
1545    s.push_str(&format!(
1546        "<StateValue>{}</StateValue>",
1547        alarm.state_value.as_str()
1548    ));
1549    s.push_str(&format!(
1550        "<StateReason>{}</StateReason>",
1551        xml_escape(&alarm.state_reason)
1552    ));
1553    s.push_str(&format!(
1554        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1555        alarm
1556            .state_updated_timestamp
1557            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1558    ));
1559    if let Some(m) = &alarm.metric_name {
1560        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1561    }
1562    if let Some(n) = &alarm.namespace {
1563        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1564    }
1565    if let Some(stat) = &alarm.statistic {
1566        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1567    }
1568    if let Some(ext) = &alarm.extended_statistic {
1569        s.push_str(&format!(
1570            "<ExtendedStatistic>{}</ExtendedStatistic>",
1571            xml_escape(ext)
1572        ));
1573    }
1574    s.push_str(&render_dimensions(&alarm.dimensions));
1575    if let Some(p) = alarm.period {
1576        s.push_str(&format!("<Period>{p}</Period>"));
1577    }
1578    if let Some(u) = &alarm.unit {
1579        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1580    }
1581    s.push_str(&format!(
1582        "<EvaluationPeriods>{}</EvaluationPeriods>",
1583        alarm.evaluation_periods
1584    ));
1585    if let Some(d) = alarm.datapoints_to_alarm {
1586        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1587    }
1588    if let Some(t) = alarm.threshold {
1589        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1590    }
1591    if let Some(tid) = &alarm.threshold_metric_id {
1592        s.push_str(&format!(
1593            "<ThresholdMetricId>{}</ThresholdMetricId>",
1594            xml_escape(tid)
1595        ));
1596    }
1597    s.push_str(&format!(
1598        "<ComparisonOperator>{}</ComparisonOperator>",
1599        xml_escape(&alarm.comparison_operator)
1600    ));
1601    if let Some(t) = &alarm.treat_missing_data {
1602        s.push_str(&format!(
1603            "<TreatMissingData>{}</TreatMissingData>",
1604            xml_escape(t)
1605        ));
1606    }
1607    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1608        s.push_str(&format!(
1609            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1610            xml_escape(e)
1611        ));
1612    }
1613    s.push_str(&format!(
1614        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1615        alarm
1616            .alarm_configuration_updated_timestamp
1617            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1618    ));
1619    s.push_str("</member>");
1620    s
1621}
1622
1623fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1624    s.push_str(&format!("<{name}>"));
1625    for action in actions {
1626        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1627    }
1628    s.push_str(&format!("</{name}>"));
1629}