cfn-guardian 0.13.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6499c49242499318fb5e91766a52219a941f2993bc6d8236734a95c47c67f392
4
- data.tar.gz: 6caf244eeb6ffbf88fa299fd9c908f84f4c4866fac81a470141aa971f5a6d461
3
+ metadata.gz: 97db5ada9a8fd070dbc9cbb851b3d0f1b0352db96014d9f29883c359a47b92a4
4
+ data.tar.gz: 8ce062a52661342ab8c78e0adccb357887234384e4ff2e1cd4307cb3066bd41b
5
5
  SHA512:
6
- metadata.gz: ffbcd0281d97cc3b4ff01e4dd4de1f5cd5f3db76759a9e2b93a831d346acbd3849702d450039bd54e1031bb9782d7a3ff9109ebda738a4c18a39a425ab728fe5
7
- data.tar.gz: 679154efbf9054ffabde0511df35e7b618cd602e170b687f496f6e8ec0710219badcc050a2ea5562914f5ce11e8e09eca74522ac048c200dd0b1feb2e081b670
6
+ metadata.gz: b58d879093561d7f836e73b02aaceb6f3499209907ec0aaf4e66e272398ac53756b51d0820ef319cf89b7bfdeedd2fb44f57d7aee1ddcec904122af523a2ca64
7
+ data.tar.gz: 0e582fcd0bda145414d31888bde92e0b866a895c82e45841760085ee2ca9466281776750787c0d7c9973a96b227e69e200c79891d9246b4bd294f7b118155fab
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cfn-guardian (0.13.3)
4
+ cfn-guardian (0.15.0)
5
5
  aws-sdk-cloudformation (~> 1.76, < 2)
6
6
  aws-sdk-cloudwatch (~> 1.72, < 2)
7
7
  aws-sdk-codecommit (~> 1.53, < 2)
data/README.md CHANGED
@@ -34,6 +34,7 @@ CfnGuardian is a AWS monitoring tool with a few capabilities:
34
34
  - EC2 Instances
35
35
  - ECS Clusters
36
36
  - ECS Services
37
+ - ECS Scheduled Tasks
37
38
  - EFS
38
39
  - Classic LoadBalancers
39
40
  - Lambda Functions
@@ -141,4 +141,20 @@ Templates:
141
141
  Period: 60
142
142
  EvaluationPeriods: 10
143
143
  DatapointsToAlarm: 6
