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