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