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