Skip to content

Commit efb4049

Browse files
committed
Address PR review feedback for search expression feature
1 parent e270a44 commit efb4049

File tree

5 files changed

+57
-73
lines changed

5 files changed

+57
-73
lines changed

docs/search_expressions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ When a CloudFormation stack replaces an ASG on deployment, the physical ASG name
88

99
## How It Works
1010

11-
Instead of emitting a CloudWatch alarm with fixed `Dimensions`, `MetricName`, `Namespace`, and `Statistic` properties, a search expression alarm emits `MetricDataQueries` with:
11+
Instead of emitting a CloudWatch alarm with fixed `Dimensions`, `MetricName`, `Namespace`, and `Statistic` properties, a search expression alarm emits the CloudFormation `Metrics` property (a list of `MetricDataQuery` objects) with:
1212

1313
1. A **SEARCH()** expression that dynamically matches metrics by partial or exact name
1414
2. An **aggregation function** (e.g. `MAX`, `AVG`, `SUM`) that reduces the matched metrics to a single time series for threshold evaluation
1515

1616
## Configuration
1717

18-
Add `SearchExpression` and optionally `SearchAggregation` to an alarm template. When `SearchExpression` is set, the `Dimensions`, `MetricName`, `Namespace`, `Statistic`, and `Period` properties are not used since CloudWatch treats these as mutually exclusive with `MetricDataQueries`.
18+
Add `SearchExpression` and optionally `SearchAggregation` to an alarm template. When `SearchExpression` is set, the `Dimensions`, `MetricName`, `Namespace`, `Statistic`, and `Period` properties are not used since CloudWatch treats these as mutually exclusive with the alarm `Metrics` property.
1919

2020
### Properties
2121

lib/cfnguardian/compile.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,17 @@ def validate_resources()
191191
case resource.type
192192
when 'Alarm'
193193
if resource.search_expression
194-
if resource.search_expression.empty?
195-
@errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has an empty SearchExpression."
194+
if !resource.search_expression.is_a?(String) || resource.search_expression.strip.empty?
195+
@errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has an invalid SearchExpression. Must be a non-empty string."
196+
end
197+
if resource.search_aggregation
198+
valid_aggregations = %w(MAX MIN AVG SUM)
199+
normalized = resource.search_aggregation.to_s.upcase
200+
if valid_aggregations.include?(normalized)
201+
resource.search_aggregation = normalized
202+
else
203+
@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+
end
196205
end
197206
else
198207
%w(metric_name namespace).each do |property|

lib/cfnguardian/resources/base.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ def get_alarms(group,overides={})
126126

127127
# String interpolation for search expressions
128128
if alarm.search_expression.is_a?(String)
129-
alarm.search_expression = alarm.search_expression.gsub(/\${Resource::([A-Za-z]+)}/) do
130-
resource_key = $1
129+
alarm.search_expression = alarm.search_expression.gsub(/\${Resource::([A-Za-z0-9_]+)}/) do
130+
resource_key = Regexp.last_match(1)
131131
if @resource.has_key?(resource_key)
132132
logger.debug "interpolating search_expression variable '#{resource_key}' with value '#{@resource[resource_key]}' for alarm #{alarm.name}"
133133
@resource[resource_key]

lib/cfnguardian/stacks/resources.rb

Lines changed: 25 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -33,67 +33,45 @@ def build_template(resources)
3333
def add_alarm(alarm)
3434
actions = alarm.alarm_action.kind_of?(Array) ? alarm.alarm_action.map{|action| Ref(action)} : [Ref(alarm.alarm_action)]
3535
actions.concat alarm.maintenance_groups.map {|mg| Ref(mg)} if alarm.maintenance_groups.any?
36+
use_search = alarm.search_expression ? true : false
3637

37-
if alarm.search_expression
38-
add_search_expression_alarm(alarm, actions)
39-
else
40-
add_standard_alarm(alarm, actions)
41-
end
42-
end
43-
44-
def add_standard_alarm(alarm, actions)
4538
@template.declare do
4639
CloudWatch_Alarm("#{alarm.resource_hash}#{alarm.group}#{alarm.name.gsub(/[^0-9a-zA-Z]/i, '')}#{alarm.type}"[0..255]) do
4740
ActionsEnabled true
4841
AlarmDescription "Guardian alarm #{alarm.name} for the resource #{alarm.resource_id} in alarm group #{alarm.group}"
4942
AlarmName CfnGuardian::CloudWatch.get_alarm_name(alarm)
5043
ComparisonOperator alarm.comparison_operator
51-
Dimensions alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil?
5244
EvaluationPeriods alarm.evaluation_periods
53-
Statistic alarm.statistic if alarm.extended_statistic.nil?
54-
Period alarm.period
5545
Threshold alarm.threshold
56-
MetricName alarm.metric_name
57-
Namespace alarm.namespace
5846
AlarmActions actions
5947
OKActions actions unless alarm.ok_action_disabled
6048
TreatMissingData alarm.treat_missing_data unless alarm.treat_missing_data.nil?
6149
DatapointsToAlarm alarm.datapoints_to_alarm unless alarm.datapoints_to_alarm.nil?
62-
ExtendedStatistic alarm.extended_statistic unless alarm.extended_statistic.nil?
63-
EvaluateLowSampleCountPercentile alarm.evaluate_low_sample_count_percentile unless alarm.evaluate_low_sample_count_percentile.nil?
64-
Unit alarm.unit unless alarm.unit.nil?
65-
end
66-
end
67-
end
6850