144
- ```
144
+ ```
145
+
146
+ ## Anomaly Detection
147
+
148
+ Instead of a static `Threshold`, an alarm can compare a metric against a CloudWatch anomaly detection band by setting `AnomalyDetection: true` along with one of the anomaly-specific `ComparisonOperator` values. `StandardDeviation` (default `2`) controls how wide the expected band is.
149
+
150
+ ```yaml
151
+ Templates:
152
+ Ec2Instance:
153
+ CPUUtilizationHigh:
154
+ AnomalyDetection: true
155
+ StandardDeviation: 2
156
+ ComparisonOperator: GreaterThanUpperThreshold
157
+ EvaluationPeriods: 3
158
+ ```
159
+
160
+ `Threshold` cannot be combined with `AnomalyDetection`. See [Anomaly Detection Alarms](anomaly_detection.md) for the full property reference, valid `ComparisonOperator` values, and more examples.
@@ -0,0 +1,74 @@
1
+ # Anomaly Detection Alarms
2
+
3
+ Anomaly detection alarms use CloudWatch [anomaly detection](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Anomaly_Detection.html) to alarm on a metric's expected behaviour rather than a fixed number. CloudWatch builds a model of the metric's normal range (a "band") from up to two weeks of history and the alarm fires when the metric strays outside that band, instead of crossing a static `Threshold`.
4
+
5
+ ## The Problem
6
+
7
+ A static `Threshold` works well when "too high" or "too low" is a known constant, but many metrics (queue depth, request count, CPU on a workload with a daily cycle) have a normal range that varies by time of day or day of week. A fixed threshold set high enough to avoid false alarms on the daily peak is too high to catch a real problem during the trough, and vice versa.
8
+
9
+ ## How It Works
10
+
11
+ Instead of emitting a CloudWatch alarm with a static `Threshold`, an anomaly detection alarm emits the CloudFormation `Metrics` property (a list of `MetricDataQuery` objects) with:
12
+
13
+ 1. A `MetricStat` entry (`m1`) that returns the raw metric, built from the alarm's usual `MetricName`, `Namespace`, `Dimensions`, `Period` and `Statistic`.
14
+ 2. An `ANOMALY_DETECTION_BAND(m1, <StandardDeviation>)` expression entry (`ad1`) that computes the expected value band for that metric.
15
+
16
+ The alarm then sets `ThresholdMetricId: ad1` instead of a `Threshold`, and compares the metric against the band using one of the anomaly-specific `ComparisonOperator` values. This implicitly creates the underlying `AWS::CloudWatch::AnomalyDetector` model in CloudWatch - no separate CloudFormation resource is required.
17
+
18
+ ## Configuration
19
+
20
+ Add `AnomalyDetection: true` and a `ComparisonOperator` to an alarm template. Optionally set `StandardDeviation` to control the width of the expected band.
21
+
22
+ ### Properties
23
+
24
+ | Property | Required | Default | Description |
25
+ | --- | --- | --- | --- |
26
+ | `AnomalyDetection` | Yes | `false` | Set to `true` to alarm on an anomaly detection band instead of a static Threshold. |
27
+ | `StandardDeviation` | No | `2` | The width of the expected value band, in standard deviations. A larger number widens the band (fewer, larger anomalies alarm); a smaller number narrows it. |
28
+ | `ComparisonOperator` | Yes | - | Must be one of `GreaterThanUpperThreshold`, `LessThanLowerThreshold`, or `LessThanLowerOrGreaterThanUpperThreshold`. |
29
+
30
+ `Threshold` must not be set on an anomaly detection alarm - CloudFormation treats `Threshold` and `ThresholdMetricId` as mutually exclusive, and `cfn-guardian` will fail validation if both are supplied.
31
+
32
+ ### Overriding Default Alarms
33
+
34
+ You can convert an existing default alarm to use anomaly detection by overriding it in the template:
35
+
36
+ ```yaml
37
+ Templates:
38
+ Ec2Instance:
39
+ CPUUtilizationHigh:
40
+ AnomalyDetection: true
41
+ StandardDeviation: 2
42
+ ComparisonOperator: GreaterThanUpperThreshold
43
+ EvaluationPeriods: 3
44
+ ```
45
+
46
+ ### Creating New Alarms
47
+
48
+ You can also create new anomaly detection alarms that don't override any defaults. `MetricName` and `Namespace` are still required, the same as any other alarm, since they identify the metric CloudWatch builds the anomaly detection model from:
49
+
50
+ ```yaml
51
+ Templates:
52
+ SQSQueue:
53
+ ApproximateNumberOfMessagesVisibleAnomaly:
54
+ MetricName: ApproximateNumberOfMessagesVisible
55
+ Statistic: Average
56
+ AnomalyDetection: true
57
+ StandardDeviation: 3
58
+ ComparisonOperator: LessThanLowerOrGreaterThanUpperThreshold
59
+ EvaluationPeriods: 3
60
+ DatapointsToAlarm: 2
61
+ AlarmAction: Warning
62
+ ```
63
+
64
+ ## Choosing A Comparison Operator
65
+
66
+ - `GreaterThanUpperThreshold` - alarm only when the metric goes above the expected band (e.g. an unexpected spike in queue depth or error count).
67
+ - `LessThanLowerThreshold` - alarm only when the metric drops below the expected band (e.g. request count dropping to zero when traffic is expected).
68
+ - `LessThanLowerOrGreaterThanUpperThreshold` - alarm on either side of the band. This is the most common choice when you don't know in advance which direction is abnormal.
69
+
70
+ ## Limitations
71
+
72
+ - **Training period**: CloudWatch needs data to learn the metric's normal pattern; a newly created anomaly detector can take some time (often a few hours, up to a few days for metrics with a weekly pattern) before the band is reliable.
73
+ - **Cannot combine with `SearchExpression`**: both features rely on the alarm's `Metrics` property, so `AnomalyDetection` and `SearchExpression` cannot be set on the same alarm.
74
+ - See the [CloudWatch anomaly detection documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Anomaly_Detection.html) for how the model is trained and how to inspect it in the console.
@@ -0,0 +1,99 @@
1
+ # ECS Scheduled Tasks
2
+
3
+ `ECSScheduledTask` monitors ECS tasks that run outside of an ECS service, for example tasks launched on a schedule by an EventBridge rule or EventBridge Scheduler. These tasks have no service to attach `ECSService` alarms to, so Guardian watches the `ECS Task State Change` events for the cluster instead and notifies when a task fails.
4
+
5
+ ## Configuration
6
+
7
+ ```yaml
8
+ Resources:
9
+ ECSScheduledTask:
10
+ # Id is the ECS cluster name. Monitors every standalone task in the cluster.
11
+ - Id: batch-jobs
12
+ # Optionally narrow to a single task definition family.
13
+ - Id: shared-cluster
14
+ TaskDefinitionFamily: report-export
15
+ ```
16
+
17
+ | Key | Required | Description |
18
+ |---|---|---|
19
+ | `Id` | yes | ECS cluster name |
20
+ | `TaskDefinitionFamily` | no | Only match tasks from this task definition family, matched on the task definition ARN |
21
+
22
+ Every event subscription in this group, including custom ones, is scoped to:
23
+
24
+ - the cluster in `Id`
25
+ - tasks not started by an ECS service (the task's `group` doesn't start with `service:`), so service deployments don't trigger alerts
26
+ - the `TaskDefinitionFamily`, if set
27
+
28
+ If a scheduled task is launched with a custom `group` starting with `service:`, it won't be matched.
29
+
30
+ ## Default Event Subscriptions
31
+
32
+ Both are sent to the `Events` topic by default.
33
+
34
+ | Name | Fires when |
35
+ |---|---|
36
+ | `TaskFailed` | A task stops and any of its containers has a non-zero exit code |
37
+ | `TaskFailedToStart` | A task stops with stop code `TaskFailedToStart`, e.g. the image can't be pulled or a secret can't be fetched |
38
+
39
+ A container with no exit code (null or missing, e.g. one that never started) doesn't match. EventBridge matches `exitCode` against every container in the task, so a sidecar (such as a log router) that exits non-zero will also trigger `TaskFailed`.
40
+
41
+ ## Overriding Defaults
42
+
43
+ Event subscriptions are configured under the `EventSubscriptions` key. See [event subscriptions](event_subscriptions.md).
44
+
45
+ ```yaml
46
+ EventSubscriptions:
47
+ ECSScheduledTask:
48
+ # page on failed runs instead of sending to the Events topic
49
+ TaskFailed:
50
+ Topic: Critical
51
+ # disable the failed-to-start notification
52
+ TaskFailedToStart: false
53
+ # custom subscriptions default to the ECS Task State Change detail type
54
+ # and are scoped to the cluster and family like the defaults
55
+ TaskStoppedBySpot:
56
+ Detail:
57
+ stopCode: [SpotInterruption]
58
+ ```
59
+
60
+ ## Alarms
61
+
62
+ There are no default alarms, as scheduled tasks only publish metrics while they run. If [Container Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ContainerInsights.html) is enabled on the cluster, alarms can be added under `Templates`. They use the `ECS/ContainerInsights` namespace with the `ClusterName` dimension, plus `TaskDefinitionFamily` if set, and treat missing data as not breaching.
63
+
64
+ Alarms are named `guardian-ECSScheduledTask-<Id>-<AlarmName>`, or `guardian-ECSScheduledTask-<Id>:<TaskDefinitionFamily>-<AlarmName>` when a family is set, so a cluster-wide entry and family entries on the same cluster each get their own alarm.
65
+
66
+ ```yaml
67
+ Templates:
68
+ ECSScheduledTask:
69
+ MemoryUtilizedHigh:
70
+ MetricName: MemoryUtilized
71
+ Statistic: Maximum
72
+ Threshold: 1800 # MiB
73
+ AlarmAction: Warning
74
+ ```
75
+
76
+ ## Missed Runs
77
+
78
+ The event subscriptions only fire when a task runs and fails. They won't fire if the schedule never launches a task. If `RunTask` itself fails (for example on a bad network configuration), ECS creates no task and emits no events. That shows up in the launcher's own metrics instead: `FailedInvocations` in the `AWS/Events` namespace for an EventBridge rule, or `TargetErrorCount` and `InvocationDroppedCount` in the `AWS/Scheduler` namespace for EventBridge Scheduler (Scheduler doesn't publish `FailedInvocations`).
79
+
80
+ To catch a missed run, add a `LogGroup` metric filter that counts a line the task logs on success, and alarm when the count is below 1 over the schedule interval. A metric filter with no matches publishes no data, so the alarm must treat missing data as breaching. See [log group metric filters](custom_checks/log_group_metric_filters.md).
81
+
82
+ ```yaml
83
+ Resources:
84
+ LogGroup:
85
+ - Id: batch-jobs
86
+ MetricFilters:
87
+ - MetricName: ReportExportSucceeded
88
+ Pattern: '"export complete"'
89
+
90
+ Templates:
91
+ LogGroup:
92
+ ReportExportSucceeded:
93
+ ComparisonOperator: LessThanThreshold
94
+ Threshold: 1
95
+ Period: 86400 # the schedule interval, here daily
96
+ EvaluationPeriods: 1
97
+ TreatMissingData: breaching
98
+ AlarmAction: Warning
99
+ ```
data/docs/overview.md CHANGED
@@ -23,4 +23,6 @@
23
23
  9. [Alarms for Custom Metrics](custom_metrics.md)
24
24
  10. [Dimension Variables](variables.md)
25
25
  11. [Search Expression Alarms](search_expressions.md)
26
- 12. [Alarm Tags](alarm_tags.md)
26
+ 12. [Alarm Tags](alarm_tags.md)
27
+ 13. [Anomaly Detection Alarms](anomaly_detection.md)
28
+ 14. [ECS Scheduled Tasks](ecs_scheduled_tasks.md)
@@ -20,6 +20,7 @@ require 'cfnguardian/resources/dynamodb_table'
20
20
  require 'cfnguardian/resources/ec2_instance'
21
21
  require 'cfnguardian/resources/ecs_cluster'
22
22
  require 'cfnguardian/resources/ecs_service'
23
+ require 'cfnguardian/resources/ecs_scheduled_task'
23
24
  require 'cfnguardian/resources/eks_container_insights'
24
25
  require 'cfnguardian/resources/elastic_file_system'
25
26
  require 'cfnguardian/resources/elasticache_replication_group'
@@ -190,6 +191,22 @@ module CfnGuardian
190
191
  @resources.each do |resource|
191
192
  case resource.type
192
193
  when 'Alarm'
194
+ unless [true, false].include?(resource.anomaly_detection)
195
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid AnomalyDetection value '#{resource.anomaly_detection.inspect}'. Must be a boolean (true or false)."
196
+ end
197
+
198
+ unless resource.standard_deviation.nil?
199
+ if !resource.standard_deviation.is_a?(Numeric) || !resource.standard_deviation.finite? || resource.standard_deviation <= 0
200
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid StandardDeviation '#{resource.standard_deviation}'. Must be a finite positive number."
201
+ elsif resource.anomaly_detection != true
202
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} sets StandardDeviation but AnomalyDetection is not true. StandardDeviation only applies to anomaly detection alarms; either remove it or set AnomalyDetection: true."
203
+ end
204
+ end
205
+
206
+ if resource.search_expression && resource.anomaly_detection == true
207
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} cannot set both SearchExpression and AnomalyDetection. They both rely on the mutually exclusive Metrics property."
208
+ end
209
+
193
210
  if resource.search_expression
