Skip to main content

fakecloud_cloudwatch/
service.rs

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