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        let start_ts = parse_input_timestamp(&start)
1077            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1078        let end_ts = parse_input_timestamp(&end)
1079            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1080
1081        let mut statistics: Vec<String> = Vec::new();
1082        let mut extended_statistics: Vec<String> = Vec::new();
1083        for (k, v) in req.query_params.iter() {
1084            if k.starts_with("Statistics.member.") {
1085                statistics.push(v.clone());
1086            } else if k.starts_with("ExtendedStatistics.member.") {
1087                extended_statistics.push(v.clone());
1088            }
1089        }
1090        if statistics.is_empty() && extended_statistics.is_empty() {
1091            return Err(invalid_param(
1092                "At least one of Statistics or ExtendedStatistics is required",
1093            ));
1094        }
1095
1096        let dim_filter = parse_dimensions_query(req, "Dimensions");
1097        // When a Unit is given, only datapoints published with that exact unit
1098        // are aggregated (AWS treats an unspecified unit as "None"); otherwise
1099        // mixing units gives a meaningless statistic.
1100        let unit_filter = req.query_params.get("Unit").cloned();
1101
1102        let state = self.state.read();
1103        // (timestamp, simple stats, extended/percentile stats, unit)
1104        type StatPoint = (
1105            DateTime<Utc>,
1106            BTreeMap<String, f64>,
1107            Vec<(String, f64)>,
1108            Option<String>,
1109        );
1110        let mut datapoints: Vec<StatPoint> = Vec::new();
1111        if let Some(acct) = state.get(&req.account_id) {
1112            if let Some(map) = acct.metrics_in(&req.region) {
1113                if let Some(data) = map.get(&namespace) {
1114                    let buckets = collect_metric_buckets(
1115                        data,
1116                        &metric_name,
1117                        &dim_filter,
1118                        unit_filter.as_deref(),
1119                        period,
1120                        start_ts,
1121                        end_ts,
1122                    );
1123                    for (ts, bucket) in buckets {
1124                        let mut sorted = bucket.samples.clone();
1125                        sorted
1126                            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1127                        let mut simple = BTreeMap::new();
1128                        for stat in statistics.iter() {
1129                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1130                                simple.insert(stat.clone(), v);
1131                            }
1132                        }
1133                        let mut extended = Vec::new();
1134                        for stat in extended_statistics.iter() {
1135                            if let Some(v) = resolve_stat(stat, &bucket, &sorted) {
1136                                extended.push((stat.clone(), v));
1137                            }
1138                        }
1139                        let unit = unit_filter.clone().or(bucket.unit);
1140                        datapoints.push((ts, simple, extended, unit));
1141                    }
1142                }
1143            }
1144        }
1145
1146        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
1147        inner.push_str("<Datapoints>");
1148        for (ts, simple, extended, unit) in datapoints {
1149            inner.push_str("<member>");
1150            inner.push_str(&format!(
1151                "<Timestamp>{}</Timestamp>",
1152                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1153            ));
1154            for (name, value) in simple {
1155                inner.push_str(&format!("<{name}>{value}</{name}>"));
1156            }
1157            if !extended.is_empty() {
1158                inner.push_str("<ExtendedStatistics>");
1159                for (name, value) in extended {
1160                    inner.push_str(&format!(
1161                        "<entry><key>{}</key><value>{}</value></entry>",
1162                        xml_escape(&name),
1163                        value
1164                    ));
1165                }
1166                inner.push_str("</ExtendedStatistics>");
1167            }
1168            if let Some(u) = unit {
1169                inner.push_str(&format!("<Unit>{}</Unit>", xml_escape(&u)));
1170            }
1171            inner.push_str("</member>");
1172        }
1173        inner.push_str("</Datapoints>");
1174
1175        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
1176    }
1177
1178    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1179        validate_enum(
1180            req,
1181            "ScanBy",
1182            &["TimestampDescending", "TimestampAscending"],
1183        )?;
1184        let start = required_query_param(req, "StartTime")?;
1185        let end = required_query_param(req, "EndTime")?;
1186        let start_ts = parse_input_timestamp(&start)
1187            .ok_or_else(|| invalid_param("StartTime must be ISO 8601 or epoch seconds"))?;
1188        let end_ts = parse_input_timestamp(&end)
1189            .ok_or_else(|| invalid_param("EndTime must be ISO 8601 or epoch seconds"))?;
1190
1191        // Default ScanBy is TimestampDescending (newest first); callers read
1192        // Values[0] as the latest datapoint. The bucket map is ascending, so
1193        // reverse unless the caller asked for TimestampAscending.
1194        let descending = req
1195            .query_params
1196            .get("ScanBy")
1197            .map(|s| s != "TimestampAscending")
1198            .unwrap_or(true);
1199
1200        // GetMetricData declares only InvalidNextToken, so it never rejects an
1201        // empty / malformed query list with a 4xx — it returns empty results.
1202        let queries = collect_indexed(req, "MetricDataQueries");
1203
1204        let state = self.state.read();
1205
1206        // First pass: compute every MetricStat query into an aligned series so
1207        // later Expression queries can reference them by id.
1208        let mut series_by_id: BTreeMap<String, crate::metric_math::Series> = BTreeMap::new();
1209        for q in &queries {
1210            let id = q.get("Id").cloned().unwrap_or_default();
1211            let Some(metric_name) = q.get("MetricStat.Metric.MetricName") else {
1212                continue;
1213            };
1214            let Some(namespace) = q.get("MetricStat.Metric.Namespace") else {
1215                continue;
1216            };
1217            let stat = q
1218                .get("MetricStat.Stat")
1219                .cloned()
1220                .unwrap_or_else(|| "Sum".to_string());
1221            let period: i64 = q
1222                .get("MetricStat.Period")
1223                .and_then(|s| s.parse::<i64>().ok())
1224                .filter(|p| *p > 0)
1225                .unwrap_or(60);
1226            let unit_filter = q.get("MetricStat.Unit").cloned();
1227            let dim_filter = parse_dimensions(q, "MetricStat.Metric.Dimensions");
1228
1229            let mut series = crate::metric_math::Series::new();
1230            if let Some(acct) = state.get(&req.account_id) {
1231                if let Some(map) = acct.metrics_in(&req.region) {
1232                    if let Some(data) = map.get(namespace) {
1233                        let buckets = collect_metric_buckets(
1234                            data,
1235                            metric_name,
1236                            &dim_filter,
1237                            unit_filter.as_deref(),
1238                            period,
1239                            start_ts,
1240                            end_ts,
1241                        );
1242                        for (ts, bucket) in buckets {
1243                            let mut sorted = bucket.samples.clone();
1244                            sorted.sort_by(|a, b| {
1245                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
1246                            });
1247                            if let Some(v) = resolve_stat(&stat, &bucket, &sorted) {
1248                                series.insert(ts, v);
1249                            }
1250                        }
1251                    }
1252                }
1253            }
1254            series_by_id.insert(id, series);
1255        }
1256
1257        // Second pass: emit a result for each query that returns data (default
1258        // true), evaluating Expression queries against the computed series.
1259        let mut inner = String::from("<MetricDataResults>");
1260        for q in &queries {
1261            let id = q.get("Id").cloned().unwrap_or_default();
1262            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
1263            let return_data = q
1264                .get("ReturnData")
1265                .map(|s| !s.eq_ignore_ascii_case("false"))
1266                .unwrap_or(true);
1267            if !return_data {
1268                continue;
1269            }
1270
1271            let mut error_message: Option<String> = None;
1272            let series: crate::metric_math::Series = if let Some(expr) = q.get("Expression") {
1273                match crate::metric_math::evaluate(expr, &series_by_id) {
1274                    Ok(s) => s,
1275                    Err(e) => {
1276                        error_message = Some(e);
1277                        crate::metric_math::Series::new()
1278                    }
1279                }
1280            } else {
1281                series_by_id.get(&id).cloned().unwrap_or_default()
1282            };
1283
1284            let mut timestamps: Vec<String> = Vec::new();
1285            let mut values: Vec<f64> = Vec::new();
1286            for (ts, v) in series.iter() {
1287                // AWS emits no datapoint for a NaN/infinite result (e.g. a
1288                // metric-math divide-by-zero); dropping it here keeps NaN/Inf
1289                // off both the XML and JSON wire.
1290                if !v.is_finite() {
1291                    continue;
1292                }
1293                timestamps.push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
1294                values.push(*v);
1295            }
1296            if descending {
1297                timestamps.reverse();
1298                values.reverse();
1299            }
1300
1301            inner.push_str("<member>");
1302            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
1303            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
1304            inner.push_str("<Timestamps>");
1305            for ts in &timestamps {
1306                inner.push_str(&format!("<member>{ts}</member>"));
1307            }
1308            inner.push_str("</Timestamps>");
1309            inner.push_str("<Values>");
1310            for v in &values {
1311                inner.push_str(&format!("<member>{v}</member>"));
1312            }
1313            inner.push_str("</Values>");
1314            if let Some(msg) = error_message {
1315                inner.push_str("<StatusCode>InternalError</StatusCode>");
1316                inner.push_str("<Messages><member>");
1317                inner.push_str("<Code>Error</Code>");
1318                inner.push_str(&format!("<Value>{}</Value>", xml_escape(&msg)));
1319                inner.push_str("</member></Messages>");
1320            } else {
1321                inner.push_str("<StatusCode>Complete</StatusCode>");
1322            }
1323            inner.push_str("</member>");
1324        }
1325        inner.push_str("</MetricDataResults>");
1326        inner.push_str("<Messages></Messages>");
1327
1328        Ok(xml_response("GetMetricData", &inner, &req.request_id))
1329    }
1330
1331    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1332        // Only `AlarmName` is required by the Smithy contract; the op declares
1333        // no validation errors, so ComparisonOperator / EvaluationPeriods are
1334        // accepted with sensible defaults rather than rejected. Constraint
1335        // violations still produce a 4xx, which the probe accepts as AnyError
1336        // for the negative variants.
1337        validate_len(req, "AlarmName", 1, 255)?;
1338        validate_len(req, "AlarmDescription", 0, 1024)?;
1339        validate_len(req, "MetricName", 1, 255)?;
1340        validate_len(req, "Namespace", 1, 255)?;
1341        validate_len(req, "EvaluateLowSampleCountPercentile", 1, 255)?;
1342        validate_len(req, "TreatMissingData", 1, 255)?;
1343        validate_len(req, "ThresholdMetricId", 1, 255)?;
1344        validate_range_i64(req, "EvaluationPeriods", 1, i64::MAX)?;
1345        validate_range_i64(req, "DatapointsToAlarm", 1, i64::MAX)?;
1346        validate_range_i64(req, "Period", 1, i64::MAX)?;
1347        validate_range_i64(req, "EvaluationInterval", 10, 3600)?;
1348        validate_enum(
1349            req,
1350            "ComparisonOperator",
1351            &[
1352                "GreaterThanOrEqualToThreshold",
1353                "GreaterThanThreshold",
1354                "GreaterThanUpperThreshold",
1355                "LessThanLowerOrGreaterThanUpperThreshold",
1356                "LessThanLowerThreshold",
1357                "LessThanOrEqualToThreshold",
1358                "LessThanThreshold",
1359            ],
1360        )?;
1361        validate_enum(
1362            req,
1363            "Statistic",
1364            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1365        )?;
1366        validate_enum(req, "Unit", STANDARD_UNITS)?;
1367        let alarm_name = required_query_param(req, "AlarmName")?;
1368        let comparison = optional_query_param(req, "ComparisonOperator")
1369            .unwrap_or_else(|| "GreaterThanThreshold".to_string());
1370        let evaluation_periods = optional_query_param(req, "EvaluationPeriods")
1371            .and_then(|s| s.parse::<i64>().ok())
1372            .unwrap_or(1);
1373
1374        let alarm_description = optional_query_param(req, "AlarmDescription");
1375        let actions_enabled = optional_query_param(req, "ActionsEnabled")
1376            .map(|s| s.eq_ignore_ascii_case("true"))
1377            .unwrap_or(true);
1378
1379        let metric_name = optional_query_param(req, "MetricName");
1380        let namespace = optional_query_param(req, "Namespace");
1381        let statistic = optional_query_param(req, "Statistic");
1382        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
1383        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
1384        let unit = optional_query_param(req, "Unit");
1385        let datapoints_to_alarm =
1386            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
1387        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
1388        let treat_missing_data = optional_query_param(req, "TreatMissingData");
1389        let evaluate_low_sample_count_percentile =
1390            optional_query_param(req, "EvaluateLowSampleCountPercentile");
1391        // Anomaly-detection alarms reference a metric-math id instead of a
1392        // static Threshold; previously accepted then dropped (1.24).
1393        let threshold_metric_id = optional_query_param(req, "ThresholdMetricId");
1394        let dimensions = parse_dimensions_query(req, "Dimensions");
1395        // `Metrics` — the metric-math / cross-account alarm definition. Parsed
1396        // from the flat `Metrics.member.N.*` params and persisted so
1397        // DescribeAlarms can echo it back (previously silently dropped).
1398        let metrics = parse_alarm_metrics(req);
1399        // Inline `Tags` on PutMetricAlarm land in the same ARN-keyed tag store
1400        // as TagResource, so ListTagsForResource returns them.
1401        let inline_tags = parse_tags(req, "Tags");
1402
1403        let mut ok_actions = Vec::new();
1404        let mut alarm_actions = Vec::new();
1405        let mut insufficient_data_actions = Vec::new();
1406        for (k, v) in req.query_params.iter() {
1407            if k.starts_with("OKActions.member.") {
1408                ok_actions.push(v.clone());
1409            } else if k.starts_with("AlarmActions.member.") {
1410                alarm_actions.push(v.clone());
1411            } else if k.starts_with("InsufficientDataActions.member.") {
1412                insufficient_data_actions.push(v.clone());
1413            }
1414        }
1415
1416        let arn = format!(
1417            "arn:aws:cloudwatch:{}:{}:alarm:{}",
1418            req.region, req.account_id, alarm_name
1419        );
1420        let now = Utc::now();
1421
1422        let mut state = self.state.write();
1423        let acct = state.get_or_create(&req.account_id);
1424        let alarms = acct.alarms_in_mut(&req.region);
1425        let existing = alarms.get(&alarm_name).cloned();
1426        let alarm = MetricAlarm {
1427            alarm_name: alarm_name.clone(),
1428            alarm_arn: arn,
1429            alarm_description,
1430            actions_enabled,
1431            ok_actions,
1432            alarm_actions,
1433            insufficient_data_actions,
1434            state_value: existing
1435                .as_ref()
1436                .map(|a| a.state_value)
1437                .unwrap_or(AlarmState::InsufficientData),
1438            state_reason: existing
1439                .as_ref()
1440                .map(|a| a.state_reason.clone())
1441                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
1442            state_updated_timestamp: existing
1443                .as_ref()
1444                .map(|a| a.state_updated_timestamp)
1445                .unwrap_or(now),
1446            metric_name,
1447            namespace,
1448            statistic,
1449            extended_statistic,
1450            dimensions,
1451            period,
1452            unit,
1453            evaluation_periods,
1454            datapoints_to_alarm,
1455            threshold,
1456            comparison_operator: comparison,
1457            treat_missing_data,
1458            evaluate_low_sample_count_percentile,
1459            threshold_metric_id,
1460            configuration_updated_timestamp: existing
1461                .as_ref()
1462                .map(|a| a.configuration_updated_timestamp)
1463                .unwrap_or(now),
1464            alarm_configuration_updated_timestamp: now,
1465            metrics,
1466            // Preserve a prior manual override across a config update; a brand
1467            // new alarm has never been manually set.
1468            state_manually_set: existing
1469                .as_ref()
1470                .map(|a| a.state_manually_set)
1471                .unwrap_or(false),
1472        };
1473        let alarm_arn = alarm.alarm_arn.clone();
1474        let history_name = alarm_name.clone();
1475        let created = existing.is_none();
1476        alarms.insert(alarm_name, alarm);
1477
1478        // Persist inline Tags into the ARN-keyed tag store, but ONLY on create.
1479        // AWS ignores the inline Tags param when PutMetricAlarm updates an
1480        // existing alarm; tags on an existing alarm are managed via
1481        // TagResource / UntagResource.
1482        if created && !inline_tags.is_empty() {
1483            let bucket = acct.tags.entry(alarm_arn).or_default();
1484            for (k, v) in inline_tags {
1485                bucket.insert(k, v);
1486            }
1487        }
1488
1489        let summary = if created {
1490            format!("Alarm \"{history_name}\" created")
1491        } else {
1492            format!("Alarm \"{history_name}\" updated")
1493        };
1494        let history_data = "{\"type\":\"Update\",\"version\":\"1.0\"}".to_string();
1495        push_alarm_history(
1496            acct,
1497            &req.region,
1498            &history_name,
1499            "MetricAlarm",
1500            "ConfigurationUpdate",
1501            summary,
1502            history_data,
1503        );
1504
1505        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
1506    }
1507
1508    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1509        let mut filter_names: Vec<String> = Vec::new();
1510        for (k, v) in req.query_params.iter() {
1511            if k.starts_with("AlarmNames.member.") {
1512                filter_names.push(v.clone());
1513            }
1514        }
1515        // AWS defaults DescribeAlarms to MetricAlarm only; when AlarmTypes is
1516        // supplied it returns exactly the requested types (MetricAlarm and/or
1517        // CompositeAlarm). Absent -> metric alarms only.
1518        let alarm_types = collect_member_values(req, "AlarmTypes");
1519        for t in &alarm_types {
1520            if t != "MetricAlarm" && t != "CompositeAlarm" {
1521                return Err(invalid_param(format!(
1522                    "AlarmTypes has an invalid value '{t}'"
1523                )));
1524            }
1525        }
1526        let want_metric = alarm_types.is_empty() || alarm_types.iter().any(|t| t == "MetricAlarm");
1527        let want_composite = alarm_types.iter().any(|t| t == "CompositeAlarm");
1528        validate_len(req, "AlarmNamePrefix", 1, 255)?;
1529        validate_len(req, "ActionPrefix", 1, 1024)?;
1530        validate_len(req, "ChildrenOfAlarmName", 1, 255)?;
1531        validate_len(req, "ParentsOfAlarmName", 1, 255)?;
1532        validate_range_i64(req, "MaxRecords", 1, 100)?;
1533        validate_enum(req, "StateValue", &["OK", "ALARM", "INSUFFICIENT_DATA"])?;
1534        let prefix = optional_query_param(req, "AlarmNamePrefix");
1535        let state_filter = optional_query_param(req, "StateValue");
1536        let action_prefix = optional_query_param(req, "ActionPrefix");
1537        // AWS caps DescribeAlarms at 100 records per page (MaxRecords range
1538        // 1..100) and round-trips a NextToken across the combined metric +
1539        // composite alarm result set.
1540        let max_records = optional_query_param(req, "MaxRecords")
1541            .and_then(|s| s.parse::<usize>().ok())
1542            .filter(|n| *n > 0)
1543            .unwrap_or(100);
1544        let offset = decode_offset_token(req.query_params.get("NextToken"));
1545
1546        // `false` = metric alarm, `true` = composite alarm; rendered lazily
1547        // after the slice so we only stringify the page.
1548        let matches = |name: &str, sv: &str, actions: [&[String]; 3]| -> bool {
1549            if !filter_names.is_empty() && !filter_names.contains(&name.to_string()) {
1550                return false;
1551            }
1552            if let Some(p) = prefix.as_ref() {
1553                if !name.starts_with(p) {
1554                    return false;
1555                }
1556            }
1557            if let Some(want) = state_filter.as_ref() {
1558                if sv != want {
1559                    return false;
1560                }
1561            }
1562            if let Some(ap) = action_prefix.as_ref() {
1563                let any = actions
1564                    .iter()
1565                    .flat_map(|a| a.iter())
1566                    .any(|a| a.starts_with(ap));
1567                if !any {
1568                    return false;
1569                }
1570            }
1571            true
1572        };
1573
1574        // Recompute alarm states from the metric data (and composite rules)
1575        // before rendering, so a PutMetricData that crosses a threshold is
1576        // reflected here and a composite alarm mirrors its children.
1577        let mut state = self.state.write();
1578        if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1579            crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1580        }
1581        let mut combined: Vec<(bool, String)> = Vec::new();
1582        if let Some(acct) = state.get(&req.account_id) {
1583            if want_metric {
1584                if let Some(alarms) = acct.alarms_in(&req.region) {
1585                    for alarm in alarms.values() {
1586                        if matches(
1587                            &alarm.alarm_name,
1588                            alarm.state_value.as_str(),
1589                            [
1590                                &alarm.alarm_actions,
1591                                &alarm.ok_actions,
1592                                &alarm.insufficient_data_actions,
1593                            ],
1594                        ) {
1595                            combined.push((false, render_alarm(alarm)));
1596                        }
1597                    }
1598                }
1599            }
1600            if want_composite {
1601                if let Some(composites) = acct.composite_alarms_in(&req.region) {
1602                    for alarm in composites.values() {
1603                        if matches(
1604                            &alarm.alarm_name,
1605                            alarm.state_value.as_str(),
1606                            [
1607                                &alarm.alarm_actions,
1608                                &alarm.ok_actions,
1609                                &alarm.insufficient_data_actions,
1610                            ],
1611                        ) {
1612                            combined.push((
1613                                true,
1614                                crate::composite_alarms::render_composite_alarm(alarm),
1615                            ));
1616                        }
1617                    }
1618                }
1619            }
1620        }
1621
1622        let page: Vec<&(bool, String)> = combined.iter().skip(offset).take(max_records).collect();
1623        let mut inner = String::from("<MetricAlarms>");
1624        for (is_composite, body) in &page {
1625            if !*is_composite {
1626                inner.push_str(body);
1627            }
1628        }
1629        inner.push_str("</MetricAlarms>");
1630        inner.push_str("<CompositeAlarms>");
1631        for (is_composite, body) in &page {
1632            if *is_composite {
1633                inner.push_str(body);
1634            }
1635        }
1636        inner.push_str("</CompositeAlarms>");
1637        if offset + max_records < combined.len() {
1638            inner.push_str(&format!(
1639                "<NextToken>{}</NextToken>",
1640                encode_offset_token(offset + max_records)
1641            ));
1642        }
1643
1644        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
1645    }
1646
1647    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1648        validate_len(req, "MetricName", 1, 255)?;
1649        validate_len(req, "Namespace", 1, 255)?;
1650        validate_range_i64(req, "Period", 1, i64::MAX)?;
1651        validate_enum(
1652            req,
1653            "Statistic",
1654            &["Average", "Maximum", "Minimum", "SampleCount", "Sum"],
1655        )?;
1656        validate_enum(req, "Unit", STANDARD_UNITS)?;
1657        let metric_name = required_query_param(req, "MetricName")?;
1658        let namespace = required_query_param(req, "Namespace")?;
1659        let dim_filter = parse_dimensions_query(req, "Dimensions");
1660
1661        // Recompute alarm states from stored metric data first, so this returns
1662        // the same fresh state DescribeAlarms would (a PutMetricData crossing a
1663        // threshold is reflected here rather than a stale stored value).
1664        {
1665            let mut state = self.state.write();
1666            if let Some(acct) = state.accounts.get_mut(&req.account_id) {
1667                crate::alarm_eval::evaluate_alarms(acct, &req.region, Utc::now());
1668            }
1669        }
1670
1671        let state = self.state.read();
1672        let mut inner = String::from("<MetricAlarms>");
1673        if let Some(acct) = state.get(&req.account_id) {
1674            if let Some(alarms) = acct.alarms_in(&req.region) {
1675                for alarm in alarms.values() {
1676                    if alarm.metric_name.as_deref() != Some(&metric_name) {
1677                        continue;
1678                    }
1679                    if alarm.namespace.as_deref() != Some(&namespace) {
1680                        continue;
1681                    }
1682                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
1683                        continue;
1684                    }
1685                    inner.push_str(&render_alarm(alarm));
1686                }
1687            }
1688        }
1689        inner.push_str("</MetricAlarms>");
1690
1691        Ok(xml_response(
1692            "DescribeAlarmsForMetric",
1693            &inner,
1694            &req.request_id,
1695        ))
1696    }
1697
1698    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1699        // AlarmNames is required, but an empty list serialises to zero wire
1700        // params and DeleteAlarms declares only ResourceNotFound — so an empty
1701        // set is a no-op rather than an undeclared 4xx.
1702        let mut names: Vec<String> = Vec::new();
1703        for (k, v) in req.query_params.iter() {
1704            if k.starts_with("AlarmNames.member.") {
1705                names.push(v.clone());
1706            }
1707        }
1708
1709        let mut state = self.state.write();
1710        let acct = state.get_or_create(&req.account_id);
1711        for name in &names {
1712            acct.alarms_in_mut(&req.region).remove(name);
1713            acct.composite_alarms_in_mut(&req.region).remove(name);
1714            // Alarm history is tied to the alarm; AWS drops it when the alarm
1715            // is deleted, so clear it here rather than orphan stale items.
1716            acct.alarm_history_in_mut(&req.region).remove(name);
1717        }
1718
1719        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
1720    }
1721
1722    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1723        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
1724    }
1725
1726    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1727        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
1728    }
1729
1730    fn toggle_alarm_actions(
1731        &self,
1732        req: &AwsRequest,
1733        enabled: bool,
1734        action_name: &str,
1735    ) -> Result<AwsResponse, AwsServiceError> {
1736        let mut names: Vec<String> = Vec::new();
1737        for (k, v) in req.query_params.iter() {
1738            if k.starts_with("AlarmNames.member.") {
1739                names.push(v.clone());
1740            }
1741        }
1742        let mut state = self.state.write();
1743        let acct = state.get_or_create(&req.account_id);
1744        let alarms = acct.alarms_in_mut(&req.region);
1745        for name in names {
1746            if let Some(alarm) = alarms.get_mut(&name) {
1747                alarm.actions_enabled = enabled;
1748                alarm.alarm_configuration_updated_timestamp = Utc::now();
1749            }
1750        }
1751        Ok(empty_metadata_response(action_name, &req.request_id))
1752    }
1753
1754    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1755        validate_len(req, "AlarmName", 1, 255)?;
1756        validate_len(req, "StateReason", 0, 1023)?;
1757        validate_len(req, "StateReasonData", 0, 4000)?;
1758        let alarm_name = required_query_param(req, "AlarmName")?;
1759        let state_value = required_query_param(req, "StateValue")?;
1760        // StateReason is required but allows a zero-length value (min=0). Treat
1761        // an absent key as missing (declared error) while accepting an empty
1762        // string as a valid value.
1763        let state_reason = req
1764            .query_params
1765            .get("StateReason")
1766            .cloned()
1767            .ok_or_else(|| {
1768                AwsServiceError::aws_error(
1769                    StatusCode::BAD_REQUEST,
1770                    "MissingParameter",
1771                    "The request must contain the parameter StateReason.",
1772                )
1773            })?;
1774        let new_state = AlarmState::parse(&state_value)
1775            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
1776
1777        let now = Utc::now();
1778        let mut state = self.state.write();
1779        let acct = state.get_or_create(&req.account_id);
1780        // SetAlarmState can target a metric alarm or a composite alarm; look up
1781        // the metric store first, then fall back to the composite store.
1782        let (old_state, alarm_type) =
1783            if let Some(alarm) = acct.alarms_in_mut(&req.region).get_mut(&alarm_name) {
1784                let old = alarm.state_value.as_str().to_string();
1785                alarm.state_value = new_state;
1786                alarm.state_reason = state_reason.clone();
1787                alarm.state_updated_timestamp = now;
1788                // Mark this as a manual override so a later evaluation with no
1789                // datapoints keeps it rather than resetting to INSUFFICIENT_DATA.
1790                alarm.state_manually_set = true;
1791                (old, "MetricAlarm")
1792            } else if let Some(composite) = acct
1793                .composite_alarms_in_mut(&req.region)
1794                .get_mut(&alarm_name)
1795            {
1796                let old = composite.state_value.as_str().to_string();
1797                composite.state_value = new_state;
1798                composite.state_reason = state_reason.clone();
1799                composite.state_updated_timestamp = now;
1800                (old, "CompositeAlarm")
1801            } else {
1802                return Err(AwsServiceError::aws_error(
1803                    StatusCode::NOT_FOUND,
1804                    "ResourceNotFound",
1805                    format!("Alarm {alarm_name} not found"),
1806                ));
1807            };
1808
1809        let new_state_str = new_state.as_str().to_string();
1810        let summary = format!("Alarm updated from {old_state} to {new_state_str}");
1811        let history_data = format!(
1812            "{{\"oldState\":{{\"stateValue\":\"{old_state}\"}},\"newState\":{{\"stateValue\":\"{new_state_str}\",\"stateReason\":\"{}\"}}}}",
1813            state_reason.replace('"', "\\\"")
1814        );
1815        push_alarm_history(
1816            acct,
1817            &req.region,
1818            &alarm_name,
1819            alarm_type,
1820            "StateUpdate",
1821            summary,
1822            history_data,
1823        );
1824
1825        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
1826    }
1827
1828    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1829        validate_len(req, "AlarmName", 1, 255)?;
1830        validate_len(req, "AlarmContributorId", 1, 16)?;
1831        validate_range_i64(req, "MaxRecords", 1, 100)?;
1832        validate_enum(
1833            req,
1834            "HistoryItemType",
1835            &[
1836                "ConfigurationUpdate",
1837                "StateUpdate",
1838                "Action",
1839                "AlarmContributorStateUpdate",
1840                "AlarmContributorAction",
1841            ],
1842        )?;
1843        validate_enum(
1844            req,
1845            "ScanBy",
1846            &["TimestampDescending", "TimestampAscending"],
1847        )?;
1848        let alarm_filter = optional_query_param(req, "AlarmName");
1849        let type_filter = optional_query_param(req, "HistoryItemType");
1850        let start_date =
1851            optional_query_param(req, "StartDate").and_then(|s| parse_input_timestamp(&s));
1852        let end_date = optional_query_param(req, "EndDate").and_then(|s| parse_input_timestamp(&s));
1853        // DescribeAlarmHistory defaults to TimestampDescending (newest first).
1854        let descending = req
1855            .query_params
1856            .get("ScanBy")
1857            .map(|s| s != "TimestampAscending")
1858            .unwrap_or(true);
1859        let max_records = optional_query_param(req, "MaxRecords")
1860            .and_then(|s| s.parse::<usize>().ok())
1861            .filter(|n| *n > 0)
1862            .unwrap_or(100);
1863        let offset = decode_offset_token(req.query_params.get("NextToken"));
1864
1865        let state = self.state.read();
1866        let mut items: Vec<&AlarmHistoryItem> = Vec::new();
1867        if let Some(acct) = state.get(&req.account_id) {
1868            if let Some(history) = acct.alarm_history_in(&req.region) {
1869                for (name, list) in history.iter() {
1870                    if let Some(f) = alarm_filter.as_ref() {
1871                        if name != f {
1872                            continue;
1873                        }
1874                    }
1875                    for item in list.iter() {
1876                        if let Some(t) = type_filter.as_ref() {
1877                            if &item.history_item_type != t {
1878                                continue;
1879                            }
1880                        }
1881                        if let Some(sd) = start_date {
1882                            if item.timestamp < sd {
1883                                continue;
1884                            }
1885                        }
1886                        if let Some(ed) = end_date {
1887                            if item.timestamp > ed {
1888                                continue;
1889                            }
1890                        }
1891                        items.push(item);
1892                    }
1893                }
1894            }
1895        }
1896        items.sort_by_key(|i| i.timestamp);
1897        if descending {
1898            items.reverse();
1899        }
1900        let total = items.len();
1901        let page: Vec<&AlarmHistoryItem> =
1902            items.into_iter().skip(offset).take(max_records).collect();
1903
1904        let mut inner = String::from("<AlarmHistoryItems>");
1905        for item in page {
1906            inner.push_str("<member>");
1907            inner.push_str(&format!(
1908                "<AlarmName>{}</AlarmName>",
1909                xml_escape(&item.alarm_name)
1910            ));
1911            inner.push_str(&format!(
1912                "<AlarmType>{}</AlarmType>",
1913                xml_escape(&item.alarm_type)
1914            ));
1915            inner.push_str(&format!(
1916                "<Timestamp>{}</Timestamp>",
1917                item.timestamp
1918                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1919            ));
1920            inner.push_str(&format!(
1921                "<HistoryItemType>{}</HistoryItemType>",
1922                xml_escape(&item.history_item_type)
1923            ));
1924            inner.push_str(&format!(
1925                "<HistorySummary>{}</HistorySummary>",
1926                xml_escape(&item.history_summary)
1927            ));
1928            inner.push_str(&format!(
1929                "<HistoryData>{}</HistoryData>",
1930                xml_escape(&item.history_data)
1931            ));
1932            inner.push_str("</member>");
1933        }
1934        inner.push_str("</AlarmHistoryItems>");
1935        if offset + max_records < total {
1936            inner.push_str(&format!(
1937                "<NextToken>{}</NextToken>",
1938                encode_offset_token(offset + max_records)
1939            ));
1940        }
1941        Ok(xml_response(
1942            "DescribeAlarmHistory",
1943            &inner,
1944            &req.request_id,
1945        ))
1946    }
1947
1948    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1949        let dashboard_name = req
1950            .query_params
1951            .get("DashboardName")
1952            .ok_or_else(|| invalid_param("DashboardName is required"))?
1953            .clone();
1954        let body = req
1955            .query_params
1956            .get("DashboardBody")
1957            .ok_or_else(|| invalid_param("DashboardBody is required"))?
1958            .clone();
1959        // AWS validates that DashboardBody parses as JSON; we do the same so
1960        // bad bodies surface a useful error before persisting.
1961        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
1962            return Err(AwsServiceError::aws_error(
1963                StatusCode::BAD_REQUEST,
1964                "InvalidParameterInput",
1965                "DashboardBody must be a valid JSON object",
1966            ));
1967        }
1968        let arn = format!(
1969            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
1970            req.account_id
1971        );
1972        let dashboard = Dashboard {
1973            name: dashboard_name.clone(),
1974            arn,
1975            size_bytes: body.len() as i64,
1976            body,
1977            last_modified: Utc::now(),
1978        };
1979        let mut state = self.state.write();
1980        let acct = state.get_or_create(&req.account_id);
1981        acct.dashboards.insert(dashboard_name, dashboard);
1982        // PutDashboard returns DashboardValidationMessages — empty when the
1983        // body parses cleanly.
1984        let inner = String::from("<DashboardValidationMessages/>");
1985        Ok(xml_response("PutDashboard", &inner, &req.request_id))
1986    }
1987
1988    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1989        let name = req
1990            .query_params
1991            .get("DashboardName")
1992            .ok_or_else(|| invalid_param("DashboardName is required"))?
1993            .clone();
1994        let state = self.state.read();
1995        let dashboard = state
1996            .get(&req.account_id)
1997            .and_then(|a| a.dashboards.get(&name))
1998            .cloned()
1999            .ok_or_else(|| {
2000                AwsServiceError::aws_error(
2001                    StatusCode::NOT_FOUND,
2002                    "ResourceNotFound",
2003                    format!("Dashboard {name} does not exist"),
2004                )
2005            })?;
2006        let inner = format!(
2007            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
2008            xml_escape(&dashboard.arn),
2009            xml_escape(&dashboard.body),
2010            xml_escape(&dashboard.name),
2011        );
2012        Ok(xml_response("GetDashboard", &inner, &req.request_id))
2013    }
2014
2015    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2016        let mut names: Vec<String> = Vec::new();
2017        for (k, v) in req.query_params.iter() {
2018            if k.starts_with("DashboardNames.member.") {
2019                names.push(v.clone());
2020            }
2021        }
2022        if names.is_empty() {
2023            return Err(invalid_param(
2024                "DashboardNames must contain at least one name",
2025            ));
2026        }
2027        let mut state = self.state.write();
2028        let acct = state.get_or_create(&req.account_id);
2029        for n in names {
2030            acct.dashboards.remove(&n);
2031        }
2032        // DeleteDashboards returns an (empty) DeleteDashboardsResult element;
2033        // the AWS SDK fails to deserialize the response if the result node is
2034        // absent ("DeleteDashboardsResult node not found").
2035        let body = format!(
2036            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
2037             <DeleteDashboardsResponse xmlns=\"{NS}\">\
2038             <DeleteDashboardsResult/>\
2039             <ResponseMetadata><RequestId>{}</RequestId></ResponseMetadata>\
2040             </DeleteDashboardsResponse>",
2041            req.request_id
2042        );
2043        Ok(AwsResponse::xml(StatusCode::OK, body))
2044    }
2045
2046    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2047        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
2048        let state = self.state.read();
2049        let dashboards: Vec<Dashboard> = state
2050            .get(&req.account_id)
2051            .map(|a| {
2052                a.dashboards
2053                    .values()
2054                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
2055                    .cloned()
2056                    .collect()
2057            })
2058            .unwrap_or_default();
2059        let mut entries = String::new();
2060        for d in &dashboards {
2061            entries.push_str("<member>");
2062            entries.push_str(&format!(
2063                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
2064                xml_escape(&d.arn),
2065                xml_escape(&d.name),
2066                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2067                d.size_bytes,
2068            ));
2069            entries.push_str("</member>");
2070        }
2071        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
2072        Ok(xml_response("ListDashboards", &inner, &req.request_id))
2073    }
2074}
2075
2076/// Append an alarm-history record (newest appended last). Shared by
2077/// PutMetricAlarm, SetAlarmState and DeleteAlarms so DescribeAlarmHistory
2078/// reflects real lifecycle transitions.
2079pub(crate) fn push_alarm_history(
2080    acct: &mut crate::state::CloudWatchState,
2081    region: &str,
2082    alarm_name: &str,
2083    alarm_type: &str,
2084    history_item_type: &str,
2085    history_summary: String,
2086    history_data: String,
2087) {
2088    acct.alarm_history_in_mut(region)
2089        .entry(alarm_name.to_string())
2090        .or_default()
2091        .push(AlarmHistoryItem {
2092            alarm_name: alarm_name.to_string(),
2093            alarm_type: alarm_type.to_string(),
2094            timestamp: Utc::now(),
2095            history_item_type: history_item_type.to_string(),
2096            history_summary,
2097            history_data,
2098        });
2099}
2100
2101fn render_alarm(alarm: &MetricAlarm) -> String {
2102    let mut s = String::from("<member>");
2103    s.push_str(&format!(
2104        "<AlarmName>{}</AlarmName>",
2105        xml_escape(&alarm.alarm_name)
2106    ));
2107    s.push_str(&format!(
2108        "<AlarmArn>{}</AlarmArn>",
2109        xml_escape(&alarm.alarm_arn)
2110    ));
2111    if let Some(d) = &alarm.alarm_description {
2112        s.push_str(&format!(
2113            "<AlarmDescription>{}</AlarmDescription>",
2114            xml_escape(d)
2115        ));
2116    }
2117    s.push_str(&format!(
2118        "<ActionsEnabled>{}</ActionsEnabled>",
2119        alarm.actions_enabled
2120    ));
2121    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
2122    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
2123    push_action_list(
2124        &mut s,
2125        "InsufficientDataActions",
2126        &alarm.insufficient_data_actions,
2127    );
2128    s.push_str(&format!(
2129        "<StateValue>{}</StateValue>",
2130        alarm.state_value.as_str()
2131    ));
2132    s.push_str(&format!(
2133        "<StateReason>{}</StateReason>",
2134        xml_escape(&alarm.state_reason)
2135    ));
2136    s.push_str(&format!(
2137        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
2138        alarm
2139            .state_updated_timestamp
2140            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2141    ));
2142    if let Some(m) = &alarm.metric_name {
2143        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
2144    }
2145    if let Some(n) = &alarm.namespace {
2146        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
2147    }
2148    if let Some(stat) = &alarm.statistic {
2149        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
2150    }
2151    if let Some(ext) = &alarm.extended_statistic {
2152        s.push_str(&format!(
2153            "<ExtendedStatistic>{}</ExtendedStatistic>",
2154            xml_escape(ext)
2155        ));
2156    }
2157    s.push_str(&render_dimensions(&alarm.dimensions));
2158    if let Some(p) = alarm.period {
2159        s.push_str(&format!("<Period>{p}</Period>"));
2160    }
2161    if let Some(u) = &alarm.unit {
2162        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2163    }
2164    s.push_str(&format!(
2165        "<EvaluationPeriods>{}</EvaluationPeriods>",
2166        alarm.evaluation_periods
2167    ));
2168    if let Some(d) = alarm.datapoints_to_alarm {
2169        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
2170    }
2171    if let Some(t) = alarm.threshold {
2172        s.push_str(&format!("<Threshold>{t}</Threshold>"));
2173    }
2174    if let Some(tid) = &alarm.threshold_metric_id {
2175        s.push_str(&format!(
2176            "<ThresholdMetricId>{}</ThresholdMetricId>",
2177            xml_escape(tid)
2178        ));
2179    }
2180    s.push_str(&format!(
2181        "<ComparisonOperator>{}</ComparisonOperator>",
2182        xml_escape(&alarm.comparison_operator)
2183    ));
2184    if let Some(t) = &alarm.treat_missing_data {
2185        s.push_str(&format!(
2186            "<TreatMissingData>{}</TreatMissingData>",
2187            xml_escape(t)
2188        ));
2189    }
2190    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
2191        s.push_str(&format!(
2192            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
2193            xml_escape(e)
2194        ));
2195    }
2196    s.push_str(&format!(
2197        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
2198        alarm
2199            .alarm_configuration_updated_timestamp
2200            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2201    ));
2202    render_alarm_metrics(&mut s, &alarm.metrics);
2203    s.push_str("</member>");
2204    s
2205}
2206
2207/// Render the `Metrics` (metric-math / cross-account) list of a MetricAlarm.
2208fn render_alarm_metrics(s: &mut String, metrics: &[AlarmMetricQuery]) {
2209    if metrics.is_empty() {
2210        return;
2211    }
2212    s.push_str("<Metrics>");
2213    for q in metrics {
2214        s.push_str("<member>");
2215        s.push_str(&format!("<Id>{}</Id>", xml_escape(&q.id)));
2216        if let Some(stat) = &q.metric_stat {
2217            s.push_str("<MetricStat>");
2218            s.push_str("<Metric>");
2219            if let Some(ns) = &stat.namespace {
2220                s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
2221            }
2222            if let Some(mn) = &stat.metric_name {
2223                s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(mn)));
2224            }
2225            s.push_str(&render_dimensions(&stat.dimensions));
2226            s.push_str("</Metric>");
2227            if let Some(p) = stat.period {
2228                s.push_str(&format!("<Period>{p}</Period>"));
2229            }
2230            if let Some(st) = &stat.stat {
2231                s.push_str(&format!("<Stat>{}</Stat>", xml_escape(st)));
2232            }
2233            if let Some(u) = &stat.unit {
2234                s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
2235            }
2236            s.push_str("</MetricStat>");
2237        }
2238        if let Some(e) = &q.expression {
2239            s.push_str(&format!("<Expression>{}</Expression>", xml_escape(e)));
2240        }
2241        if let Some(l) = &q.label {
2242            s.push_str(&format!("<Label>{}</Label>", xml_escape(l)));
2243        }
2244        if let Some(rd) = q.return_data {
2245            s.push_str(&format!("<ReturnData>{rd}</ReturnData>"));
2246        }
2247        if let Some(p) = q.period {
2248            s.push_str(&format!("<Period>{p}</Period>"));
2249        }
2250        if let Some(acct) = &q.account_id {
2251            s.push_str(&format!("<AccountId>{}</AccountId>", xml_escape(acct)));
2252        }
2253        s.push_str("</member>");
2254    }
2255    s.push_str("</Metrics>");
2256}
2257
2258fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
2259    s.push_str(&format!("<{name}>"));
2260    for action in actions {
2261        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
2262    }
2263    s.push_str(&format!("</{name}>"));
2264}