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    // Misc.
106    "DescribeAlarmContributors",
107    "GetMetricWidgetImage",
108    // Tagging.
109    "TagResource",
110    "UntagResource",
111    "ListTagsForResource",
112];
113
114pub struct CloudWatchService {
115    pub(crate) state: SharedCloudWatchState,
116    snapshot_store: Option<Arc<dyn SnapshotStore>>,
117    snapshot_lock: Arc<Mutex<()>>,
118}
119
120impl CloudWatchService {
121    pub fn new(state: SharedCloudWatchState) -> Self {
122        Self {
123            state,
124            snapshot_store: None,
125            snapshot_lock: Arc::new(Mutex::new(())),
126        }
127    }
128
129    /// Attach a `SnapshotStore` so alarms / dashboards / metrics survive
130    /// restarts. Without this, all CloudWatch state is in-memory only —
131    /// alarms wired to actions fire on a freshly-started process.
132    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
133        self.snapshot_store = Some(store);
134        self
135    }
136
137    /// Persist current state as a snapshot. Cloned + serialized under
138    /// the snapshot lock so concurrent mutators can't race a stale-last
139    /// write.
140    pub(crate) async fn save_snapshot(&self) {
141        let Some(store) = self.snapshot_store.clone() else {
142            return;
143        };
144        let _guard = self.snapshot_lock.lock().await;
145        let snapshot = CloudWatchSnapshot {
146            schema_version: CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
147            accounts: self.state.read().clone_for_snapshot(),
148        };
149        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
150            let bytes = serde_json::to_vec(&snapshot)
151                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
152            store.save(&bytes)
153        })
154        .await;
155        match join {
156            Ok(Ok(())) => {}
157            Ok(Err(err)) => tracing::error!(%err, "failed to write cloudwatch snapshot"),
158            Err(err) => tracing::error!(%err, "cloudwatch snapshot task panicked"),
159        }
160    }
161}
162
163#[async_trait]
164impl AwsService for CloudWatchService {
165    fn service_name(&self) -> &str {
166        "monitoring"
167    }
168
169    fn supported_actions(&self) -> &[&str] {
170        SUPPORTED_ACTIONS
171    }
172
173    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
174        let mutates = matches!(
175            req.action.as_str(),
176            "PutMetricData"
177                | "PutMetricAlarm"
178                | "DeleteAlarms"
179                | "EnableAlarmActions"
180                | "DisableAlarmActions"
181                | "SetAlarmState"
182                | "PutDashboard"
183                | "DeleteDashboards"
184                | "PutAnomalyDetector"
185                | "DeleteAnomalyDetector"
186                | "PutInsightRule"
187                | "DeleteInsightRules"
188                | "EnableInsightRules"
189                | "DisableInsightRules"
190                | "PutManagedInsightRules"
191                | "PutMetricStream"
192                | "DeleteMetricStream"
193                | "StartMetricStreams"
194                | "StopMetricStreams"
195                | "PutCompositeAlarm"
196                | "PutAlarmMuteRule"
197                | "DeleteAlarmMuteRule"
198                | "StartOTelEnrichment"
199                | "StopOTelEnrichment"
200                | "TagResource"
201                | "UntagResource"
202        );
203        let result = match req.action.as_str() {
204            "PutMetricData" => self.put_metric_data(&req),
205            "GetMetricStatistics" => self.get_metric_statistics(&req),
206            "GetMetricData" => self.get_metric_data(&req),
207            "ListMetrics" => self.list_metrics(&req),
208            "PutMetricAlarm" => self.put_metric_alarm(&req),
209            "DescribeAlarms" => self.describe_alarms(&req),
210            "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
211            "DeleteAlarms" => self.delete_alarms(&req),
212            "EnableAlarmActions" => self.enable_alarm_actions(&req),
213            "DisableAlarmActions" => self.disable_alarm_actions(&req),
214            "SetAlarmState" => self.set_alarm_state(&req),
215            "DescribeAlarmHistory" => self.describe_alarm_history(&req),
216            "PutDashboard" => self.put_dashboard(&req),
217            "GetDashboard" => self.get_dashboard(&req),
218            "DeleteDashboards" => self.delete_dashboards(&req),
219            "ListDashboards" => self.list_dashboards(&req),
220            // Anomaly detectors.
221            "PutAnomalyDetector" => self.put_anomaly_detector(&req),
222            "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
223            "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
224            // Insight rules.
225            "PutInsightRule" => self.put_insight_rule(&req),
226            "DescribeInsightRules" => self.describe_insight_rules(&req),
227            "DeleteInsightRules" => self.delete_insight_rules(&req),
228            "EnableInsightRules" => self.enable_insight_rules(&req),
229            "DisableInsightRules" => self.disable_insight_rules(&req),
230            "GetInsightRuleReport" => self.get_insight_rule_report(&req),
231            "PutManagedInsightRules" => self.put_managed_insight_rules(&req),
232            "ListManagedInsightRules" => self.list_managed_insight_rules(&req),
233            // Metric streams.
234            "PutMetricStream" => self.put_metric_stream(&req),
235            "GetMetricStream" => self.get_metric_stream(&req),
236            "ListMetricStreams" => self.list_metric_streams(&req),
237            "DeleteMetricStream" => self.delete_metric_stream(&req),
238            "StartMetricStreams" => self.start_metric_streams(&req),
239            "StopMetricStreams" => self.stop_metric_streams(&req),
240            // Composite alarms.
241            "PutCompositeAlarm" => self.put_composite_alarm(&req),
242            // Mute rules.
243            "PutAlarmMuteRule" => self.put_alarm_mute_rule(&req),
244            "GetAlarmMuteRule" => self.get_alarm_mute_rule(&req),
245            "ListAlarmMuteRules" => self.list_alarm_mute_rules(&req),
246            "DeleteAlarmMuteRule" => self.delete_alarm_mute_rule(&req),
247            // OTel enrichment.
248            "GetOTelEnrichment" => self.get_otel_enrichment(&req),
249            "StartOTelEnrichment" => self.start_otel_enrichment(&req),
250            "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
251            // Misc.
252            "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
253            "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
254            // Tagging.
255            "TagResource" => self.tag_resource(&req),
256            "UntagResource" => self.untag_resource(&req),
257            "ListTagsForResource" => self.list_tags_for_resource(&req),
258            _ => Err(AwsServiceError::action_not_implemented(
259                "monitoring",
260                &req.action,
261            )),
262        };
263        if mutates && result.is_ok() {
264            self.save_snapshot().await;
265        }
266        result
267    }
268}
269
270pub(crate) fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
271    AwsResponse::xml(
272        StatusCode::OK,
273        query_response_xml(action, NS, inner, request_id),
274    )
275}
276
277pub(crate) fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
278    AwsResponse::xml(
279        StatusCode::OK,
280        query_metadata_only_xml(action, NS, request_id),
281    )
282}
283
284pub(crate) fn invalid_param(message: impl Into<String>) -> AwsServiceError {
285    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
286}
287
288/// `ResourceNotFoundException` — wire code matches the awsQueryError trait.
289pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
290    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
291}
292
293/// `MissingRequiredParameterException` — awsQueryError wire code is
294/// `MissingParameter`.
295pub(crate) fn missing_param(name: &str) -> AwsServiceError {
296    AwsServiceError::aws_error(
297        StatusCode::BAD_REQUEST,
298        "MissingParameter",
299        format!("The request must contain the parameter {name}."),
300    )
301}
302
303pub(crate) fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
304    let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
305    let needle = format!("{prefix}.member.");
306    for (k, v) in req.query_params.iter() {
307        let Some(rest) = k.strip_prefix(&needle) else {
308            continue;
309        };
310        let mut parts = rest.splitn(2, '.');
311        let Some(idx_str) = parts.next() else {
312            continue;
313        };
314        let Ok(idx) = idx_str.parse::<u32>() else {
315            continue;
316        };
317        let field = parts.next().unwrap_or("").to_string();
318        by_index.entry(idx).or_default().insert(field, v.clone());
319    }
320    by_index.into_values().collect()
321}
322
323fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
324    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
325    let needle = format!("{prefix}.member.");
326    for (k, v) in member.iter() {
327        let Some(rest) = k.strip_prefix(&needle) else {
328            continue;
329        };
330        let mut parts = rest.splitn(2, '.');
331        let Some(idx_str) = parts.next() else {
332            continue;
333        };
334        let Ok(idx) = idx_str.parse::<u32>() else {
335            continue;
336        };
337        let field = parts.next().unwrap_or("");
338        let entry = dims.entry(idx).or_default();
339        match field {
340            "Name" => entry.0 = Some(v.clone()),
341            "Value" => entry.1 = Some(v.clone()),
342            _ => {}
343        }
344    }
345    let mut out = BTreeMap::new();
346    for (_, (name, value)) in dims {
347        if let (Some(n), Some(v)) = (name, value) {
348            out.insert(n, v);
349        }
350    }
351    out
352}
353
354pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
355    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
356    let needle = format!("{prefix}.member.");
357    for (k, v) in req.query_params.iter() {
358        let Some(rest) = k.strip_prefix(&needle) else {
359            continue;
360        };
361        let mut parts = rest.splitn(2, '.');
362        let Some(idx_str) = parts.next() else {
363            continue;
364        };
365        let Ok(idx) = idx_str.parse::<u32>() else {
366            continue;
367        };
368        let field = parts.next().unwrap_or("");
369        let entry = dims.entry(idx).or_default();
370        match field {
371            "Name" => entry.0 = Some(v.clone()),
372            "Value" => entry.1 = Some(v.clone()),
373            _ => {}
374        }
375    }
376    let mut out = BTreeMap::new();
377    for (_, (name, value)) in dims {
378        if let (Some(n), Some(v)) = (name, value) {
379            out.insert(n, v);
380        }
381    }
382    out
383}
384
385/// Validate the length of an optional string param against `[min, max]`.
386/// Returns a 4xx on violation. AWS measures length in characters; the
387/// conformance probe only sends ASCII so byte length is equivalent here.
388pub(crate) fn validate_len(
389    req: &AwsRequest,
390    param: &str,
391    min: usize,
392    max: usize,
393) -> Result<(), AwsServiceError> {
394    if let Some(v) = req.query_params.get(param) {
395        let len = v.chars().count();
396        if len < min || len > max {
397            return Err(invalid_param(format!(
398                "{param} length {len} is outside [{min}, {max}]"
399            )));
400        }
401    }
402    Ok(())
403}
404
405/// Validate an optional integer param against `[min, max]` (inclusive).
406pub(crate) fn validate_range_i64(
407    req: &AwsRequest,
408    param: &str,
409    min: i64,
410    max: i64,
411) -> Result<(), AwsServiceError> {
412    if let Some(v) = req.query_params.get(param) {
413        if v.is_empty() {
414            return Ok(());
415        }
416        let n = v
417            .parse::<i64>()
418            .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
419        if n < min || n > max {
420            return Err(invalid_param(format!(
421                "{param} value {n} is outside [{min}, {max}]"
422            )));
423        }
424    }
425    Ok(())
426}
427
428/// Validate that an optional param, when present, is one of `allowed`.
429pub(crate) fn validate_enum(
430    req: &AwsRequest,
431    param: &str,
432    allowed: &[&str],
433) -> Result<(), AwsServiceError> {
434    if let Some(v) = req.query_params.get(param) {
435        if !v.is_empty() && !allowed.contains(&v.as_str()) {
436            return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
437        }
438    }
439    Ok(())
440}
441
442/// Collect repeated `<Prefix>.member.N` scalar values, ordered by index.
443pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
444    let needle = format!("{prefix}.member.");
445    let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
446    for (k, v) in req.query_params.iter() {
447        let Some(rest) = k.strip_prefix(&needle) else {
448            continue;
449        };
450        if let Ok(idx) = rest.parse::<u32>() {
451            by_index.insert(idx, v.clone());
452        }
453    }
454    by_index.into_values().collect()
455}
456
457/// Parse a `Tags.member.N.Key` / `Tags.member.N.Value` list into a map.
458pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
459    let members = collect_indexed(req, prefix);
460    let mut out = BTreeMap::new();
461    for m in members {
462        if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
463            out.insert(k.clone(), v.clone());
464        }
465    }
466    out
467}
468
469pub(crate) fn xml_escape(s: &str) -> String {
470    s.replace('&', "&amp;")
471        .replace('<', "&lt;")
472        .replace('>', "&gt;")
473        .replace('"', "&quot;")
474        .replace('\'', "&apos;")
475}
476
477/// Per-datapoint aggregation summary covering both the simple `Value` form
478/// and the `StatisticValues` form so callers don't lose the count or
479/// min/max baked into a `StatisticSet`.
480#[derive(Clone, Copy)]
481struct DatumStats {
482    sum: f64,
483    min: f64,
484    max: f64,
485    count: f64,
486}
487
488fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
489    if let Some(v) = d.value {
490        return Some(DatumStats {
491            sum: v,
492            min: v,
493            max: v,
494            count: 1.0,
495        });
496    }
497    if let Some(s) = &d.statistic_values {
498        return Some(DatumStats {
499            sum: s.sum,
500            min: s.minimum,
501            max: s.maximum,
502            count: s.sample_count,
503        });
504    }
505    None
506}
507
508fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
509    acc.sum += other.sum;
510    acc.count += other.count;
511    if other.min < acc.min {
512        acc.min = other.min;
513    }
514    if other.max > acc.max {
515        acc.max = other.max;
516    }
517}
518
519fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
520    match stat {
521        "Sum" => Some(agg.sum),
522        "Average" => {
523            if agg.count > 0.0 {
524                Some(agg.sum / agg.count)
525            } else {
526                None
527            }
528        }
529        "Minimum" => Some(agg.min),
530        "Maximum" => Some(agg.max),
531        "SampleCount" => Some(agg.count),
532        _ => None,
533    }
534}
535
536pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
537    let mut s = String::from("<Dimensions>");
538    for (name, value) in dims.iter() {
539        s.push_str(&format!(
540            "<member><Name>{}</Name><Value>{}</Value></member>",
541            xml_escape(name),
542            xml_escape(value),
543        ));
544    }
545    s.push_str("</Dimensions>");
546    s
547}
548
549impl CloudWatchService {
550    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
551        let namespace = required_query_param(req, "Namespace")?;
552        let members = collect_indexed(req, "MetricData");
553        if members.is_empty() {
554            return Err(invalid_param(
555                "PutMetricData requires at least one MetricData entry",
556            ));
557        }
558
559        let now = Utc::now();
560        let mut state = self.state.write();
561        let acct = state.get_or_create(&req.account_id);
562        let metrics_map = acct.metrics_in_mut(&req.region);
563        let bucket = metrics_map.entry(namespace.clone()).or_default();
564
565        for member in members {
566            let metric_name = member
567                .get("MetricName")
568                .cloned()
569                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
570            let value = member
571                .get("Value")
572                .map(|s| s.parse::<f64>())
573                .transpose()
574                .map_err(|_| invalid_param("Value must be a valid number"))?;
575            let timestamp = member
576                .get("Timestamp")
577                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
578                .map(|d| d.with_timezone(&Utc))
579                .unwrap_or(now);
580            let unit = member.get("Unit").cloned();
581            let storage_resolution = member
582                .get("StorageResolution")
583                .and_then(|s| s.parse::<i64>().ok());
584            let dimensions = parse_dimensions(&member, "Dimensions");
585
586            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
587                member.get("StatisticValues.SampleCount"),
588                member.get("StatisticValues.Sum"),
589                member.get("StatisticValues.Minimum"),
590                member.get("StatisticValues.Maximum"),
591            ) {
592                Some(StatisticSet {
593                    sample_count: sc.parse::<f64>().map_err(|_| {
594                        invalid_param("StatisticValues.SampleCount must be a number")
595                    })?,
596                    sum: sum
597                        .parse::<f64>()
598                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
599                    minimum: min
600                        .parse::<f64>()
601                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
602                    maximum: max
603                        .parse::<f64>()
604                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
605                })
606            } else {
607                None
608            };
609
610            if value.is_none() && statistic_values.is_none() {
611                return Err(invalid_param(
612                    "MetricData entry must supply either Value or StatisticValues",
613                ));
614            }
615
616            bucket.push(MetricDatum {
617                metric_name,
618                dimensions,
619                timestamp,
620                value,
621                statistic_values,
622                unit,
623                storage_resolution,
624            });
625        }
626
627        Ok(empty_metadata_response("PutMetricData", &req.request_id))
628    }
629
630    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631        validate_len(req, "Namespace", 1, 255)?;
632        validate_len(req, "MetricName", 1, 255)?;
633        validate_len(req, "OwningAccount", 1, 255)?;
634        validate_enum(req, "RecentlyActive", &["PT3H"])?;
635        let namespace = optional_query_param(req, "Namespace");
636        let metric_name = optional_query_param(req, "MetricName");
637        let dim_filter = parse_dimensions_query(req, "Dimensions");
638
639        let state = self.state.read();
640        let mut out = String::from("<Metrics>");
641        if let Some(acct) = state.get(&req.account_id) {
642            if let Some(map) = acct.metrics_in(&req.region) {
643                for (ns, data) in map.iter() {
644                    if let Some(filter_ns) = namespace.as_ref() {
645                        if ns != filter_ns {
646                            continue;
647                        }
648                    }
649                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
650                        BTreeMap::new();
651                    for d in data.iter() {
652                        if let Some(filter_name) = metric_name.as_ref() {
653                            if &d.metric_name != filter_name {
654                                continue;
655                            }
656                        }
657                        if !dim_filter.is_empty()
658                            && !dim_filter
659                                .iter()
660                                .all(|(k, v)| d.dimensions.get(k) == Some(v))
661                        {
662                            continue;
663                        }
664                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
665                    }
666                    for ((name, dims), _) in seen {
667                        out.push_str("<member>");
668                        out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
669                        out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
670                        out.push_str(&render_dimensions(&dims));
671                        out.push_str("</member>");
672                    }
673                }
674            }
675        }
676        out.push_str("</Metrics>");
677
678        Ok(xml_response("ListMetrics", &out, &req.request_id))
679    }
680
681    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
682        let namespace = required_query_param(req, "Namespace")?;
683        let metric_name = required_query_param(req, "MetricName")?;
684        let start = required_query_param(req, "StartTime")?;
685        let end = required_query_param(req, "EndTime")?;
686        let period = required_query_param(req, "Period")?
687            .parse::<i64>()
688            .map_err(|_| invalid_param("Period must be an integer"))?;
689        if period <= 0 {
690            return Err(invalid_param("Period must be positive"));
691        }
692        let start_ts = DateTime::parse_from_rfc3339(&start)
693            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
694            .with_timezone(&Utc);
695        let end_ts = DateTime::parse_from_rfc3339(&end)
696            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
697            .with_timezone(&Utc);
698
699        let mut statistics: Vec<String> = Vec::new();
700        for (k, v) in req.query_params.iter() {
701            if k.starts_with("Statistics.member.") {
702                statistics.push(v.clone());
703            }
704        }
705        if statistics.is_empty() {
706            return Err(invalid_param("At least one Statistic is required"));
707        }
708
709        let dim_filter = parse_dimensions_query(req, "Dimensions");
710
711        let state = self.state.read();
712        let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
713        if let Some(acct) = state.get(&req.account_id) {
714            if let Some(map) = acct.metrics_in(&req.region) {
715                if let Some(data) = map.get(&namespace) {
716                    let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
717                    for d in data.iter() {
718                        if d.metric_name != metric_name {
719                            continue;
720                        }
721                        if !dim_filter
722                            .iter()
723                            .all(|(k, v)| d.dimensions.get(k) == Some(v))
724                        {
725                            continue;
726                        }
727                        if d.timestamp < start_ts || d.timestamp >= end_ts {
728                            continue;
729                        }
730                        let Some(stats) = datum_stats(d) else {
731                            continue;
732                        };
733                        let secs = d.timestamp.timestamp();
734                        let bucket_secs = secs - secs.rem_euclid(period);
735                        let bucket_ts =
736                            DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
737                        buckets
738                            .entry(bucket_ts)
739                            .and_modify(|acc| merge_stats(acc, stats))
740                            .or_insert(stats);
741                    }
742                    for (ts, agg) in buckets {
743                        let mut stats = BTreeMap::new();
744                        for stat in statistics.iter() {
745                            if let Some(v) = stat_value(stat, agg) {
746                                stats.insert(stat.clone(), v);
747                            }
748                        }
749                        datapoints.push((ts, stats));
750                    }
751                }
752            }
753        }
754
755        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
756        inner.push_str("<Datapoints>");
757        for (ts, stats) in datapoints {
758            inner.push_str("<member>");
759            inner.push_str(&format!(
760                "<Timestamp>{}</Timestamp>",
761                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
762            ));
763            for (name, value) in stats {
764                inner.push_str(&format!("<{name}>{value}</{name}>"));
765            }
766            inner.push_str("</member>");
767        }
768        inner.push_str("</Datapoints>");
769
770        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
771    }
772
773    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
774        validate_enum(
775            req,
776            "ScanBy",
777            &["TimestampDescending", "TimestampAscending"],
778        )?;
779        let start = required_query_param(req, "StartTime")?;
780        let end = required_query_param(req, "EndTime")?;
781        let start_ts = DateTime::parse_from_rfc3339(&start)
782            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
783            .with_timezone(&Utc);
784        let end_ts = DateTime::parse_from_rfc3339(&end)
785            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
786            .with_timezone(&Utc);
787
788        // GetMetricData declares only InvalidNextToken, so it never rejects an
789        // empty / malformed query list with a 4xx — it returns empty results.
790        let queries = collect_indexed(req, "MetricDataQueries");
791
792        let state = self.state.read();
793        let mut inner = String::from("<MetricDataResults>");
794        for q in queries {
795            let id = q.get("Id").cloned().unwrap_or_default();
796            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
797            let stat = q
798                .get("MetricStat.Stat")
799                .cloned()
800                .unwrap_or_else(|| "Sum".to_string());
801            let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
802            let namespace = q.get("MetricStat.Metric.Namespace").cloned();
803            let period: i64 = q
804                .get("MetricStat.Period")
805                .and_then(|s| s.parse::<i64>().ok())
806                .filter(|p| *p > 0)
807                .unwrap_or(60);
808            let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
809
810            let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
811            if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
812                if let Some(acct) = state.get(&req.account_id) {
813                    if let Some(map) = acct.metrics_in(&req.region) {
814                        if let Some(data) = map.get(&namespace) {
815                            let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
816                            for d in data.iter() {
817                                if d.metric_name != metric_name {
818                                    continue;
819                                }
820                                if !dim_filter
821                                    .iter()
822                                    .all(|(k, v)| d.dimensions.get(k) == Some(v))
823                                {
824                                    continue;
825                                }
826                                if d.timestamp < start_ts || d.timestamp >= end_ts {
827                                    continue;
828                                }
829                                let Some(stats) = datum_stats(d) else {
830                                    continue;
831                                };
832                                let secs = d.timestamp.timestamp();
833                                let bucket_secs = secs - secs.rem_euclid(period);
834                                let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
835                                    .unwrap_or(d.timestamp);
836                                buckets
837                                    .entry(bucket_ts)
838                                    .and_modify(|acc| merge_stats(acc, stats))
839                                    .or_insert(stats);
840                            }
841                            for (ts, agg) in buckets {
842                                let Some(v) = stat_value(&stat, agg) else {
843                                    continue;
844                                };
845                                timestamps
846                                    .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
847                                values.push(v);
848                            }
849                        }
850                    }
851                }
852            }
853
854            inner.push_str("<member>");
855            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
856            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
857            inner.push_str("<StatusCode>Complete</StatusCode>");
858            inner.push_str("<Timestamps>");
859            for ts in timestamps {
860                inner.push_str(&format!("<member>{ts}</member>"));
861            }
862            inner.push_str("</Timestamps>");
863            inner.push_str("<Values>");
864            for v in values {
865                inner.push_str(&format!("<member>{v}</member>"));
866            }
867            inner.push_str("</Values>");
868            inner.push_str("</member>");
869        }
870        inner.push_str("</MetricDataResults>");
871        inner.push_str("<Messages></Messages>");
872
873        Ok(xml_response("GetMetricData", &inner, &req.request_id))
874    }
875
876    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
877        // Only `AlarmName` is required by the Smithy contract; the op declares
878        // no validation errors, so ComparisonOperator / EvaluationPeriods are
879        // accepted with sensible defaults rather than rejected. Constraint
880        // violations still produce a 4xx, which the probe accepts as AnyError
881        // for the negative variants.
882        validate_len(req, "AlarmName", 1, 255)?;
883        validate_len(req, "AlarmDescription", 0, 1024)?;
884        validate_len(req, "MetricName", 1, 255)?;
885        validate_len(req, "Namespace", 1, 255)?;
886        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
887        validate_len(req, "TreatMissingData", 1, 255)?;
888        validate_len(req, "ThresholdMetricId", 1, 255)?;
889        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
890        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
891        validate_range_i64(req, "Period", 1, i64::MAX)?;
892        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
893        validate_enum(
894            req,
895            "ComparisonOperator",
896            &[
897                "GreaterThanOrEqualToThreshold",
898                "GreaterThanThreshold",
899                "GreaterThanUpperThreshold",
900                "LessThanLowerOrGreaterThanUpperThreshold",
901                "LessThanLowerThreshold",
902                "LessThanOrEqualToThreshold",
903                "LessThanThreshold",
904            ],
905        )?;
906        validate_enum(
907            req,
908            "Statistic",
909            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
910        )?;
911        validate_enum(req, "Unit", STANDARD_UNITS)?;
912        let alarm_name = required_query_param(req, "AlarmName")?;
913        let comparison = optional_query_param(req, "ComparisonOperator")
914            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
915        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
916            .and_then(|s| s.parse::<i64>().ok())
917            .unwrap_or(1);
918
919        let alarm_description = optional_query_param(req, "AlarmDescription");
920        let actions_enabled = optional_query_param(req, "ActionsEnabled")
921            .map(|s| s.eq_ignore_ascii_case("true"))
922            .unwrap_or(true);
923
924        let metric_name = optional_query_param(req, "MetricName");
925        let namespace = optional_query_param(req, "Namespace");
926        let statistic = optional_query_param(req, "Statistic");
927        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
928        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
929        let unit = optional_query_param(req, "Unit");
930        let datapoints_to_alarm =
931            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
932        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
933        let treat_missing_data = optional_query_param(req, "TreatMissingData");
934        let evaluate_low_sample_count_percentile =
935            optional_query_param(req, "EvaluateLowSampleCountPercentile");
936        let dimensions = parse_dimensions_query(req, "Dimensions");
937
938        let mut ok_actions = Vec::new();
939        let mut alarm_actions = Vec::new();
940        let mut insufficient_data_actions = Vec::new();
941        for (k, v) in req.query_params.iter() {
942            if k.starts_with("OKActions.member.") {
943                ok_actions.push(v.clone());
944            } else if k.starts_with("AlarmActions.member.") {
945                alarm_actions.push(v.clone());
946            } else if k.starts_with("InsufficientDataActions.member.") {
947                insufficient_data_actions.push(v.clone());
948            }
949        }
950
951        let arn = format!(
952            "arn:aws:cloudwatch:{}:{}:alarm:{}",
953            req.region, req.account_id, alarm_name
954        );
955        let now = Utc::now();
956
957        let mut state = self.state.write();
958        let acct = state.get_or_create(&req.account_id);
959        let alarms = acct.alarms_in_mut(&req.region);
960        let existing = alarms.get(&alarm_name).cloned();
961        let alarm = MetricAlarm {
962            alarm_name: alarm_name.clone(),
963            alarm_arn: arn,
964            alarm_description,
965            actions_enabled,
966            ok_actions,
967            alarm_actions,
968            insufficient_data_actions,
969            state_value: existing
970                .as_ref()
971                .map(|a| a.state_value)
972                .unwrap_or(AlarmState::InsufficientData),
973            state_reason: existing
974                .as_ref()
975                .map(|a| a.state_reason.clone())
976                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
977            state_updated_timestamp: existing
978                .as_ref()
979                .map(|a| a.state_updated_timestamp)
980                .unwrap_or(now),
981            metric_name,
982            namespace,
983            statistic,
984            extended_statistic,
985            dimensions,
986            period,
987            unit,
988            evaluation_periods,
989            datapoints_to_alarm,
990            threshold,
991            comparison_operator: comparison,
992            treat_missing_data,
993            evaluate_low_sample_count_percentile,
994            configuration_updated_timestamp: existing
995                .as_ref()
996                .map(|a| a.configuration_updated_timestamp)
997                .unwrap_or(now),
998            alarm_configuration_updated_timestamp: now,
999        };
1000        alarms.insert(alarm_name, alarm);
1001
1002        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1003    }
1004
1005    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1006        let mut filter_names: Vec<String> = Vec::new();
1007        for (k, v) in req.query_params.iter() {
1008            if k.starts_with("AlarmNames.member.") {
1009                filter_names.push(v.clone());
1010            }
1011        }
1012        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1013        validate_len(req, "ActionPrefix", 1, 1024)?;
1014        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1015        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1016        validate_range_i64(req, "MaxRecords", 1, 100)?;
1017        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1018        let prefix = optional_query_param(req, "AlarmNamePrefix");
1019        let state_filter = optional_query_param(req, "StateValue");
1020        let action_prefix = optional_query_param(req, "ActionPrefix");
1021
1022        let state = self.state.read();
1023        let mut inner = String::from("<MetricAlarms>");
1024        if let Some(acct) = state.get(&req.account_id) {
1025            if let Some(alarms) = acct.alarms_in(&req.region) {
1026                for alarm in alarms.values() {
1027                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1028                        continue;
1029                    }
1030                    if let Some(p) = prefix.as_ref() {
1031                        if !alarm.alarm_name.starts_with(p) {
1032                            continue;
1033                        }
1034                    }
1035                    if let Some(sv) = state_filter.as_ref() {
1036                        if alarm.state_value.as_str() != sv {
1037                            continue;
1038                        }
1039                    }
1040                    if let Some(ap) = action_prefix.as_ref() {
1041                        let any = alarm
1042                            .alarm_actions
1043                            .iter()
1044                            .chain(alarm.ok_actions.iter())
1045                            .chain(alarm.insufficient_data_actions.iter())
1046                            .any(|a| a.starts_with(ap));
1047                        if !any {
1048                            continue;
1049                        }
1050                    }
1051                    inner.push_str(&render_alarm(alarm));
1052                }
1053            }
1054        }
1055        inner.push_str("</MetricAlarms>");
1056        inner.push_str("<CompositeAlarms>");
1057        if let Some(acct) = state.get(&req.account_id) {
1058            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1059                for alarm in composites.values() {
1060                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1061                        continue;
1062                    }
1063                    if let Some(p) = prefix.as_ref() {
1064                        if !alarm.alarm_name.starts_with(p) {
1065                            continue;
1066                        }
1067                    }
1068                    if let Some(sv) = state_filter.as_ref() {
1069                        if alarm.state_value.as_str() != sv {
1070                            continue;
1071                        }
1072                    }
1073                    if let Some(ap) = action_prefix.as_ref() {
1074                        let any = alarm
1075                            .alarm_actions
1076                            .iter()
1077                            .chain(alarm.ok_actions.iter())
1078                            .chain(alarm.insufficient_data_actions.iter())
1079                            .any(|a| a.starts_with(ap));
1080                        if !any {
1081                            continue;
1082                        }
1083                    }
1084                    inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1085                }
1086            }
1087        }
1088        inner.push_str("</CompositeAlarms>");
1089
1090        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1091    }
1092
1093    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1094        validate_len(req, "MetricName", 1, 255)?;
1095        validate_len(req, "Namespace", 1, 255)?;
1096        validate_range_i64(req, "Period", 1, i64::MAX)?;
1097        validate_enum(
1098            req,
1099            "Statistic",
1100            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1101        )?;
1102        validate_enum(req, "Unit", STANDARD_UNITS)?;
1103        let metric_name = required_query_param(req, "MetricName")?;
1104        let namespace = required_query_param(req, "Namespace")?;
1105        let dim_filter = parse_dimensions_query(req, "Dimensions");
1106
1107        let state = self.state.read();
1108        let mut inner = String::from("<MetricAlarms>");
1109        if let Some(acct) = state.get(&req.account_id) {
1110            if let Some(alarms) = acct.alarms_in(&req.region) {
1111                for alarm in alarms.values() {
1112                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1113                        continue;
1114                    }
1115                    if alarm.namespace.as_deref() != Some(&namespace) {
1116                        continue;
1117                    }
1118                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1119                        continue;
1120                    }
1121                    inner.push_str(&render_alarm(alarm));
1122                }
1123            }
1124        }
1125        inner.push_str("</MetricAlarms>");
1126
1127        Ok(xml_response(
1128            "DescribeAlarmsForMetric",
1129            &inner,
1130            &req.request_id,
1131        ))
1132    }
1133
1134    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1135        // AlarmNames is required, but an empty list serialises to zero wire
1136        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1137        // set is a no-op rather than an undeclared 4xx.
1138        let mut names: Vec<String> = Vec::new();
1139        for (k, v) in req.query_params.iter() {
1140            if k.starts_with("AlarmNames.member.") {
1141                names.push(v.clone());
1142            }
1143        }
1144
1145        let mut state = self.state.write();
1146        let acct = state.get_or_create(&req.account_id);
1147        for name in &names {
1148            acct.alarms_in_mut(&req.region).remove(name);
1149            acct.composite_alarms_in_mut(&req.region).remove(name);
1150        }
1151
1152        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1153    }
1154
1155    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1156        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1157    }
1158
1159    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1160        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1161    }
1162
1163    fn toggle_alarm_actions(
1164        &self,
1165        req: &AwsRequest,
1166        enabled: bool,
1167        action_name: &str,
1168    ) -> Result<AwsResponse, AwsServiceError> {
1169        let mut names: Vec<String> = Vec::new();
1170        for (k, v) in req.query_params.iter() {
1171            if k.starts_with("AlarmNames.member.") {
1172                names.push(v.clone());
1173            }
1174        }
1175        let mut state = self.state.write();
1176        let acct = state.get_or_create(&req.account_id);
1177        let alarms = acct.alarms_in_mut(&req.region);
1178        for name in names {
1179            if let Some(alarm) = alarms.get_mut(&name) {
1180                alarm.actions_enabled = enabled;
1181                alarm.alarm_configuration_updated_timestamp = Utc::now();
1182            }
1183        }
1184        Ok(empty_metadata_response(action_name, &req.request_id))
1185    }
1186
1187    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1188        validate_len(req, "AlarmName", 1, 255)?;
1189        validate_len(req, "StateReason", 0, 1023)?;
1190        validate_len(req, "StateReasonData", 0, 4000)?;
1191        let alarm_name = required_query_param(req, "AlarmName")?;
1192        let state_value = required_query_param(req, "StateValue")?;
1193        // StateReason is required but allows a zero-length value (min=0). Treat
1194        // an absent key as missing (declared error) while accepting an empty
1195        // string as a valid value.
1196        let state_reason = req
1197            .query_params
1198            .get("StateReason")
1199            .cloned()
1200            .ok_or_else(|| {
1201                AwsServiceError::aws_error(
1202                    StatusCode::BAD_REQUEST,
1203                    "MissingParameter",
1204                    "The request must contain the parameter StateReason.",
1205                )
1206            })?;
1207        let new_state = AlarmState::parse(&state_value)
1208            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1209
1210        let mut state = self.state.write();
1211        let acct = state.get_or_create(&req.account_id);
1212        let alarms = acct.alarms_in_mut(&req.region);
1213        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1214            AwsServiceError::aws_error(
1215                StatusCode::NOT_FOUND,
1216                "ResourceNotFound",
1217                format!("Alarm {alarm_name} not found"),
1218            )
1219        })?;
1220        alarm.state_value = new_state;
1221        alarm.state_reason = state_reason;
1222        alarm.state_updated_timestamp = Utc::now();
1223
1224        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1225    }
1226
1227    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1228        validate_len(req, "AlarmName", 1, 255)?;
1229        validate_len(req, "AlarmContributorId", 1, 16)?;
1230        validate_range_i64(req, "MaxRecords", 1, 100)?;
1231        validate_enum(
1232            req,
1233            "HistoryItemType",
1234            &[
1235                "ConfigurationUpdate",
1236                "StateUpdate",
1237                "Action",
1238                "AlarmContributorStateUpdate",
1239                "AlarmContributorAction",
1240            ],
1241        )?;
1242        validate_enum(
1243            req,
1244            "ScanBy",
1245            &["TimestampDescending", "TimestampAscending"],
1246        )?;
1247        // Minimal implementation: return empty history. AWS pagination tokens are
1248        // not tracked locally, so callers see an empty list rather than a stub.
1249        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1250        Ok(xml_response(
1251            "DescribeAlarmHistory",
1252            &inner,
1253            &req.request_id,
1254        ))
1255    }
1256
1257    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1258        let dashboard_name = req
1259            .query_params
1260            .get("DashboardName")
1261            .ok_or_else(|| invalid_param("DashboardName is required"))?
1262            .clone();
1263        let body = req
1264            .query_params
1265            .get("DashboardBody")
1266            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1267            .clone();
1268        // AWS validates that DashboardBody parses as JSON; we do the same so
1269        // bad bodies surface a useful error before persisting.
1270        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1271            return Err(AwsServiceError::aws_error(
1272                StatusCode::BAD_REQUEST,
1273                "InvalidParameterInput",
1274                "DashboardBody must be a valid JSON object",
1275            ));
1276        }
1277        let arn = format!(
1278            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1279            req.account_id
1280        );
1281        let dashboard = Dashboard {
1282            name: dashboard_name.clone(),
1283            arn,
1284            size_bytes: body.len() as i64,
1285            body,
1286            last_modified: Utc::now(),
1287        };
1288        let mut state = self.state.write();
1289        let acct = state.get_or_create(&req.account_id);
1290        acct.dashboards.insert(dashboard_name, dashboard);
1291        // PutDashboard returns DashboardValidationMessages — empty when the
1292        // body parses cleanly.
1293        let inner = String::from("<DashboardValidationMessages/>");
1294        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1295    }
1296
1297    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1298        let name = req
1299            .query_params
1300            .get("DashboardName")
1301            .ok_or_else(|| invalid_param("DashboardName is required"))?
1302            .clone();
1303        let state = self.state.read();
1304        let dashboard = state
1305            .get(&req.account_id)
1306            .and_then(|a| a.dashboards.get(&name))
1307            .cloned()
1308            .ok_or_else(|| {
1309                AwsServiceError::aws_error(
1310                    StatusCode::NOT_FOUND,
1311                    "ResourceNotFound",
1312                    format!("Dashboard {name} does not exist"),
1313                )
1314            })?;
1315        let inner = format!(
1316            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1317            xml_escape(&dashboard.arn),
1318            xml_escape(&dashboard.body),
1319            xml_escape(&dashboard.name),
1320        );
1321        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1322    }
1323
1324    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1325        let mut names: Vec<String> = Vec::new();
1326        for (k, v) in req.query_params.iter() {
1327            if k.starts_with("DashboardNames.member.") {
1328                names.push(v.clone());
1329            }
1330        }
1331        if names.is_empty() {
1332            return Err(invalid_param(
1333                "DashboardNames must contain at least one name",
1334            ));
1335        }
1336        let mut state = self.state.write();
1337        let acct = state.get_or_create(&req.account_id);
1338        for n in names {
1339            acct.dashboards.remove(&n);
1340        }
1341        Ok(empty_metadata_response("DeleteDashboards", &req.request_id))
1342    }
1343
1344    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1345        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1346        let state = self.state.read();
1347        let dashboards: Vec<Dashboard> = state
1348            .get(&req.account_id)
1349            .map(|a| {
1350                a.dashboards
1351                    .values()
1352                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1353                    .cloned()
1354                    .collect()
1355            })
1356            .unwrap_or_default();
1357        let mut entries = String::new();
1358        for d in &dashboards {
1359            entries.push_str("<member>");
1360            entries.push_str(&format!(
1361                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1362                xml_escape(&d.arn),
1363                xml_escape(&d.name),
1364                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1365                d.size_bytes,
1366            ));
1367            entries.push_str("</member>");
1368        }
1369        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1370        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1371    }
1372}
1373
1374fn render_alarm(alarm: &MetricAlarm) -> String {
1375    let mut s = String::from("<member>");
1376    s.push_str(&format!(
1377        "<AlarmName>{}</AlarmName>",
1378        xml_escape(&alarm.alarm_name)
1379    ));
1380    s.push_str(&format!(
1381        "<AlarmArn>{}</AlarmArn>",
1382        xml_escape(&alarm.alarm_arn)
1383    ));
1384    if let Some(d) = &alarm.alarm_description {
1385        s.push_str(&format!(
1386            "<AlarmDescription>{}</AlarmDescription>",
1387            xml_escape(d)
1388        ));
1389    }
1390    s.push_str(&format!(
1391        "<ActionsEnabled>{}</ActionsEnabled>",
1392        alarm.actions_enabled
1393    ));
1394    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1395    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1396    push_action_list(
1397        &mut s,
1398        "InsufficientDataActions",
1399        &alarm.insufficient_data_actions,
1400    );
1401    s.push_str(&format!(
1402        "<StateValue>{}</StateValue>",
1403        alarm.state_value.as_str()
1404    ));
1405    s.push_str(&format!(
1406        "<StateReason>{}</StateReason>",
1407        xml_escape(&alarm.state_reason)
1408    ));
1409    s.push_str(&format!(
1410        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1411        alarm
1412            .state_updated_timestamp
1413            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1414    ));
1415    if let Some(m) = &alarm.metric_name {
1416        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1417    }
1418    if let Some(n) = &alarm.namespace {
1419        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1420    }
1421    if let Some(stat) = &alarm.statistic {
1422        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1423    }
1424    if let Some(ext) = &alarm.extended_statistic {
1425        s.push_str(&format!(
1426            "<ExtendedStatistic>{}</ExtendedStatistic>",
1427            xml_escape(ext)
1428        ));
1429    }
1430    s.push_str(&render_dimensions(&alarm.dimensions));
1431    if let Some(p) = alarm.period {
1432        s.push_str(&format!("<Period>{p}</Period>"));
1433    }
1434    if let Some(u) = &alarm.unit {
1435        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1436    }
1437    s.push_str(&format!(
1438        "<EvaluationPeriods>{}</EvaluationPeriods>",
1439        alarm.evaluation_periods
1440    ));
1441    if let Some(d) = alarm.datapoints_to_alarm {
1442        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1443    }
1444    if let Some(t) = alarm.threshold {
1445        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1446    }
1447    s.push_str(&format!(
1448        "<ComparisonOperator>{}</ComparisonOperator>",
1449        xml_escape(&alarm.comparison_operator)
1450    ));
1451    if let Some(t) = &alarm.treat_missing_data {
1452        s.push_str(&format!(
1453            "<TreatMissingData>{}</TreatMissingData>",
1454            xml_escape(t)
1455        ));
1456    }
1457    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1458        s.push_str(&format!(
1459            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1460            xml_escape(e)
1461        ));
1462    }
1463    s.push_str(&format!(
1464        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1465        alarm
1466            .alarm_configuration_updated_timestamp
1467            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1468    ));
1469    s.push_str("</member>");
1470    s
1471}
1472
1473fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1474    s.push_str(&format!("<{name}>"));
1475    for action in actions {
1476        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1477    }
1478    s.push_str(&format!("</{name}>"));
1479}