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