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    AlarmHistoryItem, AlarmMetricQuery, AlarmMetricStat, AlarmState, CloudWatchSnapshot, Dashboard,
19    MetricAlarm, MetricDatum, SharedCloudWatchState, StatisticSet,
20    CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
21};
22
23pub(crate) const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
24
25/// Valid `StandardUnit` wire values, per the Smithy enum.
26pub(crate) const STANDARD_UNITS: &[&str] = &[
27    "Seconds",
28    "Microseconds",
29    "Milliseconds",
30    "Bytes",
31    "Kilobytes",
32    "Megabytes",
33    "Gigabytes",
34    "Terabytes",
35    "Bits",
36    "Kilobits",
37    "Megabits",
38    "Gigabits",
39    "Terabits",
40    "Percent",
41    "Count",
42    "Bytes/Second",
43    "Kilobytes/Second",
44    "Megabytes/Second",
45    "Gigabytes/Second",
46    "Terabytes/Second",
47    "Bits/Second",
48    "Kilobits/Second",
49    "Megabits/Second",
50    "Gigabits/Second",
51    "Terabits/Second",
52    "Count/Second",
53    "None",
54];
55
56const SUPPORTED_ACTIONS: &[&str] = &[
57    // Metrics & alarms (original surface).
58    "PutMetricData",
59    "GetMetricStatistics",
60    "GetMetricData",
61    "ListMetrics",
62    "PutMetricAlarm",
63    "DescribeAlarms",
64    "DescribeAlarmsForMetric",
65    "DeleteAlarms",
66    "EnableAlarmActions",
67    "DisableAlarmActions",
68    "SetAlarmState",
69    "DescribeAlarmHistory",
70    // Dashboards.
71    "PutDashboard",
72    "GetDashboard",
73    "DeleteDashboards",
74    "ListDashboards",
75    // Anomaly detectors.
76    "PutAnomalyDetector",
77    "DescribeAnomalyDetectors",
78    "DeleteAnomalyDetector",
79    // Insight rules.
80    "PutInsightRule",
81    "DescribeInsightRules",
82    "DeleteInsightRules",
83    "EnableInsightRules",
84    "DisableInsightRules",
85    "GetInsightRuleReport",
86    "PutManagedInsightRules",
87    "ListManagedInsightRules",
88    // Metric streams.
89    "PutMetricStream",
90    "GetMetricStream",
91    "ListMetricStreams",
92    "DeleteMetricStream",
93    "StartMetricStreams",
94    "StopMetricStreams",
95    // Composite alarms.
96    "PutCompositeAlarm",
97    // Mute rules.
98    "PutAlarmMuteRule",
99    "GetAlarmMuteRule",
100    "ListAlarmMuteRules",
101    "DeleteAlarmMuteRule",
102    // OTel enrichment.
103    "GetOTelEnrichment",
104    "StartOTelEnrichment",
105    "StopOTelEnrichment",
106    // Dataset KMS key management.
107    "AssociateDatasetKmsKey",
108    "DisassociateDatasetKmsKey",
109    "GetDataset",
110    // Misc.
111    "DescribeAlarmContributors",
112    "GetMetricWidgetImage",
113    // Tagging.
114    "TagResource",
115    "UntagResource",
116    "ListTagsForResource",
117];
118
119pub struct CloudWatchService {
120    pub(crate) state: SharedCloudWatchState,
121    snapshot_store: Option<Arc<dyn SnapshotStore>>,
122    snapshot_lock: Arc<Mutex<()>>,
123}
124
125impl CloudWatchService {
126    pub fn new(state: SharedCloudWatchState) -> Self {
127        Self {
128            state,
129            snapshot_store: None,
130            snapshot_lock: Arc::new(Mutex::new(())),
131        }
132    }
133
134    /// Attach a `SnapshotStore` so alarms / dashboards / metrics survive
135    /// restarts. Without this, all CloudWatch state is in-memory only —
136    /// alarms wired to actions fire on a freshly-started process.
137    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
138        self.snapshot_store = Some(store);
139        self
140    }
141
142    /// Persist current state as a snapshot. Cloned + serialized under
143    /// the snapshot lock so concurrent mutators can't race a stale-last
144    /// write.
145    pub(crate) async fn save_snapshot(&self) {
146        save_cloudwatch_snapshot(
147            &self.state,
148            self.snapshot_store.clone(),
149            &self.snapshot_lock,
150        )
151        .await;
152    }
153
154    /// Build a hook that persists the current CloudWatch state when invoked, or
155    /// `None` in memory mode (no snapshot store). The CloudFormation provisioner
156    /// mutates `state` directly and uses this to write a CFN-provisioned
157    /// resource through to disk, the same way a direct mutating API call would.
158    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
159        let store = self.snapshot_store.clone()?;
160        let state = self.state.clone();
161        let lock = self.snapshot_lock.clone();
162        Some(Arc::new(move || {
163            let state = state.clone();
164            let store = store.clone();
165            let lock = lock.clone();
166            Box::pin(async move {
167                save_cloudwatch_snapshot(&state, Some(store), &lock).await;
168            })
169        }))
170    }
171}
172
173/// Persist the current CloudWatch state as a snapshot. Cloned + serialized
174/// under the snapshot lock so concurrent mutators can't race a stale-last
175/// write. Noop when `store` is `None` (memory mode). Shared by
176/// `CloudWatchService::save_snapshot` and the CloudFormation provisioner's
177/// post-provision persist hook so both route through the same
178/// serialize-and-write path.
179pub async fn save_cloudwatch_snapshot(
180    state: &SharedCloudWatchState,
181    store: Option<Arc<dyn SnapshotStore>>,
182    lock: &Mutex<()>,
183) {
184    let Some(store) = store else {
185        return;
186    };
187    let _guard = lock.lock().await;
188    let snapshot = CloudWatchSnapshot {
189        schema_version: CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION,
190        accounts: state.read().clone_for_snapshot(),
191    };
192    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
193        let bytes = serde_json::to_vec(&snapshot)
194            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
195        store.save(&bytes)
196    })
197    .await;
198    match join {
199        Ok(Ok(())) => {}
200        Ok(Err(err)) => tracing::error!(%err, "failed to write cloudwatch snapshot"),
201        Err(err) => tracing::error!(%err, "cloudwatch snapshot task panicked"),
202    }
203}
204
205#[async_trait]
206impl AwsService for CloudWatchService {
207    fn service_name(&self) -> &str {
208        "monitoring"
209    }
210
211    fn supported_actions(&self) -> &[&str] {
212        SUPPORTED_ACTIONS
213    }
214
215    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
216        let mutates = matches!(
217            req.action.as_str(),
218            "PutMetricData"
219                | "PutMetricAlarm"
220                | "DeleteAlarms"
221                | "EnableAlarmActions"
222                | "DisableAlarmActions"
223                | "SetAlarmState"
224                | "PutDashboard"
225                | "DeleteDashboards"
226                | "PutAnomalyDetector"
227                | "DeleteAnomalyDetector"
228                | "PutInsightRule"
229                | "DeleteInsightRules"
230                | "EnableInsightRules"
231                | "DisableInsightRules"
232                | "PutManagedInsightRules"
233                | "PutMetricStream"
234                | "DeleteMetricStream"
235                | "StartMetricStreams"
236                | "StopMetricStreams"
237                | "PutCompositeAlarm"
238                | "PutAlarmMuteRule"
239                | "DeleteAlarmMuteRule"
240                | "StartOTelEnrichment"
241                | "StopOTelEnrichment"
242                | "AssociateDatasetKmsKey"
243                | "DisassociateDatasetKmsKey"
244                | "TagResource"
245                | "UntagResource"
246        );
247        let result = match req.action.as_str() {
248            "PutMetricData" => self.put_metric_data(&req),
249            "GetMetricStatistics" => self.get_metric_statistics(&req),
250            "GetMetricData" => self.get_metric_data(&req),
251            "ListMetrics" => self.list_metrics(&req),
252            "PutMetricAlarm" => self.put_metric_alarm(&req),
253            "DescribeAlarms" => self.describe_alarms(&req),
254            "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
255            "DeleteAlarms" => self.delete_alarms(&req),
256            "EnableAlarmActions" => self.enable_alarm_actions(&req),
257            "DisableAlarmActions" => self.disable_alarm_actions(&req),
258            "SetAlarmState" => self.set_alarm_state(&req),
259            "DescribeAlarmHistory" => self.describe_alarm_history(&req),
260            "PutDashboard" => self.put_dashboard(&req),
261            "GetDashboard" => self.get_dashboard(&req),
262            "DeleteDashboards" => self.delete_dashboards(&req),
263            "ListDashboards" => self.list_dashboards(&req),
264            // Anomaly detectors.
265            "PutAnomalyDetector" => self.put_anomaly_detector(&req),
266            "DescribeAnomalyDetectors" => self.describe_anomaly_detectors(&req),
267            "DeleteAnomalyDetector" => self.delete_anomaly_detector(&req),
268            // Insight rules.
269            "PutInsightRule" => self.put_insight_rule(&req),
270            "DescribeInsightRules" => self.describe_insight_rules(&req),
271            "DeleteInsightRules" => self.delete_insight_rules(&req),
272            "EnableInsightRules" => self.enable_insight_rules(&req),
273            "DisableInsightRules" => self.disable_insight_rules(&req),
274            "GetInsightRuleReport" => self.get_insight_rule_report(&req),
275            "PutManagedInsightRules" => self.put_managed_insight_rules(&req),
276            "ListManagedInsightRules" => self.list_managed_insight_rules(&req),
277            // Metric streams.
278            "PutMetricStream" => self.put_metric_stream(&req),
279            "GetMetricStream" => self.get_metric_stream(&req),
280            "ListMetricStreams" => self.list_metric_streams(&req),
281            "DeleteMetricStream" => self.delete_metric_stream(&req),
282            "StartMetricStreams" => self.start_metric_streams(&req),
283            "StopMetricStreams" => self.stop_metric_streams(&req),
284            // Composite alarms.
285            "PutCompositeAlarm" => self.put_composite_alarm(&req),
286            // Mute rules.
287            "PutAlarmMuteRule" => self.put_alarm_mute_rule(&req),
288            "GetAlarmMuteRule" => self.get_alarm_mute_rule(&req),
289            "ListAlarmMuteRules" => self.list_alarm_mute_rules(&req),
290            "DeleteAlarmMuteRule" => self.delete_alarm_mute_rule(&req),
291            // OTel enrichment.
292            "GetOTelEnrichment" => self.get_otel_enrichment(&req),
293            "StartOTelEnrichment" => self.start_otel_enrichment(&req),
294            "StopOTelEnrichment" => self.stop_otel_enrichment(&req),
295            // Dataset KMS key management.
296            "AssociateDatasetKmsKey" => self.associate_dataset_kms_key(&req),
297            "DisassociateDatasetKmsKey" => self.disassociate_dataset_kms_key(&req),
298            "GetDataset" => self.get_dataset(&req),
299            // Misc.
300            "DescribeAlarmContributors" => self.describe_alarm_contributors(&req),
301            "GetMetricWidgetImage" => self.get_metric_widget_image(&req),
302            // Tagging.
303            "TagResource" => self.tag_resource(&req),
304            "UntagResource" => self.untag_resource(&req),
305            "ListTagsForResource" => self.list_tags_for_resource(&req),
306            _ => Err(AwsServiceError::action_not_implemented(
307                "monitoring",
308                &req.action,
309            )),
310        };
311        if mutates && result.is_ok() {
312            self.save_snapshot().await;
313        }
314        // A JSON-protocol caller (awsJson1_0, identified by the X-Amz-Target
315        // header) expects a JSON response body; the handlers produce awsQuery
316        // XML, so convert it. Query-protocol callers keep the XML unchanged.
317        if request_is_json(&req) {
318            return result.map(crate::json_protocol::xml_response_to_json);
319        }
320        result
321    }
322}
323
324/// True when the request arrived over the awsJson1_0 protocol (CloudWatch
325/// advertises both awsJson1_0 and awsQuery). JSON callers set `X-Amz-Target`.
326fn request_is_json(req: &AwsRequest) -> bool {
327    req.headers.contains_key("x-amz-target")
328}
329
330pub(crate) fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
331    AwsResponse::xml(
332        StatusCode::OK,
333        query_response_xml(action, NS, inner, request_id),
334    )
335}
336
337pub(crate) fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
338    AwsResponse::xml(
339        StatusCode::OK,
340        query_metadata_only_xml(action, NS, request_id),
341    )
342}
343
344pub(crate) fn invalid_param(message: impl Into<String>) -> AwsServiceError {
345    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
346}
347
348/// `ResourceNotFoundException` — wire code matches the awsQueryError trait.
349pub(crate) fn not_found(message: impl Into<String>) -> AwsServiceError {
350    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", message)
351}
352
353/// `MissingRequiredParameterException` — awsQueryError wire code is
354/// `MissingParameter`.
355pub(crate) fn missing_param(name: &str) -> AwsServiceError {
356    AwsServiceError::aws_error(
357        StatusCode::BAD_REQUEST,
358        "MissingParameter",
359        format!("The request must contain the parameter {name}."),
360    )
361}
362
363pub(crate) fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
364    let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
365    let needle = format!("{prefix}.member.");
366    for (k, v) in req.query_params.iter() {
367        let Some(rest) = k.strip_prefix(&needle) else {
368            continue;
369        };
370        let mut parts = rest.splitn(2, '.');
371        let Some(idx_str) = parts.next() else {
372            continue;
373        };
374        let Ok(idx) = idx_str.parse::<u32>() else {
375            continue;
376        };
377        let field = parts.next().unwrap_or("").to_string();
378        by_index.entry(idx).or_default().insert(field, v.clone());
379    }
380    by_index.into_values().collect()
381}
382
383/// Collect an indexed `<prefix>.member.N` numeric array out of a flattened
384/// MetricData member (e.g. `Values.member.1`, `Counts.member.1`).
385fn collect_member_numbers(
386    member: &HashMap<String, String>,
387    prefix: &str,
388) -> Result<Vec<f64>, AwsServiceError> {
389    let needle = format!("{prefix}.member.");
390    let mut by_index: BTreeMap<u32, f64> = BTreeMap::new();
391    for (k, v) in member.iter() {
392        let Some(idx_str) = k.strip_prefix(&needle) else {
393            continue;
394        };
395        let Ok(idx) = idx_str.parse::<u32>() else {
396            continue;
397        };
398        let n = v
399            .parse::<f64>()
400            .map_err(|_| invalid_param(format!("{prefix} entries must be numbers")))?;
401        by_index.insert(idx, n);
402    }
403    Ok(by_index.into_values().collect())
404}
405
406/// Build a [`StatisticSet`] from a MetricDatum's `Values`/`Counts` distribution.
407/// Returns `Ok(None)` when no `Values` array is present.
408fn values_counts_statistic(
409    member: &HashMap<String, String>,
410) -> Result<Option<StatisticSet>, AwsServiceError> {
411    let values = collect_member_numbers(member, "Values")?;
412    if values.is_empty() {
413        return Ok(None);
414    }
415    let counts = collect_member_numbers(member, "Counts")?;
416    let mut sample_count = 0.0;
417    let mut sum = 0.0;
418    let mut minimum = f64::INFINITY;
419    let mut maximum = f64::NEG_INFINITY;
420    for (i, v) in values.iter().enumerate() {
421        let c = counts.get(i).copied().unwrap_or(1.0);
422        sample_count += c;
423        sum += v * c;
424        minimum = minimum.min(*v);
425        maximum = maximum.max(*v);
426    }
427    Ok(Some(StatisticSet {
428        sample_count,
429        sum,
430        minimum,
431        maximum,
432    }))
433}
434
435fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
436    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
437    let needle = format!("{prefix}.member.");
438    for (k, v) in member.iter() {
439        let Some(rest) = k.strip_prefix(&needle) else {
440            continue;
441        };
442        let mut parts = rest.splitn(2, '.');
443        let Some(idx_str) = parts.next() else {
444            continue;
445        };
446        let Ok(idx) = idx_str.parse::<u32>() else {
447            continue;
448        };
449        let field = parts.next().unwrap_or("");
450        let entry = dims.entry(idx).or_default();
451        match field {
452            "Name" => entry.0 = Some(v.clone()),
453            "Value" => entry.1 = Some(v.clone()),
454            _ => {}
455        }
456    }
457    let mut out = BTreeMap::new();
458    for (_, (name, value)) in dims {
459        if let (Some(n), Some(v)) = (name, value) {
460            out.insert(n, v);
461        }
462    }
463    out
464}
465
466/// Parse the `Metrics.member.N.*` list of a `PutMetricAlarm` request into the
467/// persisted [`AlarmMetricQuery`] form.
468fn parse_alarm_metrics(req: &AwsRequest) -> Vec<AlarmMetricQuery> {
469    let mut out = Vec::new();
470    for member in collect_indexed(req, "Metrics") {
471        let Some(id) = member.get("Id").cloned() else {
472            continue;
473        };
474        let metric_stat = if member.keys().any(|k| k.starts_with("MetricStat.")) {
475            let dimensions = parse_dimensions(&member, "MetricStat.Metric.Dimensions");
476            Some(AlarmMetricStat {
477                namespace: member.get("MetricStat.Metric.Namespace").cloned(),
478                metric_name: member.get("MetricStat.Metric.MetricName").cloned(),
479                dimensions,
480                period: member
481                    .get("MetricStat.Period")
482                    .and_then(|s| s.parse::<i64>().ok()),
483                stat: member.get("MetricStat.Stat").cloned(),
484                unit: member.get("MetricStat.Unit").cloned(),
485            })
486        } else {
487            None
488        };
489        out.push(AlarmMetricQuery {
490            id,
491            metric_stat,
492            expression: member.get("Expression").cloned(),
493            label: member.get("Label").cloned(),
494            return_data: member
495                .get("ReturnData")
496                .map(|s| s.eq_ignore_ascii_case("true")),
497            account_id: member.get("AccountId").cloned(),
498            period: member.get("Period").and_then(|s| s.parse::<i64>().ok()),
499        });
500    }
501    out
502}
503
504pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
505    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
506    let needle = format!("{prefix}.member.");
507    for (k, v) in req.query_params.iter() {
508        let Some(rest) = k.strip_prefix(&needle) else {
509            continue;
510        };
511        let mut parts = rest.splitn(2, '.');
512        let Some(idx_str) = parts.next() else {
513            continue;
514        };
515        let Ok(idx) = idx_str.parse::<u32>() else {
516            continue;
517        };
518        let field = parts.next().unwrap_or("");
519        let entry = dims.entry(idx).or_default();
520        match field {
521            "Name" => entry.0 = Some(v.clone()),
522            "Value" => entry.1 = Some(v.clone()),
523            _ => {}
524        }
525    }
526    let mut out = BTreeMap::new();
527    for (_, (name, value)) in dims {
528        if let (Some(n), Some(v)) = (name, value) {
529            out.insert(n, v);
530        }
531    }
532    out
533}
534
535/// Validate the length of an optional string param against `[min, max]`.
536/// Returns a 4xx on violation. AWS measures length in characters; the
537/// conformance probe only sends ASCII so byte length is equivalent here.
538pub(crate) fn validate_len(
539    req: &AwsRequest,
540    param: &str,
541    min: usize,
542    max: usize,
543) -> Result<(), AwsServiceError> {
544    if let Some(v) = req.query_params.get(param) {
545        let len = v.chars().count();
546        if len < min || len > max {
547            return Err(invalid_param(format!(
548                "{param} length {len} is outside [{min}, {max}]"
549            )));
550        }
551    }
552    Ok(())
553}
554
555/// Validate an optional integer param against `[min, max]` (inclusive).
556pub(crate) fn validate_range_i64(
557    req: &AwsRequest,
558    param: &str,
559    min: i64,
560    max: i64,
561) -> Result<(), AwsServiceError> {
562    if let Some(v) = req.query_params.get(param) {
563        if v.is_empty() {
564            return Ok(());
565        }
566        let n = v
567            .parse::<i64>()
568            .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
569        if n < min || n > max {
570            return Err(invalid_param(format!(
571                "{param} value {n} is outside [{min}, {max}]"
572            )));
573        }
574    }
575    Ok(())
576}
577
578/// Validate that an optional param, when present, is one of `allowed`.
579pub(crate) fn validate_enum(
580    req: &AwsRequest,
581    param: &str,
582    allowed: &[&str],
583) -> Result<(), AwsServiceError> {
584    if let Some(v) = req.query_params.get(param) {
585        if !v.is_empty() && !allowed.contains(&v.as_str()) {
586            return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
587        }
588    }
589    Ok(())
590}
591
592/// Collect repeated `<Prefix>.member.N` scalar values, ordered by index.
593pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
594    let needle = format!("{prefix}.member.");
595    let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
596    for (k, v) in req.query_params.iter() {
597        let Some(rest) = k.strip_prefix(&needle) else {
598            continue;
599        };
600        if let Ok(idx) = rest.parse::<u32>() {
601            by_index.insert(idx, v.clone());
602        }
603    }
604    by_index.into_values().collect()
605}
606
607/// Parse a `Tags.member.N.Key` / `Tags.member.N.Value` list into a map.
608pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
609    let members = collect_indexed(req, prefix);
610    let mut out = BTreeMap::new();
611    for m in members {
612        if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
613            out.insert(k.clone(), v.clone());
614        }
615    }
616    out
617}
618
619pub(crate) fn xml_escape(s: &str) -> String {
620    s.replace('&', "&amp;")
621        .replace('<', "&lt;")
622        .replace('>', "&gt;")
623        .replace('"', "&quot;")
624        .replace('\'', "&apos;")
625}
626
627/// Encode a pagination offset into an opaque base64 NextToken.
628pub(crate) fn encode_offset_token(offset: usize) -> String {
629    use base64::Engine;
630    base64::engine::general_purpose::STANDARD.encode(format!("offset:{offset}"))
631}
632
633/// Decode a NextToken produced by [`encode_offset_token`]. Returns 0 for an
634/// absent or unparseable token (AWS rejects bad tokens, but treating it as the
635/// first page is friendlier and never loses data).
636pub(crate) fn decode_offset_token(token: Option<&String>) -> usize {
637    use base64::Engine;
638    let Some(token) = token else {
639        return 0;
640    };
641    base64::engine::general_purpose::STANDARD
642        .decode(token)
643        .ok()
644        .and_then(|b| String::from_utf8(b).ok())
645        .and_then(|s| s.strip_prefix("offset:").map(|n| n.to_string()))
646        .and_then(|n| n.parse::<usize>().ok())
647        .unwrap_or(0)
648}
649
650/// Parse an input timestamp, accepting either RFC3339 (the query-protocol
651/// form) or a numeric epoch-seconds value (which JSON-protocol / X-Amz-Target
652/// callers send). Previously only RFC3339 was accepted, so an epoch-second
653/// timestamp was silently dropped or rejected.
654pub(crate) fn parse_input_timestamp(s: &str) -> Option<DateTime<Utc>> {
655    let s = s.trim();
656    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
657        return Some(dt.with_timezone(&Utc));
658    }
659    // Epoch seconds (optionally fractional).
660    if let Ok(secs) = s.parse::<f64>() {
661        if secs.is_finite() {
662            let whole = secs.trunc() as i64;
663            let nanos = (secs.fract().abs() * 1_000_000_000.0).round() as u32;
664            return DateTime::<Utc>::from_timestamp(whole, nanos);
665        }
666    }
667    None
668}
669
670/// Per-datapoint aggregation summary covering both the simple `Value` form
671/// and the `StatisticValues` form so callers don't lose the count or
672/// min/max baked into a `StatisticSet`.
673#[derive(Clone, Copy)]
674struct DatumStats {
675    sum: f64,
676    min: f64,
677    max: f64,
678    count: f64,
679}
680
681fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
682    if let Some(v) = d.value {
683        return Some(DatumStats {
684            sum: v,
685            min: v,
686            max: v,
687            count: 1.0,
688        });
689    }
690    if let Some(s) = &d.statistic_values {
691        return Some(DatumStats {
692            sum: s.sum,
693            min: s.minimum,
694            max: s.maximum,
695            count: s.sample_count,
696        });
697    }
698    None
699}
700
701fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
702    acc.sum += other.sum;
703    acc.count += other.count;
704    if other.min < acc.min {
705        acc.min = other.min;
706    }
707    if other.max > acc.max {
708        acc.max = other.max;
709    }
710}
711
712fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
713    match stat {
714        "Sum" => Some(agg.sum),
715        "Average" => {
716            if agg.count > 0.0 {
717                Some(agg.sum / agg.count)
718            } else {
719                None
720            }
721        }
722        "Minimum" => Some(agg.min),
723        "Maximum" => Some(agg.max),
724        "SampleCount" => Some(agg.count),
725        _ => None,
726    }
727}
728
729/// Parse an extended statistic / percentile stat like `p99` or `p99.9` into the
730/// percentile in `[0, 100]`. Returns `None` for anything that isn't a `pNN`
731/// form (so callers can fall through to the simple statistics).
732pub(crate) fn parse_percentile(stat: &str) -> Option<f64> {
733    let rest = stat.strip_prefix('p').or_else(|| stat.strip_prefix('P'))?;
734    let p = rest.parse::<f64>().ok()?;
735    if (0.0..=100.0).contains(&p) {
736        Some(p)
737    } else {
738        None
739    }
740}
741
742/// Linear-interpolation percentile over a pre-sorted sample slice. Uses the
743/// common `rank = p/100 * (n-1)` method — close enough to CloudWatch's
744/// percentile for fakecloud's purposes.
745pub(crate) fn percentile(sorted: &[f64], p: f64) -> Option<f64> {
746    if sorted.is_empty() {
747        return None;
748    }
749    if sorted.len() == 1 {
750        return Some(sorted[0]);
751    }
752    let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
753    let lo = rank.floor() as usize;
754    let hi = rank.ceil() as usize;
755    if lo == hi {
756        return Some(sorted[lo]);
757    }
758    let frac = rank - lo as f64;
759    Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
760}
761
762/// One period bucket of a metric series: the merged [`DatumStats`], the
763/// individual `value` samples (used for percentiles — distributions published
764/// as `StatisticValues` don't retain their raw values so they don't
765/// contribute), and the bucket's unit when consistent.
766pub(crate) struct MetricBucket {
767    agg: DatumStats,
768    pub(crate) samples: Vec<f64>,
769    unit: Option<String>,
770}
771
772/// Resolve a single statistic (simple, e.g. `Sum`, or percentile, e.g. `p99`)
773/// for one bucket. `samples` must be sorted ascending.
774pub(crate) fn resolve_stat(
775    stat: &str,
776    bucket: &MetricBucket,
777    samples_sorted: &[f64],
778) -> Option<f64> {
779    if let Some(p) = parse_percentile(stat) {
780        return percentile(samples_sorted, p);
781    }
782    stat_value(stat, bucket.agg)
783}
784
785/// Collect a metric's datapoints into period buckets, matching dimensions
786/// EXACTLY (an empty filter matches only dimensionless data, the way AWS treats
787/// each distinct dimension combination as its own metric) and, when a unit
788/// filter is set, only datapoints published with that unit.
789#[allow(clippy::too_many_arguments)]
790pub(crate) fn collect_metric_buckets(
791    data: &[MetricDatum],
792    metric_name: &str,
793    dim_filter: &BTreeMap<String, String>,
794    unit_filter: Option<&str>,
795    period: i64,
796    start_ts: DateTime<Utc>,
797    end_ts: DateTime<Utc>,
798) -> BTreeMap<DateTime<Utc>, MetricBucket> {
799    let mut buckets: BTreeMap<DateTime<Utc>, MetricBucket> = BTreeMap::new();
800    for d in data.iter() {
801        if d.metric_name != metric_name {
802            continue;
803        }
804        if let Some(uf) = unit_filter {
805            if d.unit.as_deref().unwrap_or("None") != uf {
806                continue;
807            }
808        }
809        // Exact dimension-set equality: each unique dimension combination is a
810        // distinct metric, so a subset never matches and an empty filter only
811        // matches data published with no dimensions.
812        if &d.dimensions != dim_filter {
813            continue;
814        }
815        if d.timestamp < start_ts || d.timestamp >= end_ts {
816            continue;
817        }
818        let Some(stats) = datum_stats(d) else {
819            continue;
820        };
821        let secs = d.timestamp.timestamp();
822        let bucket_secs = secs - secs.rem_euclid(period);
823        let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
824        match buckets.get_mut(&bucket_ts) {
825            Some(bucket) => {
826                merge_stats(&mut bucket.agg, stats);
827                if bucket.unit != d.unit {
828                    bucket.unit = None;
829                }
830                if let Some(v) = d.value {
831                    bucket.samples.push(v);
832                }
833            }
834            None => {
835                buckets.insert(
836                    bucket_ts,
837                    MetricBucket {
838                        agg: stats,
839                        samples: d.value.map(|v| vec![v]).unwrap_or_default(),
840                        unit: d.unit.clone(),
841                    },
842                );
843            }
844        }
845    }
846    buckets
847}
848
849pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
850    let mut s = String::from("<Dimensions>");
851    for (name, value) in dims.iter() {
852        s.push_str(&format!(
853            "<member><Name>{}</Name><Value>{}</Value></member>",
854            xml_escape(name),
855            xml_escape(value),
856        ));
857    }
858    s.push_str("</Dimensions>");
859    s
860}
861
862impl CloudWatchService {
863    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
864        let namespace = required_query_param(req, "Namespace")?;
865        let members = collect_indexed(req, "MetricData");
866        if members.is_empty() {
867            return Err(invalid_param(
868                "PutMetricData requires at least one MetricData entry",
869            ));
870        }
871
872        let now = Utc::now();
873        let mut state = self.state.write();
874        let acct = state.get_or_create(&req.account_id);
875        let metrics_map = acct.metrics_in_mut(&req.region);
876        let bucket = metrics_map.entry(namespace.clone()).or_default();
877
878        for member in members {
879            let metric_name = member
880                .get("MetricName")
881                .cloned()
882                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
883            let value = member
884                .get("Value")
885                .map(|s| s.parse::<f64>())
886                .transpose()
887                .map_err(|_| invalid_param("Value must be a valid number"))?;
888            let timestamp = member
889                .get("Timestamp")
890                .and_then(|s| parse_input_timestamp(s))
891                .unwrap_or(now);
892            let unit = member.get("Unit").cloned();
893            let storage_resolution = member
894                .get("StorageResolution")
895                .and_then(|s| s.parse::<i64>().ok());
896            let dimensions = parse_dimensions(&member, "Dimensions");
897
898            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
899                member.get("StatisticValues.SampleCount"),
900                member.get("StatisticValues.Sum"),
901                member.get("StatisticValues.Minimum"),
902                member.get("StatisticValues.Maximum"),
903            ) {
904                Some(StatisticSet {
905                    sample_count: sc.parse::<f64>().map_err(|_| {
906                        invalid_param("StatisticValues.SampleCount must be a number")
907                    })?,
908                    sum: sum
909                        .parse::<f64>()
910                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
911                    minimum: min
912                        .parse::<f64>()
913                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
914                    maximum: max
915                        .parse::<f64>()
916                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
917                })
918            } else {
919                None
920            };
921
922            // A `Values`/`Counts` value-distribution is collapsed into a
923            // StatisticSet (which the statistics path already aggregates), so
924            // the common histogram publish path stops 400-ing.
925            let statistic_values = match statistic_values {
926                Some(s) => Some(s),
927                None => values_counts_statistic(&member)?,
928            };
929
930            if value.is_none() && statistic_values.is_none() {
931                return Err(invalid_param(
932                    "MetricData entry must supply either Value, StatisticValues, or Values",
933                ));
934            }
935
936            bucket.push(MetricDatum {
937                metric_name,
938                dimensions,
939                timestamp,
940                value,
941                statistic_values,
942                unit,
943                storage_resolution,
944            });
945        }
946
947        Ok(empty_metadata_response("PutMetricData", &req.request_id))
948    }
949
950    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
951        validate_len(req, "Namespace", 1, 255)?;
952        validate_len(req, "MetricName", 1, 255)?;
953        validate_len(req, "OwningAccount", 1, 255)?;
954        validate_enum(req, "RecentlyActive", &["PT3H"])?;
955        let namespace = optional_query_param(req, "Namespace");
956        let metric_name = optional_query_param(req, "MetricName");
957        let dim_filter = parse_dimensions_query(req, "Dimensions");
958        // ListMetrics has no MaxResults param — AWS caps each page at 500 and
959        // round-trips a NextToken.
960        const LIST_METRICS_PAGE: usize = 500;
961        let offset = decode_offset_token(req.query_params.get("NextToken"));
962
963        let state = self.state.read();
964        // Flatten every distinct (namespace, metric, dims) into a stable,
965        // ordered list so the offset token is deterministic across pages.
966        let mut all: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
967        if let Some(acct) = state.get(&req.account_id) {
968            if let Some(map) = acct.metrics_in(&req.region) {
969                for (ns, data) in map.iter() {
970                    if let Some(filter_ns) = namespace.as_ref() {
971                        if ns != filter_ns {
972                            continue;
973                        }
974                    }
975                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
976                        BTreeMap::new();
977                    for d in data.iter() {
978                        if let Some(filter_name) = metric_name.as_ref() {
979                            if &d.metric_name != filter_name {
980                                continue;
981                            }
982                        }
983                        // ListMetrics filters by dimension containment (a metric
984                        // matches if it carries all the requested name/value
985                        // pairs), unlike the exact-set match used by the
986                        // statistics APIs.
987                        if !dim_filter.is_empty()
988                            && !dim_filter
989                                .iter()
990                                .all(|(k, v)| d.dimensions.get(k) == Some(v))
991                        {
992                            continue;
993                        }
994                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
995                    }
996                    for ((name, dims), _) in seen {
997                        all.push((ns.clone(), name, dims));
998                    }
999                }
1000            }
1001        }
1002
1003        let page = all.iter().skip(offset).take(LIST_METRICS_PAGE);
1004        let mut out = String::from("<Metrics>");
1005        {
1006            for (ns, name, dims) in page {
1007                out.push_str("<member>");
1008                out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
1009                out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(name)));
1010                out.push_str(&render_dimensions(dims));
1011                out.push_str("</member>");
1012            }
1013        }
1014        out.push_str("</Metrics>");
1015        if offset + LIST_METRICS_PAGE < all.len() {
1016            out.push_str(&format!(
1017                "<NextToken>{}</NextToken>",
1018                encode_offset_token(offset + LIST_METRICS_PAGE)
1019            ));
1020        }
1021
1022        Ok(xml_response("ListMetrics", &out, &req.request_id))
1023    }
1024
1025    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1026        let namespace = required_query_param(req, "Namespace")?;
1027        let metric_name = required_query_param(req, "MetricName")?;
1028        let start = required_query_param(req, "StartTime")?;
1029        let end = required_query_param(req, "EndTime")?;
1030        let period = required_query_param(req, "Period")?
1031            .parse::<i64>()
1032            .map_err(|_| invalid_param("Period must be an integer"))?;
1033        if period <= 0 {
1034            return Err(invalid_param("Period must be positive"));
1035        }
1036        let start_ts = parse_input_timestamp(&start)
1037            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1038        let end_ts = parse_input_timestamp(&end)
1039            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1040
1041        let mut statistics: Vec<String> = Vec::new();
1042        let mut extended_statistics: Vec<String> = Vec::new();
1043        for (k, v) in req.query_params.iter() {
1044            if k.starts_with("Statistics.member.") {
1045                statistics.push(v.clone());
1046            } else if k.starts_with("ExtendedStatistics.member.") {
1047                extended_statistics.push(v.clone());
1048            }
1049        }
1050        if statistics.is_empty() && extended_statistics.is_empty() {
1051            return Err(invalid_param(
1052                "At least one of Statistics or ExtendedStatistics is required",
1053            ));
1054        }
1055
1056        let dim_filter = parse_dimensions_query(req, "Dimensions");
1057        // When a Unit is given, only datapoints published with that exact unit
1058        // are aggregated (AWS treats an unspecified unit as "None"); otherwise
1059        // mixing units gives a meaningless statistic.
1060        let unit_filter = req.query_params.get("Unit").cloned();
1061
1062        let state = self.state.read();
1063        // (timestamp, simple stats, extended/percentile stats, unit)
1064        type StatPoint = (
1065            DateTime<Utc>,
1066            BTreeMap<String, f64>,
1067            Vec<(String, f64)>,
1068            Option<String>,
1069        );
1070        let mut datapoints: Vec<StatPoint> = Vec::new();
1071        if let Some(acct) = state.get(&req.account_id) {
1072            if let Some(map) = acct.metrics_in(&req.region) {
1073                if let Some(data) = map.get(&namespace) {
1074                    let buckets = collect_metric_buckets(
1075                        data,
1076                        &metric_name,
1077                        &dim_filter,
1078                        unit_filter.as_deref(),
1079                        period,
1080                        start_ts,
1081                        end_ts,
1082                    );
1083                    for (ts, bucket) in buckets {
1084                        let mut sorted = bucket.samples.clone();
1085                        sorted
1086                            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1087                        let mut simple = BTreeMap::new();
1088                        for stat in statistics.iter() {
1089                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1090                                simple.insert(stat.clone(), v);
1091                            }
1092                        }
1093                        let mut extended = Vec::new();
1094                        for stat in extended_statistics.iter() {
1095                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1096                                extended.push((stat.clone(), v));
1097                            }
1098                        }
1099                        let unit = unit_filter.clone().or(bucket.unit);
1100                        datapoints.push((ts, simple, extended, unit));
1101                    }
1102                }
1103            }
1104        }
1105
1106        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1107        inner.push_str("<Datapoints>");
1108        for (ts, simple, extended, unit) in datapoints {
1109            inner.push_str("<member>");
1110            inner.push_str(&format!(
1111                "<Timestamp>{}</Timestamp>",
1112                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1113            ));
1114            for (name, value) in simple {
1115                inner.push_str(&format!("<{name}>{value}</{name}>"));
1116            }
1117            if !extended.is_empty() {
1118                inner.push_str("<ExtendedStatistics>");
1119                for (name, value) in extended {
1120                    inner.push_str(&format!(
1121                        "<entry><key>{}</key><value>{}</value></entry>",
1122                        xml_escape(&name),
1123                        value
1124                    ));
1125                }
1126                inner.push_str("</ExtendedStatistics>");
1127            }
1128            if let Some(u) = unit {
1129                inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1130            }
1131            inner.push_str("</member>");
1132        }
1133        inner.push_str("</Datapoints>");
1134
1135        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1136    }
1137
1138    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1139        validate_enum(
1140            req,
1141            "ScanBy",
1142            &["TimestampDescending", "TimestampAscending"],
1143        )?;
1144        let start = required_query_param(req, "StartTime")?;
1145        let end = required_query_param(req, "EndTime")?;
1146        let start_ts = parse_input_timestamp(&start)
1147            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1148        let end_ts = parse_input_timestamp(&end)
1149            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1150
1151        // Default ScanBy is TimestampDescending (newest first); callers read
1152        // Values[0] as the latest datapoint. The bucket map is ascending, so
1153        // reverse unless the caller asked for TimestampAscending.
1154        let descending = req
1155            .query_params
1156            .get("ScanBy")
1157            .map(|s| s != "TimestampAscending")
1158            .unwrap_or(true);
1159
1160        // GetMetricData declares only InvalidNextToken, so it never rejects an
1161        // empty / malformed query list with a 4xx — it returns empty results.
1162        let queries = collect_indexed(req, "MetricDataQueries");
1163
1164        let state = self.state.read();
1165
1166        // First pass: compute every MetricStat query into an aligned series so
1167        // later Expression queries can reference them by id.
1168        let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1169        for q in &queries {
1170            let id = q.get("Id").cloned().unwrap_or_default();
1171            let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1172                continue;
1173            };
1174            let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1175                continue;
1176            };
1177            let stat = q
1178                .get("MetricStat.Stat")
1179                .cloned()
1180                .unwrap_or_else(|| "Sum".to_string());
1181            let period: i64 = q
1182                .get("MetricStat.Period")
1183                .and_then(|s| s.parse::<i64>().ok())
1184                .filter(|p| *p > 0)
1185                .unwrap_or(60);
1186            let unit_filter = q.get("MetricStat.Unit").cloned();
1187            let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1188
1189            let mut series = crate::metric_math::Series::new();
1190            if let Some(acct) = state.get(&req.account_id) {
1191                if let Some(map) = acct.metrics_in(&req.region) {
1192                    if let Some(data) = map.get(namespace) {
1193                        let buckets = collect_metric_buckets(
1194                            data,
1195                            metric_name,
1196                            &dim_filter,
1197                            unit_filter.as_deref(),
1198                            period,
1199                            start_ts,
1200                            end_ts,
1201                        );
1202                        for (ts, bucket) in buckets {
1203                            let mut sorted = bucket.samples.clone();
1204                            sorted.sort_by(|a, b| {
1205                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1206                            });
1207                            if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1208                                series.insert(ts, v);
1209                            }
1210                        }
1211                    }
1212                }
1213            }
1214            series_by_id.insert(id, series);
1215        }
1216
1217        // Second pass: emit a result for each query that returns data (default
1218        // true), evaluating Expression queries against the computed series.
1219        let mut inner = String::from("<MetricDataResults>");
1220        for q in &queries {
1221            let id = q.get("Id").cloned().unwrap_or_default();
1222            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1223            let return_data = q
1224                .get("ReturnData")
1225                .map(|s| !s.eq_ignore_ascii_case("false"))
1226                .unwrap_or(true);
1227            if !return_data {
1228                continue;
1229            }
1230
1231            let mut error_message: Option<String> = None;
1232            let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1233                match crate::metric_math::evaluate(expr, &series_by_id) {
1234                    Ok(s) => s,
1235                    Err(e) => {
1236                        error_message = Some(e);
1237                        crate::metric_math::Series::new()
1238                    }
1239                }
1240            } else {
1241                series_by_id.get(&id).cloned().unwrap_or_default()
1242            };
1243
1244            let mut timestamps: Vec<String> = Vec::new();
1245            let mut values: Vec<f64> = Vec::new();
1246            for (ts, v) in series.iter() {
1247                timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1248                values.push(*v);
1249            }
1250            if descending {
1251                timestamps.reverse();
1252                values.reverse();
1253            }
1254
1255            inner.push_str("<member>");
1256            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1257            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1258            inner.push_str("<Timestamps>");
1259            for ts in &timestamps {
1260                inner.push_str(&format!("<member>{ts}</member>"));
1261            }
1262            inner.push_str("</Timestamps>");
1263            inner.push_str("<Values>");
1264            for v in &values {
1265                inner.push_str(&format!("<member>{v}</member>"));
1266            }
1267            inner.push_str("</Values>");
1268            if let Some(msg) = error_message {
1269                inner.push_str("<StatusCode>InternalError</StatusCode>");
1270                inner.push_str("<Messages><member>");
1271                inner.push_str("<Code>Error</Code>");
1272                inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1273                inner.push_str("</member></Messages>");
1274            } else {
1275                inner.push_str("<StatusCode>Complete</StatusCode>");
1276            }
1277            inner.push_str("</member>");
1278        }
1279        inner.push_str("</MetricDataResults>");
1280        inner.push_str("<Messages></Messages>");
1281
1282        Ok(xml_response("GetMetricData", &inner, &req.request_id))
1283    }
1284
1285    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1286        // Only `AlarmName` is required by the Smithy contract; the op declares
1287        // no validation errors, so ComparisonOperator / EvaluationPeriods are
1288        // accepted with sensible defaults rather than rejected. Constraint
1289        // violations still produce a 4xx, which the probe accepts as AnyError
1290        // for the negative variants.
1291        validate_len(req, "AlarmName", 1, 255)?;
1292        validate_len(req, "AlarmDescription", 0, 1024)?;
1293        validate_len(req, "MetricName", 1, 255)?;
1294        validate_len(req, "Namespace", 1, 255)?;
1295        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1296        validate_len(req, "TreatMissingData", 1, 255)?;
1297        validate_len(req, "ThresholdMetricId", 1, 255)?;
1298        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1299        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1300        validate_range_i64(req, "Period", 1, i64::MAX)?;
1301        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1302        validate_enum(
1303            req,
1304            "ComparisonOperator",
1305            &[
1306                "GreaterThanOrEqualToThreshold",
1307                "GreaterThanThreshold",
1308                "GreaterThanUpperThreshold",
1309                "LessThanLowerOrGreaterThanUpperThreshold",
1310                "LessThanLowerThreshold",
1311                "LessThanOrEqualToThreshold",
1312                "LessThanThreshold",
1313            ],
1314        )?;
1315        validate_enum(
1316            req,
1317            "Statistic",
1318            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1319        )?;
1320        validate_enum(req, "Unit", STANDARD_UNITS)?;
1321        let alarm_name = required_query_param(req, "AlarmName")?;
1322        let comparison = optional_query_param(req, "ComparisonOperator")
1323            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1324        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1325            .and_then(|s| s.parse::<i64>().ok())
1326            .unwrap_or(1);
1327
1328        let alarm_description = optional_query_param(req, "AlarmDescription");
1329        let actions_enabled = optional_query_param(req, "ActionsEnabled")
1330            .map(|s| s.eq_ignore_ascii_case("true"))
1331            .unwrap_or(true);
1332
1333        let metric_name = optional_query_param(req, "MetricName");
1334        let namespace = optional_query_param(req, "Namespace");
1335        let statistic = optional_query_param(req, "Statistic");
1336        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1337        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1338        let unit = optional_query_param(req, "Unit");
1339        let datapoints_to_alarm =
1340            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1341        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1342        let treat_missing_data = optional_query_param(req, "TreatMissingData");
1343        let evaluate_low_sample_count_percentile =
1344            optional_query_param(req, "EvaluateLowSampleCountPercentile");
1345        // Anomaly-detection alarms reference a metric-math id instead of a
1346        // static Threshold; previously accepted then dropped (1.24).
1347        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1348        let dimensions = parse_dimensions_query(req, "Dimensions");
1349        // `Metrics` — the metric-math / cross-account alarm definition. Parsed
1350        // from the flat `Metrics.member.N.*` params and persisted so
1351        // DescribeAlarms can echo it back (previously silently dropped).
1352        let metrics = parse_alarm_metrics(req);
1353        // Inline `Tags` on PutMetricAlarm land in the same ARN-keyed tag store
1354        // as TagResource, so ListTagsForResource returns them.
1355        let inline_tags = parse_tags(req, "Tags");
1356
1357        let mut ok_actions = Vec::new();
1358        let mut alarm_actions = Vec::new();
1359        let mut insufficient_data_actions = Vec::new();
1360        for (k, v) in req.query_params.iter() {
1361            if k.starts_with("OKActions.member.") {
1362                ok_actions.push(v.clone());
1363            } else if k.starts_with("AlarmActions.member.") {
1364                alarm_actions.push(v.clone());
1365            } else if k.starts_with("InsufficientDataActions.member.") {
1366                insufficient_data_actions.push(v.clone());
1367            }
1368        }
1369
1370        let arn = format!(
1371            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1372            req.region, req.account_id, alarm_name
1373        );
1374        let now = Utc::now();
1375
1376        let mut state = self.state.write();
1377        let acct = state.get_or_create(&req.account_id);
1378        let alarms = acct.alarms_in_mut(&req.region);
1379        let existing = alarms.get(&alarm_name).cloned();
1380        let alarm = MetricAlarm {
1381            alarm_name: alarm_name.clone(),
1382            alarm_arn: arn,
1383            alarm_description,
1384            actions_enabled,
1385            ok_actions,
1386            alarm_actions,
1387            insufficient_data_actions,
1388            state_value: existing
1389                .as_ref()
1390                .map(|a| a.state_value)
1391                .unwrap_or(AlarmState::InsufficientData),
1392            state_reason: existing
1393                .as_ref()
1394                .map(|a| a.state_reason.clone())
1395                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1396            state_updated_timestamp: existing
1397                .as_ref()
1398                .map(|a| a.state_updated_timestamp)
1399                .unwrap_or(now),
1400            metric_name,
1401            namespace,
1402            statistic,
1403            extended_statistic,
1404            dimensions,
1405            period,
1406            unit,
1407            evaluation_periods,
1408            datapoints_to_alarm,
1409            threshold,
1410            comparison_operator: comparison,
1411            treat_missing_data,
1412            evaluate_low_sample_count_percentile,
1413            threshold_metric_id,
1414            configuration_updated_timestamp: existing
1415                .as_ref()
1416                .map(|a| a.configuration_updated_timestamp)
1417                .unwrap_or(now),
1418            alarm_configuration_updated_timestamp: now,
1419            metrics,
1420        };
1421        let alarm_arn = alarm.alarm_arn.clone();
1422        let history_name = alarm_name.clone();
1423        let created = existing.is_none();
1424        alarms.insert(alarm_name, alarm);
1425
1426        // Persist inline Tags into the ARN-keyed tag store, but ONLY on create.
1427        // AWS ignores the inline Tags param when PutMetricAlarm updates an
1428        // existing alarm; tags on an existing alarm are managed via
1429        // TagResource / UntagResource.
1430        if created && !inline_tags.is_empty() {
1431            let bucket = acct.tags.entry(alarm_arn).or_default();
1432            for (k, v) in inline_tags {
1433                bucket.insert(k, v);
1434            }
1435        }
1436
1437        let summary = if created {
1438            format!("Alarm \"{history_name}\" created")
1439        } else {
1440            format!("Alarm \"{history_name}\" updated")
1441        };
1442        let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1443        push_alarm_history(
1444            acct,
1445            &req.region,
1446            &history_name,
1447            "MetricAlarm",
1448            "ConfigurationUpdate",
1449            summary,
1450            history_data,
1451        );
1452
1453        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1454    }
1455
1456    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1457        let mut filter_names: Vec<String> = Vec::new();
1458        for (k, v) in req.query_params.iter() {
1459            if k.starts_with("AlarmNames.member.") {
1460                filter_names.push(v.clone());
1461            }
1462        }
1463        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1464        validate_len(req, "ActionPrefix", 1, 1024)?;
1465        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1466        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1467        validate_range_i64(req, "MaxRecords", 1, 100)?;
1468        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1469        let prefix = optional_query_param(req, "AlarmNamePrefix");
1470        let state_filter = optional_query_param(req, "StateValue");
1471        let action_prefix = optional_query_param(req, "ActionPrefix");
1472        // AWS caps DescribeAlarms at 100 records per page (MaxRecords range
1473        // 1..100) and round-trips a NextToken across the combined metric +
1474        // composite alarm result set.
1475        let max_records = optional_query_param(req, "MaxRecords")
1476            .and_then(|s| s.parse::<usize>().ok())
1477            .filter(|n| *n > 0)
1478            .unwrap_or(100);
1479        let offset = decode_offset_token(req.query_params.get("NextToken"));
1480
1481        // `false` = metric alarm, `true` = composite alarm; rendered lazily
1482        // after the slice so we only stringify the page.
1483        let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1484            if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1485                return false;
1486            }
1487            if let Some(p) = prefix.as_ref() {
1488                if !name.starts_with(p) {
1489                    return false;
1490                }
1491            }
1492            if let Some(want) = state_filter.as_ref() {
1493                if sv != want {
1494                    return false;
1495                }
1496            }
1497            if let Some(ap) = action_prefix.as_ref() {
1498                let any = actions
1499                    .iter()
1500                    .flat_map(|a| a.iter())
1501                    .any(|a| a.starts_with(ap));
1502                if !any {
1503                    return false;
1504                }
1505            }
1506            true
1507        };
1508
1509        // Recompute alarm states from the metric data (and composite rules)
1510        // before rendering, so a PutMetricData that crosses a threshold is
1511        // reflected here and a composite alarm mirrors its children.
1512        let mut state = self.state.write();
1513        if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1514            crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1515        }
1516        let mut combined: Vec<(bool, String)> = Vec::new();
1517        if let Some(acct) = state.get(&req.account_id) {
1518            if let Some(alarms) = acct.alarms_in(&req.region) {
1519                for alarm in alarms.values() {
1520                    if matches(
1521                        &alarm.alarm_name,
1522                        alarm.state_value.as_str(),
1523                        [
1524                            &alarm.alarm_actions,
1525                            &alarm.ok_actions,
1526                            &alarm.insufficient_data_actions,
1527                        ],
1528                    ) {
1529                        combined.push((false, render_alarm(alarm)));
1530                    }
1531                }
1532            }
1533            if let Some(composites) = acct.composite_alarms_in(&req.region) {
1534                for alarm in composites.values() {
1535                    if matches(
1536                        &alarm.alarm_name,
1537                        alarm.state_value.as_str(),
1538                        [
1539                            &alarm.alarm_actions,
1540                            &alarm.ok_actions,
1541                            &alarm.insufficient_data_actions,
1542                        ],
1543                    ) {
1544                        combined
1545                            .push((true, crate::composite_alarms::render_composite_alarm(alarm)));
1546                    }
1547                }
1548            }
1549        }
1550
1551        let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1552        let mut inner = String::from("<MetricAlarms>");
1553        for (is_composite, body) in &page {
1554            if !*is_composite {
1555                inner.push_str(body);
1556            }
1557        }
1558        inner.push_str("</MetricAlarms>");
1559        inner.push_str("<CompositeAlarms>");
1560        for (is_composite, body) in &page {
1561            if *is_composite {
1562                inner.push_str(body);
1563            }
1564        }
1565        inner.push_str("</CompositeAlarms>");
1566        if offset + max_records < combined.len() {
1567            inner.push_str(&format!(
1568                "<NextToken>{}</NextToken>",
1569                encode_offset_token(offset + max_records)
1570            ));
1571        }
1572
1573        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1574    }
1575
1576    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1577        validate_len(req, "MetricName", 1, 255)?;
1578        validate_len(req, "Namespace", 1, 255)?;
1579        validate_range_i64(req, "Period", 1, i64::MAX)?;
1580        validate_enum(
1581            req,
1582            "Statistic",
1583            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1584        )?;
1585        validate_enum(req, "Unit", STANDARD_UNITS)?;
1586        let metric_name = required_query_param(req, "MetricName")?;
1587        let namespace = required_query_param(req, "Namespace")?;
1588        let dim_filter = parse_dimensions_query(req, "Dimensions");
1589
1590        let state = self.state.read();
1591        let mut inner = String::from("<MetricAlarms>");
1592        if let Some(acct) = state.get(&req.account_id) {
1593            if let Some(alarms) = acct.alarms_in(&req.region) {
1594                for alarm in alarms.values() {
1595                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1596                        continue;
1597                    }
1598                    if alarm.namespace.as_deref() != Some(&namespace) {
1599                        continue;
1600                    }
1601                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1602                        continue;
1603                    }
1604                    inner.push_str(&render_alarm(alarm));
1605                }
1606            }
1607        }
1608        inner.push_str("</MetricAlarms>");
1609
1610        Ok(xml_response(
1611            "DescribeAlarmsForMetric",
1612            &inner,
1613            &req.request_id,
1614        ))
1615    }
1616
1617    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1618        // AlarmNames is required, but an empty list serialises to zero wire
1619        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1620        // set is a no-op rather than an undeclared 4xx.
1621        let mut names: Vec<String> = Vec::new();
1622        for (k, v) in req.query_params.iter() {
1623            if k.starts_with("AlarmNames.member.") {
1624                names.push(v.clone());
1625            }
1626        }
1627
1628        let mut state = self.state.write();
1629        let acct = state.get_or_create(&req.account_id);
1630        for name in &names {
1631            acct.alarms_in_mut(&req.region).remove(name);
1632            acct.composite_alarms_in_mut(&req.region).remove(name);
1633            // Alarm history is tied to the alarm; AWS drops it when the alarm
1634            // is deleted, so clear it here rather than orphan stale items.
1635            acct.alarm_history_in_mut(&req.region).remove(name);
1636        }
1637
1638        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1639    }
1640
1641    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1642        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1643    }
1644
1645    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1646        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1647    }
1648
1649    fn toggle_alarm_actions(
1650        &self,
1651        req: &AwsRequest,
1652        enabled: bool,
1653        action_name: &str,
1654    ) -> Result<AwsResponse, AwsServiceError> {
1655        let mut names: Vec<String> = Vec::new();
1656        for (k, v) in req.query_params.iter() {
1657            if k.starts_with("AlarmNames.member.") {
1658                names.push(v.clone());
1659            }
1660        }
1661        let mut state = self.state.write();
1662        let acct = state.get_or_create(&req.account_id);
1663        let alarms = acct.alarms_in_mut(&req.region);
1664        for name in names {
1665            if let Some(alarm) = alarms.get_mut(&name) {
1666                alarm.actions_enabled = enabled;
1667                alarm.alarm_configuration_updated_timestamp = Utc::now();
1668            }
1669        }
1670        Ok(empty_metadata_response(action_name, &req.request_id))
1671    }
1672
1673    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1674        validate_len(req, "AlarmName", 1, 255)?;
1675        validate_len(req, "StateReason", 0, 1023)?;
1676        validate_len(req, "StateReasonData", 0, 4000)?;
1677        let alarm_name = required_query_param(req, "AlarmName")?;
1678        let state_value = required_query_param(req, "StateValue")?;
1679        // StateReason is required but allows a zero-length value (min=0). Treat
1680        // an absent key as missing (declared error) while accepting an empty
1681        // string as a valid value.
1682        let state_reason = req
1683            .query_params
1684            .get("StateReason")
1685            .cloned()
1686            .ok_or_else(|| {
1687                AwsServiceError::aws_error(
1688                    StatusCode::BAD_REQUEST,
1689                    "MissingParameter",
1690                    "The request must contain the parameter StateReason.",
1691                )
1692            })?;
1693        let new_state = AlarmState::parse(&state_value)
1694            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1695
1696        let now = Utc::now();
1697        let mut state = self.state.write();
1698        let acct = state.get_or_create(&req.account_id);
1699        // SetAlarmState can target a metric alarm or a composite alarm; look up
1700        // the metric store first, then fall back to the composite store.
1701        let (old_state, alarm_type) =
1702            if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1703                let old = alarm.state_value.as_str().to_string();
1704                alarm.state_value = new_state;
1705                alarm.state_reason = state_reason.clone();
1706                alarm.state_updated_timestamp = now;
1707                (old, "MetricAlarm")
1708            } else if let Some(composite) = acct
1709                .composite_alarms_in_mut(&req.region)
1710                .get_mut(&alarm_name)
1711            {
1712                let old = composite.state_value.as_str().to_string();
1713                composite.state_value = new_state;
1714                composite.state_reason = state_reason.clone();
1715                composite.state_updated_timestamp = now;
1716                (old, "CompositeAlarm")
1717            } else {
1718                return Err(AwsServiceError::aws_error(
1719                    StatusCode::NOT_FOUND,
1720                    "ResourceNotFound",
1721                    format!("Alarm {alarm_name} not found"),
1722                ));
1723            };
1724
1725        let new_state_str = new_state.as_str().to_string();
1726        let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1727        let history_data = format!(
1728            "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1729            state_reason.replace('"', "\\\"")
1730        );
1731        push_alarm_history(
1732            acct,
1733            &req.region,
1734            &alarm_name,
1735            alarm_type,
1736            "StateUpdate",
1737            summary,
1738            history_data,
1739        );
1740
1741        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1742    }
1743
1744    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1745        validate_len(req, "AlarmName", 1, 255)?;
1746        validate_len(req, "AlarmContributorId", 1, 16)?;
1747        validate_range_i64(req, "MaxRecords", 1, 100)?;
1748        validate_enum(
1749            req,
1750            "HistoryItemType",
1751            &[
1752                "ConfigurationUpdate",
1753                "StateUpdate",
1754                "Action",
1755                "AlarmContributorStateUpdate",
1756                "AlarmContributorAction",
1757            ],
1758        )?;
1759        validate_enum(
1760            req,
1761            "ScanBy",
1762            &["TimestampDescending", "TimestampAscending"],
1763        )?;
1764        let alarm_filter = optional_query_param(req, "AlarmName");
1765        let type_filter = optional_query_param(req, "HistoryItemType");
1766        let start_date =
1767            optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1768        let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1769        // DescribeAlarmHistory defaults to TimestampDescending (newest first).
1770        let descending = req
1771            .query_params
1772            .get("ScanBy")
1773            .map(|s| s != "TimestampAscending")
1774            .unwrap_or(true);
1775        let max_records = optional_query_param(req, "MaxRecords")
1776            .and_then(|s| s.parse::<usize>().ok())
1777            .filter(|n| *n > 0)
1778            .unwrap_or(100);
1779        let offset = decode_offset_token(req.query_params.get("NextToken"));
1780
1781        let state = self.state.read();
1782        let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1783        if let Some(acct) = state.get(&req.account_id) {
1784            if let Some(history) = acct.alarm_history_in(&req.region) {
1785                for (name, list) in history.iter() {
1786                    if let Some(f) = alarm_filter.as_ref() {
1787                        if name != f {
1788                            continue;
1789                        }
1790                    }
1791                    for item in list.iter() {
1792                        if let Some(t) = type_filter.as_ref() {
1793                            if &item.history_item_type != t {
1794                                continue;
1795                            }
1796                        }
1797                        if let Some(sd) = start_date {
1798                            if item.timestamp < sd {
1799                                continue;
1800                            }
1801                        }
1802                        if let Some(ed) = end_date {
1803                            if item.timestamp > ed {
1804                                continue;
1805                            }
1806                        }
1807                        items.push(item);
1808                    }
1809                }
1810            }
1811        }
1812        items.sort_by_key(|i| i.timestamp);
1813        if descending {
1814            items.reverse();
1815        }
1816        let total = items.len();
1817        let page: Vec<&AlarmHistoryItem> =
1818            items.into_iter().skip(offset).take(max_records).collect();
1819
1820        let mut inner = String::from("<AlarmHistoryItems>");
1821        for item in page {
1822            inner.push_str("<member>");
1823            inner.push_str(&format!(
1824                "<AlarmName>{}</AlarmName>",
1825                xml_escape(&item.alarm_name)
1826            ));
1827            inner.push_str(&format!(
1828                "<AlarmType>{}</AlarmType>",
1829                xml_escape(&item.alarm_type)
1830            ));
1831            inner.push_str(&format!(
1832                "<Timestamp>{}</Timestamp>",
1833                item.timestamp
1834                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1835            ));
1836            inner.push_str(&format!(
1837                "<HistoryItemType>{}</HistoryItemType>",
1838                xml_escape(&item.history_item_type)
1839            ));
1840            inner.push_str(&format!(
1841                "<HistorySummary>{}</HistorySummary>",
1842                xml_escape(&item.history_summary)
1843            ));
1844            inner.push_str(&format!(
1845                "<HistoryData>{}</HistoryData>",
1846                xml_escape(&item.history_data)
1847            ));
1848            inner.push_str("</member>");
1849        }
1850        inner.push_str("</AlarmHistoryItems>");
1851        if offset + max_records < total {
1852            inner.push_str(&format!(
1853                "<NextToken>{}</NextToken>",
1854                encode_offset_token(offset + max_records)
1855            ));
1856        }
1857        Ok(xml_response(
1858            "DescribeAlarmHistory",
1859            &inner,
1860            &req.request_id,
1861        ))
1862    }
1863
1864    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1865        let dashboard_name = req
1866            .query_params
1867            .get("DashboardName")
1868            .ok_or_else(|| invalid_param("DashboardName is required"))?
1869            .clone();
1870        let body = req
1871            .query_params
1872            .get("DashboardBody")
1873            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1874            .clone();
1875        // AWS validates that DashboardBody parses as JSON; we do the same so
1876        // bad bodies surface a useful error before persisting.
1877        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1878            return Err(AwsServiceError::aws_error(
1879                StatusCode::BAD_REQUEST,
1880                "InvalidParameterInput",
1881                "DashboardBody must be a valid JSON object",
1882            ));
1883        }
1884        let arn = format!(
1885            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1886            req.account_id
1887        );
1888        let dashboard = Dashboard {
1889            name: dashboard_name.clone(),
1890            arn,
1891            size_bytes: body.len() as i64,
1892            body,
1893            last_modified: Utc::now(),
1894        };
1895        let mut state = self.state.write();
1896        let acct = state.get_or_create(&req.account_id);
1897        acct.dashboards.insert(dashboard_name, dashboard);
1898        // PutDashboard returns DashboardValidationMessages — empty when the
1899        // body parses cleanly.
1900        let inner = String::from("<DashboardValidationMessages/>");
1901        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1902    }
1903
1904    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1905        let name = req
1906            .query_params
1907            .get("DashboardName")
1908            .ok_or_else(|| invalid_param("DashboardName is required"))?
1909            .clone();
1910        let state = self.state.read();
1911        let dashboard = state
1912            .get(&req.account_id)
1913            .and_then(|a| a.dashboards.get(&name))
1914            .cloned()
1915            .ok_or_else(|| {
1916                AwsServiceError::aws_error(
1917                    StatusCode::NOT_FOUND,
1918                    "ResourceNotFound",
1919                    format!("Dashboard {name} does not exist"),
1920                )
1921            })?;
1922        let inner = format!(
1923            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
1924            xml_escape(&dashboard.arn),
1925            xml_escape(&dashboard.body),
1926            xml_escape(&dashboard.name),
1927        );
1928        Ok(xml_response("GetDashboard", &inner, &req.request_id))
1929    }
1930
1931    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1932        let mut names: Vec<String> = Vec::new();
1933        for (k, v) in req.query_params.iter() {
1934            if k.starts_with("DashboardNames.member.") {
1935                names.push(v.clone());
1936            }
1937        }
1938        if names.is_empty() {
1939            return Err(invalid_param(
1940                "DashboardNames must contain at least one name",
1941            ));
1942        }
1943        let mut state = self.state.write();
1944        let acct = state.get_or_create(&req.account_id);
1945        for n in names {
1946            acct.dashboards.remove(&n);
1947        }
1948        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
1949        // the AWS SDK fails to deserialize the response if the result node is
1950        // absent ("DeleteDashboardsResult node not found").
1951        let body = format!(
1952            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1953             <DeleteDashboardsResponse xmlns=\"{NS}\">\
1954             <DeleteDashboardsResult/>\
1955             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
1956             </DeleteDashboardsResponse>",
1957            req.request_id
1958        );
1959        Ok(AwsResponse::xml(StatusCode::OK, body))
1960    }
1961
1962    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1963        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
1964        let state = self.state.read();
1965        let dashboards: Vec<Dashboard> = state
1966            .get(&req.account_id)
1967            .map(|a| {
1968                a.dashboards
1969                    .values()
1970                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
1971                    .cloned()
1972                    .collect()
1973            })
1974            .unwrap_or_default();
1975        let mut entries = String::new();
1976        for d in &dashboards {
1977            entries.push_str("<member>");
1978            entries.push_str(&format!(
1979                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
1980                xml_escape(&d.arn),
1981                xml_escape(&d.name),
1982                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1983                d.size_bytes,
1984            ));
1985            entries.push_str("</member>");
1986        }
1987        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
1988        Ok(xml_response("ListDashboards", &inner, &req.request_id))
1989    }
1990}
1991
1992/// Append an alarm-history record (newest appended last). Shared by
1993/// PutMetricAlarm, SetAlarmState and DeleteAlarms so DescribeAlarmHistory
1994/// reflects real lifecycle transitions.
1995pub(crate) fn push_alarm_history(
1996    acct: &mut crate::state::CloudWatchState,
1997    region: &str,
1998    alarm_name: &str,
1999    alarm_type: &str,
2000    history_item_type: &str,
2001    history_summary: String,
2002    history_data: String,
2003) {
2004    acct.alarm_history_in_mut(region)
2005        .entry(alarm_name.to_string())
2006        .or_default()
2007        .push(AlarmHistoryItem {
2008            alarm_name: alarm_name.to_string(),
2009            alarm_type: alarm_type.to_string(),
2010            timestamp: Utc::now(),
2011            history_item_type: history_item_type.to_string(),
2012            history_summary,
2013            history_data,
2014        });
2015}
2016
2017fn render_alarm(alarm: &MetricAlarm) -> String {
2018    let mut s = String::from("<member>");
2019    s.push_str(&format!(
2020        "<AlarmName>{}</AlarmName>",
2021        xml_escape(&alarm.alarm_name)
2022    ));
2023    s.push_str(&format!(
2024        "<AlarmArn>{}</AlarmArn>",
2025        xml_escape(&alarm.alarm_arn)
2026    ));
2027    if let Some(d) = &alarm.alarm_description {
2028        s.push_str(&format!(
2029            "<AlarmDescription>{}</AlarmDescription>",
2030            xml_escape(d)
2031        ));
2032    }
2033    s.push_str(&format!(
2034        "<ActionsEnabled>{}</ActionsEnabled>",
2035        alarm.actions_enabled
2036    ));
2037    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2038    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2039    push_action_list(
2040        &mut s,
2041        "InsufficientDataActions",
2042        &alarm.insufficient_data_actions,
2043    );
2044    s.push_str(&format!(
2045        "<StateValue>{}</StateValue>",
2046        alarm.state_value.as_str()
2047    ));
2048    s.push_str(&format!(
2049        "<StateReason>{}</StateReason>",
2050        xml_escape(&alarm.state_reason)
2051    ));
2052    s.push_str(&format!(
2053        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2054        alarm
2055            .state_updated_timestamp
2056            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2057    ));
2058    if let Some(m) = &alarm.metric_name {
2059        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2060    }
2061    if let Some(n) = &alarm.namespace {
2062        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2063    }
2064    if let Some(stat) = &alarm.statistic {
2065        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2066    }
2067    if let Some(ext) = &alarm.extended_statistic {
2068        s.push_str(&format!(
2069            "<ExtendedStatistic>{}</ExtendedStatistic>",
2070            xml_escape(ext)
2071        ));
2072    }
2073    s.push_str(&render_dimensions(&alarm.dimensions));
2074    if let Some(p) = alarm.period {
2075        s.push_str(&format!("<Period>{p}</Period>"));
2076    }
2077    if let Some(u) = &alarm.unit {
2078        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2079    }
2080    s.push_str(&format!(
2081        "<EvaluationPeriods>{}</EvaluationPeriods>",
2082        alarm.evaluation_periods
2083    ));
2084    if let Some(d) = alarm.datapoints_to_alarm {
2085        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2086    }
2087    if let Some(t) = alarm.threshold {
2088        s.push_str(&format!("<Threshold>{t}</Threshold>"));
2089    }
2090    if let Some(tid) = &alarm.threshold_metric_id {
2091        s.push_str(&format!(
2092            "<ThresholdMetricId>{}</ThresholdMetricId>",
2093            xml_escape(tid)
2094        ));
2095    }
2096    s.push_str(&format!(
2097        "<ComparisonOperator>{}</ComparisonOperator>",
2098        xml_escape(&alarm.comparison_operator)
2099    ));
2100    if let Some(t) = &alarm.treat_missing_data {
2101        s.push_str(&format!(
2102            "<TreatMissingData>{}</TreatMissingData>",
2103            xml_escape(t)
2104        ));
2105    }
2106    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2107        s.push_str(&format!(
2108            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2109            xml_escape(e)
2110        ));
2111    }
2112    s.push_str(&format!(
2113        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2114        alarm
2115            .alarm_configuration_updated_timestamp
2116            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2117    ));
2118    render_alarm_metrics(&mut s, &alarm.metrics);
2119    s.push_str("</member>");
2120    s
2121}
2122
2123/// Render the `Metrics` (metric-math / cross-account) list of a MetricAlarm.
2124fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2125    if metrics.is_empty() {
2126        return;
2127    }
2128    s.push_str("<Metrics>");
2129    for q in metrics {
2130        s.push_str("<member>");
2131        s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2132        if let Some(stat) = &q.metric_stat {
2133            s.push_str("<MetricStat>");
2134            s.push_str("<Metric>");
2135            if let Some(ns) = &stat.namespace {
2136                s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2137            }
2138            if let Some(mn) = &stat.metric_name {
2139                s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2140            }
2141            s.push_str(&render_dimensions(&stat.dimensions));
2142            s.push_str("</Metric>");
2143            if let Some(p) = stat.period {
2144                s.push_str(&format!("<Period>{p}</Period>"));
2145            }
2146            if let Some(st) = &stat.stat {
2147                s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2148            }
2149            if let Some(u) = &stat.unit {
2150                s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2151            }
2152            s.push_str("</MetricStat>");
2153        }
2154        if let Some(e) = &q.expression {
2155            s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2156        }
2157        if let Some(l) = &q.label {
2158            s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2159        }
2160        if let Some(rd) = q.return_data {
2161            s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2162        }
2163        if let Some(p) = q.period {
2164            s.push_str(&format!("<Period>{p}</Period>"));
2165        }
2166        if let Some(acct) = &q.account_id {
2167            s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2168        }
2169        s.push_str("</member>");
2170    }
2171    s.push_str("</Metrics>");
2172}
2173
2174fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2175    s.push_str(&format!("<{name}>"));
2176    for action in actions {
2177        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2178    }
2179    s.push_str(&format!("</{name}>"));
2180}