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 crate::state::{
13    AlarmState, Dashboard, MetricAlarm, MetricDatum, SharedCloudWatchState, StatisticSet,
14};
15
16const NS: &str = "http://monitoring.amazonaws.com/doc/2010-08-01/";
17
18const SUPPORTED_ACTIONS: &[&str] = &[
19    "PutMetricData",
20    "GetMetricStatistics",
21    "GetMetricData",
22    "ListMetrics",
23    "PutMetricAlarm",
24    "DescribeAlarms",
25    "DescribeAlarmsForMetric",
26    "DeleteAlarms",
27    "EnableAlarmActions",
28    "DisableAlarmActions",
29    "SetAlarmState",
30    "DescribeAlarmHistory",
31];
32
33pub struct CloudWatchService {
34    state: SharedCloudWatchState,
35}
36
37impl CloudWatchService {
38    pub fn new(state: SharedCloudWatchState) -> Self {
39        Self { state }
40    }
41}
42
43#[async_trait]
44impl AwsService for CloudWatchService {
45    fn service_name(&self) -> &str {
46        "monitoring"
47    }
48
49    fn supported_actions(&self) -> &[&str] {
50        SUPPORTED_ACTIONS
51    }
52
53    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
54        match req.action.as_str() {
55            "PutMetricData" => self.put_metric_data(&req),
56            "GetMetricStatistics" => self.get_metric_statistics(&req),
57            "GetMetricData" => self.get_metric_data(&req),
58            "ListMetrics" => self.list_metrics(&req),
59            "PutMetricAlarm" => self.put_metric_alarm(&req),
60            "DescribeAlarms" => self.describe_alarms(&req),
61            "DescribeAlarmsForMetric" => self.describe_alarms_for_metric(&req),
62            "DeleteAlarms" => self.delete_alarms(&req),
63            "EnableAlarmActions" => self.enable_alarm_actions(&req),
64            "DisableAlarmActions" => self.disable_alarm_actions(&req),
65            "SetAlarmState" => self.set_alarm_state(&req),
66            "DescribeAlarmHistory" => self.describe_alarm_history(&req),
67            "PutDashboard" => self.put_dashboard(&req),
68            "GetDashboard" => self.get_dashboard(&req),
69            "DeleteDashboards" => self.delete_dashboards(&req),
70            "ListDashboards" => self.list_dashboards(&req),
71            _ => Err(AwsServiceError::action_not_implemented(
72                "monitoring",
73                &req.action,
74            )),
75        }
76    }
77}
78
79fn xml_response(action: &str, inner: &str, request_id: &str) -> AwsResponse {
80    AwsResponse::xml(
81        StatusCode::OK,
82        query_response_xml(action, NS, inner, request_id),
83    )
84}
85
86fn empty_metadata_response(action: &str, request_id: &str) -> AwsResponse {
87    AwsResponse::xml(
88        StatusCode::OK,
89        query_metadata_only_xml(action, NS, request_id),
90    )
91}
92
93fn invalid_param(message: impl Into<String>) -> AwsServiceError {
94    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterValue", message)
95}
96
97fn collect_indexed(req: &AwsRequest, prefix: &str) -> Vec<HashMap<String, String>> {
98    let mut by_index: BTreeMap<u32, HashMap<String, String>> = BTreeMap::new();
99    let needle = format!("{prefix}.member.");
100    for (k, v) in req.query_params.iter() {
101        let Some(rest) = k.strip_prefix(&needle) else {
102            continue;
103        };
104        let mut parts = rest.splitn(2, '.');
105        let Some(idx_str) = parts.next() else {
106            continue;
107        };
108        let Ok(idx) = idx_str.parse::<u32>() else {
109            continue;
110        };
111        let field = parts.next().unwrap_or("").to_string();
112        by_index.entry(idx).or_default().insert(field, v.clone());
113    }
114    by_index.into_values().collect()
115}
116
117fn parse_dimensions(member: &HashMap<String, String>, prefix: &str) -> BTreeMap<String, String> {
118    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
119    let needle = format!("{prefix}.member.");
120    for (k, v) in member.iter() {
121        let Some(rest) = k.strip_prefix(&needle) else {
122            continue;
123        };
124        let mut parts = rest.splitn(2, '.');
125        let Some(idx_str) = parts.next() else {
126            continue;
127        };
128        let Ok(idx) = idx_str.parse::<u32>() else {
129            continue;
130        };
131        let field = parts.next().unwrap_or("");
132        let entry = dims.entry(idx).or_default();
133        match field {
134            "Name" => entry.0 = Some(v.clone()),
135            "Value" => entry.1 = Some(v.clone()),
136            _ => {}
137        }
138    }
139    let mut out = BTreeMap::new();
140    for (_, (name, value)) in dims {
141        if let (Some(n), Some(v)) = (name, value) {
142            out.insert(n, v);
143        }
144    }
145    out
146}
147
148fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap<String, String> {
149    let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
150    let needle = format!("{prefix}.member.");
151    for (k, v) in req.query_params.iter() {
152        let Some(rest) = k.strip_prefix(&needle) else {
153            continue;
154        };
155        let mut parts = rest.splitn(2, '.');
156        let Some(idx_str) = parts.next() else {
157            continue;
158        };
159        let Ok(idx) = idx_str.parse::<u32>() else {
160            continue;
161        };
162        let field = parts.next().unwrap_or("");
163        let entry = dims.entry(idx).or_default();
164        match field {
165            "Name" => entry.0 = Some(v.clone()),
166            "Value" => entry.1 = Some(v.clone()),
167            _ => {}
168        }
169    }
170    let mut out = BTreeMap::new();
171    for (_, (name, value)) in dims {
172        if let (Some(n), Some(v)) = (name, value) {
173            out.insert(n, v);
174        }
175    }
176    out
177}
178
179fn xml_escape(s: &str) -> String {
180    s.replace('&', "&amp;")
181        .replace('<', "&lt;")
182        .replace('>', "&gt;")
183        .replace('"', "&quot;")
184        .replace('\'', "&apos;")
185}
186
187/// Per-datapoint aggregation summary covering both the simple `Value` form
188/// and the `StatisticValues` form so callers don't lose the count or
189/// min/max baked into a `StatisticSet`.
190#[derive(Clone, Copy)]
191struct DatumStats {
192    sum: f64,
193    min: f64,
194    max: f64,
195    count: f64,
196}
197
198fn datum_stats(d: &MetricDatum) -> Option<DatumStats> {
199    if let Some(v) = d.value {
200        return Some(DatumStats {
201            sum: v,
202            min: v,
203            max: v,
204            count: 1.0,
205        });
206    }
207    if let Some(s) = &d.statistic_values {
208        return Some(DatumStats {
209            sum: s.sum,
210            min: s.minimum,
211            max: s.maximum,
212            count: s.sample_count,
213        });
214    }
215    None
216}
217
218fn merge_stats(acc: &mut DatumStats, other: DatumStats) {
219    acc.sum += other.sum;
220    acc.count += other.count;
221    if other.min < acc.min {
222        acc.min = other.min;
223    }
224    if other.max > acc.max {
225        acc.max = other.max;
226    }
227}
228
229fn stat_value(stat: &str, agg: DatumStats) -> Option<f64> {
230    match stat {
231        "Sum" => Some(agg.sum),
232        "Average" => {
233            if agg.count > 0.0 {
234                Some(agg.sum / agg.count)
235            } else {
236                None
237            }
238        }
239        "Minimum" => Some(agg.min),
240        "Maximum" => Some(agg.max),
241        "SampleCount" => Some(agg.count),
242        _ => None,
243    }
244}
245
246fn render_dimensions(dims: &BTreeMap<String, String>) -> String {
247    let mut s = String::from("<Dimensions>");
248    for (name, value) in dims.iter() {
249        s.push_str(&format!(
250            "<member><Name>{}</Name><Value>{}</Value></member>",
251            xml_escape(name),
252            xml_escape(value),
253        ));
254    }
255    s.push_str("</Dimensions>");
256    s
257}
258
259impl CloudWatchService {
260    fn put_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
261        let namespace = required_query_param(req, "Namespace")?;
262        let members = collect_indexed(req, "MetricData");
263        if members.is_empty() {
264            return Err(invalid_param(
265                "PutMetricData requires at least one MetricData entry",
266            ));
267        }
268
269        let now = Utc::now();
270        let mut state = self.state.write();
271        let acct = state.get_or_create(&req.account_id);
272        let metrics_map = acct.metrics_in_mut(&req.region);
273        let bucket = metrics_map.entry(namespace.clone()).or_default();
274
275        for member in members {
276            let metric_name = member
277                .get("MetricName")
278                .cloned()
279                .ok_or_else(|| invalid_param("MetricData.member.N.MetricName is required"))?;
280            let value = member
281                .get("Value")
282                .map(|s| s.parse::<f64>())
283                .transpose()
284                .map_err(|_| invalid_param("Value must be a valid number"))?;
285            let timestamp = member
286                .get("Timestamp")
287                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
288                .map(|d| d.with_timezone(&Utc))
289                .unwrap_or(now);
290            let unit = member.get("Unit").cloned();
291            let storage_resolution = member
292                .get("StorageResolution")
293                .and_then(|s| s.parse::<i64>().ok());
294            let dimensions = parse_dimensions(&member, "Dimensions");
295
296            let statistic_values = if let (Some(sc), Some(sum), Some(min), Some(max)) = (
297                member.get("StatisticValues.SampleCount"),
298                member.get("StatisticValues.Sum"),
299                member.get("StatisticValues.Minimum"),
300                member.get("StatisticValues.Maximum"),
301            ) {
302                Some(StatisticSet {
303                    sample_count: sc.parse::<f64>().map_err(|_| {
304                        invalid_param("StatisticValues.SampleCount must be a number")
305                    })?,
306                    sum: sum
307                        .parse::<f64>()
308                        .map_err(|_| invalid_param("StatisticValues.Sum must be a number"))?,
309                    minimum: min
310                        .parse::<f64>()
311                        .map_err(|_| invalid_param("StatisticValues.Minimum must be a number"))?,
312                    maximum: max
313                        .parse::<f64>()
314                        .map_err(|_| invalid_param("StatisticValues.Maximum must be a number"))?,
315                })
316            } else {
317                None
318            };
319
320            if value.is_none() && statistic_values.is_none() {
321                return Err(invalid_param(
322                    "MetricData entry must supply either Value or StatisticValues",
323                ));
324            }
325
326            bucket.push(MetricDatum {
327                metric_name,
328                dimensions,
329                timestamp,
330                value,
331                statistic_values,
332                unit,
333                storage_resolution,
334            });
335        }
336
337        Ok(empty_metadata_response("PutMetricData", &req.request_id))
338    }
339
340    fn list_metrics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
341        let namespace = optional_query_param(req, "Namespace");
342        let metric_name = optional_query_param(req, "MetricName");
343        let dim_filter = parse_dimensions_query(req, "Dimensions");
344
345        let state = self.state.read();
346        let mut out = String::from("<Metrics>");
347        if let Some(acct) = state.get(&req.account_id) {
348            if let Some(map) = acct.metrics_in(&req.region) {
349                for (ns, data) in map.iter() {
350                    if let Some(filter_ns) = namespace.as_ref() {
351                        if ns != filter_ns {
352                            continue;
353                        }
354                    }
355                    let mut seen: BTreeMap<(String, BTreeMap<String, String>), ()> =
356                        BTreeMap::new();
357                    for d in data.iter() {
358                        if let Some(filter_name) = metric_name.as_ref() {
359                            if &d.metric_name != filter_name {
360                                continue;
361                            }
362                        }
363                        if !dim_filter.is_empty()
364                            && !dim_filter
365                                .iter()
366                                .all(|(k, v)| d.dimensions.get(k) == Some(v))
367                        {
368                            continue;
369                        }
370                        seen.insert((d.metric_name.clone(), d.dimensions.clone()), ());
371                    }
372                    for ((name, dims), _) in seen {
373                        out.push_str("<member>");
374                        out.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(ns)));
375                        out.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(&name)));
376                        out.push_str(&render_dimensions(&dims));
377                        out.push_str("</member>");
378                    }
379                }
380            }
381        }
382        out.push_str("</Metrics>");
383
384        Ok(xml_response("ListMetrics", &out, &req.request_id))
385    }
386
387    fn get_metric_statistics(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
388        let namespace = required_query_param(req, "Namespace")?;
389        let metric_name = required_query_param(req, "MetricName")?;
390        let start = required_query_param(req, "StartTime")?;
391        let end = required_query_param(req, "EndTime")?;
392        let period = required_query_param(req, "Period")?
393            .parse::<i64>()
394            .map_err(|_| invalid_param("Period must be an integer"))?;
395        if period <= 0 {
396            return Err(invalid_param("Period must be positive"));
397        }
398        let start_ts = DateTime::parse_from_rfc3339(&start)
399            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
400            .with_timezone(&Utc);
401        let end_ts = DateTime::parse_from_rfc3339(&end)
402            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
403            .with_timezone(&Utc);
404
405        let mut statistics: Vec<String> = Vec::new();
406        for (k, v) in req.query_params.iter() {
407            if k.starts_with("Statistics.member.") {
408                statistics.push(v.clone());
409            }
410        }
411        if statistics.is_empty() {
412            return Err(invalid_param("At least one Statistic is required"));
413        }
414
415        let dim_filter = parse_dimensions_query(req, "Dimensions");
416
417        let state = self.state.read();
418        let mut datapoints: Vec<(DateTime<Utc>, BTreeMap<String, f64>)> = Vec::new();
419        if let Some(acct) = state.get(&req.account_id) {
420            if let Some(map) = acct.metrics_in(&req.region) {
421                if let Some(data) = map.get(&namespace) {
422                    let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
423                    for d in data.iter() {
424                        if d.metric_name != metric_name {
425                            continue;
426                        }
427                        if !dim_filter
428                            .iter()
429                            .all(|(k, v)| d.dimensions.get(k) == Some(v))
430                        {
431                            continue;
432                        }
433                        if d.timestamp < start_ts || d.timestamp >= end_ts {
434                            continue;
435                        }
436                        let Some(stats) = datum_stats(d) else {
437                            continue;
438                        };
439                        let secs = d.timestamp.timestamp();
440                        let bucket_secs = secs - secs.rem_euclid(period);
441                        let bucket_ts =
442                            DateTime::<Utc>::from_timestamp(bucket_secs, 0).unwrap_or(d.timestamp);
443                        buckets
444                            .entry(bucket_ts)
445                            .and_modify(|acc| merge_stats(acc, stats))
446                            .or_insert(stats);
447                    }
448                    for (ts, agg) in buckets {
449                        let mut stats = BTreeMap::new();
450                        for stat in statistics.iter() {
451                            if let Some(v) = stat_value(stat, agg) {
452                                stats.insert(stat.clone(), v);
453                            }
454                        }
455                        datapoints.push((ts, stats));
456                    }
457                }
458            }
459        }
460
461        let mut inner = format!("<Label>{}</Label>", xml_escape(&metric_name));
462        inner.push_str("<Datapoints>");
463        for (ts, stats) in datapoints {
464            inner.push_str("<member>");
465            inner.push_str(&format!(
466                "<Timestamp>{}</Timestamp>",
467                ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
468            ));
469            for (name, value) in stats {
470                inner.push_str(&format!("<{name}>{value}</{name}>"));
471            }
472            inner.push_str("</member>");
473        }
474        inner.push_str("</Datapoints>");
475
476        Ok(xml_response("GetMetricStatistics", &inner, &req.request_id))
477    }
478
479    fn get_metric_data(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
480        let start = required_query_param(req, "StartTime")?;
481        let end = required_query_param(req, "EndTime")?;
482        let start_ts = DateTime::parse_from_rfc3339(&start)
483            .map_err(|_| invalid_param("StartTime must be ISO 8601"))?
484            .with_timezone(&Utc);
485        let end_ts = DateTime::parse_from_rfc3339(&end)
486            .map_err(|_| invalid_param("EndTime must be ISO 8601"))?
487            .with_timezone(&Utc);
488
489        let queries = collect_indexed(req, "MetricDataQueries");
490        if queries.is_empty() {
491            return Err(invalid_param(
492                "MetricDataQueries must contain at least one entry",
493            ));
494        }
495
496        let state = self.state.read();
497        let mut inner = String::from("<MetricDataResults>");
498        for q in queries {
499            let id = q.get("Id").cloned().unwrap_or_default();
500            let label = q.get("Label").cloned().unwrap_or_else(|| id.clone());
501            let stat = q
502                .get("MetricStat.Stat")
503                .cloned()
504                .unwrap_or_else(|| "Sum".to_string());
505            let metric_name = q.get("MetricStat.Metric.MetricName").cloned();
506            let namespace = q.get("MetricStat.Metric.Namespace").cloned();
507            let period: i64 = q
508                .get("MetricStat.Period")
509                .and_then(|s| s.parse::<i64>().ok())
510                .unwrap_or(60);
511            if period <= 0 {
512                return Err(invalid_param(
513                    "MetricStat.Period must be a positive integer",
514                ));
515            }
516            let dim_filter = parse_dimensions(&q, "MetricStat.Metric.Dimensions");
517
518            let (mut timestamps, mut values): (Vec<String>, Vec<f64>) = (Vec::new(), Vec::new());
519            if let (Some(metric_name), Some(namespace)) = (metric_name, namespace) {
520                if let Some(acct) = state.get(&req.account_id) {
521                    if let Some(map) = acct.metrics_in(&req.region) {
522                        if let Some(data) = map.get(&namespace) {
523                            let mut buckets: BTreeMap<DateTime<Utc>, DatumStats> = BTreeMap::new();
524                            for d in data.iter() {
525                                if d.metric_name != metric_name {
526                                    continue;
527                                }
528                                if !dim_filter
529                                    .iter()
530                                    .all(|(k, v)| d.dimensions.get(k) == Some(v))
531                                {
532                                    continue;
533                                }
534                                if d.timestamp < start_ts || d.timestamp >= end_ts {
535                                    continue;
536                                }
537                                let Some(stats) = datum_stats(d) else {
538                                    continue;
539                                };
540                                let secs = d.timestamp.timestamp();
541                                let bucket_secs = secs - secs.rem_euclid(period);
542                                let bucket_ts = DateTime::<Utc>::from_timestamp(bucket_secs, 0)
543                                    .unwrap_or(d.timestamp);
544                                buckets
545                                    .entry(bucket_ts)
546                                    .and_modify(|acc| merge_stats(acc, stats))
547                                    .or_insert(stats);
548                            }
549                            for (ts, agg) in buckets {
550                                let Some(v) = stat_value(&stat, agg) else {
551                                    continue;
552                                };
553                                timestamps
554                                    .push(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
555                                values.push(v);
556                            }
557                        }
558                    }
559                }
560            }
561
562            inner.push_str("<member>");
563            inner.push_str(&format!("<Id>{}</Id>", xml_escape(&id)));
564            inner.push_str(&format!("<Label>{}</Label>", xml_escape(&label)));
565            inner.push_str("<StatusCode>Complete</StatusCode>");
566            inner.push_str("<Timestamps>");
567            for ts in timestamps {
568                inner.push_str(&format!("<member>{ts}</member>"));
569            }
570            inner.push_str("</Timestamps>");
571            inner.push_str("<Values>");
572            for v in values {
573                inner.push_str(&format!("<member>{v}</member>"));
574            }
575            inner.push_str("</Values>");
576            inner.push_str("</member>");
577        }
578        inner.push_str("</MetricDataResults>");
579        inner.push_str("<Messages></Messages>");
580
581        Ok(xml_response("GetMetricData", &inner, &req.request_id))
582    }
583
584    fn put_metric_alarm(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
585        let alarm_name = required_query_param(req, "AlarmName")?;
586        let comparison = required_query_param(req, "ComparisonOperator")?;
587        let evaluation_periods = required_query_param(req, "EvaluationPeriods")?
588            .parse::<i64>()
589            .map_err(|_| invalid_param("EvaluationPeriods must be an integer"))?;
590
591        let alarm_description = optional_query_param(req, "AlarmDescription");
592        let actions_enabled = optional_query_param(req, "ActionsEnabled")
593            .map(|s| s.eq_ignore_ascii_case("true"))
594            .unwrap_or(true);
595
596        let metric_name = optional_query_param(req, "MetricName");
597        let namespace = optional_query_param(req, "Namespace");
598        let statistic = optional_query_param(req, "Statistic");
599        let extended_statistic = optional_query_param(req, "ExtendedStatistic");
600        let period = optional_query_param(req, "Period").and_then(|s| s.parse::<i64>().ok());
601        let unit = optional_query_param(req, "Unit");
602        let datapoints_to_alarm =
603            optional_query_param(req, "DatapointsToAlarm").and_then(|s| s.parse::<i64>().ok());
604        let threshold = optional_query_param(req, "Threshold").and_then(|s| s.parse::<f64>().ok());
605        let treat_missing_data = optional_query_param(req, "TreatMissingData");
606        let evaluate_low_sample_count_percentile =
607            optional_query_param(req, "EvaluateLowSampleCountPercentile");
608        let dimensions = parse_dimensions_query(req, "Dimensions");
609
610        let mut ok_actions = Vec::new();
611        let mut alarm_actions = Vec::new();
612        let mut insufficient_data_actions = Vec::new();
613        for (k, v) in req.query_params.iter() {
614            if k.starts_with("OKActions.member.") {
615                ok_actions.push(v.clone());
616            } else if k.starts_with("AlarmActions.member.") {
617                alarm_actions.push(v.clone());
618            } else if k.starts_with("InsufficientDataActions.member.") {
619                insufficient_data_actions.push(v.clone());
620            }
621        }
622
623        let arn = format!(
624            "arn:aws:cloudwatch:{}:{}:alarm:{}",
625            req.region, req.account_id, alarm_name
626        );
627        let now = Utc::now();
628
629        let mut state = self.state.write();
630        let acct = state.get_or_create(&req.account_id);
631        let alarms = acct.alarms_in_mut(&req.region);
632        let existing = alarms.get(&alarm_name).cloned();
633        let alarm = MetricAlarm {
634            alarm_name: alarm_name.clone(),
635            alarm_arn: arn,
636            alarm_description,
637            actions_enabled,
638            ok_actions,
639            alarm_actions,
640            insufficient_data_actions,
641            state_value: existing
642                .as_ref()
643                .map(|a| a.state_value)
644                .unwrap_or(AlarmState::InsufficientData),
645            state_reason: existing
646                .as_ref()
647                .map(|a| a.state_reason.clone())
648                .unwrap_or_else(|| "Unchecked: Initial alarm creation".to_string()),
649            state_updated_timestamp: existing
650                .as_ref()
651                .map(|a| a.state_updated_timestamp)
652                .unwrap_or(now),
653            metric_name,
654            namespace,
655            statistic,
656            extended_statistic,
657            dimensions,
658            period,
659            unit,
660            evaluation_periods,
661            datapoints_to_alarm,
662            threshold,
663            comparison_operator: comparison,
664            treat_missing_data,
665            evaluate_low_sample_count_percentile,
666            configuration_updated_timestamp: existing
667                .as_ref()
668                .map(|a| a.configuration_updated_timestamp)
669                .unwrap_or(now),
670            alarm_configuration_updated_timestamp: now,
671        };
672        alarms.insert(alarm_name, alarm);
673
674        Ok(empty_metadata_response("PutMetricAlarm", &req.request_id))
675    }
676
677    fn describe_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
678        let mut filter_names: Vec<String> = Vec::new();
679        for (k, v) in req.query_params.iter() {
680            if k.starts_with("AlarmNames.member.") {
681                filter_names.push(v.clone());
682            }
683        }
684        let prefix = optional_query_param(req, "AlarmNamePrefix");
685        let state_filter = optional_query_param(req, "StateValue");
686        let action_prefix = optional_query_param(req, "ActionPrefix");
687
688        let state = self.state.read();
689        let mut inner = String::from("<MetricAlarms>");
690        if let Some(acct) = state.get(&req.account_id) {
691            if let Some(alarms) = acct.alarms_in(&req.region) {
692                for alarm in alarms.values() {
693                    if !filter_names.is_empty() && !filter_names.contains(&alarm.alarm_name) {
694                        continue;
695                    }
696                    if let Some(p) = prefix.as_ref() {
697                        if !alarm.alarm_name.starts_with(p) {
698                            continue;
699                        }
700                    }
701                    if let Some(sv) = state_filter.as_ref() {
702                        if alarm.state_value.as_str() != sv {
703                            continue;
704                        }
705                    }
706                    if let Some(ap) = action_prefix.as_ref() {
707                        let any = alarm
708                            .alarm_actions
709                            .iter()
710                            .chain(alarm.ok_actions.iter())
711                            .chain(alarm.insufficient_data_actions.iter())
712                            .any(|a| a.starts_with(ap));
713                        if !any {
714                            continue;
715                        }
716                    }
717                    inner.push_str(&render_alarm(alarm));
718                }
719            }
720        }
721        inner.push_str("</MetricAlarms>");
722        inner.push_str("<CompositeAlarms></CompositeAlarms>");
723
724        Ok(xml_response("DescribeAlarms", &inner, &req.request_id))
725    }
726
727    fn describe_alarms_for_metric(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
728        let metric_name = required_query_param(req, "MetricName")?;
729        let namespace = required_query_param(req, "Namespace")?;
730        let dim_filter = parse_dimensions_query(req, "Dimensions");
731
732        let state = self.state.read();
733        let mut inner = String::from("<MetricAlarms>");
734        if let Some(acct) = state.get(&req.account_id) {
735            if let Some(alarms) = acct.alarms_in(&req.region) {
736                for alarm in alarms.values() {
737                    if alarm.metric_name.as_deref() != Some(&metric_name) {
738                        continue;
739                    }
740                    if alarm.namespace.as_deref() != Some(&namespace) {
741                        continue;
742                    }
743                    if !dim_filter.is_empty() && alarm.dimensions != dim_filter {
744                        continue;
745                    }
746                    inner.push_str(&render_alarm(alarm));
747                }
748            }
749        }
750        inner.push_str("</MetricAlarms>");
751
752        Ok(xml_response(
753            "DescribeAlarmsForMetric",
754            &inner,
755            &req.request_id,
756        ))
757    }
758
759    fn delete_alarms(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
760        let mut names: Vec<String> = Vec::new();
761        for (k, v) in req.query_params.iter() {
762            if k.starts_with("AlarmNames.member.") {
763                names.push(v.clone());
764            }
765        }
766        if names.is_empty() {
767            return Err(invalid_param("AlarmNames must contain at least one name"));
768        }
769
770        let mut state = self.state.write();
771        let acct = state.get_or_create(&req.account_id);
772        let alarms = acct.alarms_in_mut(&req.region);
773        for name in names {
774            alarms.remove(&name);
775        }
776
777        Ok(empty_metadata_response("DeleteAlarms", &req.request_id))
778    }
779
780    fn enable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
781        self.toggle_alarm_actions(req, true, "EnableAlarmActions")
782    }
783
784    fn disable_alarm_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
785        self.toggle_alarm_actions(req, false, "DisableAlarmActions")
786    }
787
788    fn toggle_alarm_actions(
789        &self,
790        req: &AwsRequest,
791        enabled: bool,
792        action_name: &str,
793    ) -> Result<AwsResponse, AwsServiceError> {
794        let mut names: Vec<String> = Vec::new();
795        for (k, v) in req.query_params.iter() {
796            if k.starts_with("AlarmNames.member.") {
797                names.push(v.clone());
798            }
799        }
800        let mut state = self.state.write();
801        let acct = state.get_or_create(&req.account_id);
802        let alarms = acct.alarms_in_mut(&req.region);
803        for name in names {
804            if let Some(alarm) = alarms.get_mut(&name) {
805                alarm.actions_enabled = enabled;
806                alarm.alarm_configuration_updated_timestamp = Utc::now();
807            }
808        }
809        Ok(empty_metadata_response(action_name, &req.request_id))
810    }
811
812    fn set_alarm_state(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
813        let alarm_name = required_query_param(req, "AlarmName")?;
814        let state_value = required_query_param(req, "StateValue")?;
815        let state_reason = required_query_param(req, "StateReason")?;
816        let new_state = AlarmState::parse(&state_value)
817            .ok_or_else(|| invalid_param("StateValue must be OK | ALARM | INSUFFICIENT_DATA"))?;
818
819        let mut state = self.state.write();
820        let acct = state.get_or_create(&req.account_id);
821        let alarms = acct.alarms_in_mut(&req.region);
822        let alarm = alarms.get_mut(&alarm_name).ok_or_else(|| {
823            AwsServiceError::aws_error(
824                StatusCode::NOT_FOUND,
825                "ResourceNotFound",
826                format!("Alarm {alarm_name} not found"),
827            )
828        })?;
829        alarm.state_value = new_state;
830        alarm.state_reason = state_reason;
831        alarm.state_updated_timestamp = Utc::now();
832
833        Ok(empty_metadata_response("SetAlarmState", &req.request_id))
834    }
835
836    fn describe_alarm_history(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
837        // Minimal implementation: return empty history. AWS pagination tokens are
838        // not tracked locally, so callers see an empty list rather than a stub.
839        let inner = String::from("<AlarmHistoryItems></AlarmHistoryItems>");
840        Ok(xml_response(
841            "DescribeAlarmHistory",
842            &inner,
843            &req.request_id,
844        ))
845    }
846
847    fn put_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
848        let dashboard_name = req
849            .query_params
850            .get("DashboardName")
851            .ok_or_else(|| invalid_param("DashboardName is required"))?
852            .clone();
853        let body = req
854            .query_params
855            .get("DashboardBody")
856            .ok_or_else(|| invalid_param("DashboardBody is required"))?
857            .clone();
858        // AWS validates that DashboardBody parses as JSON; we do the same so
859        // bad bodies surface a useful error before persisting.
860        if serde_json::from_str::<serde_json::Value>(&body).is_err() {
861            return Err(AwsServiceError::aws_error(
862                StatusCode::BAD_REQUEST,
863                "InvalidParameterInput",
864                "DashboardBody must be a valid JSON object",
865            ));
866        }
867        let arn = format!(
868            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
869            req.account_id
870        );
871        let dashboard = Dashboard {
872            name: dashboard_name.clone(),
873            arn,
874            size_bytes: body.len() as i64,
875            body,
876            last_modified: Utc::now(),
877        };
878        let mut state = self.state.write();
879        let acct = state.get_or_create(&req.account_id);
880        acct.dashboards.insert(dashboard_name, dashboard);
881        // PutDashboard returns DashboardValidationMessages — empty when the
882        // body parses cleanly.
883        let inner = String::from("<DashboardValidationMessages/>");
884        Ok(xml_response("PutDashboard", &inner, &req.request_id))
885    }
886
887    fn get_dashboard(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
888        let name = req
889            .query_params
890            .get("DashboardName")
891            .ok_or_else(|| invalid_param("DashboardName is required"))?
892            .clone();
893        let state = self.state.read();
894        let dashboard = state
895            .get(&req.account_id)
896            .and_then(|a| a.dashboards.get(&name))
897            .cloned()
898            .ok_or_else(|| {
899                AwsServiceError::aws_error(
900                    StatusCode::NOT_FOUND,
901                    "ResourceNotFound",
902                    format!("Dashboard {name} does not exist"),
903                )
904            })?;
905        let inner = format!(
906            "<DashboardArn>{}</DashboardArn><DashboardBody>{}</DashboardBody><DashboardName>{}</DashboardName>",
907            xml_escape(&dashboard.arn),
908            xml_escape(&dashboard.body),
909            xml_escape(&dashboard.name),
910        );
911        Ok(xml_response("GetDashboard", &inner, &req.request_id))
912    }
913
914    fn delete_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
915        let mut names: Vec<String> = Vec::new();
916        for (k, v) in req.query_params.iter() {
917            if k.starts_with("DashboardNames.member.") {
918                names.push(v.clone());
919            }
920        }
921        if names.is_empty() {
922            return Err(invalid_param(
923                "DashboardNames must contain at least one name",
924            ));
925        }
926        let mut state = self.state.write();
927        let acct = state.get_or_create(&req.account_id);
928        for n in names {
929            acct.dashboards.remove(&n);
930        }
931        Ok(empty_metadata_response("DeleteDashboards", &req.request_id))
932    }
933
934    fn list_dashboards(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
935        let prefix = req.query_params.get("DashboardNamePrefix").cloned();
936        let state = self.state.read();
937        let dashboards: Vec<Dashboard> = state
938            .get(&req.account_id)
939            .map(|a| {
940                a.dashboards
941                    .values()
942                    .filter(|d| prefix.as_ref().is_none_or(|p| d.name.starts_with(p)))
943                    .cloned()
944                    .collect()
945            })
946            .unwrap_or_default();
947        let mut entries = String::new();
948        for d in &dashboards {
949            entries.push_str("<member>");
950            entries.push_str(&format!(
951                "<DashboardArn>{}</DashboardArn><DashboardName>{}</DashboardName><LastModified>{}</LastModified><Size>{}</Size>",
952                xml_escape(&d.arn),
953                xml_escape(&d.name),
954                d.last_modified.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
955                d.size_bytes,
956            ));
957            entries.push_str("</member>");
958        }
959        let inner = format!("<DashboardEntries>{entries}</DashboardEntries>");
960        Ok(xml_response("ListDashboards", &inner, &req.request_id))
961    }
962}
963
964fn render_alarm(alarm: &MetricAlarm) -> String {
965    let mut s = String::from("<member>");
966    s.push_str(&format!(
967        "<AlarmName>{}</AlarmName>",
968        xml_escape(&alarm.alarm_name)
969    ));
970    s.push_str(&format!(
971        "<AlarmArn>{}</AlarmArn>",
972        xml_escape(&alarm.alarm_arn)
973    ));
974    if let Some(d) = &alarm.alarm_description {
975        s.push_str(&format!(
976            "<AlarmDescription>{}</AlarmDescription>",
977            xml_escape(d)
978        ));
979    }
980    s.push_str(&format!(
981        "<ActionsEnabled>{}</ActionsEnabled>",
982        alarm.actions_enabled
983    ));
984    push_action_list(&mut s, "OKActions", &alarm.ok_actions);
985    push_action_list(&mut s, "AlarmActions", &alarm.alarm_actions);
986    push_action_list(
987        &mut s,
988        "InsufficientDataActions",
989        &alarm.insufficient_data_actions,
990    );
991    s.push_str(&format!(
992        "<StateValue>{}</StateValue>",
993        alarm.state_value.as_str()
994    ));
995    s.push_str(&format!(
996        "<StateReason>{}</StateReason>",
997        xml_escape(&alarm.state_reason)
998    ));
999    s.push_str(&format!(
1000        "<StateUpdatedTimestamp>{}</StateUpdatedTimestamp>",
1001        alarm
1002            .state_updated_timestamp
1003            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1004    ));
1005    if let Some(m) = &alarm.metric_name {
1006        s.push_str(&format!("<MetricName>{}</MetricName>", xml_escape(m)));
1007    }
1008    if let Some(n) = &alarm.namespace {
1009        s.push_str(&format!("<Namespace>{}</Namespace>", xml_escape(n)));
1010    }
1011    if let Some(stat) = &alarm.statistic {
1012        s.push_str(&format!("<Statistic>{}</Statistic>", xml_escape(stat)));
1013    }
1014    if let Some(ext) = &alarm.extended_statistic {
1015        s.push_str(&format!(
1016            "<ExtendedStatistic>{}</ExtendedStatistic>",
1017            xml_escape(ext)
1018        ));
1019    }
1020    s.push_str(&render_dimensions(&alarm.dimensions));
1021    if let Some(p) = alarm.period {
1022        s.push_str(&format!("<Period>{p}</Period>"));
1023    }
1024    if let Some(u) = &alarm.unit {
1025        s.push_str(&format!("<Unit>{}</Unit>", xml_escape(u)));
1026    }
1027    s.push_str(&format!(
1028        "<EvaluationPeriods>{}</EvaluationPeriods>",
1029        alarm.evaluation_periods
1030    ));
1031    if let Some(d) = alarm.datapoints_to_alarm {
1032        s.push_str(&format!("<DatapointsToAlarm>{d}</DatapointsToAlarm>"));
1033    }
1034    if let Some(t) = alarm.threshold {
1035        s.push_str(&format!("<Threshold>{t}</Threshold>"));
1036    }
1037    s.push_str(&format!(
1038        "<ComparisonOperator>{}</ComparisonOperator>",
1039        xml_escape(&alarm.comparison_operator)
1040    ));
1041    if let Some(t) = &alarm.treat_missing_data {
1042        s.push_str(&format!(
1043            "<TreatMissingData>{}</TreatMissingData>",
1044            xml_escape(t)
1045        ));
1046    }
1047    if let Some(e) = &alarm.evaluate_low_sample_count_percentile {
1048        s.push_str(&format!(
1049            "<EvaluateLowSampleCountPercentile>{}</EvaluateLowSampleCountPercentile>",
1050            xml_escape(e)
1051        ));
1052    }
1053    s.push_str(&format!(
1054        "<AlarmConfigurationUpdatedTimestamp>{}</AlarmConfigurationUpdatedTimestamp>",
1055        alarm
1056            .alarm_configuration_updated_timestamp
1057            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1058    ));
1059    s.push_str("</member>");
1060    s
1061}
1062
1063fn push_action_list(s: &mut String, name: &str, actions: &[String]) {
1064    s.push_str(&format!("<{name}>"));
1065    for action in actions {
1066        s.push_str(&format!("<member>{}</member>", xml_escape(action)));
1067    }
1068    s.push_str(&format!("</{name}>"));
1069}