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        // Anomaly-detection alarms reference a metric-math id instead of a
984        // static Threshold; previously accepted then dropped (1.24).
985        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
986        let dimensions = parse_dimensions_query(req, "Dimensions");
987
988        let mut ok_actions = Vec::new();
989        let mut alarm_actions = Vec::new();
990        let mut insufficient_data_actions = Vec::new();
991        for (k, v) in req.query_params.iter() {
992            if k.starts_with("OKActions.member.") {
993                ok_actions.push(v.clone());
994            } else if k.starts_with("AlarmActions.member.") {
995                alarm_actions.push(v.clone());
996            } else if k.starts_with("InsufficientDataActions.member.") {
997                insufficient_data_actions.push(v.clone());
998            }
999        }
1000
1001        let arn = format!(
1002            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1003            req.region, req.account_id, alarm_name
1004        );
1005        let now = Utc::now();
1006
1007        let mut state = self.state.write();
1008        let acct = state.get_or_create(&req.account_id);
1009        let alarms = acct.alarms_in_mut(&req.region);
1010        let existing = alarms.get(&alarm_name).cloned();
1011        let alarm = MetricAlarm {
1012            alarm_name: alarm_name.clone(),
1013            alarm_arn: arn,
1014            alarm_description,
1015            actions_enabled,
1016            ok_actions,
1017            alarm_actions,
1018            insufficient_data_actions,
1019            state_value: existing
1020                .as_ref()
1021                .map(|a| a.state_value)
1022                .unwrap_or(AlarmState::InsufficientData),
1023            state_reason: existing
1024                .as_ref()
1025                .map(|a| a.state_reason.clone())
1026                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1027            state_updated_timestamp: existing
1028                .as_ref()
1029                .map(|a| a.state_updated_timestamp)
1030                .unwrap_or(now),
1031            metric_name,
1032            namespace,
1033            statistic,
1034            extended_statistic,
1035            dimensions,
1036            period,
1037            unit,
1038            evaluation_periods,
1039            datapoints_to_alarm,
1040            threshold,
1041            comparison_operator: comparison,
1042            treat_missing_data,
1043            evaluate_low_sample_count_percentile,
1044            threshold_metric_id,
1045            configuration_updated_timestamp: existing
1046                .as_ref()
1047                .map(|a| a.configuration_updated_timestamp)
1048                .unwrap_or(now),
1049            alarm_configuration_updated_timestamp: now,
1050        };
1051        alarms.insert(alarm_name, alarm);
1052
1053        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1054    }
1055
1056    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1057        let mut filter_names: Vec<String> = Vec::new();
1058        for (k, v) in req.query_params.iter() {
1059            if k.starts_with("AlarmNames.member.") {
1060                filter_names.push(v.clone());
1061            }
1062        }
1063        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1064        validate_len(req, "ActionPrefix", 1, 1024)?;
1065        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1066        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1067        validate_range_i64(req, "MaxRecords", 1, 100)?;
1068        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1069        let prefix = optional_query_param(req, "AlarmNamePrefix");
1070        let state_filter = optional_query_param(req, "StateValue");
1071        let action_prefix = optional_query_param(req, "ActionPrefix");
1072
1073        let state = self.state.read();
1074        let mut inner = String::from("<MetricAlarms>");
1075        if let Some(acct) = state.get(&req.account_id) {
1076            if let Some(alarms) = acct.alarms_in(&req.region) {
1077                for alarm in alarms.values() {
1078                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1079                        continue;
1080                    }
1081                    if let Some(p) = prefix.as_ref() {
1082                        if !alarm.alarm_name.starts_with(p) {
1083                            continue;
1084                        }
1085                    }
1086                    if let Some(sv) = state_filter.as_ref() {
1087                        if alarm.state_value.as_str() != sv {
1088                            continue;
1089                        }
1090                    }
1091                    if let Some(ap) = action_prefix.as_ref() {
1092                        let any = alarm
1093                            .alarm_actions
1094                            .iter()
1095                            .chain(alarm.ok_actions.iter())
1096                            .chain(alarm.insufficient_data_actions.iter())
1097                            .any(|a| a.starts_with(ap));
1098                        if !any {
1099                            continue;
1100                        }
1101                    }
1102                    inner.push_str(&render_alarm(alarm));
1103                }
1104            }
1105        }
1106        inner.push_str("</MetricAlarms>");
1107        inner.push_str("<CompositeAlarms>");
1108        if let Some(acct) = state.get(&req.account_id) {
1109            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1110                for alarm in composites.values() {
1111                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
1112                        continue;
1113                    }
1114                    if let Some(p) = prefix.as_ref() {
1115                        if !alarm.alarm_name.starts_with(p) {
1116                            continue;
1117                        }
1118                    }
1119                    if let Some(sv) = state_filter.as_ref() {
1120                        if alarm.state_value.as_str() != sv {
1121                            continue;
1122                        }
1123                    }
1124                    if let Some(ap) = action_prefix.as_ref() {
1125                        let any = alarm
1126                            .alarm_actions
1127                            .iter()
1128                            .chain(alarm.ok_actions.iter())
1129                            .chain(alarm.insufficient_data_actions.iter())
1130                            .any(|a| a.starts_with(ap));
1131                        if !any {
1132                            continue;
1133                        }
1134                    }
1135                    inner.push_str(&crate::composite_alarms::render_composite_alarm(alarm));
1136                }
1137            }
1138        }
1139        inner.push_str("</CompositeAlarms>");
1140
1141        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1142    }
1143
1144    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1145        validate_len(req, "MetricName", 1, 255)?;
1146        validate_len(req, "Namespace", 1, 255)?;
1147        validate_range_i64(req, "Period", 1, i64::MAX)?;
1148        validate_enum(
1149            req,
1150            "Statistic",
1151            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1152        )?;
1153        validate_enum(req, "Unit", STANDARD_UNITS)?;
1154        let metric_name = required_query_param(req, "MetricName")?;
1155        let namespace = required_query_param(req, "Namespace")?;
1156        let dim_filter = parse_dimensions_query(req, "Dimensions");
1157
1158        let state = self.state.read();
1159        let mut inner = String::from("<MetricAlarms>");
1160        if let Some(acct) = state.get(&req.account_id) {
1161            if let Some(alarms) = acct.alarms_in(&req.region) {
1162                for alarm in alarms.values() {
1163                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1164                        continue;
1165                    }
1166                    if alarm.namespace.as_deref() != Some(&namespace) {
1167                        continue;
1168                    }
1169                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1170                        continue;
1171                    }
1172                    inner.push_str(&render_alarm(alarm));
1173                }
1174            }
1175        }
1176        inner.push_str("</MetricAlarms>");
1177
1178        Ok(xml_response(
1179            "DescribeAlarmsForMetric",
1180            &inner,
1181            &req.request_id,
1182        ))
1183    }
1184
1185    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1186        // AlarmNames is required, but an empty list serialises to zero wire
1187        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1188        // set is a no-op rather than an undeclared 4xx.
1189        let mut names: Vec<String> = Vec::new();
1190        for (k, v) in req.query_params.iter() {
1191            if k.starts_with("AlarmNames.member.") {
1192                names.push(v.clone());
1193            }
1194        }
1195
1196        let mut state = self.state.write();
1197        let acct = state.get_or_create(&req.account_id);
1198        for name in &names {
1199            acct.alarms_in_mut(&req.region).remove(name);
1200            acct.composite_alarms_in_mut(&req.region).remove(name);
1201        }
1202
1203        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1204    }
1205
1206    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1207        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1208    }
1209
1210    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1211        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1212    }
1213
1214    fn toggle_alarm_actions(
1215        &self,
1216        req: &AwsRequest,
1217        enabled: bool,
1218        action_name: &str,
1219    ) -> Result<AwsResponse, AwsServiceError> {
1220        let mut names: Vec<String> = Vec::new();
1221        for (k, v) in req.query_params.iter() {
1222            if k.starts_with("AlarmNames.member.") {
1223                names.push(v.clone());
1224            }
1225        }
1226        let mut state = self.state.write();
1227        let acct = state.get_or_create(&req.account_id);
1228        let alarms = acct.alarms_in_mut(&req.region);
1229        for name in names {
1230            if let Some(alarm) = alarms.get_mut(&name) {
1231                alarm.actions_enabled = enabled;
1232                alarm.alarm_configuration_updated_timestamp = Utc::now();
1233            }
1234        }
1235        Ok(empty_metadata_response(action_name, &req.request_id))
1236    }
1237
1238    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1239        validate_len(req, "AlarmName", 1, 255)?;
1240        validate_len(req, "StateReason", 0, 1023)?;
1241        validate_len(req, "StateReasonData", 0, 4000)?;
1242        let alarm_name = required_query_param(req, "AlarmName")?;
1243        let state_value = required_query_param(req, "StateValue")?;
1244        // StateReason is required but allows a zero-length value (min=0). Treat
1245        // an absent key as missing (declared error) while accepting an empty
1246        // string as a valid value.
1247        let state_reason = req
1248            .query_params
1249            .get("StateReason")
1250            .cloned()
1251            .ok_or_else(|| {
1252                AwsServiceError::aws_error(
1253                    StatusCode::BAD_REQUEST,
1254                    "MissingParameter",
1255                    "The request must contain the parameter StateReason.",
1256                )
1257            })?;
1258        let new_state = AlarmState::parse(&state_value)
1259            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1260
1261        let mut state = self.state.write();
1262        let acct = state.get_or_create(&req.account_id);
1263        let alarms = acct.alarms_in_mut(&req.region);
1264        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
1265            AwsServiceError::aws_error(
1266                StatusCode::NOT_FOUND,
1267                "ResourceNotFound",
1268                format!("Alarm {alarm_name} not found"),
1269            )
1270        })?;
1271        alarm.state_value = new_state;
1272        alarm.state_reason = state_reason;
1273        alarm.state_updated_timestamp = Utc::now();
1274
1275        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1276    }
1277
1278    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1279        validate_len(req, "AlarmName", 1, 255)?;
1280        validate_len(req, "AlarmContributorId", 1, 16)?;
1281        validate_range_i64(req, "MaxRecords", 1, 100)?;
1282        validate_enum(
1283            req,
1284            "HistoryItemType",
1285            &[
1286                "ConfigurationUpdate",
1287                "StateUpdate",
1288                "Action",
1289                "AlarmContributorStateUpdate",
1290                "AlarmContributorAction",
1291            ],
1292        )?;
1293        validate_enum(
1294            req,
1295            "ScanBy",
1296            &["TimestampDescending", "TimestampAscending"],
1297        )?;
1298        // Minimal implementation: return empty history. AWS pagination tokens are
1299        // not tracked locally, so callers see an empty list rather than a stub.
1300        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
1301        Ok(xml_response(
1302            "DescribeAlarmHistory",
1303            &inner,
1304            &req.request_id,
1305        ))
1306    }
1307
1308    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1309        let dashboard_name = req
1310            .query_params
1311            .get("DashboardName")
1312            .ok_or_else(|| invalid_param("DashboardName is required"))?
1313            .clone();
1314        let body = req
1315            .query_params
1316            .get("DashboardBody")
1317            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1318            .clone();
1319        // AWS validates that DashboardBody parses as JSON; we do the same so
1320        // bad bodies surface a useful error before persisting.
1321        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1322            return Err(AwsServiceError::aws_error(
1323                StatusCode::BAD_REQUEST,
1324                "InvalidParameterInput",
1325                "DashboardBody must be a valid JSON object",
1326            ));
1327        }
1328        let arn = format!(
1329            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1330            req.account_id
1331        );
1332        let dashboard = Dashboard {
1333            name: dashboard_name.clone(),
1334            arn,
1335            size_bytes: body.len() as i64,
1336            body,
1337            last_modified: Utc::now(),
1338        };
1339        let mut state = self.state.write();
1340        let acct = state.get_or_create(&req.account_id);
1341        acct.dashboards.insert(dashboard_name, dashboard);
1342        // PutDashboard returns DashboardValidationMessages — empty when the
1343        // body parses cleanly.
1344        let inner = String::from("<DashboardValidationMessages/>");
1345        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1346    }
1347
1348    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1349        let name = req
1350            .query_params
1351            .get("DashboardName")
1352            .ok_or_else(|| invalid_param("DashboardName is required"))?
1353            .clone();
1354        let state = self.state.read();
1355        let dashboard = state
1356            .get(&req.account_id)
1357            .and_then(|a| a.dashboards.get(&name))
1358            .cloned()
1359            .ok_or_else(|| {
1360                AwsServiceError::aws_error(
1361                    StatusCode::NOT_FOUND,
1362                    "ResourceNotFound",
1363                    format!("Dashboard {name} does not exist"),
1364                )
1365            })?;
1366        let inner = format!(
1367            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1368            xml_escape(&dashboard.arn),
1369            xml_escape(&dashboard.body),
1370            xml_escape(&dashboard.name),
1371        );
1372        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1373    }
1374
1375    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1376        let mut names: Vec<String> = Vec::new();
1377        for (k, v) in req.query_params.iter() {
1378            if k.starts_with("DashboardNames.member.") {
1379                names.push(v.clone());
1380            }
1381        }
1382        if names.is_empty() {
1383            return Err(invalid_param(
1384                "DashboardNames must contain at least one name",
1385            ));
1386        }
1387        let mut state = self.state.write();
1388        let acct = state.get_or_create(&req.account_id);
1389        for n in names {
1390            acct.dashboards.remove(&n);
1391        }
1392        Ok(empty_metadata_response("DeleteDashboards", &req.request_id))
1393    }
1394
1395    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1396        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1397        let state = self.state.read();
1398        let dashboards: Vec<Dashboard> = state
1399            .get(&req.account_id)
1400            .map(|a| {
1401                a.dashboards
1402                    .values()
1403                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1404                    .cloned()
1405                    .collect()
1406            })
1407            .unwrap_or_default();
1408        let mut entries = String::new();
1409        for d in &dashboards {
1410            entries.push_str("<member>");
1411            entries.push_str(&format!(
1412                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1413                xml_escape(&d.arn),
1414                xml_escape(&d.name),
1415                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1416                d.size_bytes,
1417            ));
1418            entries.push_str("</member>");
1419        }
1420        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1421        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1422    }
1423}
1424
1425fn render_alarm(alarm: &MetricAlarm) -> String {
1426    let mut s = String::from("<member>");
1427    s.push_str(&format!(
1428        "<AlarmName>{}</AlarmName>",
1429        xml_escape(&alarm.alarm_name)
1430    ));
1431    s.push_str(&format!(
1432        "<AlarmArn>{}</AlarmArn>",
1433        xml_escape(&alarm.alarm_arn)
1434    ));
1435    if let Some(d) = &alarm.alarm_description {
1436        s.push_str(&format!(
1437            "<AlarmDescription>{}</AlarmDescription>",
1438            xml_escape(d)
1439        ));
1440    }
1441    s.push_str(&format!(
1442        "<ActionsEnabled>{}</ActionsEnabled>",
1443        alarm.actions_enabled
1444    ));
1445    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
1446    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
1447    push_action_list(
1448        &mut s,
1449        "InsufficientDataActions",
1450        &alarm.insufficient_data_actions,
1451    );
1452    s.push_str(&format!(
1453        "<StateValue>{}</StateValue>",
1454        alarm.state_value.as_str()
1455    ));
1456    s.push_str(&format!(
1457        "<StateReason>{}</StateReason>",
1458        xml_escape(&alarm.state_reason)
1459    ));
1460    s.push_str(&format!(
1461        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1462        alarm
1463            .state_updated_timestamp
1464            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1465    ));
1466    if let Some(m) = &alarm.metric_name {
1467        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1468    }
1469    if let Some(n) = &alarm.namespace {
1470        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1471    }
1472    if let Some(stat) = &alarm.statistic {
1473        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1474    }
1475    if let Some(ext) = &alarm.extended_statistic {
1476        s.push_str(&format!(
1477            "<ExtendedStatistic>{}</ExtendedStatistic>",
1478            xml_escape(ext)
1479        ));
1480    }
1481    s.push_str(&render_dimensions(&alarm.dimensions));
1482    if let Some(p) = alarm.period {
1483        s.push_str(&format!("<Period>{p}</Period>"));
1484    }
1485    if let Some(u) = &alarm.unit {
1486        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1487    }
1488    s.push_str(&format!(
1489        "<EvaluationPeriods>{}</EvaluationPeriods>",
1490        alarm.evaluation_periods
1491    ));
1492    if let Some(d) = alarm.datapoints_to_alarm {
1493        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1494    }
1495    if let Some(t) = alarm.threshold {
1496        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1497    }
1498    if let Some(tid) = &alarm.threshold_metric_id {
1499        s.push_str(&format!(
1500            "<ThresholdMetricId>{}</ThresholdMetricId>",
1501            xml_escape(tid)
1502        ));
1503    }
1504    s.push_str(&format!(
1505        "<ComparisonOperator>{}</ComparisonOperator>",
1506        xml_escape(&alarm.comparison_operator)
1507    ));
1508    if let Some(t) = &alarm.treat_missing_data {
1509        s.push_str(&format!(
1510            "<TreatMissingData>{}</TreatMissingData>",
1511            xml_escape(t)
1512        ));
1513    }
1514    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1515        s.push_str(&format!(
1516            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1517            xml_escape(e)
1518        ));
1519    }
1520    s.push_str(&format!(
1521        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1522        alarm
1523            .alarm_configuration_updated_timestamp
1524            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1525    ));
1526    s.push_str("</member>");
1527    s
1528}
1529
1530fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1531    s.push_str(&format!("<{name}>"));
1532    for action in actions {
1533        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1534    }
1535    s.push_str(&format!("</{name}>"));
1536}