194
211
  if !resource.search_expression.is_a?(String) || resource.search_expression.strip.empty?
195
212
  @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has an invalid SearchExpression. Must be a non-empty string."
@@ -203,7 +220,27 @@ module CfnGuardian
203
220
  @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid SearchAggregation '#{resource.search_aggregation}'. Must be one of: #{valid_aggregations.join(', ')}."
204
221
  end
205
222
  end
223
+ elsif resource.anomaly_detection == true
224
+ valid_operators = %w(GreaterThanUpperThreshold LessThanLowerOrGreaterThanUpperThreshold LessThanLowerThreshold)
225
+ unless valid_operators.include?(resource.comparison_operator)
226
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid ComparisonOperator '#{resource.comparison_operator}' for an AnomalyDetection alarm. Must be one of: #{valid_operators.join(', ')}."
227
+ end
228
+
229
+ if resource.threshold_overridden
230
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} cannot set both Threshold and AnomalyDetection. Anomaly detection alarms use StandardDeviation to size the expected band instead of a static Threshold."
231
+ end
232
+
233
+ %w(metric_name namespace).each do |property|
234
+ if resource.send(property).nil?
235
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has nil value for property #{property.to_camelcase}. This could be due to incorrect spelling of a default alarm name or missing property #{property.to_camelcase} on a new alarm."
236
+ end
237
+ end
206
238
  else
