cfn-guardian 0.13.3 → 0.14.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: e4595a270016361e041e9aa0bfc6a4921388036bc224152e63b4d71fa3c0f3c8
4
+ data.tar.gz: 0fc14a5114332c794335432edfc28907d5080a5faeab449118da714b17b1b54f
5
5
  SHA512:
6
- metadata.gz: ffbcd0281d97cc3b4ff01e4dd4de1f5cd5f3db76759a9e2b93a831d346acbd3849702d450039bd54e1031bb9782d7a3ff9109ebda738a4c18a39a425ab728fe5
7
- data.tar.gz: 679154efbf9054ffabde0511df35e7b618cd602e170b687f496f6e8ec0710219badcc050a2ea5562914f5ce11e8e09eca74522ac048c200dd0b1feb2e081b670
6
+ metadata.gz: 053dc2627f05cfda432920324f0b7c5b171fe9121b6d46f528c99c3c6042eb1660d508c41ba65f00cd38a67befdea8936a7ceb9bedc96e8ab83ea02a7bea2c60
7
+ data.tar.gz: 31ad2da72a21a7952b93934f1b67dd8e3f78849dd1957355efb799b796eda2d69ee7e270abee63aa64140db940c16f5c607ca06316b361bfb719f68c7f3662f9
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.14.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)
@@ -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.
data/docs/overview.md CHANGED
@@ -23,4 +23,5 @@
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)
@@ -190,6 +190,22 @@ module CfnGuardian
190
190
  @resources.each do |resource|
191
191
  case resource.type
192
192
  when 'Alarm'
193
+ unless [true, false].include?(resource.anomaly_detection)
194
+ @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)."
195
+ end
196
+
197
+ unless resource.standard_deviation.nil?
198
+ if !resource.standard_deviation.is_a?(Numeric) || !resource.standard_deviation.finite? || resource.standard_deviation <= 0
199
+ @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid StandardDeviation '#{resource.standard_deviation}'. Must be a finite positive number."
200
+ elsif resource.anomaly_detection != true
201
+ @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."
202
+ end
203
+ end
204
+
205
+ if resource.search_expression && resource.anomaly_detection == true
206
+ @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."
207
+ end
208
+
193
209
  if resource.search_expression
194
210
  if !resource.search_expression.is_a?(String) || resource.search_expression.strip.empty?
195
211
  @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has an invalid SearchExpression. Must be a non-empty string."
@@ -203,7 +219,27 @@ module CfnGuardian
203
219
  @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
220
  end
205
221
  end
222
+ elsif resource.anomaly_detection == true
223
+ valid_operators = %w(GreaterThanUpperThreshold LessThanLowerOrGreaterThanUpperThreshold LessThanLowerThreshold)
224
+ unless valid_operators.include?(resource.comparison_operator)
225
+ @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(', ')}."
226
+ end
227
+
228
+ if resource.threshold_overridden
229
+ @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."
230
+ end
231
+
232
+ %w(metric_name namespace).each do |property|
233
+ if resource.send(property).nil?
234
+ @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."
235
+ end
236
+ end
206
237
  else
238
+ anomaly_only_operators = %w(GreaterThanUpperThreshold LessThanLowerOrGreaterThanUpperThreshold LessThanLowerThreshold)
239
+ if anomaly_only_operators.include?(resource.comparison_operator)
240
+ @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."
241
+ end
242
+
207
243
  %w(metric_name namespace).each do |property|
208
244
  if resource.send(property).nil?
209
245
  @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
@@ -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}"
@@ -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.14.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.14.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-09-07 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