diff --git a/.github/workflows/build-gem.yml b/.github/workflows/build-gem.yml index e2a41fd..08853f3 100644 --- a/.github/workflows/build-gem.yml +++ b/.github/workflows/build-gem.yml @@ -21,8 +21,9 @@ jobs: - name: rspec run: | - gem install rspec - rspec + gem install bundler -v 2.4.22 + bundle install + bundle exec rspec - name: build gem run: | diff --git a/Gemfile.lock b/Gemfile.lock index ad49c3c..0a3c669 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cfn-guardian (0.10.4) + cfn-guardian (0.13.0) aws-sdk-cloudformation (~> 1.76, < 2) aws-sdk-cloudwatch (~> 1.72, < 2) aws-sdk-codecommit (~> 1.53, < 2) @@ -10,71 +10,97 @@ PATH aws-sdk-rds (~> 1.174, < 2) aws-sdk-s3 (~> 1.119, < 2) cfndsl (~> 1.0, < 2) - rexml + rexml (= 3.3.0) term-ansicolor (~> 1, < 2) terminal-table (~> 1, < 2) thor (~> 0.20) + tins (~> 1.42.0) GEM remote: https://rubygems.org/ specs: - aws-eventstream (1.2.0) - aws-partitions (1.737.0) - aws-sdk-cloudformation (1.76.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-cloudwatch (1.72.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-codecommit (1.53.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-codepipeline (1.55.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-core (3.171.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) + aws-eventstream (1.4.0) + aws-partitions (1.1239.0) + aws-sdk-cloudformation (1.150.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-cloudwatch (1.134.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-codecommit (1.97.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-codepipeline (1.113.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) + aws-sdk-core (3.244.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-ec2 (1.371.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-rds (1.174.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.119.2) - aws-sdk-core (~> 3, >= 3.165.0) + logger + aws-sdk-ec2 (1.611.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-rds (1.311.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - cfndsl (1.6.0) + base64 (0.3.0) + bigdecimal (4.1.1) + cfndsl (1.7.3) hana (~> 1.3) + diff-lcs (1.6.2) hana (1.3.7) jmespath (1.6.2) + logger (1.7.0) rake (13.0.6) - rexml (3.2.5) + rexml (3.3.0) + strscan + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + strscan (3.1.8) sync (0.5.0) - term-ansicolor (1.7.1) - tins (~> 1.0) + term-ansicolor (1.11.3) + tins (~> 1) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) thor (0.20.3) - tins (1.32.1) + tins (1.42.0) + bigdecimal sync unicode-display_width (1.8.0) PLATFORMS + aarch64-linux x86_64-darwin-21 DEPENDENCIES bundler (~> 2.0) cfn-guardian! rake (~> 13.0) + rspec (~> 3.0) BUNDLED WITH 2.3.19 diff --git a/cfn-guardian.gemspec b/cfn-guardian.gemspec index b182ed9..2f86e2c 100644 --- a/cfn-guardian.gemspec +++ b/cfn-guardian.gemspec @@ -43,4 +43,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler", "~> 2.0" spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.0" end diff --git a/docs/overview.md b/docs/overview.md index 4dd787e..adb6b3c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,4 +22,5 @@ 8. [Composite Alarms](composite_alarms.md) 9. [Alarms for Custom Metrics](custom_metrics.md) 10. [Dimension Variables](variables.md) -11. [Alarm Tags](alarm_tags.md) \ No newline at end of file +11. [Search Expression Alarms](search_expressions.md) +12. [Alarm Tags](alarm_tags.md) \ No newline at end of file diff --git a/docs/search_expressions.md b/docs/search_expressions.md new file mode 100644 index 0000000..7654864 --- /dev/null +++ b/docs/search_expressions.md @@ -0,0 +1,120 @@ +# Search Expression Alarms + +Search expression alarms use CloudWatch [SEARCH()](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html) to dynamically match metrics instead of targeting a fixed set of dimensions. This is useful when the physical resource ID changes between deployments, such as Auto Scaling Groups that use replacement update policies in CloudFormation. + +## The Problem + +When a CloudFormation stack replaces an ASG on deployment, the physical ASG name changes (e.g. `my-app-AsgGroup-abc123` becomes `my-app-AsgGroup-xyz789`). Standard alarms use fixed dimensions that reference the exact ASG name, so they break after every deployment until Guardian is recompiled and redeployed with the new name. + +## How It Works + +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: + +1. A **SEARCH()** expression that dynamically matches metrics by partial or exact name +2. An **aggregation function** (e.g. `MAX`, `AVG`, `SUM`) that reduces the matched metrics to a single time series for threshold evaluation + +## Configuration + +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. + +### Properties + +| Property | Required | Default | Description | +| --- | --- | --- | --- | +| `SearchExpression` | Yes | - | A CloudWatch SEARCH() expression string. Supports `${Resource::...}` [variables](variables.md). | +| `SearchAggregation` | No | `MAX` | Aggregation function applied to the search results. Valid values: `MAX`, `MIN`, `AVG`, `SUM`. | + +### Overriding Default Alarms + +You can convert existing default alarms to use search expressions by overriding them in the template: + +```yaml +Resources: + AutoScalingGroup: + - Id: my-app-AsgGroup + +Templates: + AutoScalingGroup: + CPUUtilizationHighBase: + SearchExpression: "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" \"${Resource::Id}\"', 'Minimum', 60)" + SearchAggregation: MAX + StatusCheckFailed: + SearchExpression: "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"StatusCheckFailed\" \"${Resource::Id}\"', 'Maximum', 60)" + SearchAggregation: MAX +``` + +In this example the `Id` is the stable prefix of the ASG name. The double quotes around `\"${Resource::Id}\"` inside the SEARCH expression perform an exact substring match, so `my-app-AsgGroup-abc123` and `my-app-AsgGroup-xyz789` both match but unrelated ASGs do not. + +### Creating New Alarms + +You can also create new search expression alarms that don't override any defaults: + +```yaml +Templates: + AutoScalingGroup: + NetworkOutHigh: + SearchExpression: "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"NetworkOut\" \"${Resource::Id}\"', 'Average', 300)" + SearchAggregation: SUM + Threshold: 1000000000 + ComparisonOperator: GreaterThanThreshold + EvaluationPeriods: 3 + AlarmAction: Warning +``` + +### Using With Other Resource Groups + +Search expressions work with any resource group, not just AutoScalingGroup: + +```yaml +Resources: + ECSService: + - Id: my-service + Cluster: my-cluster + +Templates: + ECSService: + CPUUtilizationHigh: + SearchExpression: "SEARCH('{AWS/ECS,ServiceName,ClusterName} MetricName=\"CPUUtilization\" \"${Resource::Id}\"', 'Average', 60)" + SearchAggregation: MAX + Threshold: 90 + EvaluationPeriods: 5 +``` + +## Variables + +`${Resource::...}` variables are interpolated inside search expressions the same way as in [dimension variables](variables.md). You can reference any key from the resource definition: + +```yaml +Resources: + AutoScalingGroup: + - Id: my-app-AsgGroup + Environment: production + +Templates: + AutoScalingGroup: + CPUUtilizationHighBase: + SearchExpression: "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" \"${Resource::Id}\"', 'Minimum', 60)" + SearchAggregation: MAX +``` + +## CloudWatch SEARCH() Syntax Quick Reference + +The general format is: + +``` +SEARCH('{Namespace,DimensionName} SearchTerm', 'Statistic', Period) +``` + +- **Partial match**: `my-app` matches any metric with a token `my` or `app` in any dimension value +- **Exact match**: `"my-app-AsgGroup"` matches only the exact substring `my-app-AsgGroup` +- **Boolean operators**: `AND`, `OR`, `NOT` can be used to combine terms +- **Property designators**: `MetricName="CPUUtilization"` restricts matching to the metric name + +See the [CloudWatch search expression syntax documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html) for full details. + +## Limitations + +- **2-week lookback**: SEARCH() only finds metrics that have reported data within the last 2 weeks +- **100 metric limit**: A single SEARCH expression can match up to 100 time series +- **1024 character limit**: The search expression query string cannot exceed 1024 characters +- **Aggregation required**: Since SEARCH can return multiple time series, the aggregation function reduces them to a single series for threshold comparison diff --git a/docs/variables.md b/docs/variables.md index 116d324..e763da7 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -1,6 +1,6 @@ ## Dimension Variables -variables can be used to reference resource group values such as the resource Id within the dimensions section of an alarm template. +Variables can be used to reference resource group values such as the resource Id within the dimensions section of an alarm template. They are also supported inside [search expressions](search_expressions.md). For example here we are creating an alarm for a disk usage metric for a group of EC2 instances. diff --git a/lib/cfnguardian/compile.rb b/lib/cfnguardian/compile.rb index 8753421..b95b3a3 100644 --- a/lib/cfnguardian/compile.rb +++ b/lib/cfnguardian/compile.rb @@ -190,9 +190,24 @@ def validate_resources() @resources.each do |resource| case resource.type when 'Alarm' - %w(metric_name namespace).each do |property| - if resource.send(property).nil? - @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." + if resource.search_expression + if !resource.search_expression.is_a?(String) || resource.search_expression.strip.empty? + @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has an invalid SearchExpression. Must be a non-empty string." + end + if resource.search_aggregation + valid_aggregations = %w(MAX MIN AVG SUM) + normalized = resource.search_aggregation.to_s.upcase + if valid_aggregations.include?(normalized) + resource.search_aggregation = normalized + else + @errors << "CfnGuardian::AlarmPropertyError - alarm #{resource.name} for resource #{resource.resource_id} has invalid SearchAggregation '#{resource.search_aggregation}'. Must be one of: #{valid_aggregations.join(', ')}." + end + end + else + %w(metric_name namespace).each do |property| + if resource.send(property).nil? + @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." + end end end when 'Check' diff --git a/lib/cfnguardian/models/alarm.rb b/lib/cfnguardian/models/alarm.rb index 6f1309d..f0576ae 100644 --- a/lib/cfnguardian/models/alarm.rb +++ b/lib/cfnguardian/models/alarm.rb @@ -31,7 +31,9 @@ class BaseAlarm :unit, :maintenance_groups, :additional_notifiers, - :tags + :tags, + :search_expression, + :search_aggregation def initialize(resource) @type = 'Alarm' @@ -60,6 +62,8 @@ def initialize(resource) @maintenance_groups = [] @additional_notifiers = [] @tags = {} + @search_expression = nil + @search_aggregation = nil end def metric_name=(metric_name) diff --git a/lib/cfnguardian/resources/base.rb b/lib/cfnguardian/resources/base.rb index 5f440cc..0e8a3bc 100644 --- a/lib/cfnguardian/resources/base.rb +++ b/lib/cfnguardian/resources/base.rb @@ -110,15 +110,29 @@ def get_alarms(group,overides={}) @alarms.each {|a| a.group = @override_group} end - # String interpolation for alarm dimensions @alarms.each do |alarm| - next if alarm.dimensions.nil? - alarm.dimensions.each do |k,v| - if v.is_a?(String) && v.match?(/^\${Resource::.*[A-Za-z]}$/) - resource_key = v.tr('${}', '').split('Resource::').last + # String interpolation for alarm dimensions + unless alarm.dimensions.nil? + alarm.dimensions.each do |k,v| + if v.is_a?(String) && v.match?(/^\${Resource::.*[A-Za-z]}$/) + resource_key = v.tr('${}', '').split('Resource::').last + if @resource.has_key?(resource_key) + logger.debug "overriding alarm #{alarm.name} dimension key '#{k}' with value '#{@resource[resource_key]}'" + alarm.dimensions[k] = @resource[resource_key] + end + end + end + end + + # String interpolation for search expressions + if alarm.search_expression.is_a?(String) + alarm.search_expression = alarm.search_expression.gsub(/\${Resource::([A-Za-z0-9_]+)}/) do + resource_key = Regexp.last_match(1) if @resource.has_key?(resource_key) - logger.debug "overriding alarm #{alarm.name} dimension key '#{k}' with value '#{@resource[resource_key]}'" - alarm.dimensions[k] = @resource[resource_key] + logger.debug "interpolating search_expression variable '#{resource_key}' with value '#{@resource[resource_key]}' for alarm #{alarm.name}" + @resource[resource_key] + else + "${Resource::#{resource_key}}" end end end diff --git a/lib/cfnguardian/stacks/resources.rb b/lib/cfnguardian/stacks/resources.rb index b87e209..c43037f 100644 --- a/lib/cfnguardian/stacks/resources.rb +++ b/lib/cfnguardian/stacks/resources.rb @@ -33,6 +33,7 @@ def build_template(resources) def add_alarm(alarm) actions = alarm.alarm_action.kind_of?(Array) ? alarm.alarm_action.map{|action| Ref(action)} : [Ref(alarm.alarm_action)] actions.concat alarm.maintenance_groups.map {|mg| Ref(mg)} if alarm.maintenance_groups.any? + use_search = alarm.search_expression.is_a?(String) && !alarm.search_expression.strip.empty? @template.declare do CloudWatch_Alarm("#{alarm.resource_hash}#{alarm.group}#{alarm.name.gsub(/[^0-9a-zA-Z]/i, '')}#{alarm.type}"[0..255]) do @@ -40,20 +41,37 @@ def add_alarm(alarm) AlarmDescription "Guardian alarm #{alarm.name} for the resource #{alarm.resource_id} in alarm group #{alarm.group}" AlarmName CfnGuardian::CloudWatch.get_alarm_name(alarm) ComparisonOperator alarm.comparison_operator - Dimensions alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil? EvaluationPeriods alarm.evaluation_periods - Statistic alarm.statistic if alarm.extended_statistic.nil? - Period alarm.period Threshold alarm.threshold - MetricName alarm.metric_name - Namespace alarm.namespace AlarmActions actions OKActions actions unless alarm.ok_action_disabled TreatMissingData alarm.treat_missing_data unless alarm.treat_missing_data.nil? DatapointsToAlarm alarm.datapoints_to_alarm unless alarm.datapoints_to_alarm.nil? - ExtendedStatistic alarm.extended_statistic unless alarm.extended_statistic.nil? - EvaluateLowSampleCountPercentile alarm.evaluate_low_sample_count_percentile unless alarm.evaluate_low_sample_count_percentile.nil? - Unit alarm.unit unless alarm.unit.nil? + + if use_search + aggregation = alarm.search_aggregation || 'MAX' + Metrics [ + { + Id: 'search_expression', + Expression: alarm.search_expression, + ReturnData: false + }, + { + Id: 'aggregate', + Expression: "#{aggregation}(search_expression)", + ReturnData: true + } + ] + else + Dimensions alarm.dimensions.map {|k,v| {Name: k, Value: v}} unless alarm.dimensions.nil? + Statistic alarm.statistic if alarm.extended_statistic.nil? + Period alarm.period + MetricName alarm.metric_name + Namespace alarm.namespace + ExtendedStatistic alarm.extended_statistic unless alarm.extended_statistic.nil? + EvaluateLowSampleCountPercentile alarm.evaluate_low_sample_count_percentile unless alarm.evaluate_low_sample_count_percentile.nil? + Unit alarm.unit unless alarm.unit.nil? + end end end end diff --git a/lib/cfnguardian/version.rb b/lib/cfnguardian/version.rb index 68a9580..73636aa 100644 --- a/lib/cfnguardian/version.rb +++ b/lib/cfnguardian/version.rb @@ -1,4 +1,4 @@ module CfnGuardian - VERSION = "0.12.1" + VERSION = "0.13.0" CHANGE_SET_VERSION = VERSION.gsub('.', '-').freeze end diff --git a/spec/search_expression_spec.rb b/spec/search_expression_spec.rb new file mode 100644 index 0000000..ee39569 --- /dev/null +++ b/spec/search_expression_spec.rb @@ -0,0 +1,301 @@ +require 'spec_helper' +require 'json' +require 'yaml' +require 'tmpdir' +require 'term/ansicolor' +require 'cfnguardian/log' +require 'cfnguardian/models/alarm' +require 'cfnguardian/stacks/resources' +require 'cfnguardian/resources/base' +require 'cfnguardian/resources/autoscaling_group' +require 'cfnguardian/compile' + +RSpec.describe 'Search expression alarm support' do + + describe CfnGuardian::Models::BaseAlarm do + let(:resource) { { 'Id' => 'test-resource' } } + let(:alarm) { CfnGuardian::Models::BaseAlarm.new(resource) } + + it 'initializes search_expression to nil' do + expect(alarm.search_expression).to be_nil + end + + it 'initializes search_aggregation to nil' do + expect(alarm.search_aggregation).to be_nil + end + + it 'allows setting search_expression' do + alarm.search_expression = "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\"', 'Maximum', 60)" + expect(alarm.search_expression).to include('SEARCH') + end + + it 'allows setting search_aggregation' do + alarm.search_aggregation = 'AVG' + expect(alarm.search_aggregation).to eq('AVG') + end + end + + describe CfnGuardian::Models::AutoScalingGroupAlarm do + let(:resource) { { 'Id' => 'my-app-AsgGroup-abc123' } } + let(:alarm) { CfnGuardian::Models::AutoScalingGroupAlarm.new(resource) } + + it 'defaults to standard dimensions with no search expression' do + expect(alarm.dimensions).to eq({ AutoScalingGroupName: 'my-app-AsgGroup-abc123' }) + expect(alarm.search_expression).to be_nil + end + + it 'can be converted to a search expression alarm' do + alarm.search_expression = "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app-AsgGroup', 'Minimum', 60)" + alarm.search_aggregation = 'MAX' + expect(alarm.search_expression).to include('my-app-AsgGroup') + expect(alarm.search_aggregation).to eq('MAX') + end + end + + describe CfnGuardian::Stacks::Resources do + let(:template) { CfnDsl::CloudFormationTemplate.new } + let(:stack) { CfnGuardian::Stacks::Resources.new(template) } + let(:resource) { { 'Id' => 'my-asg-abc123' } } + + context 'with a standard alarm' do + let(:alarm) do + a = CfnGuardian::Models::AutoScalingGroupAlarm.new(resource) + a.name = 'CPUUtilizationHighBase' + a.metric_name = 'CPUUtilization' + a.statistic = 'Minimum' + a.threshold = 90 + a.evaluation_periods = 10 + a.alarm_action = 'Critical' + a.maintenance_groups = [] + a + end + + it 'emits Dimensions, MetricName, Namespace, and Statistic' do + stack.build_template([alarm]) + output = JSON.parse(template.to_json) + alarm_resource = output['Resources'].values.first + props = alarm_resource['Properties'] + + expect(props).to have_key('Dimensions') + expect(props).to have_key('MetricName') + expect(props).to have_key('Namespace') + expect(props).to have_key('Statistic') + expect(props).not_to have_key('Metrics') + end + end + + context 'with a search expression alarm' do + let(:alarm) do + a = CfnGuardian::Models::AutoScalingGroupAlarm.new(resource) + a.name = 'CPUUtilizationHighBase' + a.metric_name = 'CPUUtilization' + a.threshold = 90 + a.evaluation_periods = 10 + a.alarm_action = 'Critical' + a.maintenance_groups = [] + a.search_expression = "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-asg', 'Minimum', 60)" + a.search_aggregation = 'MAX' + a + end + + it 'emits Metrics instead of Dimensions' do + stack.build_template([alarm]) + output = JSON.parse(template.to_json) + alarm_resource = output['Resources'].values.first + props = alarm_resource['Properties'] + + expect(props).to have_key('Metrics') + expect(props).not_to have_key('Dimensions') + expect(props).not_to have_key('MetricName') + expect(props).not_to have_key('Namespace') + expect(props).not_to have_key('Statistic') + expect(props).not_to have_key('Period') + end + + it 'sets up the search and aggregation metric data queries' do + stack.build_template([alarm]) + output = JSON.parse(template.to_json) + alarm_resource = output['Resources'].values.first + metrics = alarm_resource['Properties']['Metrics'] + + expect(metrics.length).to eq(2) + + search_metric = metrics.find { |m| m['Id'] == 'search_expression' } + expect(search_metric['Expression']).to include('SEARCH') + expect(search_metric['ReturnData']).to eq(false) + + agg_metric = metrics.find { |m| m['Id'] == 'aggregate' } + expect(agg_metric['Expression']).to eq('MAX(search_expression)') + expect(agg_metric['ReturnData']).to eq(true) + end + + it 'defaults aggregation to MAX when search_aggregation is nil' do + alarm.search_aggregation = nil + new_template = CfnDsl::CloudFormationTemplate.new + new_stack = CfnGuardian::Stacks::Resources.new(new_template) + new_stack.build_template([alarm]) + output = JSON.parse(new_template.to_json) + alarm_resource = output['Resources'].values.first + metrics = alarm_resource['Properties']['Metrics'] + + agg_metric = metrics.find { |m| m['Id'] == 'aggregate' } + expect(agg_metric['Expression']).to eq('MAX(search_expression)') + end + end + end + + describe 'Search expression variable interpolation' do + let(:resource) { { 'Id' => 'my-app-AsgGroup-abc123', 'Name' => 'my-app' } } + let(:resource_class) { CfnGuardian::Resource::AutoScalingGroup.new(resource) } + + it 'interpolates ${Resource::Id} in search expressions' do + overrides = { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" ${Resource::Id}', 'Minimum', 60)", + 'SearchAggregation' => 'MAX' + } + } + + alarms = resource_class.get_alarms('AutoScalingGroup', overrides) + cpu_alarm = alarms.find { |a| a.name == 'CPUUtilizationHighBase' } + + expect(cpu_alarm.search_expression).to include('my-app-AsgGroup-abc123') + expect(cpu_alarm.search_expression).not_to include('${Resource::Id}') + end + + it 'interpolates ${Resource::Name} in search expressions' do + overrides = { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" ${Resource::Name}', 'Minimum', 60)", + 'SearchAggregation' => 'MAX' + } + } + + alarms = resource_class.get_alarms('AutoScalingGroup', overrides) + cpu_alarm = alarms.find { |a| a.name == 'CPUUtilizationHighBase' } + + expect(cpu_alarm.search_expression).to include('my-app') + expect(cpu_alarm.search_expression).not_to include('${Resource::Name}') + end + end + + describe 'Validation' do + def compile_config(config) + Dir.mktmpdir do |tmpdir| + fixture = File.join(tmpdir, 'test_alarms.yaml') + File.write(fixture, config.to_yaml) + compile = CfnGuardian::Compile.new(fixture, false) + compile.get_resources + compile + end + end + + context 'when search expression alarm has no metric_name or namespace' do + it 'does not raise validation errors' do + result = compile_config({ + 'Resources' => { + 'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }] + }, + 'Templates' => { + 'AutoScalingGroup' => { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app', 'Minimum', 60)", + 'SearchAggregation' => 'MAX' + }, + 'StatusCheckFailed' => false + } + } + }) + + search_alarms = result.alarms.select { |a| a.search_expression } + expect(search_alarms.length).to eq(1) + expect(search_alarms.first.search_expression).to include('SEARCH') + end + end + + context 'when search expression is blank' do + it 'raises a validation error' do + expect { + compile_config({ + 'Resources' => { + 'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }] + }, + 'Templates' => { + 'AutoScalingGroup' => { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => ' ', + 'SearchAggregation' => 'MAX' + }, + 'StatusCheckFailed' => false + } + } + }) + }.to raise_error(CfnGuardian::ValidationError, /invalid SearchExpression/) + end + end + + context 'when search expression is not a string' do + it 'raises a validation error' do + expect { + compile_config({ + 'Resources' => { + 'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }] + }, + 'Templates' => { + 'AutoScalingGroup' => { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => ['not', 'a', 'string'], + 'SearchAggregation' => 'MAX' + }, + 'StatusCheckFailed' => false + } + } + }) + }.to raise_error(CfnGuardian::ValidationError, /invalid SearchExpression/) + end + end + + context 'when search aggregation is invalid' do + it 'raises a validation error' do + expect { + compile_config({ + 'Resources' => { + 'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }] + }, + 'Templates' => { + 'AutoScalingGroup' => { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app', 'Minimum', 60)", + 'SearchAggregation' => 'MEDIAN' + }, + 'StatusCheckFailed' => false + } + } + }) + }.to raise_error(CfnGuardian::ValidationError, /invalid SearchAggregation/) + end + end + + context 'when search aggregation is lowercase' do + it 'normalizes to uppercase' do + result = compile_config({ + 'Resources' => { + 'AutoScalingGroup' => [{ 'Id' => 'my-app-AsgGroup-abc123' }] + }, + 'Templates' => { + 'AutoScalingGroup' => { + 'CPUUtilizationHighBase' => { + 'SearchExpression' => "SEARCH('{AWS/EC2,AutoScalingGroupName} MetricName=\"CPUUtilization\" my-app', 'Minimum', 60)", + 'SearchAggregation' => 'avg' + }, + 'StatusCheckFailed' => false + } + } + }) + + search_alarms = result.alarms.select { |a| a.search_expression } + expect(search_alarms.first.search_aggregation).to eq('AVG') + end + end + end +end