239
+ anomaly_only_operators = %w(GreaterThanUpperThreshold LessThanLowerOrGreaterThanUpperThreshold LessThanLowerThreshold)
240
+ if anomaly_only_operators.include?(resource.comparison_operator)
241
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has ComparisonOperator '#{resource.comparison_operator}' which requires AnomalyDetection to be true. Either set AnomalyDetection: true or use a static-threshold ComparisonOperator."
242
+ end
243
+
207
244
  %w(metric_name namespace).each do |property|
208
245
  if resource.send(property).nil?
209
246
  @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has nil value for property #{property.to_camelcase}. This could be due to incorrect spelling of a default alarm name or missing property #{property.to_camelcase} on a new alarm."
@@ -14,6 +14,8 @@ module CfnGuardian
14
14
 
15
15
  @alarms.each do |alarm|
16
16
  alarm_name = CfnGuardian::CloudWatch.get_alarm_name(alarm)
17
+ use_anomaly_detection = alarm.anomaly_detection == true
18
+
17
19
  rows = [
18
20
  ['ResourceId', alarm.resource_id],
19
21
  ['ResourceHash', alarm.resource_hash],
@@ -21,7 +23,8 @@ module CfnGuardian
21
23
  ['Enabled', alarm.enabled],
22
24
  ['MetricName', alarm.metric_name],
23
25
  ['Dimensions', alarm.dimensions],
24
- ['Threshold', alarm.threshold],
26
+ # An anomaly detection alarm uses ThresholdMetricId/StandardDeviation instead of a static Threshold.
27
+ ['Threshold', use_anomaly_detection ? nil : alarm.threshold],
25
28
  ['Period', alarm.period],
26
29
  ['EvaluationPeriods', alarm.evaluation_periods],
27
30
  ['ComparisonOperator', alarm.comparison_operator],
@@ -33,9 +36,11 @@ module CfnGuardian
33
36
  ['Unit', alarm.unit],
34
37
  ['AlarmAction', alarm.alarm_action],
35
38
  ['OkActionDisabled', alarm.ok_action_disabled],
36
- ['TreatMissingData', alarm.treat_missing_data]
39
+ ['TreatMissingData', alarm.treat_missing_data],
40
+ ['AnomalyDetection', use_anomaly_detection ? alarm.anomaly_detection : nil],
41
+ ['StandardDeviation', use_anomaly_detection ? (alarm.standard_deviation || 2) : nil]
37
42
  ]
38
-
43
+
39
44
  rows.select! {|row| !row[1].nil?}
40
45
 
