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/// Parse `{prefix}.member.N.Name` / `.Value` query params into ListMetrics'
536/// `DimensionFilter` list, where `Value` is OPTIONAL (per the Smithy model).
537/// A name-only filter matches any metric carrying a dimension with that name
538/// (any value); a name+value filter is an exact match.
539///
540/// Distinct from [`parse_dimensions_query`], which drops a name-only entry —
541/// correct for the put/statistics APIs (exact dimension sets) but wrong for
542/// ListMetrics, where a name-only filter must still narrow the results
543/// instead of silently returning every metric in the namespace.
544pub(crate) fn parse_dimension_filters(
545    req: &AwsRequest,
546    prefix: &str,
547) -> Vec<(String, Option<String>)> {
548    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
549    let needle = format!("{prefix}.member.");
550    for (k, v) in req.query_params.iter() {
551        let Some(rest) = k.strip_prefix(&needle) else {
552            continue;
553        };
554        let mut parts = rest.splitn(2, '.');
555        let Some(idx_str) = parts.next() else {
556            continue;
557        };
558        let Ok(idx) = idx_str.parse::<u32>() else {
559            continue;
560        };
561        let field = parts.next().unwrap_or("");
562        let entry = dims.entry(idx).or_default();
563        match field {
564            "Name" => entry.0 = Some(v.clone()),
565            "Value" => entry.1 = Some(v.clone()),
566            _ => {}
567        }
568    }
569    dims.into_values()
570        .filter_map(|(name, value)| name.map(|n| (n, value)))
571        .collect()
572}
573
574/// Validate the length of an optional string param against `[min, max]`.
575/// Returns a 4xx on violation. AWS measures length in characters; the
576/// conformance probe only sends ASCII so byte length is equivalent here.
577pub(crate) fn validate_len(
578    req: &AwsRequest,
579    param: &str,
580    min: usize,
581    max: usize,
582) -> Result<(), AwsServiceError> {
583    if let Some(v) = req.query_params.get(param) {
584        let len = v.chars().count();
585        if len < min || len > max {
586            return Err(invalid_param(format!(
587                "{param} length {len} is outside [{min}, {max}]"
588            )));
589        }
590    }
591    Ok(())
592}
593
594/// Validate an optional integer param against `[min, max]` (inclusive).
595pub(crate) fn validate_range_i64(
596    req: &AwsRequest,
597    param: &str,
598    min: i64,
599    max: i64,
600) -> Result<(), AwsServiceError> {
601    if let Some(v) = req.query_params.get(param) {
602        if v.is_empty() {
603            return Ok(());
604        }
605        let n = v
606            .parse::<i64>()
607            .map_err(|_| invalid_param(format!("{param} must be an integer")))?;
608        if n < min || n > max {
609            return Err(invalid_param(format!(
610                "{param} value {n} is outside [{min}, {max}]"
611            )));
612        }
613    }
614    Ok(())
615}
616
617/// Validate that an optional param, when present, is one of `allowed`.
618pub(crate) fn validate_enum(
619    req: &AwsRequest,
620    param: &str,
621    allowed: &[&str],
622) -> Result<(), AwsServiceError> {
623    if let Some(v) = req.query_params.get(param) {
624        if !v.is_empty() && !allowed.contains(&v.as_str()) {
625            return Err(invalid_param(format!("{param} has an invalid value '{v}'")));
626        }
627    }
628    Ok(())
629}
630
631/// Collect repeated `<Prefix>.member.N` scalar values, ordered by index.
632pub(crate) fn collect_member_values(req: &AwsRequest, prefix: &str) -> Vec<String> {
633    let needle = format!("{prefix}.member.");
634    let mut by_index: BTreeMap<u32, String> = BTreeMap::new();
635    for (k, v) in req.query_params.iter() {
636        let Some(rest) = k.strip_prefix(&needle) else {
637            continue;
638        };
639        if let Ok(idx) = rest.parse::<u32>() {
640            by_index.insert(idx, v.clone());
641        }
642    }
643    by_index.into_values().collect()
644}
645
646/// Parse a `Tags.member.N.Key` / `Tags.member.N.Value` list into a map.
647pub(crate) fn parse_tags(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
648    let members = collect_indexed(req, prefix);
649    let mut out = BTreeMap::new();
650    for m in members {
651        if let (Some(k), Some(v)) = (m.get("Key"), m.get("Value")) {
652            out.insert(k.clone(), v.clone());
653        }
654    }
655    out
656}
657
658/// Re-export the canonical XML escaper, which also encodes C0 control chars
659/// as numeric character references so a poisoned field cannot make a whole
660/// list-response document unparseable by a strict XML SDK.
661pub(crate) use fakecloud_aws::xml::xml_escape;
662
663/// Encode a pagination offset into an opaque base64 NextToken.
664pub(crate) fn encode_offset_token(offset: usize) -> String {
665    use base64::Engine;
666    base64::engine::general_purpose::STANDARD.encode(format!("offset:{offset}"))
667}
668
669/// Decode a NextToken produced by [`encode_offset_token`]. Returns 0 for an
670/// absent or unparseable token (AWS rejects bad tokens, but treating it as the
671/// first page is friendlier and never loses data).
672pub(crate) fn decode_offset_token(token: Option<&String>) -> usize {
673    use base64::Engine;
674    let Some(token) = token else {
675        return 0;
676    };
677    base64::engine::general_purpose::STANDARD
678        .decode(token)
679        .ok()
680        .and_then(|b| String::from_utf8(b).ok())
681        .and_then(|s| s.strip_prefix("offset:").map(|n| n.to_string()))
682        .and_then(|n| n.parse::<usize>().ok())
683        .unwrap_or(0)
684}
685
686/// Parse an input timestamp, accepting either RFC3339 (the query-protocol
687/// form) or a numeric epoch-seconds value (which JSON-protocol / X-Amz-Target
688/// callers send). Previously only RFC3339 was accepted, so an epoch-second
689/// timestamp was silently dropped or rejected.
690pub(crate) fn parse_input_timestamp(s: &str) -> Option<DateTime<Utc>> {
691    let s = s.trim();
692    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
693        return Some(dt.with_timezone(&Utc));
694    }
695    // Epoch seconds (optionally fractional).
696    if let Ok(secs) = s.parse::<f64>() {
697        if secs.is_finite() {
698            let whole = secs.trunc() as i64;
699            let nanos = (secs.fract().abs() * 1_000_000_000.0).round() as u32;
700            return DateTime::<Utc>::from_timestamp(whole, nanos);
701        }
702    }
703    None
704}
705
706/// Per-datapoint aggregation summary covering both the simple `Value` form
707/// and the `StatisticValues` form so callers don't lose the count or
708/// min/max baked into a `StatisticSet`.
709#[derive(Clone, Copy)]
710struct DatumStats {
711    sum: f64,
712    min: f64,
713    max: f64,
714    count: f64,
715}
716
717fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
718    if let Some(v) = d.value {
719        return Some(DatumStats {
720            sum: v,
721            min: v,
722            max: v,
723            count: 1.0,
724        });
725    }
726    if let Some(s) = &d.statistic_values {
727        return Some(DatumStats {
728            sum: s.sum,
729            min: s.minimum,
730            max: s.maximum,
731            count: s.sample_count,
732        });
733    }
734    None
735}
736
737fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
738    acc.sum += other.sum;
739    acc.count += other.count;
740    if other.min < acc.min {
741        acc.min = other.min;
742    }
743    if other.max > acc.max {
744        acc.max = other.max;
745    }
746}
747
748fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
749    match stat {
750        "Sum" => Some(agg.sum),
751        "Average" => {
752            if agg.count > 0.0 {
753                Some(agg.sum / agg.count)
754            } else {
755                None
756            }
757        }
758        "Minimum" => Some(agg.min),
759        "Maximum" => Some(agg.max),
760        "SampleCount" => Some(agg.count),
761        _ => None,
762    }
763}
764
765/// Parse an extended statistic / percentile stat like `p99` or `p99.9` into the
766/// percentile in `[0, 100]`. Returns `None` for anything that isn't a `pNN`
767/// form (so callers can fall through to the simple statistics).
768pub(crate) fn parse_percentile(stat: &str) -> Option<f64> {
769    let rest = stat.strip_prefix('p').or_else(|| stat.strip_prefix('P'))?;
770    let p = rest.parse::<f64>().ok()?;
771    if (0.0..=100.0).contains(&p) {
772        Some(p)
773    } else {
774        None
775    }
776}
777
778/// Linear-interpolation percentile over a pre-sorted sample slice. Uses the
779/// common `rank = p/100 * (n-1)` method — close enough to CloudWatch's
780/// percentile for fakecloud's purposes.
781pub(crate) fn percentile(sorted: &[f64], p: f64) -> Option<f64> {
782    if sorted.is_empty() {
783        return None;
784    }
785    if sorted.len() == 1 {
786        return Some(sorted[0]);
787    }
788    let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
789    let lo = rank.floor() as usize;
790    let hi = rank.ceil() as usize;
791    if lo == hi {
792        return Some(sorted[lo]);
793    }
794    let frac = rank - lo as f64;
795    Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
796}
797
798/// One period bucket of a metric series: the merged [`DatumStats`], the
799/// individual `value` samples (used for percentiles — distributions published
800/// as `StatisticValues` don't retain their raw values so they don't
801/// contribute), and the bucket's unit when consistent.
802pub(crate) struct MetricBucket {
803    agg: DatumStats,
804    pub(crate) samples: Vec<f64>,
805    unit: Option<String>,
806}
807
808/// Resolve a single statistic (simple, e.g. `Sum`, or percentile, e.g. `p99`)
809/// for one bucket. `samples` must be sorted ascending.
810pub(crate) fn resolve_stat(
811    stat: &str,
812    bucket: &MetricBucket,
813    samples_sorted: &[f64],
814) -> Option<f64> {
815    if let Some(p) = parse_percentile(stat) {
816        return percentile(samples_sorted, p);
817    }
818    stat_value(stat, bucket.agg)
819}
820
821/// Collect a metric's datapoints into period buckets, matching dimensions
822/// EXACTLY (an empty filter matches only dimensionless data, the way AWS treats
823/// each distinct dimension combination as its own metric) and, when a unit
824/// filter is set, only datapoints published with that unit.
825#[allow(clippy::too_many_arguments)]
826pub(crate) fn collect_metric_buckets(
827    data: &[MetricDatum],
828    metric_name: &str,
829    dim_filter: &BTreeMap<String, String>,
830    unit_filter: Option<&str>,
831    period: i64,
832    start_ts: DateTime<Utc>,
833    end_ts: DateTime<Utc>,
834) -> BTreeMap<DateTime<Utc>, MetricBucket> {
835    let mut buckets: BTreeMap<DateTime<Utc>, MetricBucket> = BTreeMap::new();
836    for d in data.iter() {
837        if d.metric_name != metric_name {
838            continue;
839        }
840        if let Some(uf) = unit_filter {
841            if d.unit.as_deref().unwrap_or("None") != uf {
842                continue;
843            }
844        }
845        // Exact dimension-set equality: each unique dimension combination is a
846        // distinct metric, so a subset never matches and an empty filter only
847        // matches data published with no dimensions.
848        if &d.dimensions != dim_filter {
849            continue;
850        }
851        if d.timestamp < start_ts || d.timestamp >= end_ts {
852            continue;
853        }
854        let Some(stats) = datum_stats(d) else {
855            continue;
856        };
857        let secs = d.timestamp.timestamp();
858        let bucket_secs = secs - secs.rem_euclid(period);
859        let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
860        match buckets.get_mut(&bucket_ts) {
861            Some(bucket) => {
862                merge_stats(&mut bucket.agg, stats);
863                if bucket.unit != d.unit {
864                    bucket.unit = None;
865                }
866                if let Some(v) = d.value {
867                    bucket.samples.push(v);
868                }
869            }
870            None => {
871                buckets.insert(
872                    bucket_ts,
873                    MetricBucket {
874                        agg: stats,
875                        samples: d.value.map(|v| vec![v]).unwrap_or_default(),
876                        unit: d.unit.clone(),
877                    },
878                );
879            }
880        }
881    }
882    buckets
883}
884
885pub(crate) fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
886    let mut s = String::from("<Dimensions>");
887    for (name, value) in dims.iter() {
888        s.push_str(&format!(
889            "<member><Name>{}</Name><Value>{}</Value></member>",
890            xml_escape(name),
891            xml_escape(value),
892        ));
893    }
894    s.push_str("</Dimensions>");
895    s
896}
897
898impl CloudWatchService {
899    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
900        let namespace = required_query_param(req, "Namespace")?;
901        let members = collect_indexed(req, "MetricData");
902        if members.is_empty() {
903            return Err(invalid_param(
904                "PutMetricData requires at least one MetricData entry",
905            ));
906        }
907
908        let now = Utc::now();
909        let mut state = self.state.write();
910        let acct = state.get_or_create(&req.account_id);
911        let metrics_map = acct.metrics_in_mut(&req.region);
912        let bucket = metrics_map.entry(namespace.clone()).or_default();
913
914        for member in members {
915            let metric_name = member
916                .get("MetricName")
917                .cloned()
918                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
919            let value = member
920                .get("Value")
921                .map(|s| s.parse::<f64>())
922                .transpose()
923                .map_err(|_| invalid_param("Value must be a valid number"))?;
924            let timestamp = member
925                .get("Timestamp")
926                .and_then(|s| parse_input_timestamp(s))
927                .unwrap_or(now);
928            let unit = member.get("Unit").cloned();
929            let storage_resolution = member
930                .get("StorageResolution")
931                .and_then(|s| s.parse::<i64>().ok());
932            let dimensions = parse_dimensions(&member, "Dimensions");
933
934            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
935                member.get("StatisticValues.SampleCount"),
936                member.get("StatisticValues.Sum"),
937                member.get("StatisticValues.Minimum"),
938                member.get("StatisticValues.Maximum"),
939            ) {
940                Some(StatisticSet {
941                    sample_count: sc.parse::<f64>().map_err(|_| {
942                        invalid_param("StatisticValues.SampleCount must be a number")
943                    })?,
944                    sum: sum
945                        .parse::<f64>()
946                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
947                    minimum: min
948                        .parse::<f64>()
949                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
950                    maximum: max
951                        .parse::<f64>()
952                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
953                })
954            } else {
955                None
956            };
957
958            // A `Values`/`Counts` value-distribution is collapsed into a
959            // StatisticSet (which the statistics path already aggregates), so
960            // the common histogram publish path stops 400-ing.
961            let statistic_values = match statistic_values {
962                Some(s) => Some(s),
963                None => values_counts_statistic(&member)?,
964            };
965
966            if value.is_none() && statistic_values.is_none() {
967                return Err(invalid_param(
968                    "MetricData entry must supply either Value, StatisticValues, or Values",
969                ));
970            }
971
972            bucket.push(MetricDatum {
973                metric_name,
974                dimensions,
975                timestamp,
976                value,
977                statistic_values,
978                unit,
979                storage_resolution,
980            });
981        }
982
983        Ok(empty_metadata_response("PutMetricData", &req.request_id))
984    }
985
986    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
987        validate_len(req, "Namespace", 1, 255)?;
988        validate_len(req, "MetricName", 1, 255)?;
989        validate_len(req, "OwningAccount", 1, 255)?;
990        validate_enum(req, "RecentlyActive", &["PT3H"])?;
991        let namespace = optional_query_param(req, "Namespace");
992        let metric_name = optional_query_param(req, "MetricName");
993        let dim_filter = parse_dimension_filters(req, "Dimensions");
994        // ListMetrics has no MaxResults param — AWS caps each page at 500 and
995        // round-trips a NextToken.
996        const LIST_METRICS_PAGE: usize = 500;
997        let offset = decode_offset_token(req.query_params.get("NextToken"));
998
999        let state = self.state.read();
1000        // Flatten every distinct (namespace, metric, dims) into a stable,
1001        // ordered list so the offset token is deterministic across pages.
1002        let mut all: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
1003        if let Some(acct) = state.get(&req.account_id) {
1004            if let Some(map) = acct.metrics_in(&req.region) {
1005                for (ns, data) in map.iter() {
1006                    if let Some(filter_ns) = namespace.as_ref() {
1007                        if ns != filter_ns {
1008                            continue;
1009                        }
1010                    }
1011                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
1012                        BTreeMap::new();
1013                    for d in data.iter() {
1014                        if let Some(filter_name) = metric_name.as_ref() {
1015                            if &d.metric_name != filter_name {
1016                                continue;
1017                            }
1018                        }
1019                        // ListMetrics filters by dimension containment (a metric
1020                        // matches if it carries all the requested filters),
1021                        // unlike the exact-set match used by the statistics
1022                        // APIs. A name-only DimensionFilter matches any value.
1023                        if !dim_filter.is_empty()
1024                            && !dim_filter.iter().all(|(k, v)| match v {
1025                                Some(val) => d.dimensions.get(k) == Some(val),
1026                                None => d.dimensions.contains_key(k),
1027                            })
1028                        {
1029                            continue;
1030                        }
1031                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
1032                    }
1033                    for ((name, dims), _) in seen {
1034                        all.push((ns.clone(), name, dims));
1035                    }
1036                }
1037            }
1038        }
1039
1040        let page = all.iter().skip(offset).take(LIST_METRICS_PAGE);
1041        let mut out = String::from("<Metrics>");
1042        {
1043            for (ns, name, dims) in page {
1044                out.push_str("<member>");
1045                out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
1046                out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(name)));
1047                out.push_str(&render_dimensions(dims));
1048                out.push_str("</member>");
1049            }
1050        }
1051        out.push_str("</Metrics>");
1052        if offset + LIST_METRICS_PAGE < all.len() {
1053            out.push_str(&format!(
1054                "<NextToken>{}</NextToken>",
1055                encode_offset_token(offset + LIST_METRICS_PAGE)
1056            ));
1057        }
1058
1059        Ok(xml_response("ListMetrics", &out, &req.request_id))
1060    }
1061
1062    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1063        let namespace = required_query_param(req, "Namespace")?;
1064        let metric_name = required_query_param(req, "MetricName")?;
1065        let start = required_query_param(req, "StartTime")?;
1066        let end = required_query_param(req, "EndTime")?;
1067        let period = required_query_param(req, "Period")?
1068            .parse::<i64>()
1069            .map_err(|_| invalid_param("Period must be an integer"))?;
1070        if period <= 0 {
1071            return Err(invalid_param("Period must be positive"));
1072        }
1073        // AWS requires Period to be 1, 5, 10, 30, or any positive multiple of
1074        // 60 (high-resolution values below one minute plus per-minute buckets).
1075        if !matches!(period, 1 | 5 | 10 | 30) && period % 60 != 0 {
1076            return Err(invalid_param(
1077                "The parameter Period must be a positive multiple of 60, or one of 1, 5, 10, 30.",
1078            ));
1079        }
1080        let start_ts = parse_input_timestamp(&start)
1081            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1082        let end_ts = parse_input_timestamp(&end)
1083            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1084        if start_ts >= end_ts {
1085            return Err(invalid_param(
1086                "The parameter StartTime must be less than the parameter EndTime.",
1087            ));
1088        }
1089
1090        let mut statistics: Vec<String> = Vec::new();
1091        let mut extended_statistics: Vec<String> = Vec::new();
1092        for (k, v) in req.query_params.iter() {
1093            if k.starts_with("Statistics.member.") {
1094                statistics.push(v.clone());
1095            } else if k.starts_with("ExtendedStatistics.member.") {
1096                extended_statistics.push(v.clone());
1097            }
1098        }
1099        if statistics.is_empty() && extended_statistics.is_empty() {
1100            return Err(invalid_param(
1101                "At least one of Statistics or ExtendedStatistics is required",
1102            ));
1103        }
1104
1105        let dim_filter = parse_dimensions_query(req, "Dimensions");
1106        // When a Unit is given, only datapoints published with that exact unit
1107        // are aggregated (AWS treats an unspecified unit as "None"); otherwise
1108        // mixing units gives a meaningless statistic.
1109        let unit_filter = req.query_params.get("Unit").cloned();
1110
1111        let state = self.state.read();
1112        // (timestamp, simple stats, extended/percentile stats, unit)
1113        type StatPoint = (
1114            DateTime<Utc>,
1115            BTreeMap<String, f64>,
1116            Vec<(String, f64)>,
1117            Option<String>,
1118        );
1119        let mut datapoints: Vec<StatPoint> = Vec::new();
1120        if let Some(acct) = state.get(&req.account_id) {
1121            if let Some(map) = acct.metrics_in(&req.region) {
1122                if let Some(data) = map.get(&namespace) {
1123                    let buckets = collect_metric_buckets(
1124                        data,
1125                        &metric_name,
1126                        &dim_filter,
1127                        unit_filter.as_deref(),
1128                        period,
1129                        start_ts,
1130                        end_ts,
1131                    );
1132                    for (ts, bucket) in buckets {
1133                        let mut sorted = bucket.samples.clone();
1134                        sorted
1135                            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1136                        let mut simple = BTreeMap::new();
1137                        for stat in statistics.iter() {
1138                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1139                                simple.insert(stat.clone(), v);
1140                            }
1141                        }
1142                        let mut extended = Vec::new();
1143                        for stat in extended_statistics.iter() {
1144                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1145                                extended.push((stat.clone(), v));
1146                            }
1147                        }
1148                        let unit = unit_filter.clone().or(bucket.unit);
1149                        datapoints.push((ts, simple, extended, unit));
1150                    }
1151                }
1152            }
1153        }
1154
1155        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1156        inner.push_str("<Datapoints>");
1157        for (ts, simple, extended, unit) in datapoints {
1158            inner.push_str("<member>");
1159            inner.push_str(&format!(
1160                "<Timestamp>{}</Timestamp>",
1161                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1162            ));
1163            for (name, value) in simple {
1164                inner.push_str(&format!("<{name}>{value}</{name}>"));
1165            }
1166            if !extended.is_empty() {
1167                inner.push_str("<ExtendedStatistics>");
1168                for (name, value) in extended {
1169                    inner.push_str(&format!(
1170                        "<entry><key>{}</key><value>{}</value></entry>",
1171                        xml_escape(&name),
1172                        value
1173                    ));
1174                }
1175                inner.push_str("</ExtendedStatistics>");
1176            }
1177            if let Some(u) = unit {
1178                inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1179            }
1180            inner.push_str("</member>");
1181        }
1182        inner.push_str("</Datapoints>");
1183
1184        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1185    }
1186
1187    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1188        validate_enum(
1189            req,
1190            "ScanBy",
1191            &["TimestampDescending", "TimestampAscending"],
1192        )?;
1193        let start = required_query_param(req, "StartTime")?;
1194        let end = required_query_param(req, "EndTime")?;
1195        let start_ts = parse_input_timestamp(&start)
1196            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1197        let end_ts = parse_input_timestamp(&end)
1198            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1199
1200        // Default ScanBy is TimestampDescending (newest first); callers read
1201        // Values[0] as the latest datapoint. The bucket map is ascending, so
1202        // reverse unless the caller asked for TimestampAscending.
1203        let descending = req
1204            .query_params
1205            .get("ScanBy")
1206            .map(|s| s != "TimestampAscending")
1207            .unwrap_or(true);
1208
1209        // GetMetricData declares only InvalidNextToken, so it never rejects an
1210        // empty / malformed query list with a 4xx — it returns empty results.
1211        let queries = collect_indexed(req, "MetricDataQueries");
1212
1213        let state = self.state.read();
1214
1215        // First pass: compute every MetricStat query into an aligned series so
1216        // later Expression queries can reference them by id.
1217        let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1218        for q in &queries {
1219            let id = q.get("Id").cloned().unwrap_or_default();
1220            let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1221                continue;
1222            };
1223            let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1224                continue;
1225            };
1226            let stat = q
1227                .get("MetricStat.Stat")
1228                .cloned()
1229                .unwrap_or_else(|| "Sum".to_string());
1230            let period: i64 = q
1231                .get("MetricStat.Period")
1232                .and_then(|s| s.parse::<i64>().ok())
1233                .filter(|p| *p > 0)
1234                .unwrap_or(60);
1235            let unit_filter = q.get("MetricStat.Unit").cloned();
1236            let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1237
1238            let mut series = crate::metric_math::Series::new();
1239            if let Some(acct) = state.get(&req.account_id) {
1240                if let Some(map) = acct.metrics_in(&req.region) {
1241                    if let Some(data) = map.get(namespace) {
1242                        let buckets = collect_metric_buckets(
1243                            data,
1244                            metric_name,
1245                            &dim_filter,
1246                            unit_filter.as_deref(),
1247                            period,
1248                            start_ts,
1249                            end_ts,
1250                        );
1251                        for (ts, bucket) in buckets {
1252                            let mut sorted = bucket.samples.clone();
1253                            sorted.sort_by(|a, b| {
1254                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1255                            });
1256                            if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1257                                series.insert(ts, v);
1258                            }
1259                        }
1260                    }
1261                }
1262            }
1263            series_by_id.insert(id, series);
1264        }
1265
1266        // Second pass: emit a result for each query that returns data (default
1267        // true), evaluating Expression queries against the computed series.
1268        let mut inner = String::from("<MetricDataResults>");
1269        for q in &queries {
1270            let id = q.get("Id").cloned().unwrap_or_default();
1271            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1272            let return_data = q
1273                .get("ReturnData")
1274                .map(|s| !s.eq_ignore_ascii_case("false"))
1275                .unwrap_or(true);
1276            if !return_data {
1277                continue;
1278            }
1279
1280            let mut error_message: Option<String> = None;
1281            let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1282                match crate::metric_math::evaluate(expr, &series_by_id) {
1283                    Ok(s) => s,
1284                    Err(e) => {
1285                        error_message = Some(e);
1286                        crate::metric_math::Series::new()
1287                    }
1288                }
1289            } else {
1290                series_by_id.get(&id).cloned().unwrap_or_default()
1291            };
1292
1293            let mut timestamps: Vec<String> = Vec::new();
1294            let mut values: Vec<f64> = Vec::new();
1295            for (ts, v) in series.iter() {
1296                // AWS emits no datapoint for a NaN/infinite result (e.g. a
1297                // metric-math divide-by-zero); dropping it here keeps NaN/Inf
1298                // off both the XML and JSON wire.
1299                if !v.is_finite() {
1300                    continue;
1301                }
1302                timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1303                values.push(*v);
1304            }
1305            if descending {
1306                timestamps.reverse();
1307                values.reverse();
1308            }
1309
1310            inner.push_str("<member>");
1311            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1312            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1313            inner.push_str("<Timestamps>");
1314            for ts in &timestamps {
1315                inner.push_str(&format!("<member>{ts}</member>"));
1316            }
1317            inner.push_str("</Timestamps>");
1318            inner.push_str("<Values>");
1319            for v in &values {
1320                inner.push_str(&format!("<member>{v}</member>"));
1321            }
1322            inner.push_str("</Values>");
1323            if let Some(msg) = error_message {
1324                inner.push_str("<StatusCode>InternalError</StatusCode>");
1325                inner.push_str("<Messages><member>");
1326                inner.push_str("<Code>Error</Code>");
1327                inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1328                inner.push_str("</member></Messages>");
1329            } else {
1330                inner.push_str("<StatusCode>Complete</StatusCode>");
1331            }
1332            inner.push_str("</member>");
1333        }
1334        inner.push_str("</MetricDataResults>");
1335        inner.push_str("<Messages></Messages>");
1336
1337        Ok(xml_response("GetMetricData", &inner, &req.request_id))
1338    }
1339
1340    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1341        // Only `AlarmName` is required by the Smithy contract; the op declares
1342        // no validation errors, so ComparisonOperator / EvaluationPeriods are
1343        // accepted with sensible defaults rather than rejected. Constraint
1344        // violations still produce a 4xx, which the probe accepts as AnyError
1345        // for the negative variants.
1346        validate_len(req, "AlarmName", 1, 255)?;
1347        validate_len(req, "AlarmDescription", 0, 1024)?;
1348        validate_len(req, "MetricName", 1, 255)?;
1349        validate_len(req, "Namespace", 1, 255)?;
1350        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1351        validate_len(req, "TreatMissingData", 1, 255)?;
1352        validate_len(req, "ThresholdMetricId", 1, 255)?;
1353        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1354        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1355        validate_range_i64(req, "Period", 1, i64::MAX)?;
1356        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1357        validate_enum(
1358            req,
1359            "ComparisonOperator",
1360            &[
1361                "GreaterThanOrEqualToThreshold",
1362                "GreaterThanThreshold",
1363                "GreaterThanUpperThreshold",
1364                "LessThanLowerOrGreaterThanUpperThreshold",
1365                "LessThanLowerThreshold",
1366                "LessThanOrEqualToThreshold",
1367                "LessThanThreshold",
1368            ],
1369        )?;
1370        validate_enum(
1371            req,
1372            "Statistic",
1373            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1374        )?;
1375        validate_enum(req, "Unit", STANDARD_UNITS)?;
1376        let alarm_name = required_query_param(req, "AlarmName")?;
1377        let comparison = optional_query_param(req, "ComparisonOperator")
1378            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1379        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1380            .and_then(|s| s.parse::<i64>().ok())
1381            .unwrap_or(1);
1382
1383        let alarm_description = optional_query_param(req, "AlarmDescription");
1384        let actions_enabled = optional_query_param(req, "ActionsEnabled")
1385            .map(|s| s.eq_ignore_ascii_case("true"))
1386            .unwrap_or(true);
1387
1388        let metric_name = optional_query_param(req, "MetricName");
1389        let namespace = optional_query_param(req, "Namespace");
1390        let statistic = optional_query_param(req, "Statistic");
1391        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1392        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1393        let unit = optional_query_param(req, "Unit");
1394        let datapoints_to_alarm =
1395            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1396        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1397        let treat_missing_data = optional_query_param(req, "TreatMissingData");
1398        let evaluate_low_sample_count_percentile =
1399            optional_query_param(req, "EvaluateLowSampleCountPercentile");
1400        // Anomaly-detection alarms reference a metric-math id instead of a
1401        // static Threshold; previously accepted then dropped (1.24).
1402        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1403        let dimensions = parse_dimensions_query(req, "Dimensions");
1404        // `Metrics` — the metric-math / cross-account alarm definition. Parsed
1405        // from the flat `Metrics.member.N.*` params and persisted so
1406        // DescribeAlarms can echo it back (previously silently dropped).
1407        let metrics = parse_alarm_metrics(req);
1408        // Inline `Tags` on PutMetricAlarm land in the same ARN-keyed tag store
1409        // as TagResource, so ListTagsForResource returns them.
1410        let inline_tags = parse_tags(req, "Tags");
1411
1412        let mut ok_actions = Vec::new();
1413        let mut alarm_actions = Vec::new();
1414        let mut insufficient_data_actions = Vec::new();
1415        for (k, v) in req.query_params.iter() {
1416            if k.starts_with("OKActions.member.") {
1417                ok_actions.push(v.clone());
1418            } else if k.starts_with("AlarmActions.member.") {
1419                alarm_actions.push(v.clone());
1420            } else if k.starts_with("InsufficientDataActions.member.") {
1421                insufficient_data_actions.push(v.clone());
1422            }
1423        }
1424
1425        let arn = format!(
1426            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1427            req.region, req.account_id, alarm_name
1428        );
1429        let now = Utc::now();
1430
1431        let mut state = self.state.write();
1432        let acct = state.get_or_create(&req.account_id);
1433        let alarms = acct.alarms_in_mut(&req.region);
1434        let existing = alarms.get(&alarm_name).cloned();
1435        let alarm = MetricAlarm {
1436            alarm_name: alarm_name.clone(),
1437            alarm_arn: arn,
1438            alarm_description,
1439            actions_enabled,
1440            ok_actions,
1441            alarm_actions,
1442            insufficient_data_actions,
1443            state_value: existing
1444                .as_ref()
1445                .map(|a| a.state_value)
1446                .unwrap_or(AlarmState::InsufficientData),
1447            state_reason: existing
1448                .as_ref()
1449                .map(|a| a.state_reason.clone())
1450                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1451            state_updated_timestamp: existing
1452                .as_ref()
1453                .map(|a| a.state_updated_timestamp)
1454                .unwrap_or(now),
1455            metric_name,
1456            namespace,
1457            statistic,
1458            extended_statistic,
1459            dimensions,
1460            period,
1461            unit,
1462            evaluation_periods,
1463            datapoints_to_alarm,
1464            threshold,
1465            comparison_operator: comparison,
1466            treat_missing_data,
1467            evaluate_low_sample_count_percentile,
1468            threshold_metric_id,
1469            configuration_updated_timestamp: existing
1470                .as_ref()
1471                .map(|a| a.configuration_updated_timestamp)
1472                .unwrap_or(now),
1473            alarm_configuration_updated_timestamp: now,
1474            metrics,
1475            // Preserve a prior manual override across a config update; a brand
1476            // new alarm has never been manually set.
1477            state_manually_set: existing
1478                .as_ref()
1479                .map(|a| a.state_manually_set)
1480                .unwrap_or(false),
1481        };
1482        let alarm_arn = alarm.alarm_arn.clone();
1483        let history_name = alarm_name.clone();
1484        let created = existing.is_none();
1485        alarms.insert(alarm_name, alarm);
1486
1487        // Persist inline Tags into the ARN-keyed tag store, but ONLY on create.
1488        // AWS ignores the inline Tags param when PutMetricAlarm updates an
1489        // existing alarm; tags on an existing alarm are managed via
1490        // TagResource / UntagResource.
1491        if created && !inline_tags.is_empty() {
1492            let bucket = acct.tags.entry(alarm_arn).or_default();
1493            for (k, v) in inline_tags {
1494                bucket.insert(k, v);
1495            }
1496        }
1497
1498        let summary = if created {
1499            format!("Alarm \"{history_name}\" created")
1500        } else {
1501            format!("Alarm \"{history_name}\" updated")
1502        };
1503        let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1504        push_alarm_history(
1505            acct,
1506            &req.region,
1507            &history_name,
1508            "MetricAlarm",
1509            "ConfigurationUpdate",
1510            summary,
1511            history_data,
1512        );
1513
1514        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1515    }
1516
1517    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1518        let mut filter_names: Vec<String> = Vec::new();
1519        for (k, v) in req.query_params.iter() {
1520            if k.starts_with("AlarmNames.member.") {
1521                filter_names.push(v.clone());
1522            }
1523        }
1524        // AWS defaults DescribeAlarms to MetricAlarm only; when AlarmTypes is
1525        // supplied it returns exactly the requested types (MetricAlarm and/or
1526        // CompositeAlarm). Absent -> metric alarms only.
1527        let alarm_types = collect_member_values(req, "AlarmTypes");
1528        for t in &alarm_types {
1529            if t != "MetricAlarm" && t != "CompositeAlarm" {
1530                return Err(invalid_param(format!(
1531                    "AlarmTypes has an invalid value '{t}'"
1532                )));
1533            }
1534        }
1535        let want_metric = alarm_types.is_empty() || alarm_types.iter().any(|t| t == "MetricAlarm");
1536        let want_composite = alarm_types.iter().any(|t| t == "CompositeAlarm");
1537        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1538        validate_len(req, "ActionPrefix", 1, 1024)?;
1539        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1540        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1541        validate_range_i64(req, "MaxRecords", 1, 100)?;
1542        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1543        let prefix = optional_query_param(req, "AlarmNamePrefix");
1544        let state_filter = optional_query_param(req, "StateValue");
1545        let action_prefix = optional_query_param(req, "ActionPrefix");
1546        // AWS caps DescribeAlarms at 100 records per page (MaxRecords range
1547        // 1..100) and round-trips a NextToken across the combined metric +
1548        // composite alarm result set.
1549        let max_records = optional_query_param(req, "MaxRecords")
1550            .and_then(|s| s.parse::<usize>().ok())
1551            .filter(|n| *n > 0)
1552            .unwrap_or(100);
1553        let offset = decode_offset_token(req.query_params.get("NextToken"));
1554
1555        // `false` = metric alarm, `true` = composite alarm; rendered lazily
1556        // after the slice so we only stringify the page.
1557        let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1558            if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1559                return false;
1560            }
1561            if let Some(p) = prefix.as_ref() {
1562                if !name.starts_with(p) {
1563                    return false;
1564                }
1565            }
1566            if let Some(want) = state_filter.as_ref() {
1567                if sv != want {
1568                    return false;
1569                }
1570            }
1571            if let Some(ap) = action_prefix.as_ref() {
1572                let any = actions
1573                    .iter()
1574                    .flat_map(|a| a.iter())
1575                    .any(|a| a.starts_with(ap));
1576                if !any {
1577                    return false;
1578                }
1579            }
1580            true
1581        };
1582
1583        // Recompute alarm states from the metric data (and composite rules)
1584        // before rendering, so a PutMetricData that crosses a threshold is
1585        // reflected here and a composite alarm mirrors its children.
1586        let mut state = self.state.write();
1587        if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1588            crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1589        }
1590        let mut combined: Vec<(bool, String)> = Vec::new();
1591        if let Some(acct) = state.get(&req.account_id) {
1592            if want_metric {
1593                if let Some(alarms) = acct.alarms_in(&req.region) {
1594                    for alarm in alarms.values() {
1595                        if matches(
1596                            &alarm.alarm_name,
1597                            alarm.state_value.as_str(),
1598                            [
1599                                &alarm.alarm_actions,
1600                                &alarm.ok_actions,
1601                                &alarm.insufficient_data_actions,
1602                            ],
1603                        ) {
1604                            combined.push((false, render_alarm(alarm)));
1605                        }
1606                    }
1607                }
1608            }
1609            if want_composite {
1610                if let Some(composites) = acct.composite_alarms_in(&req.region) {
1611                    for alarm in composites.values() {
1612                        if matches(
1613                            &alarm.alarm_name,
1614                            alarm.state_value.as_str(),
1615                            [
1616                                &alarm.alarm_actions,
1617                                &alarm.ok_actions,
1618                                &alarm.insufficient_data_actions,
1619                            ],
1620                        ) {
1621                            combined.push((
1622                                true,
1623                                crate::composite_alarms::render_composite_alarm(alarm),
1624                            ));
1625                        }
1626                    }
1627                }
1628            }
1629        }
1630
1631        let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1632        let mut inner = String::from("<MetricAlarms>");
1633        for (is_composite, body) in &page {
1634            if !*is_composite {
1635                inner.push_str(body);
1636            }
1637        }
1638        inner.push_str("</MetricAlarms>");
1639        inner.push_str("<CompositeAlarms>");
1640        for (is_composite, body) in &page {
1641            if *is_composite {
1642                inner.push_str(body);
1643            }
1644        }
1645        inner.push_str("</CompositeAlarms>");
1646        if offset + max_records < combined.len() {
1647            inner.push_str(&format!(
1648                "<NextToken>{}</NextToken>",
1649                encode_offset_token(offset + max_records)
1650            ));
1651        }
1652
1653        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1654    }
1655
1656    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1657        validate_len(req, "MetricName", 1, 255)?;
1658        validate_len(req, "Namespace", 1, 255)?;
1659        validate_range_i64(req, "Period", 1, i64::MAX)?;
1660        validate_enum(
1661            req,
1662            "Statistic",
1663            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1664        )?;
1665        validate_enum(req, "Unit", STANDARD_UNITS)?;
1666        let metric_name = required_query_param(req, "MetricName")?;
1667        let namespace = required_query_param(req, "Namespace")?;
1668        let dim_filter = parse_dimensions_query(req, "Dimensions");
1669
1670        // Recompute alarm states from stored metric data first, so this returns
1671        // the same fresh state DescribeAlarms would (a PutMetricData crossing a
1672        // threshold is reflected here rather than a stale stored value).
1673        {
1674            let mut state = self.state.write();
1675            if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1676                crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1677            }
1678        }
1679
1680        let state = self.state.read();
1681        let mut inner = String::from("<MetricAlarms>");
1682        if let Some(acct) = state.get(&req.account_id) {
1683            if let Some(alarms) = acct.alarms_in(&req.region) {
1684                for alarm in alarms.values() {
1685                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1686                        continue;
1687                    }
1688                    if alarm.namespace.as_deref() != Some(&namespace) {
1689                        continue;
1690                    }
1691                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1692                        continue;
1693                    }
1694                    inner.push_str(&render_alarm(alarm));
1695                }
1696            }
1697        }
1698        inner.push_str("</MetricAlarms>");
1699
1700        Ok(xml_response(
1701            "DescribeAlarmsForMetric",
1702            &inner,
1703            &req.request_id,
1704        ))
1705    }
1706
1707    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1708        // AlarmNames is required, but an empty list serialises to zero wire
1709        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1710        // set is a no-op rather than an undeclared 4xx.
1711        let mut names: Vec<String> = Vec::new();
1712        for (k, v) in req.query_params.iter() {
1713            if k.starts_with("AlarmNames.member.") {
1714                names.push(v.clone());
1715            }
1716        }
1717
1718        let mut state = self.state.write();
1719        let acct = state.get_or_create(&req.account_id);
1720        for name in &names {
1721            acct.alarms_in_mut(&req.region).remove(name);
1722            acct.composite_alarms_in_mut(&req.region).remove(name);
1723            // Alarm history is tied to the alarm; AWS drops it when the alarm
1724            // is deleted, so clear it here rather than orphan stale items.
1725            acct.alarm_history_in_mut(&req.region).remove(name);
1726        }
1727
1728        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1729    }
1730
1731    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1732        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1733    }
1734
1735    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1736        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1737    }
1738
1739    fn toggle_alarm_actions(
1740        &self,
1741        req: &AwsRequest,
1742        enabled: bool,
1743        action_name: &str,
1744    ) -> Result<AwsResponse, AwsServiceError> {
1745        let mut names: Vec<String> = Vec::new();
1746        for (k, v) in req.query_params.iter() {
1747            if k.starts_with("AlarmNames.member.") {
1748                names.push(v.clone());
1749            }
1750        }
1751        let mut state = self.state.write();
1752        let acct = state.get_or_create(&req.account_id);
1753        let alarms = acct.alarms_in_mut(&req.region);
1754        for name in names {
1755            if let Some(alarm) = alarms.get_mut(&name) {
1756                alarm.actions_enabled = enabled;
1757                alarm.alarm_configuration_updated_timestamp = Utc::now();
1758            }
1759        }
1760        Ok(empty_metadata_response(action_name, &req.request_id))
1761    }
1762
1763    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1764        validate_len(req, "AlarmName", 1, 255)?;
1765        validate_len(req, "StateReason", 0, 1023)?;
1766        validate_len(req, "StateReasonData", 0, 4000)?;
1767        let alarm_name = required_query_param(req, "AlarmName")?;
1768        let state_value = required_query_param(req, "StateValue")?;
1769        // StateReason is required but allows a zero-length value (min=0). Treat
1770        // an absent key as missing (declared error) while accepting an empty
1771        // string as a valid value.
1772        let state_reason = req
1773            .query_params
1774            .get("StateReason")
1775            .cloned()
1776            .ok_or_else(|| {
1777                AwsServiceError::aws_error(
1778                    StatusCode::BAD_REQUEST,
1779                    "MissingParameter",
1780                    "The request must contain the parameter StateReason.",
1781                )
1782            })?;
1783        let new_state = AlarmState::parse(&state_value)
1784            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1785
1786        let now = Utc::now();
1787        let mut state = self.state.write();
1788        let acct = state.get_or_create(&req.account_id);
1789        // SetAlarmState can target a metric alarm or a composite alarm; look up
1790        // the metric store first, then fall back to the composite store.
1791        let (old_state, alarm_type) =
1792            if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1793                let old = alarm.state_value.as_str().to_string();
1794                alarm.state_value = new_state;
1795                alarm.state_reason = state_reason.clone();
1796                alarm.state_updated_timestamp = now;
1797                // Mark this as a manual override so a later evaluation with no
1798                // datapoints keeps it rather than resetting to INSUFFICIENT_DATA.
1799                alarm.state_manually_set = true;
1800                (old, "MetricAlarm")
1801            } else if let Some(composite) = acct
1802                .composite_alarms_in_mut(&req.region)
1803                .get_mut(&alarm_name)
1804            {
1805                let old = composite.state_value.as_str().to_string();
1806                composite.state_value = new_state;
1807                composite.state_reason = state_reason.clone();
1808                composite.state_updated_timestamp = now;
1809                (old, "CompositeAlarm")
1810            } else {
1811                return Err(AwsServiceError::aws_error(
1812                    StatusCode::NOT_FOUND,
1813                    "ResourceNotFound",
1814                    format!("Alarm {alarm_name} not found"),
1815                ));
1816            };
1817
1818        let new_state_str = new_state.as_str().to_string();
1819        let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1820        let history_data = format!(
1821            "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1822            state_reason.replace('"', "\\\"")
1823        );
1824        push_alarm_history(
1825            acct,
1826            &req.region,
1827            &alarm_name,
1828            alarm_type,
1829            "StateUpdate",
1830            summary,
1831            history_data,
1832        );
1833
1834        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1835    }
1836
1837    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1838        validate_len(req, "AlarmName", 1, 255)?;
1839        validate_len(req, "AlarmContributorId", 1, 16)?;
1840        validate_range_i64(req, "MaxRecords", 1, 100)?;
1841        validate_enum(
1842            req,
1843            "HistoryItemType",
1844            &[
1845                "ConfigurationUpdate",
1846                "StateUpdate",
1847                "Action",
1848                "AlarmContributorStateUpdate",
1849                "AlarmContributorAction",
1850            ],
1851        )?;
1852        validate_enum(
1853            req,
1854            "ScanBy",
1855            &["TimestampDescending", "TimestampAscending"],
1856        )?;
1857        let alarm_filter = optional_query_param(req, "AlarmName");
1858        let type_filter = optional_query_param(req, "HistoryItemType");
1859        let start_date =
1860            optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1861        let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1862        // DescribeAlarmHistory defaults to TimestampDescending (newest first).
1863        let descending = req
1864            .query_params
1865            .get("ScanBy")
1866            .map(|s| s != "TimestampAscending")
1867            .unwrap_or(true);
1868        let max_records = optional_query_param(req, "MaxRecords")
1869            .and_then(|s| s.parse::<usize>().ok())
1870            .filter(|n| *n > 0)
1871            .unwrap_or(100);
1872        let offset = decode_offset_token(req.query_params.get("NextToken"));
1873
1874        let state = self.state.read();
1875        let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1876        if let Some(acct) = state.get(&req.account_id) {
1877            if let Some(history) = acct.alarm_history_in(&req.region) {
1878                for (name, list) in history.iter() {
1879                    if let Some(f) = alarm_filter.as_ref() {
1880                        if name != f {
1881                            continue;
1882                        }
1883                    }
1884                    for item in list.iter() {
1885                        if let Some(t) = type_filter.as_ref() {
1886                            if &item.history_item_type != t {
1887                                continue;
1888                            }
1889                        }
1890                        if let Some(sd) = start_date {
1891                            if item.timestamp < sd {
1892                                continue;
1893                            }
1894                        }
1895                        if let Some(ed) = end_date {
1896                            if item.timestamp > ed {
1897                                continue;
1898                            }
1899                        }
1900                        items.push(item);
1901                    }
1902                }
1903            }
1904        }
1905        items.sort_by_key(|i| i.timestamp);
1906        if descending {
1907            items.reverse();
1908        }
1909        let total = items.len();
1910        let page: Vec<&AlarmHistoryItem> =
1911            items.into_iter().skip(offset).take(max_records).collect();
1912
1913        let mut inner = String::from("<AlarmHistoryItems>");
1914        for item in page {
1915            inner.push_str("<member>");
1916            inner.push_str(&format!(
1917                "<AlarmName>{}</AlarmName>",
1918                xml_escape(&item.alarm_name)
1919            ));
1920            inner.push_str(&format!(
1921                "<AlarmType>{}</AlarmType>",
1922                xml_escape(&item.alarm_type)
1923            ));
1924            inner.push_str(&format!(
1925                "<Timestamp>{}</Timestamp>",
1926                item.timestamp
1927                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1928            ));
1929            inner.push_str(&format!(
1930                "<HistoryItemType>{}</HistoryItemType>",
1931                xml_escape(&item.history_item_type)
1932            ));
1933            inner.push_str(&format!(
1934                "<HistorySummary>{}</HistorySummary>",
1935                xml_escape(&item.history_summary)
1936            ));
1937            inner.push_str(&format!(
1938                "<HistoryData>{}</HistoryData>",
1939                xml_escape(&item.history_data)
1940            ));
1941            inner.push_str("</member>");
1942        }
1943        inner.push_str("</AlarmHistoryItems>");
1944        if offset + max_records < total {
1945            inner.push_str(&format!(
1946                "<NextToken>{}</NextToken>",
1947                encode_offset_token(offset + max_records)
1948            ));
1949        }
1950        Ok(xml_response(
1951            "DescribeAlarmHistory",
1952            &inner,
1953            &req.request_id,
1954        ))
1955    }
1956
1957    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1958        let dashboard_name = req
1959            .query_params
1960            .get("DashboardName")
1961            .ok_or_else(|| invalid_param("DashboardName is required"))?
1962            .clone();
1963        let body = req
1964            .query_params
1965            .get("DashboardBody")
1966            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1967            .clone();
1968        // AWS validates that DashboardBody parses as JSON; we do the same so
1969        // bad bodies surface a useful error before persisting.
1970        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1971            return Err(AwsServiceError::aws_error(
1972                StatusCode::BAD_REQUEST,
1973                "InvalidParameterInput",
1974                "DashboardBody must be a valid JSON object",
1975            ));
1976        }
1977        let arn = format!(
1978            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1979            req.account_id
1980        );
1981        let dashboard = Dashboard {
1982            name: dashboard_name.clone(),
1983            arn,
1984            size_bytes: body.len() as i64,
1985            body,
1986            last_modified: Utc::now(),
1987        };
1988        let mut state = self.state.write();
1989        let acct = state.get_or_create(&req.account_id);
1990        acct.dashboards.insert(dashboard_name, dashboard);
1991        // PutDashboard returns DashboardValidationMessages — empty when the
1992        // body parses cleanly.
1993        let inner = String::from("<DashboardValidationMessages/>");
1994        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1995    }
1996
1997    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1998        let name = req
1999            .query_params
2000            .get("DashboardName")
2001            .ok_or_else(|| invalid_param("DashboardName is required"))?
2002            .clone();
2003        let state = self.state.read();
2004        let dashboard = state
2005            .get(&req.account_id)
2006            .and_then(|a| a.dashboards.get(&name))
2007            .cloned()
2008            .ok_or_else(|| {
2009                AwsServiceError::aws_error(
2010                    StatusCode::NOT_FOUND,
2011                    "ResourceNotFound",
2012                    format!("Dashboard {name} does not exist"),
2013                )
2014            })?;
2015        let inner = format!(
2016            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
2017            xml_escape(&dashboard.arn),
2018            xml_escape(&dashboard.body),
2019            xml_escape(&dashboard.name),
2020        );
2021        Ok(xml_response("GetDashboard", &inner, &req.request_id))
2022    }
2023
2024    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2025        let mut names: Vec<String> = Vec::new();
2026        for (k, v) in req.query_params.iter() {
2027            if k.starts_with("DashboardNames.member.") {
2028                names.push(v.clone());
2029            }
2030        }
2031        if names.is_empty() {
2032            return Err(invalid_param(
2033                "DashboardNames must contain at least one name",
2034            ));
2035        }
2036        let mut state = self.state.write();
2037        let acct = state.get_or_create(&req.account_id);
2038        for n in names {
2039            acct.dashboards.remove(&n);
2040        }
2041        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
2042        // the AWS SDK fails to deserialize the response if the result node is
2043        // absent ("DeleteDashboardsResult node not found").
2044        let body = format!(
2045            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
2046             <DeleteDashboardsResponse xmlns=\"{NS}\">\
2047             <DeleteDashboardsResult/>\
2048             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
2049             </DeleteDashboardsResponse>",
2050            req.request_id
2051        );
2052        Ok(AwsResponse::xml(StatusCode::OK, body))
2053    }
2054
2055    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2056        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
2057        let state = self.state.read();
2058        let dashboards: Vec<Dashboard> = state
2059            .get(&req.account_id)
2060            .map(|a| {
2061                a.dashboards
2062                    .values()
2063                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
2064                    .cloned()
2065                    .collect()
2066            })
2067            .unwrap_or_default();
2068        let mut entries = String::new();
2069        for d in &dashboards {
2070            entries.push_str("<member>");
2071            entries.push_str(&format!(
2072                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
2073                xml_escape(&d.arn),
2074                xml_escape(&d.name),
2075                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2076                d.size_bytes,
2077            ));
2078            entries.push_str("</member>");
2079        }
2080        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
2081        Ok(xml_response("ListDashboards", &inner, &req.request_id))
2082    }
2083}
2084
2085/// Append an alarm-history record (newest appended last). Shared by
2086/// PutMetricAlarm, SetAlarmState and DeleteAlarms so DescribeAlarmHistory
2087/// reflects real lifecycle transitions.
2088pub(crate) fn push_alarm_history(
2089    acct: &mut crate::state::CloudWatchState,
2090    region: &str,
2091    alarm_name: &str,
2092    alarm_type: &str,
2093    history_item_type: &str,
2094    history_summary: String,
2095    history_data: String,
2096) {
2097    acct.alarm_history_in_mut(region)
2098        .entry(alarm_name.to_string())
2099        .or_default()
2100        .push(AlarmHistoryItem {
2101            alarm_name: alarm_name.to_string(),
2102            alarm_type: alarm_type.to_string(),
2103            timestamp: Utc::now(),
2104            history_item_type: history_item_type.to_string(),
2105            history_summary,
2106            history_data,
2107        });
2108}
2109
2110fn render_alarm(alarm: &MetricAlarm) -> String {
2111    let mut s = String::from("<member>");
2112    s.push_str(&format!(
2113        "<AlarmName>{}</AlarmName>",
2114        xml_escape(&alarm.alarm_name)
2115    ));
2116    s.push_str(&format!(
2117        "<AlarmArn>{}</AlarmArn>",
2118        xml_escape(&alarm.alarm_arn)
2119    ));
2120    if let Some(d) = &alarm.alarm_description {
2121        s.push_str(&format!(
2122            "<AlarmDescription>{}</AlarmDescription>",
2123            xml_escape(d)
2124        ));
2125    }
2126    s.push_str(&format!(
2127        "<ActionsEnabled>{}</ActionsEnabled>",
2128        alarm.actions_enabled
2129    ));
2130    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2131    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2132    push_action_list(
2133        &mut s,
2134        "InsufficientDataActions",
2135        &alarm.insufficient_data_actions,
2136    );
2137    s.push_str(&format!(
2138        "<StateValue>{}</StateValue>",
2139        alarm.state_value.as_str()
2140    ));
2141    s.push_str(&format!(
2142        "<StateReason>{}</StateReason>",
2143        xml_escape(&alarm.state_reason)
2144    ));
2145    s.push_str(&format!(
2146        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2147        alarm
2148            .state_updated_timestamp
2149            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2150    ));
2151    if let Some(m) = &alarm.metric_name {
2152        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2153    }
2154    if let Some(n) = &alarm.namespace {
2155        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2156    }
2157    if let Some(stat) = &alarm.statistic {
2158        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2159    }
2160    if let Some(ext) = &alarm.extended_statistic {
2161        s.push_str(&format!(
2162            "<ExtendedStatistic>{}</ExtendedStatistic>",
2163            xml_escape(ext)
2164        ));
2165    }
2166    s.push_str(&render_dimensions(&alarm.dimensions));
2167    if let Some(p) = alarm.period {
2168        s.push_str(&format!("<Period>{p}</Period>"));
2169    }
2170    if let Some(u) = &alarm.unit {
2171        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2172    }
2173    s.push_str(&format!(
2174        "<EvaluationPeriods>{}</EvaluationPeriods>",
2175        alarm.evaluation_periods
2176    ));
2177    if let Some(d) = alarm.datapoints_to_alarm {
2178        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2179    }
2180    if let Some(t) = alarm.threshold {
2181        s.push_str(&format!("<Threshold>{t}</Threshold>"));
2182    }
2183    if let Some(tid) = &alarm.threshold_metric_id {
2184        s.push_str(&format!(
2185            "<ThresholdMetricId>{}</ThresholdMetricId>",
2186            xml_escape(tid)
2187        ));
2188    }
2189    s.push_str(&format!(
2190        "<ComparisonOperator>{}</ComparisonOperator>",
2191        xml_escape(&alarm.comparison_operator)
2192    ));
2193    if let Some(t) = &alarm.treat_missing_data {
2194        s.push_str(&format!(
2195            "<TreatMissingData>{}</TreatMissingData>",
2196            xml_escape(t)
2197        ));
2198    }
2199    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2200        s.push_str(&format!(
2201            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2202            xml_escape(e)
2203        ));
2204    }
2205    s.push_str(&format!(
2206        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2207        alarm
2208            .alarm_configuration_updated_timestamp
2209            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2210    ));
2211    render_alarm_metrics(&mut s, &alarm.metrics);
2212    s.push_str("</member>");
2213    s
2214}
2215
2216/// Render the `Metrics` (metric-math / cross-account) list of a MetricAlarm.
2217fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2218    if metrics.is_empty() {
2219        return;
2220    }
2221    s.push_str("<Metrics>");
2222    for q in metrics {
2223        s.push_str("<member>");
2224        s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2225        if let Some(stat) = &q.metric_stat {
2226            s.push_str("<MetricStat>");
2227            s.push_str("<Metric>");
2228            if let Some(ns) = &stat.namespace {
2229                s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2230            }
2231            if let Some(mn) = &stat.metric_name {
2232                s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2233            }
2234            s.push_str(&render_dimensions(&stat.dimensions));
2235            s.push_str("</Metric>");
2236            if let Some(p) = stat.period {
2237                s.push_str(&format!("<Period>{p}</Period>"));
2238            }
2239            if let Some(st) = &stat.stat {
2240                s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2241            }
2242            if let Some(u) = &stat.unit {
2243                s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2244            }
2245            s.push_str("</MetricStat>");
2246        }
2247        if let Some(e) = &q.expression {
2248            s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2249        }
2250        if let Some(l) = &q.label {
2251            s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2252        }
2253        if let Some(rd) = q.return_data {
2254            s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2255        }
2256        if let Some(p) = q.period {
2257            s.push_str(&format!("<Period>{p}</Period>"));
2258        }
2259        if let Some(acct) = &q.account_id {
2260            s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2261        }
2262        s.push_str("</member>");
2263    }
2264    s.push_str("</Metrics>");
2265}
2266
2267fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2268    s.push_str(&format!("<{name}>"));
2269    for action in actions {
2270        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2271    }
2272    s.push_str(&format!("</{name}>"));
2273}
2274
2275#[cfg(test)]
2276mod xml_escape_tests {
2277    use super::xml_escape;
2278
2279    #[test]
2280    fn escapes_c0_control_chars_as_numeric_references() {
2281        // A raw C0 control byte (here a vertical tab, U+000B) is not a legal
2282        // XML 1.0 character. If it were passed through verbatim, a single
2283        // poisoned field would make the whole list-response document
2284        // unparseable by a strict SDK XML parser. The canonical escaper the
2285        // crate now uses must emit a numeric character reference instead.
2286        let out = xml_escape("a\u{0b}b");
2287        assert!(
2288            !out.contains('\u{0b}'),
2289            "raw control byte leaked into XML output: {out:?}"
2290        );
2291        assert_eq!(out, "a&#xB;b");
2292    }
2293
2294    #[test]
2295    fn still_escapes_the_five_standard_entities() {
2296        assert_eq!(
2297            xml_escape("a&b<c>d\"e'f"),
2298            "a&amp;b&lt;c&gt;d&quot;e&apos;f"
2299        );
2300    }
2301}