69-
def add_search_expression_alarm(alarm, actions)
70-
search_expr = alarm.search_expression
71-
aggregation = alarm.search_aggregation || 'MAX'
72-
73-
@template.declare do
74-
CloudWatch_Alarm("#{alarm.resource_hash}#{alarm.group}#{alarm.name.gsub(/[^0-9a-zA-Z]/i, '')}#{alarm.type}"[0..255]) do
75-
ActionsEnabled true
76-
AlarmDescription "Guardian alarm #{alarm.name} for the resource #{alarm.resource_id} in alarm group #{alarm.group}"
77-
AlarmName CfnGuardian::CloudWatch.get_alarm_name(alarm)
78-
ComparisonOperator alarm.comparison_operator
79-
EvaluationPeriods alarm.evaluation_periods
80-
Threshold alarm.threshold
81-
AlarmActions actions
82-
OKActions actions unless alarm.ok_action_disabled
83-
TreatMissingData alarm.treat_missing_data unless alarm.treat_missing_data.nil?
84-
DatapointsToAlarm alarm.datapoints_to_alarm unless alarm.datapoints_to_alarm.nil?
85-
Metrics [
86-
{
87-
Id: 'search_expression',
88-
Expression: search_expr,
89-
ReturnData: false
90-
},
91-
{
92-
Id: 'aggregate',
93-
Expression: "#{aggregation}(search_expression)",
94-
ReturnData: true
95-
}
96-
]
51+
if use_search
52+
aggregation = alarm.search_aggregation || 'MAX'
53+
Metrics [
54+
{
55+
Id: 'search_expression',
56+
Expression: alarm.search_expression,
57+
ReturnData: false
58+
},
59+
{
60+
Id: 'aggregate',
61+
Expression: "#{aggregation}(search_expression)",
62+
ReturnData: true
63+
}
64+
]
65+
else
66+
Dimensions alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil?
67+
Statistic alarm.statistic if alarm.extended_statistic.nil?
68+
Period alarm.period
69+
MetricName alarm.metric_name
70+
Namespace alarm.namespace
71+
ExtendedStatistic alarm.extended_statistic unless alarm.extended_statistic.nil?
72+
EvaluateLowSampleCountPercentile alarm.evaluate_low_sample_count_percentile unless alarm.evaluate_low_sample_count_percentile.nil?
73+
Unit alarm.unit unless alarm.unit.nil?
74+
end
9775
end
9876
end
9977
end

spec/search_expression_spec.rb

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require 'spec_helper'
22
require 'json'
33
require 'yaml'
4+
require 'fileutils'
5+
require 'tmpdir'
46
require 'term/ansicolor'
57
require 'cfnguardian/log'
68
require 'cfnguardian/models/alarm'
@@ -179,35 +181,30 @@
179181
end
180182

181183
describe 'Validation' do
182-
let(:config_dir) { File.join(File.dirname(__FILE__), 'fixtures') }
183-
184184
context 'when search expression alarm has no metric_name or namespace' do
185185
it 'does not raise validation errors' do
186-
fixture = File.join(config_dir, 'search_expression_alarms.yaml')
187-
FileUtils.mkdir_p(config_dir)
188-
File.write(fixture, {
189-
'Resources' => {
190-
'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }]
191-
},
192-
'Templates' => {
193-
'AutoScalingGroup' => {
194-
'CPUUtilizationHighBase' => {
195-
'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app', 'Minimum', 60)",
196-
'SearchAggregation' => 'MAX'
197-
},
198-
'StatusCheckFailed' => false
186+
Dir.mktmpdir do |tmpdir|
187+
fixture = File.join(tmpdir, 'search_expression_alarms.yaml')
188+
File.write(fixture, {
189+
'Resources' => {
190+
'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }]
191+
},
192+
'Templates' => {
193+
'AutoScalingGroup' => {
194+
'CPUUtilizationHighBase' => {
195+
'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app', 'Minimum', 60)",
196+
'SearchAggregation' => 'MAX'
197+
},
198+
'StatusCheckFailed' => false
199+
}
199200
}
200-
}
201-
}.to_yaml)
201+
}.to_yaml)
202202

203-
begin
204203
compile = CfnGuardian::Compile.new(fixture, false)
205204
compile.get_resources
206205
search_alarms = compile.alarms.select { |a| a.search_expression }
207206
expect(search_alarms.length).to eq(1)
208207
expect(search_alarms.first.search_expression).to include('SEARCH')
209-
ensure
210-
FileUtils.rm_f(fixture)
211208
end
212209
end
213210
end

0 commit comments

Comments
 (0)