41
46
  resp << {
@@ -53,30 +58,65 @@ module CfnGuardian
53
58
  @alarms.each do |alarm|
54
59
  alarm_name = CfnGuardian::CloudWatch.get_alarm_name(alarm)
55
60
  metric_alarm = metric_alarms.find {|ma| ma.alarm_name.include? alarm_name}
56
- dimensions = metric_alarm.dimensions.map {|dim| {dim.name.to_sym => dim.value}}.inject(:merge)
57
-
61
+ use_anomaly_detection = alarm.anomaly_detection == true
62
+ deployed_anomaly_detection = anomaly_detection_deployed?(metric_alarm)
63
+
64
+ # A real deployed anomaly detection alarm has no top-level MetricName/Statistic/
65
+ # Period/Unit/Dimensions - CloudWatch only populates those nested inside the raw
66
+ # metric's MetricStat (see #deployed_metric_stat). Fall back to the top-level fields
67
+ # when the deployed alarm isn't a metric-math alarm at all (e.g. local config wants
68
+ # anomaly detection but the deployed alarm is still a plain static alarm).
69
+ metric_stat = deployed_metric_stat(metric_alarm)
70
+ if metric_stat
71
+ deployed_metric_name = metric_stat.metric.metric_name
72
+ deployed_dimensions = (metric_stat.metric.dimensions || []).map {|dim| {dim.name.to_sym => dim.value}}.inject(:merge)
73
+ deployed_period = metric_stat.period
74
+ deployed_statistic = metric_stat.stat
75
+ deployed_unit = metric_stat.unit
76
+ else
77
+ deployed_metric_name = metric_alarm.metric_name
78
+ deployed_dimensions = (metric_alarm.dimensions || []).map {|dim| {dim.name.to_sym => dim.value}}.inject(:merge)
79
+ deployed_period = metric_alarm.period
80
+ deployed_statistic = metric_alarm.statistic
81
+ deployed_unit = metric_alarm.unit
82
+ end
83
+
84
+ # An anomaly detection alarm's MetricStat.Stat holds whichever of Statistic/
85
+ # ExtendedStatistic cfn-guardian generated (see stacks/resources.rb#add_alarm), so
86
+ # compare that combined value here and suppress the separate ExtendedStatistic row
87
+ # below - the same way Threshold is already suppressed for anomaly alarms.
88
+ local_statistic = use_anomaly_detection ? (alarm.extended_statistic || alarm.statistic) : alarm.statistic
89
+
58
90
  rows = [
59
91
  ['ResourceId', alarm.resource_id, alarm.resource_id],
60
92
  ['ResourceHash', alarm.resource_hash, alarm.resource_hash],
61
93
  ['ResourceName', alarm.resource_name, alarm.resource_name],
62
94
  ['Enabled', alarm.enabled, true],
63
- ['MetricName', alarm.metric_name, metric_alarm.metric_name],
64
- ['Dimensions', alarm.dimensions, dimensions],
65
- ['Threshold', alarm.threshold.to_f, metric_alarm.threshold],
66
- ['Period', alarm.period, metric_alarm.period],
95
+ ['MetricName', alarm.metric_name, deployed_metric_name],
96
+ ['Dimensions', alarm.dimensions, deployed_dimensions],
97
+ # A correctly deployed anomaly detection alarm has no static Threshold (it uses
98
+ # ThresholdMetricId/Metrics instead), so comparing Threshold here would always show
99
+ # as different. Compare AnomalyDetection/StandardDeviation below instead.
100
+ ['Threshold', use_anomaly_detection ? nil : alarm.threshold.to_f, use_anomaly_detection ? nil : metric_alarm.threshold],
101
+ ['Period', alarm.period, deployed_period],
67
102
  ['EvaluationPeriods', alarm.evaluation_periods, metric_alarm.evaluation_periods],
68
103
  ['ComparisonOperator', alarm.comparison_operator, metric_alarm.comparison_operator],
69
- ['Statistic', alarm.statistic, metric_alarm.statistic],
104
+ ['Statistic', local_statistic, deployed_statistic],
70
105
  ['ActionsEnabled', alarm.actions_enabled, metric_alarm.actions_enabled],
71
106
  ['DatapointsToAlarm', alarm.datapoints_to_alarm, metric_alarm.datapoints_to_alarm],
72
- ['ExtendedStatistic', alarm.extended_statistic, metric_alarm.extended_statistic],
107
+ ['ExtendedStatistic', use_anomaly_detection ? nil : alarm.extended_statistic, use_anomaly_detection ? nil : metric_alarm.extended_statistic],
73
108
  ['EvaluateLowSampleCountPercentile', alarm.evaluate_low_sample_count_percentile, metric_alarm.evaluate_low_sample_count_percentile],
74
- ['Unit', alarm.unit, metric_alarm.unit],
109
+ ['Unit', alarm.unit, deployed_unit],
75
110
  ['TreatMissingData', alarm.treat_missing_data, metric_alarm.treat_missing_data],
76
111
  ['AlarmAction', alarm.alarm_action, alarm.alarm_action],
77
112
  ['OkActionDisabled', alarm.ok_action_disabled]
78
113
  ]
79
-
114
+
115
+ if use_anomaly_detection || deployed_anomaly_detection
116
+ rows << ['AnomalyDetection', use_anomaly_detection, deployed_anomaly_detection]
117
+ rows << ['StandardDeviation', use_anomaly_detection ? (alarm.standard_deviation || 2).to_f : nil, deployed_standard_deviation(metric_alarm)]
118
+ end
119
+
80
120
  rows.select! {|row| !row[1].nil?}.each {|row| colour_compare_row(row)}
81
121
 
82
122
  if has_config_difference?(rows)
@@ -148,7 +188,40 @@ module CfnGuardian
148
188
  end
149
189
 
150
190
  private
151
-
191
+
192
+ # An anomaly detection alarm is identified on the deployed side by having a
193
+ # ThresholdMetricId set (it references the ANOMALY_DETECTION_BAND expression in Metrics).
194
+ def anomaly_detection_deployed?(metric_alarm)
195
+ !metric_alarm.threshold_metric_id.nil? && !metric_alarm.threshold_metric_id.to_s.empty?
196
+ end
197
+
198
+ # For an anomaly detection alarm, CloudWatch does not populate the top-level MetricName/
199
+ # Namespace/Statistic/Period/Unit/Dimensions fields on describe_alarms - those live nested
200
+ # inside the raw metric's MetricStat, referenced by the Metrics array entry cfn-guardian
201
+ # generates with Id 'm1' (see stacks/resources.rb#add_alarm). Returns nil when the deployed
202
+ # alarm has no such entry (e.g. it's a plain static alarm, not managed via Metrics/MetricStat).
203
+ def deployed_metric_stat(metric_alarm)
204
+ raw_metric = (metric_alarm.metrics || []).find {|m| m.id == 'm1' && !m.metric_stat.nil?}
205
+ raw_metric.nil? ? nil : raw_metric.metric_stat
206
+ end
207
+
208
+ # Extracts the StandardDeviation from the deployed ANOMALY_DETECTION_BAND(m1, <stddev>)
209
+ # expression referenced by ThresholdMetricId. This is approximate: it assumes the band
210
+ # expression is in the same shape cfn-guardian generates, and returns nil if it can't be
211
+ # found or parsed (e.g. an anomaly alarm not managed by cfn-guardian). The number pattern
212
+ # also matches scientific notation (e.g. "1.0e-06"), since add_alarm embeds Ruby's
213
+ # Float#to_s form of StandardDeviation and to_s switches to that notation for very
214
+ # small (or very large) finite values.
215
+ def deployed_standard_deviation(metric_alarm)
216
+ return nil unless anomaly_detection_deployed?(metric_alarm)
217
+
218
+ band_metric = (metric_alarm.metrics || []).find {|m| m.id == metric_alarm.threshold_metric_id}
219
+ return nil if band_metric.nil? || band_metric.expression.nil?
220
+
221
+ match = band_metric.expression.match(/ANOMALY_DETECTION_BAND\([^,]+,\s*(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*\)/)
222
+ match.nil? ? nil : match[1].to_f
223
+ end
224
+
152
225
  def has_config_difference?(rows)
153
226
  rows.each do |row|
154
227
  unless row[1].eql?(row[2])
@@ -6,8 +6,9 @@ module CfnGuardian
6
6
  class BaseAlarm
7
7
 
8
8
  attr_reader :type,
9
- :resource_hash
10
-
9
+ :resource_hash,
10
+ :threshold_overridden
11
+
11
12
  attr_accessor :group,
12
13
  :name,
13
14
  :metric_name,
@@ -33,8 +34,10 @@ module CfnGuardian
33
34
  :additional_notifiers,
34
35
  :tags,
35
36
  :search_expression,
36
- :search_aggregation
37
-
37
+ :search_aggregation,
38
+ :anomaly_detection,
39
+ :standard_deviation
40
+
38
41
  def initialize(resource)
39
42
  @type = 'Alarm'
40
43
  @group = nil
@@ -43,6 +46,7 @@ module CfnGuardian
43
46
  @namespace = nil
44
47
  @dimensions = nil
45
48
  @threshold = 0
49
+ @threshold_overridden = false
46
50
  @period = 60
47
51
  @evaluation_periods = 1
48
52
  @comparison_operator = 'GreaterThanThreshold'
@@ -64,12 +68,23 @@ module CfnGuardian
64
68
  @tags = {}
65
69
  @search_expression = nil
66
70
  @search_aggregation = nil
71
+ @anomaly_detection = false
72
+ @standard_deviation = nil
67
73
  end
68
-
74
+
69
75
  def metric_name=(metric_name)
70
76
  raise ArgumentError.new("metric_name '#{metric_name}' must be of type String, provided type '#{metric_name.class}'") unless metric_name.is_a?(String)
71
77
  @metric_name=metric_name
72
- end
78
+ end
79
+
80
+ # Internal-only marker used to track that Threshold was explicitly set via a
81
+ # config override (as opposed to a resource group's own default_alarms
82
+ # definition). This is intentionally not exposed as a public writer so a
83
+ # YAML config cannot set ThresholdOverridden directly and bypass the
84
+ # "can't combine Threshold and AnomalyDetection" validation.
85
+ def mark_threshold_overridden!
86
+ @threshold_overridden = true
87
+ end
73
88
  end
74
89
 
75
90
  class AcmAlarm < BaseAlarm
@@ -256,6 +271,25 @@ module CfnGuardian
256
271
  end
257
272
  end
258
273
 
274
+ class ECSScheduledTaskAlarm < BaseAlarm
275
+ def initialize(resource)
276
+ super(resource)
277
+ @group = 'ECSScheduledTask'
278
+ @namespace = 'ECS/ContainerInsights'
279
+ @dimensions = { ClusterName: resource['Id'] }
280
+ if resource.has_key?('TaskDefinitionFamily')
281
+ @dimensions[:TaskDefinitionFamily] = resource['TaskDefinitionFamily']
282
+ # keep family alarms distinct from the cluster-wide and other family alarms on the same cluster.
283
+ # ECS cluster and family names can't contain a colon, so cluster `a` with family `b`
284
+ # can't share an alarm name with a cluster called `a-b`
285
+ @resource_hash = Digest::MD5.hexdigest [resource['Id'], resource['TaskDefinitionFamily']].to_json
286
+ @resource_name = "#{resource['Id']}:#{resource['TaskDefinitionFamily']}" if @resource_name.nil?
287
+ end
288
+ # scheduled tasks only publish metrics while they are running
289
+ @treat_missing_data = 'notBreaching'
290
+ end
291
+ end
292
+
259
293
  class EKSContainerInsightsClusterAlarm < BaseAlarm
260
294
  def initialize(resource)
261
295
  super(resource)
@@ -95,6 +95,30 @@ module CfnGuardian
95
95
  end
96
96
  end
97
97
 
98
+ class ECSScheduledTaskEventSubscription < BaseEventSubscription
99
+ def initialize(resource)
100
+ super(resource)
101
+ @source = 'aws.ecs'
102
+ @detail_type = 'ECS Task State Change'
103
+ @hash = Digest::MD5.hexdigest [resource['Id'], resource['TaskDefinitionFamily']].to_json
104
+ @scope = {
105
+ 'clusterArn' => ["arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:cluster/#{resource['Id']}"],
106
+ # tasks started by an ECS service have a group of service:<name>
107
+ 'group' => [{ 'anything-but' => { 'prefix' => 'service:' } }]
108
+ }
109
+ if resource.has_key?('TaskDefinitionFamily')
110
+ # the trailing colon stops family `app` matching `app-v2`
111
+ @scope['taskDefinitionArn'] = [{ 'prefix' => "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/#{resource['TaskDefinitionFamily']}:" }]
112
+ end
113
+ end
114
+
115
+ # Always scope to the resource's cluster and family, including custom and
116
+ # inherited subscriptions that set their own Detail.
117
+ def detail
118
+ return @detail.merge(@scope)
119
+ end
120
+ end
121
+
98
122
  class AcmEventSubscription < BaseEventSubscription; end
99
123
  class ApiGatewayEventSubscription < BaseEventSubscription; end
100
124
  class ApplicationTargetGroupEventSubscription < BaseEventSubscription; end
@@ -251,6 +251,12 @@ module CfnGuardian::Resource
251
251
  logger.debug("overriding #{obj.type} property '#{attr}' with value #{value} for resource id: #{obj.resource_id}")
252
252
  begin
253
253
  obj.send("#{attr.to_underscore}=",value.clone)
254
+ # Track that Threshold was set via a config override (as opposed to a resource
255
+ # group's own default_alarms definition) so validate_resources can detect a
256
+ # config that sets both a static Threshold and AnomalyDetection. This uses a
257
+ # dedicated marker method (not a public threshold_overridden= writer) so a
258
+ # config can't set ThresholdOverridden directly to bypass that validation.
259
+ obj.mark_threshold_overridden! if attr.to_underscore == 'threshold' && obj.respond_to?(:mark_threshold_overridden!)
254
260
  rescue NoMethodError => e
255
261
  if !e.message.match?(/inherit/)
256
262
  logger.warn "Unknown property '#{attr}' for type: #{obj.type} and resource id: #{obj.resource_id}"
@@ -0,0 +1,30 @@
1
+ module CfnGuardian::Resource
2
+ # Monitors ECS tasks that run outside of an ECS service, such as tasks launched
3
+ # by an EventBridge rule or EventBridge Scheduler. These tasks have no service
4
+ # metrics, so failures are detected from the ECS Task State Change events.
5
+ # The cluster and task definition family scope is applied by
6
+ # ECSScheduledTaskEventSubscription, so custom subscriptions get it too.
7
+ class ECSScheduledTask < Base
8
+
9
+ def default_event_subscriptions()
10
+ event_subscription = CfnGuardian::Models::ECSScheduledTaskEventSubscription.new(@resource)
11
+ event_subscription.name = 'TaskFailed'
12
+ event_subscription.detail = {
13
+ 'lastStatus' => ['STOPPED'],
14
+ # numeric matching skips containers with a null or missing exit code,
15
+ # which `anything-but: 0` would match
16
+ 'containers' => { 'exitCode' => [{ 'numeric' => ['>', 0] }] }
17
+ }
18
+ @event_subscriptions.push(event_subscription)
19
+
20
+ event_subscription = CfnGuardian::Models::ECSScheduledTaskEventSubscription.new(@resource)
21
+ event_subscription.name = 'TaskFailedToStart'
22
+ event_subscription.detail = {
23
+ 'lastStatus' => ['STOPPED'],
24
+ 'stopCode' => ['TaskFailedToStart']
25
+ }
26
+ @event_subscriptions.push(event_subscription)
27
+ end
28
+
29
+ end
30
+ end
@@ -34,6 +34,7 @@ module CfnGuardian
34
34
  actions = alarm.alarm_action.kind_of?(Array) ? alarm.alarm_action.map{|action| Ref(action)} : [Ref(alarm.alarm_action)]
35
35
  actions.concat alarm.maintenance_groups.map {|mg| Ref(mg)} if alarm.maintenance_groups.any?
36
36
  use_search = alarm.search_expression.is_a?(String) && !alarm.search_expression.strip.empty?
37
+ use_anomaly_detection = alarm.anomaly_detection == true
37
38
 
38
39
  @template.declare do
39
40
  CloudWatch_Alarm("#{alarm.resource_hash}#{alarm.group}#{alarm.name.gsub(/[^0-9a-zA-Z]/i, '')}#{alarm.type}"[0..255]) do
@@ -42,7 +43,9 @@ module CfnGuardian
42
43
  AlarmName CfnGuardian::CloudWatch.get_alarm_name(alarm)
43
44
  ComparisonOperator alarm.comparison_operator
44
45
  EvaluationPeriods alarm.evaluation_periods
45
- Threshold alarm.threshold
46
+ # Threshold and ThresholdMetricId are mutually exclusive on AWS::CloudWatch::Alarm -
47
+ # an anomaly detection alarm uses ThresholdMetricId (set below) instead of a static Threshold.
48
+ Threshold alarm.threshold unless use_anomaly_detection
46
49
  AlarmActions actions
47
50
  OKActions actions unless alarm.ok_action_disabled
48
51
  TreatMissingData alarm.treat_missing_data unless alarm.treat_missing_data.nil?
@@ -63,6 +66,34 @@ module CfnGuardian
63
66
  ReturnData: true
64
67
  }
65
68
  ]
