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