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 +4 -4
- data/Gemfile.lock +1 -1
- data/docs/alarm_templates.md +17 -1
- data/docs/anomaly_detection.md +74 -0
- data/docs/overview.md +2 -1
- data/lib/cfnguardian/compile.rb +36 -0
- data/lib/cfnguardian/display_formatter.rb +87 -14
- data/lib/cfnguardian/models/alarm.rb +21 -6
- data/lib/cfnguardian/resources/base.rb +6 -0
- data/lib/cfnguardian/stacks/resources.rb +32 -1
- data/lib/cfnguardian/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4595a270016361e041e9aa0bfc6a4921388036bc224152e63b4d71fa3c0f3c8
|
|
4
|
+
data.tar.gz: 0fc14a5114332c794335432edfc28907d5080a5faeab449118da714b17b1b54f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 053dc2627f05cfda432920324f0b7c5b171fe9121b6d46f528c99c3c6042eb1660d508c41ba65f00cd38a67befdea8936a7ceb9bedc96e8ab83ea02a7bea2c60
|
|
7
|
+
data.tar.gz: 31ad2da72a21a7952b93934f1b67dd8e3f78849dd1957355efb799b796eda2d69ee7e270abee63aa64140db940c16f5c607ca06316b361bfb719f68c7f3662f9
|
data/Gemfile.lock
CHANGED
data/docs/alarm_templates.md
CHANGED
|
@@ -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)
|
data/lib/cfnguardian/compile.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
64
|
-
['Dimensions', alarm.dimensions,
|
|
65
|
-
|
|
66
|
-
|
|
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',
|
|
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,
|
|
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
|
|
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?
|
data/lib/cfnguardian/version.rb
CHANGED
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.
|
|
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
|
|
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
|