69
+ elsif use_anomaly_detection
70
+ band_width = alarm.standard_deviation || 2
71
+ metric_stat = {
72
+ Metric: {
73
+ Namespace: alarm.namespace,
74
+ MetricName: alarm.metric_name
75
+ },
76
+ Period: alarm.period,
77
+ Stat: alarm.extended_statistic.nil? ? alarm.statistic : alarm.extended_statistic
78
+ }
79
+ metric_stat[:Metric][:Dimensions] = alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil?
80
+ metric_stat[:Unit] = alarm.unit unless alarm.unit.nil?
81
+
82
+ Metrics [
83
+ {
84
+ Id: 'm1',
85
+ MetricStat: metric_stat,
86
+ ReturnData: true
87
+ },
88
+ {
89
+ Id: 'ad1',
90
+ Expression: "ANOMALY_DETECTION_BAND(m1, #{band_width})",
91
+ Label: "#{alarm.metric_name} (expected)",
92
+ ReturnData: true
93
+ }
94
+ ]
95
+ ThresholdMetricId 'ad1'
96
+ EvaluateLowSampleCountPercentile alarm.evaluate_low_sample_count_percentile unless alarm.evaluate_low_sample_count_percentile.nil?
66
97
  else
67
98
  Dimensions alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil?
68
99
  Statistic alarm.statistic if alarm.extended_statistic.nil?
