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
370fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
371    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
372    let needle = format!("{prefix}.member.");
373    for (k, v) in member.iter() {
374        let Some(rest) = k.strip_prefix(&needle) else {
375            continue;
376        };
377        let mut parts = rest.splitn(2, '.');
378        let Some(idx_str) = parts.next() else {
379            continue;
380        };
381        let Ok(idx) = idx_str.parse::<u32>() else {
382            continue;
383        };
384        let field = parts.next().unwrap_or("");
385        let entry = dims.entry(idx).or_default();
386        match field {
387            "Name" => entry.0 = Some(v.clone()),
388            "Value" => entry.1 = Some(v.clone()),
389            _ => {}
390        }
391    }
392    let mut out = BTreeMap::new();
393    for (_, (name, value)) in dims {
394        if let (Some(n), Some(v)) = (name, value) {
395            out.insert(n, v);
396        }
397    }
398    out
399}
400
401pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
402    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
403    let needle = format!("{prefix}.member.");
404    for (k, v) in req.query_params.iter() {
405        let Some(rest) = k.strip_prefix(&needle) else {
406            continue;
407        };
408        let mut parts = rest.splitn(2, '.');
409        let Some(idx_str) = parts.next() else {
410            continue;
411        };
412        let Ok(idx) = idx_str.parse::<u32>() else {
413            continue;
414        };
415        let field = parts.next().unwrap_or("");
416        let entry = dims.entry(idx).or_default();
417        match field {
418            "Name" => entry.0 = Some(v.clone()),
419            "Value" => entry.1 = Some(v.clone()),
420            _ => {}
421        }
422    }
423    let mut out = BTreeMap::new();
424    for (_, (name, value)) in dims {
425        if let (Some(n), Some(v)) = (name, value) {
426            out.insert(n, v);
427        }
428    }
429    out
430}
431
432/// Validate the length of an optional string param against `[min, max]`.
433/// Returns a 4xx on violation. AWS measures length in characters; the
434/// conformance probe only sends ASCII so byte length is equivalent here.
435pub(crate) fn validate_len(
436    req: &AwsRequest,
437    param: &str,
438    min: usize,
439    max: usize,
440) -> Result<(), AwsServiceError> {
441    if let Some(v) = req.query_params.get(param) {
442        let len = v.chars().count();
443        if len < min || len > max {
444            return Err(invalid_param(format!(
445                "{param} length {len} is outside [{min}, {max}]"
446            )));
447        }
448    }
449    Ok(())
450}
451
452/// Validate an optional integer param against `[min, max]` (inclusive).
453pub(crate) fn validate_range_i64(
454    req: &AwsRequest,
455    param: &str,
456    min: i64,
457    max: i64,
458) -> Result<(), AwsServiceError> {
459    if let Some(v) = req.query_params.get(param) {
460        if v.is_empty() {
461            return Ok(());
462        }
463        let n = v
464            .parse::<i64>()
465            .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
466        if n < min || n > max {
467            return Err(invalid_param(format!(
468                "{param} value {n} is outside [{min}, {max}]"
469            )));
470        }
471    }
472    Ok(())
473}
474
475/// Validate that an optional param, when present, is one of `allowed`.
476pub(crate) fn validate_enum(
477    req: &AwsRequest,
478    param: &str,
479    allowed: &[&str],
480) -> Result<(), AwsServiceError> {
481    if let Some(v) = req.query_params.get(param) {
482        if !v.is_empty() && !allowed.contains(&v.as_str()) {
483            return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
484        }
485    }
486    Ok(())
487}
488
489/// Collect repeated `<Prefix>.member.N` scalar values, ordered by index.
490pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
491    let needle = format!("{prefix}.member.");
492    let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
493    for (k, v) in req.query_params.iter() {
494        let Some(rest) = k.strip_prefix(&needle) else {
495            continue;
496        };
497        if let Ok(idx) = rest.parse::<u32>() {
498            by_index.insert(idx, v.clone());
499        }
500    }
501    by_index.into_values().collect()
502}
503
504/// Parse a `Tags.member.N.Key` / `Tags.member.N.Value` list into a map.
505pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
506    let members = collect_indexed(req, prefix);
507    let mut out = BTreeMap::new();
508    for m in members {
509        if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
510            out.insert(k.clone(), v.clone());
511        }
512    }
513    out
514}
515
516pub(crate) fn xml_escape(s: &str) -> String {
517    s.replace('&', "&amp;")
518        .replace('<', "&lt;")
519        .replace('>', "&gt;")
520        .replace('"', "&quot;")
521        .replace('\'', "&apos;")
522}
523
524/// Per-datapoint aggregation summary covering both the simple `Value` form
525/// and the `StatisticValues` form so callers don't lose the count or
526/// min/max baked into a `StatisticSet`.
527#[derive(Clone, Copy)]
528struct DatumStats {
529    sum: f64,
530    min: f64,
531    max: f64,
532    count: f64,
533}
534
535fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
536    if let Some(v) = d.value {
537        return Some(DatumStats {
538            sum: v,
539            min: v,
540            max: v,
541            count: 1.0,
542        });
543    }
544    if let Some(s) = &d.statistic_values {
545        return Some(DatumStats {
546            sum: s.sum,
547            min: s.minimum,
548            max: s.maximum,
549            count: s.sample_count,
550        });
551    }
552    None
553}
554
555fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
556    acc.sum += other.sum;
557    acc.count += other.count;
558    if other.min < acc.min {
559        acc.min = other.min;
560    }
561    if other.max > acc.max {
562        acc.max = other.max;
563    }
564}
565
566fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
567    match stat {
568        "Sum" => Some(agg.sum),
569        "Average" => {
570            if agg.count > 0.0 {
571                Some(agg.sum / agg.count)
572            } else {
573                None
574            }
575        }
576        "Minimum" => Some(agg.min),
577        "Maximum" => Some(agg.max),
578        "SampleCount" => Some(agg.count),
579        _ => None,
580    }
581}
582
583pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
584    let mut s = String::from("<Dimensions>");
585    for (name, value) in dims.iter() {
586        s.push_str(&format!(
587            "<member><Name>{}</Name><Value>{}</Value></member>",
588            xml_escape(name),
589            xml_escape(value),
590        ));
591    }
592    s.push_str("</Dimensions>");
593    s
594}
595
596impl CloudWatchService {
597    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
598        let namespace = required_query_param(req, "Namespace")?;
599        let members = collect_indexed(req, "MetricData");
600        if members.is_empty() {
601            return Err(invalid_param(
602                "PutMetricData requires at least one MetricData entry",
603            ));
604        }
605
606        let now = Utc::now();
607        let mut state = self.state.write();
608        let acct = state.get_or_create(&req.account_id);
609        let metrics_map = acct.metrics_in_mut(&req.region);
610        let bucket = metrics_map.entry(namespace.clone()).or_default();
611
612        for member in members {
613            let metric_name = member
614                .get("MetricName")
615                .cloned()
616                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
617            let value = member
618                .get("Value")
619                .map(|s| s.parse::<f64>())
620                .transpose()
621                .map_err(|_| invalid_param("Value must be a valid number"))?;
622            let timestamp = member
623                .get("Timestamp")
624                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
625                .map(|d| d.with_timezone(&Utc))
626                .unwrap_or(now);
627            let unit = member.get("Unit").cloned();
628            let storage_resolution = member
629                .get("StorageResolution")
630                .and_then(|s| s.parse::<i64>().ok());
631            let dimensions = parse_dimensions(&member, "Dimensions");
632
633            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
634                member.get("StatisticValues.SampleCount"),
635                member.get("StatisticValues.Sum"),
636                member.get("StatisticValues.Minimum"),
637                member.get("StatisticValues.Maximum"),
638            ) {
639                Some(StatisticSet {
640                    sample_count: sc.parse::<f64>().map_err(|_| {
641                        invalid_param("StatisticValues.SampleCount must be a number")
642                    })?,
643                    sum: sum
644                        .parse::<f64>()
645                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
646                    minimum: min
647                        .parse::<f64>()
648                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
649                    maximum: max
650                        .parse::<f64>()
651                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
652                })
653            } else {
654                None
655            };
656
657            if value.is_none() && statistic_values.is_none() {
658                return Err(invalid_param(
659                    "MetricData entry must supply either Value or StatisticValues",
660                ));
661            }
662
663            bucket.push(MetricDatum {
664                metric_name,
665                dimensions,
666                timestamp,
667                value,
668                statistic_values,
669                unit,
670                storage_resolution,
671            });
672        }
673
674        Ok(empty_metadata_response("PutMetricData", &req.request_id))
675    }
676
677    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
678        validate_len(req, "Namespace", 1, 255)?;
679        validate_len(req, "MetricName", 1, 255)?;
680        validate_len(req, "OwningAccount", 1, 255)?;
681        validate_enum(req, "RecentlyActive", &["PT3H"])?;
682        let namespace = optional_query_param(req, "Namespace");
683        let metric_name = optional_query_param(req, "MetricName");
684        let dim_filter = parse_dimensions_query(req, "Dimensions");
685
686        let state = self.state.read();
687        let mut out = String::from("<Metrics>");
688        if let Some(acct) = state.get(&req.account_id) {
689            if let Some(map) = acct.metrics_in(&req.region) {
690                for (ns, data) in map.iter() {
691                    if let Some(filter_ns) = namespace.as_ref() {
692                        if ns != filter_ns {
693                            continue;
694                        }
695                    }
696                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
697                        BTreeMap::new();
698                    for d in data.iter() {
699                        if let Some(filter_name) = metric_name.as_ref() {
700                            if &d.metric_name != filter_name {
701                                continue;
702                            }
703                        }
704                        if !dim_filter.is_empty()
705                            && !dim_filter
706                                .iter()
707                                .all(|(k, v)| d.dimensions.get(k) == Some(v))
708                        {
709                            continue;
710                        }
711                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
712                    }
713                    for ((name, dims), _) in seen {
714                        out.push_str("<member>");
715                        out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
716                        out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
717                        out.push_str(&render_dimensions(&dims));
718                        out.push_str("</member>");
719                    }
720                }
721            }
722        }
723        out.push_str("</Metrics>");
724
725        Ok(xml_response("ListMetrics", &out, &req.request_id))
726    }
727
728    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
729        let namespace = required_query_param(req, "Namespace")?;
730        let metric_name = required_query_param(req, "MetricName")?;
731        let start = required_query_param(req, "StartTime")?;
732        let end = required_query_param(req, "EndTime")?;
733        let period = required_query_param(req, "Period")?
734            .parse::<i64>()
735            .map_err(|_| invalid_param("Period must be an integer"))?;
736        if period <= 0 {
737            return Err(invalid_param("Period must be positive"));
738        }
739        let start_ts = DateTime::parse_from_rfc3339(&start)
740            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
741            .with_timezone(&Utc);
742        let end_ts = DateTime::parse_from_rfc3339(&end)
743            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
744            .with_timezone(&Utc);
745
746        let mut statistics: Vec<String> = Vec::new();
747        for (k, v) in req.query_params.iter() {
748            if k.starts_with("Statistics.member.") {
749                statistics.push(v.clone());
750            }
751        }
752        if statistics.is_empty() {
753            return Err(invalid_param("At least one Statistic is required"));
754        }
755
756        let dim_filter = parse_dimensions_query(req, "Dimensions");
757
758        let state = self.state.read();
759        let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
760        if let Some(acct) = state.get(&req.account_id) {
761            if let Some(map) = acct.metrics_in(&req.region) {
762                if let Some(data) = map.get(&namespace) {
763                    let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
764                    for d in data.iter() {
765                        if d.metric_name != metric_name {
766                            continue;
767                        }
768                        if !dim_filter
769                            .iter()
770                            .all(|(k, v)| d.dimensions.get(k) == Some(v))
771                        {
772                            continue;
773                        }
774                        if d.timestamp < start_ts || d.timestamp >= end_ts {
775                            continue;
776                        }
777                        let Some(stats) = datum_stats(d) else {
778                            continue;
779                        };
780                        let secs = d.timestamp.timestamp();
781                        let bucket_secs = secs - secs.rem_euclid(period);
782                        let bucket_ts =
783                            DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
784                        buckets
785                            .entry(bucket_ts)
786                            .and_modify(|acc| merge_stats(acc, stats))
787                            .or_insert(stats);
788                    }
789                    for (ts, agg) in buckets {
790                        let mut stats = BTreeMap::new();
791                        for stat in statistics.iter() {
792                            if let Some(v) = stat_value(stat, agg) {
793                                stats.insert(stat.clone(), v);
794                            }
795                        }
796                        datapoints.push((ts, stats));
797                    }
798                }
799            }
800        }
801
802        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
803        inner.push_str("<Datapoints>");
804        for (ts, stats) in datapoints {
805            inner.push_str("<member>");
806            inner.push_str(&format!(
807                "<Timestamp>{}</Timestamp>",
808                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
809            ));
810            for (name, value) in stats {
811                inner.push_str(&format!("<{name}>{value}</{name}>"));
812            }
813            inner.push_str("</member>");
814        }
815        inner.push_str("</Datapoints>");
816
817        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
818    }
819
820    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
821        validate_enum(
822            req,
823            "ScanBy",
824            &["TimestampDescending", "TimestampAscending"],
825        )?;
826        let start = required_query_param(req, "StartTime")?;
827        let end = required_query_param(req, "EndTime")?;
828        let start_ts = DateTime::parse_from_rfc3339(&start)
829            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
830            .with_timezone(&Utc);
831        let end_ts = DateTime::parse_from_rfc3339(&end)
832            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
833            .with_timezone(&Utc);
834
835        // Default ScanBy is TimestampDescending (newest first); callers read
836        // Values[0] as the latest datapoint. The bucket map is ascending, so
837        // reverse unless the caller asked for TimestampAscending.
838        let descending = req
839            .query_params
840            .get("ScanBy")
841            .map(|s| s != "TimestampAscending")
842            .unwrap_or(true);
843
844        // GetMetricData declares only InvalidNextToken, so it never rejects an
845        // empty / malformed query list with a 4xx — it returns empty results.
846        let queries = collect_indexed(req, "MetricDataQueries");
847
848        let state = self.state.read();
849        let mut inner = String::from("<MetricDataResults>");
850        for q in queries {
851            let id = q.get("Id").cloned().unwrap_or_default();
852            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
853            let stat = q
854                .get("MetricStat.Stat")
855                .cloned()
856                .unwrap_or_else(|| "Sum".to_string());
857            let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
858            let namespace = q.get("MetricStat.Metric.Namespace").cloned();
859            let period: i64 = q
860                .get("MetricStat.Period")
861                .and_then(|s| s.parse::<i64>().ok())
862                .filter(|p| *p > 0)
863                .unwrap_or(60);
864            let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
865
866            let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
867            if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
868                if let Some(acct) = state.get(&req.account_id) {
869                    if let Some(map) = acct.metrics_in(&req.region) {
870                        if let Some(data) = map.get(&namespace) {
871                            let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
872                            for d in data.iter() {
873                                if d.metric_name != metric_name {
874                                    continue;
875                                }
876                                if !dim_filter
877                                    .iter()
878                                    .all(|(k, v)| d.dimensions.get(k) == Some(v))
879                                {
880                                    continue;
881                                }
882                                if d.timestamp < start_ts || d.timestamp >= end_ts {
883                                    continue;
884                                }
885                                let Some(stats) = datum_stats(d) else {
886                                    continue;
887                                };
888                                let secs = d.timestamp.timestamp();
889                                let bucket_secs = secs - secs.rem_euclid(period);
890                                let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
891                                    .unwrap_or(d.timestamp);
892                                buckets
893                                    .entry(bucket_ts)
894                                    .and_modify(|acc| merge_stats(acc, stats))
895                                    .or_insert(stats);
896                            }
897                            for (ts, agg) in buckets {
898                                let Some(v) = stat_value(&stat, agg) else {
899                                    continue;
900                                };
901                                timestamps
902                                    .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
903                                values.push(v);
904                            }
905                            if descending {
906                                timestamps.reverse();
907                                values.reverse();
908                            }
909                        }
910                    }
911                }
912            }
913
914            inner.push_str("<member>");
915            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
916            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
917            inner.push_str("<StatusCode>Complete</StatusCode>");
918            inner.push_str("<Timestamps>");
919            for ts in timestamps {
920                inner.push_str(&format!("<member>{ts}</member>"));
921            }
922            inner.push_str("</Timestamps>");
923            inner.push_str("<Values>");
924            for v in values {
925                inner.push_str(&format!("<member>{v}</member>"));
926            }
927            inner.push_str("</Values>");
928            inner.push_str("</member>");
929        }
930        inner.push_str("</MetricDataResults>");
931        inner.push_str("<Messages></Messages>");
932
933        Ok(xml_response("GetMetricData", &inner, &req.request_id))
934    }
935
936    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
937        // Only `AlarmName` is required by the Smithy contract; the op declares
938        // no validation errors, so ComparisonOperator / EvaluationPeriods are
939        // accepted with sensible defaults rather than rejected. Constraint
940        // violations still produce a 4xx, which the probe accepts as AnyError
941        // for the negative variants.
942        validate_len(req, "AlarmName", 1, 255)?;
943        validate_len(req, "AlarmDescription", 0, 1024)?;
944        validate_len(req, "MetricName", 1, 255)?;
945        validate_len(req, "Namespace", 1, 255)?;
946        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
947        validate_len(req, "TreatMissingData", 1, 255)?;
948        validate_len(req, "ThresholdMetricId", 1, 255)?;
949        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
950        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
951        validate_range_i64(req, "Period", 1, i64::MAX)?;
952        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
953        validate_enum(
954            req,
955            "ComparisonOperator",
956            &[
957                "GreaterThanOrEqualToThreshold",
958                "GreaterThanThreshold",
959                "GreaterThanUpperThreshold",
960                "LessThanLowerOrGreaterThanUpperThreshold",
961                "LessThanLowerThreshold",
962                "LessThanOrEqualToThreshold",
963                "LessThanThreshold",
964            ],
965        )?;
966        validate_enum(
967            req,
968            "Statistic",
969            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
970        )?;
971        validate_enum(req, "Unit", STANDARD_UNITS)?;
972        let alarm_name = required_query_param(req, "AlarmName")?;
973        let comparison = optional_query_param(req, "ComparisonOperator")
974            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
975        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
976            .and_then(|s| s.parse::<i64>().ok())
977            .unwrap_or(1);
978
979        let alarm_description = optional_query_param(req, "AlarmDescription");
980        let actions_enabled = optional_query_param(req, "ActionsEnabled")
981            .map(|s| s.eq_ignore_ascii_case("true"))
982            .unwrap_or(true);
983
984        let metric_name = optional_query_param(req, "MetricName");
985        let namespace = optional_query_param(req, "Namespace");
986        let statistic = optional_query_param(req, "Statistic");
987        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
988        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
989        let unit = optional_query_param(req, "Unit");
990        let datapoints_to_alarm =
991            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
992        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
993        let treat_missing_data = optional_query_param(req, "TreatMissingData");
994        let evaluate_low_sample_count_percentile =
995            optional_query_param(req, "EvaluateLowSampleCountPercentile");
996        // Anomaly-detection alarms reference a metric-math id instead of a
997        // static Threshold; previously accepted then dropped (1.24).
998        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
999        let dimensions = parse_dimensions_query(req, "Dimensions");
1000
1001        let mut ok_actions = Vec::new();
1002        let mut alarm_actions = Vec::new();
1003        let mut insufficient_data_actions = Vec::new();
1004        for (k, v) in req.query_params.iter() {
1005            if k.starts_with("OKActions.member.") {
1006                ok_actions.push(v.clone());
1007            } else if k.starts_with("AlarmActions.member.") {
1008                alarm_actions.push(v.clone());
1009            } else if k.starts_with("InsufficientDataActions.member.") {
1010                insufficient_data_actions.push(v.clone());
1011            }
1012        }
1013
1014        let arn = format!(
1015            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1016            req.region, req.account_id, alarm_name
1017        );
1018        let now = Utc::now();
1019
1020        let mut state = self.state.write();
1021        let acct = state.get_or_create(&req.account_id);
1022        let alarms = acct.alarms_in_mut(&req.region);
1023        let existing = alarms.get(&alarm_name).cloned();
1024        let alarm = MetricAlarm {
1025            alarm_name: alarm_name.clone(),
1026            alarm_arn: arn,
1027            alarm_description,
1028            actions_enabled,
1029            ok_actions,
1030            alarm_actions,
1031            insufficient_data_actions,
1032            state_value: existing
1033                .as_ref()
1034                .map(|a| a.state_value)
1035                .unwrap_or(AlarmState::InsufficientData),
1036            state_reason: existing
1037                .as_ref()
1038                .map(|a| a.state_reason.clone())
1039                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1040            state_updated_timestamp: existing
1041                .as_ref()
1042                .map(|a| a.state_updated_timestamp)
1043                .unwrap_or(now),
1044            metric_name,
1045            namespace,
1046            statistic,
1047            extended_statistic,
1048            dimensions,
1049            period,
1050            unit,
1051            evaluation_periods,
1052            datapoints_to_alarm,
1053            threshold,
1054            comparison_operator: comparison,
1055            treat_missing_data,
1056            evaluate_low_sample_count_percentile,
1057            threshold_metric_id,
1058            configuration_updated_timestamp: existing
1059                .as_ref()
1060                .map(|a| a.configuration_updated_timestamp)
1061                .unwrap_or(now),
1062            alarm_configuration_updated_timestamp: now,
1063        };
1064        alarms.insert(alarm_name, alarm);
1065
1066        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1067    }
1068
1069    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1070        let mut filter_names: Vec<String> = Vec::new();
1071        for (k, v) in req.query_params.iter() {
1072            if k.starts_with("AlarmNames.member.") {
1073                filter_names.push(v.clone());
1074            }
1075        }
1076        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1077        validate_len(req, "ActionPrefix", 1, 1024)?;
1078        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1079        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1080        validate_range_i64(req, "MaxRecords", 1, 100)?;
1081        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1082        let prefix = optional_query_param(req, "AlarmNamePrefix");
1083        let state_filter = optional_query_param(req, "StateValue");
1084        let action_prefix = optional_query_param(req, "ActionPrefix");
1085
1086        let state = self.state.read();
1087        let mut inner = String::from("<MetricAlarms>");
1088        if let Some(acct) = state.get(&req.account_id) {
1089            if let Some(alarms) = acct.alarms_in(&req.region) {
1090                for alarm in alarms.values() {
1091                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1092                        continue;
1093                    }
1094                    if let Some(p) = prefix.as_ref() {
1095                        if !alarm.alarm_name.starts_with(p) {
1096                            continue;
1097                        }
1098                    }
1099                    if let Some(sv) = state_filter.as_ref() {
1100                        if alarm.state_value.as_str() != sv {
1101                            continue;
1102                        }
1103                    }
1104                    if let Some(ap) = action_prefix.as_ref() {
1105                        let any = alarm
1106                            .alarm_actions
1107                            .iter()
1108                            .chain(alarm.ok_actions.iter())
1109                            .chain(alarm.insufficient_data_actions.iter())
1110                            .any(|a| a.starts_with(ap));
1111                        if !any {
1112                            continue;
1113                        }
1114                    }
1115                    inner.push_str(&render_alarm(alarm));
1116                }
1117            }
1118        }
1119        inner.push_str("</MetricAlarms>");
1120        inner.push_str("<CompositeAlarms>");
1121        if let Some(acct) = state.get(&req.account_id) {
1122            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1123                for alarm in composites.values() {
1124                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1125                        continue;
1126                    }
1127                    if let Some(p) = prefix.as_ref() {
1128                        if !alarm.alarm_name.starts_with(p) {
1129                            continue;
1130                        }
1131                    }
1132                    if let Some(sv) = state_filter.as_ref() {
1133                        if alarm.state_value.as_str() != sv {
1134                            continue;
1135                        }
1136                    }
1137                    if let Some(ap) = action_prefix.as_ref() {
1138                        let any = alarm
1139                            .alarm_actions
1140                            .iter()
1141                            .chain(alarm.ok_actions.iter())
1142                            .chain(alarm.insufficient_data_actions.iter())
1143                            .any(|a| a.starts_with(ap));
1144                        if !any {
1145                            continue;
1146                        }
1147                    }
1148                    inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1149                }
1150            }
1151        }
1152        inner.push_str("</CompositeAlarms>");
1153
1154        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1155    }
1156
1157    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1158        validate_len(req, "MetricName", 1, 255)?;
1159        validate_len(req, "Namespace", 1, 255)?;
1160        validate_range_i64(req, "Period", 1, i64::MAX)?;
1161        validate_enum(
1162            req,
1163            "Statistic",
1164            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1165        )?;
1166        validate_enum(req, "Unit", STANDARD_UNITS)?;
1167        let metric_name = required_query_param(req, "MetricName")?;
1168        let namespace = required_query_param(req, "Namespace")?;
1169        let dim_filter = parse_dimensions_query(req, "Dimensions");
1170
1171        let state = self.state.read();
1172        let mut inner = String::from("<MetricAlarms>");
1173        if let Some(acct) = state.get(&req.account_id) {
1174            if let Some(alarms) = acct.alarms_in(&req.region) {
1175                for alarm in alarms.values() {
1176                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1177                        continue;
1178                    }
1179                    if alarm.namespace.as_deref() != Some(&namespace) {
1180                        continue;
1181                    }
1182                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1183                        continue;
1184                    }
1185                    inner.push_str(&render_alarm(alarm));
1186                }
1187            }
1188        }
1189        inner.push_str("</MetricAlarms>");
1190
1191        Ok(xml_response(
1192            "DescribeAlarmsForMetric",
1193            &inner,
1194            &req.request_id,
1195        ))
1196    }
1197
1198    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1199        // AlarmNames is required, but an empty list serialises to zero wire
1200        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1201        // set is a no-op rather than an undeclared 4xx.
1202        let mut names: Vec<String> = Vec::new();
1203        for (k, v) in req.query_params.iter() {
1204            if k.starts_with("AlarmNames.member.") {
1205                names.push(v.clone());
1206            }
1207        }
1208
1209        let mut state = self.state.write();
1210        let acct = state.get_or_create(&req.account_id);
1211        for name in &names {
1212            acct.alarms_in_mut(&req.region).remove(name);
1213            acct.composite_alarms_in_mut(&req.region).remove(name);
1214        }
1215
1216        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1217    }
1218
1219    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1220        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1221    }
1222
1223    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1224        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1225    }
1226
1227    fn toggle_alarm_actions(
1228        &self,
1229        req: &AwsRequest,
1230        enabled: bool,
1231        action_name: &str,
1232    ) -> Result<AwsResponse, AwsServiceError> {
1233        let mut names: Vec<String> = Vec::new();
1234        for (k, v) in req.query_params.iter() {
1235            if k.starts_with("AlarmNames.member.") {
1236                names.push(v.clone());
1237            }
1238        }
1239        let mut state = self.state.write();
1240        let acct = state.get_or_create(&req.account_id);
1241        let alarms = acct.alarms_in_mut(&req.region);
1242        for name in names {
1243            if let Some(alarm) = alarms.get_mut(&name) {
1244                alarm.actions_enabled = enabled;
1245                alarm.alarm_configuration_updated_timestamp = Utc::now();
1246            }
1247        }
1248        Ok(empty_metadata_response(action_name, &req.request_id))
1249    }
1250
1251    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1252        validate_len(req, "AlarmName", 1, 255)?;
1253        validate_len(req, "StateReason", 0, 1023)?;
1254        validate_len(req, "StateReasonData", 0, 4000)?;
1255        let alarm_name = required_query_param(req, "AlarmName")?;
1256        let state_value = required_query_param(req, "StateValue")?;
1257        // StateReason is required but allows a zero-length value (min=0). Treat
1258        // an absent key as missing (declared error) while accepting an empty
1259        // string as a valid value.
1260        let state_reason = req
1261            .query_params
1262            .get("StateReason")
1263            .cloned()
1264            .ok_or_else(|| {
1265                AwsServiceError::aws_error(
1266                    StatusCode::BAD_REQUEST,
1267                    "MissingParameter",
1268                    "The request must contain the parameter StateReason.",
1269                )
1270            })?;
1271        let new_state = AlarmState::parse(&state_value)
1272            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1273
1274        let mut state = self.state.write();
1275        let acct = state.get_or_create(&req.account_id);
1276        let alarms = acct.alarms_in_mut(&req.region);
1277        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1278            AwsServiceError::aws_error(
1279                StatusCode::NOT_FOUND,
1280                "ResourceNotFound",
1281                format!("Alarm {alarm_name} not found"),
1282            )
1283        })?;
1284        alarm.state_value = new_state;
1285        alarm.state_reason = state_reason;
1286        alarm.state_updated_timestamp = Utc::now();
1287
1288        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1289    }
1290
1291    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1292        validate_len(req, "AlarmName", 1, 255)?;
1293        validate_len(req, "AlarmContributorId", 1, 16)?;
1294        validate_range_i64(req, "MaxRecords", 1, 100)?;
1295        validate_enum(
1296            req,
1297            "HistoryItemType",
1298            &[
1299                "ConfigurationUpdate",
1300                "StateUpdate",
1301                "Action",
1302                "AlarmContributorStateUpdate",
1303                "AlarmContributorAction",
1304            ],
1305        )?;
1306        validate_enum(
1307            req,
1308            "ScanBy",
1309            &["TimestampDescending", "TimestampAscending"],
1310        )?;
1311        // Minimal implementation: return empty history. AWS pagination tokens are
1312        // not tracked locally, so callers see an empty list rather than a stub.
1313        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1314        Ok(xml_response(
1315            "DescribeAlarmHistory",
1316            &inner,
1317            &req.request_id,
1318        ))
1319    }
1320
1321    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1322        let dashboard_name = req
1323            .query_params
1324            .get("DashboardName")
1325            .ok_or_else(|| invalid_param("DashboardName is required"))?
1326            .clone();
1327        let body = req
1328            .query_params
1329            .get("DashboardBody")
1330            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1331            .clone();
1332        // AWS validates that DashboardBody parses as JSON; we do the same so
1333        // bad bodies surface a useful error before persisting.
1334        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1335            return Err(AwsServiceError::aws_error(
1336                StatusCode::BAD_REQUEST,
1337                "InvalidParameterInput",
1338                "DashboardBody must be a valid JSON object",
1339            ));
1340        }
1341        let arn = format!(
1342            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1343            req.account_id
1344        );
1345        let dashboard = Dashboard {
1346            name: dashboard_name.clone(),
1347            arn,
1348            size_bytes: body.len() as i64,
1349            body,
1350            last_modified: Utc::now(),
1351        };
1352        let mut state = self.state.write();
1353        let acct = state.get_or_create(&req.account_id);
1354        acct.dashboards.insert(dashboard_name, dashboard);
1355        // PutDashboard returns DashboardValidationMessages — empty when the
1356        // body parses cleanly.
1357        let inner = String::from("<DashboardValidationMessages/>");
1358        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1359    }
1360
1361    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1362        let name = req
1363            .query_params
1364            .get("DashboardName")
1365            .ok_or_else(|| invalid_param("DashboardName is required"))?
1366            .clone();
1367        let state = self.state.read();
1368        let dashboard = state
1369            .get(&req.account_id)
1370            .and_then(|a| a.dashboards.get(&name))
1371            .cloned()
1372            .ok_or_else(|| {
1373                AwsServiceError::aws_error(
1374                    StatusCode::NOT_FOUND,
1375                    "ResourceNotFound",
1376                    format!("Dashboard {name} does not exist"),
1377                )
1378            })?;
1379        let inner = format!(
1380            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1381            xml_escape(&dashboard.arn),
1382            xml_escape(&dashboard.body),
1383            xml_escape(&dashboard.name),
1384        );
1385        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1386    }
1387
1388    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1389        let mut names: Vec<String> = Vec::new();
1390        for (k, v) in req.query_params.iter() {
1391            if k.starts_with("DashboardNames.member.") {
1392                names.push(v.clone());
1393            }
1394        }
1395        if names.is_empty() {
1396            return Err(invalid_param(
1397                "DashboardNames must contain at least one name",
1398            ));
1399        }
1400        let mut state = self.state.write();
1401        let acct = state.get_or_create(&req.account_id);
1402        for n in names {
1403            acct.dashboards.remove(&n);
1404        }
1405        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
1406        // the AWS SDK fails to deserialize the response if the result node is
1407        // absent ("DeleteDashboardsResult node not found").
1408        let body = format!(
1409            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1410             <DeleteDashboardsResponse xmlns=\"{NS}\">\
1411             <DeleteDashboardsResult/>\
1412             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1413             </DeleteDashboardsResponse>",
1414            req.request_id
1415        );
1416        Ok(AwsResponse::xml(StatusCode::OK, body))
1417    }
1418
1419    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1420        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1421        let state = self.state.read();
1422        let dashboards: Vec<Dashboard> = state
1423            .get(&req.account_id)
1424            .map(|a| {
1425                a.dashboards
1426                    .values()
1427                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1428                    .cloned()
1429                    .collect()
1430            })
1431            .unwrap_or_default();
1432        let mut entries = String::new();
1433        for d in &dashboards {
1434            entries.push_str("<member>");
1435            entries.push_str(&format!(
1436                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1437                xml_escape(&d.arn),
1438                xml_escape(&d.name),
1439                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1440                d.size_bytes,
1441            ));
1442            entries.push_str("</member>");
1443        }
1444        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1445        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1446    }
1447}
1448
1449fn render_alarm(alarm: &MetricAlarm) -> String {
1450    let mut s = String::from("<member>");
1451    s.push_str(&format!(
1452        "<AlarmName>{}</AlarmName>",
1453        xml_escape(&alarm.alarm_name)
1454    ));
1455    s.push_str(&format!(
1456        "<AlarmArn>{}</AlarmArn>",
1457        xml_escape(&alarm.alarm_arn)
1458    ));
1459    if let Some(d) = &alarm.alarm_description {
1460        s.push_str(&format!(
1461            "<AlarmDescription>{}</AlarmDescription>",
1462            xml_escape(d)
1463        ));
1464    }
1465    s.push_str(&format!(
1466        "<ActionsEnabled>{}</ActionsEnabled>",
1467        alarm.actions_enabled
1468    ));
1469    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1470    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1471    push_action_list(
1472        &mut s,
1473        "InsufficientDataActions",
1474        &alarm.insufficient_data_actions,
1475    );
1476    s.push_str(&format!(
1477        "<StateValue>{}</StateValue>",
1478        alarm.state_value.as_str()
1479    ));
1480    s.push_str(&format!(
1481        "<StateReason>{}</StateReason>",
1482        xml_escape(&alarm.state_reason)
1483    ));
1484    s.push_str(&format!(
1485        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1486        alarm
1487            .state_updated_timestamp
1488            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1489    ));
1490    if let Some(m) = &alarm.metric_name {
1491        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1492    }
1493    if let Some(n) = &alarm.namespace {
1494        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1495    }
1496    if let Some(stat) = &alarm.statistic {
1497        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1498    }
1499    if let Some(ext) = &alarm.extended_statistic {
1500        s.push_str(&format!(
1501            "<ExtendedStatistic>{}</ExtendedStatistic>",
1502            xml_escape(ext)
1503        ));
1504    }
1505    s.push_str(&render_dimensions(&alarm.dimensions));
1506    if let Some(p) = alarm.period {
1507        s.push_str(&format!("<Period>{p}</Period>"));
1508    }
1509    if let Some(u) = &alarm.unit {
1510        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1511    }
1512    s.push_str(&format!(
1513        "<EvaluationPeriods>{}</EvaluationPeriods>",
1514        alarm.evaluation_periods
1515    ));
1516    if let Some(d) = alarm.datapoints_to_alarm {
1517        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1518    }
1519    if let Some(t) = alarm.threshold {
1520        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1521    }
1522    if let Some(tid) = &alarm.threshold_metric_id {
1523        s.push_str(&format!(
1524            "<ThresholdMetricId>{}</ThresholdMetricId>",
1525            xml_escape(tid)
1526        ));
1527    }
1528    s.push_str(&format!(
1529        "<ComparisonOperator>{}</ComparisonOperator>",
1530        xml_escape(&alarm.comparison_operator)
1531    ));
1532    if let Some(t) = &alarm.treat_missing_data {
1533        s.push_str(&format!(
1534            "<TreatMissingData>{}</TreatMissingData>",
1535            xml_escape(t)
1536        ));
1537    }
1538    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1539        s.push_str(&format!(
1540            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1541            xml_escape(e)
1542        ));
1543    }
1544    s.push_str(&format!(
1545        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1546        alarm
1547            .alarm_configuration_updated_timestamp
1548            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1549    ));
1550    s.push_str("</member>");
1551    s
1552}
1553
1554fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1555    s.push_str(&format!("<{name}>"));
1556    for action in actions {
1557        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1558    }
1559    s.push_str(&format!("</{name}>"));
1560}