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