@@ -1,4 +1,4 @@
1
1
  module CfnGuardian
2
- VERSION = "0.13.3"
2
+ VERSION = "0.15.0"
3
3
  CHANGE_SET_VERSION = VERSION.gsub('.', '-').freeze
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cfn-guardian
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.3
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Guslington
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -312,6 +312,7 @@ files:
312
312
  - cfn-guardian.gemspec
313
313
  - docs/alarm_tags.md
314
314
  - docs/alarm_templates.md
315
+ - docs/anomaly_detection.md
315
316
  - docs/cli.md
316
317
  - docs/composite_alarms.md
317
318
  - docs/custom_checks/azure_file_check.md
@@ -326,6 +327,7 @@ files:
326
327
  - docs/custom_checks/tls.md
327
328
  - docs/custom_checks/websocket.md
328
329
  - docs/custom_metrics.md
330
+ - docs/ecs_scheduled_tasks.md
329
331
  - docs/event_subscriptions.md
330
332
  - docs/maintenance_mode.md
331
333
  - docs/notifiers.md
@@ -368,6 +370,7 @@ files:
368
370
  - lib/cfnguardian/resources/dynamodb_table.rb
369
371
  - lib/cfnguardian/resources/ec2_instance.rb
370
372
  - lib/cfnguardian/resources/ecs_cluster.rb
373
+ - lib/cfnguardian/resources/ecs_scheduled_task.rb
371
374
  - lib/cfnguardian/resources/ecs_service.rb
372
375
  - lib/cfnguardian/resources/eks_container_insights.rb
373
376
  - lib/cfnguardian/resources/elastic_file_system.rb