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('&', "&")
181 .replace('<', "<")
182 .replace('>', ">")
183 .replace('"', """)
184 .replace('\'', "'")
185}
186
187#[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 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 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 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}