diff --git a/infrastructure/modules/cloudwatch-metric-alarm/README.md b/infrastructure/modules/cloudwatch-metric-alarm/README.md index c653cabc..951e18d9 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/README.md +++ b/infrastructure/modules/cloudwatch-metric-alarm/README.md @@ -7,7 +7,7 @@ NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https:// | Control | How it is enforced | | --- | --- | | Naming | Alarm names derived from context.id + alarm suffix | -| Period | Hardcoded to 60 seconds for consistency | +| Period | Defaults to 60 seconds; configurable per alarm | | Statistic | Defaults to Sum; configurable per alarm | | SNS actions | Optional list of topic ARNs for notifications | | Missing data | Defaults to `notBreaching` (safe default) | @@ -33,6 +33,7 @@ module "app_errors_alarm" { evaluation_periods = 2 threshold = 10 statistic = "Sum" + period = 300 } alarm_actions = [module.sns.topic_arn] @@ -53,6 +54,7 @@ module "ecs_task_alarms" { comparison_operator = "GreaterThanThreshold" evaluation_periods = 3 threshold = 80 + period = 60 dimensions = { ServiceName = "my-service" ClusterName = "my-cluster" @@ -64,6 +66,8 @@ module "ecs_task_alarms" { } ``` +`period` is optional and defaults to 60 seconds for each alarm. Set it to 3600 seconds for hourly metrics such as AWS License Manager usage. `actions_enabled` also defaults to `true`; set it to `false` when you want alarms to track state without sending configured alarm actions. SNS alarm, recovery, and insufficient-data destinations are configured separately through the corresponding action inputs. + ### Referencing log metric filter output ```hcl @@ -97,8 +101,8 @@ module "error_alarm" { ## Conventions -- Alarm names use format `{context.id}-alarm` (single) or `{context.id}-multi-alarm` (multi-dimension). -- Period is always 60 seconds for simplicity. +- Alarm names use format `{context.id}-alarm` (single) or `{context.id}-malarm` (multi-dimension). +- Period defaults to 60 seconds and may be overridden independently for each alarm input. - Missing data defaults to `notBreaching` (safe for production). - SNS actions (alarm, ok, insufficient data) are all optional. - At least one of `metric_alarm` or `metric_alarms_by_multiple_dimensions` must be configured (enforced by check block). @@ -121,8 +125,8 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | -| [metric\_alarm](#module\_metric\_alarm) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarm | 5.7.2 | -| [metric\_alarms\_by\_multiple\_dimensions](#module\_metric\_alarms\_by\_multiple\_dimensions) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions | 5.7.2 | +| [metric\_alarm](#module\_metric\_alarm) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarm | 5.7.3 | +| [metric\_alarms\_by\_multiple\_dimensions](#module\_metric\_alarms\_by\_multiple\_dimensions) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions | 5.7.3 | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -168,7 +172,7 @@ No resources. | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | -| [treat\_missing\_data](#input\_treat\_missing\_data) | How to handle missing data points: 'notBreaching', 'breaching', 'missing', 'ignoreMetricTime'. | `string` | `"notBreaching"` | no | +| [treat\_missing\_data](#input\_treat\_missing\_data) | How to handle missing data points: 'notBreaching', 'breaching', 'missing', or 'ignore'. | `string` | `"missing"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/cloudwatch-metric-alarm/locals.tf b/infrastructure/modules/cloudwatch-metric-alarm/locals.tf index 245bf71e..e23e770d 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/locals.tf +++ b/infrastructure/modules/cloudwatch-metric-alarm/locals.tf @@ -1,4 +1,27 @@ locals { single_alarm_name = format("%s-alarm", module.this.id) - multi_dimension_alarm_name = format("%s-multi-alarm", module.this.id) + multi_dimension_alarm_name = format("%s-malarm", module.this.id) + + single_metric_alarm = coalesce(var.metric_alarm, { + metric_name = "disabled" + namespace = "disabled" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + statistic = "Sum" + period = 60 + actions_enabled = false + }) + + multi_dimension_metric_alarm = coalesce(var.metric_alarms_by_multiple_dimensions, { + metric_name = "disabled" + namespace = "disabled" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 0 + statistic = "Sum" + period = 60 + actions_enabled = false + dimensions = {} + }) } diff --git a/infrastructure/modules/cloudwatch-metric-alarm/main.tf b/infrastructure/modules/cloudwatch-metric-alarm/main.tf index 20d43dee..a666e645 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/main.tf +++ b/infrastructure/modules/cloudwatch-metric-alarm/main.tf @@ -6,7 +6,7 @@ # screening platform baseline controls: # # * Naming: derived from context.id + alarm suffix -# * Period: hardcoded to 60 seconds (enforced) +# * Period: defaults to 60 seconds (configurable per alarm) # * Statistic: defaults to Sum (configurable per-alarm) # * Actions: SNS topic ARNs optional for notifications # * Enabled flag: create = module.this.enabled @@ -14,20 +14,20 @@ module "metric_alarm" { source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" - version = "5.7.2" + version = "5.7.3" create_metric_alarm = module.this.enabled && var.metric_alarm != null alarm_name = local.single_alarm_name - comparison_operator = var.metric_alarm.comparison_operator - evaluation_periods = var.metric_alarm.evaluation_periods - threshold = var.metric_alarm.threshold - statistic = var.metric_alarm.statistic - period = var.metric_alarm.period - actions_enabled = var.metric_alarm.actions_enabled + comparison_operator = local.single_metric_alarm.comparison_operator + evaluation_periods = local.single_metric_alarm.evaluation_periods + threshold = local.single_metric_alarm.threshold + statistic = local.single_metric_alarm.statistic + period = local.single_metric_alarm.period + actions_enabled = local.single_metric_alarm.actions_enabled - metric_name = var.metric_alarm.metric_name - namespace = var.metric_alarm.namespace + metric_name = local.single_metric_alarm.metric_name + namespace = local.single_metric_alarm.namespace alarm_actions = var.alarm_actions ok_actions = var.ok_actions @@ -39,21 +39,24 @@ module "metric_alarm" { module "metric_alarms_by_multiple_dimensions" { source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions" - version = "5.7.2" + version = "5.7.3" create_metric_alarm = module.this.enabled && var.metric_alarms_by_multiple_dimensions != null - alarm_name = local.multi_dimension_alarm_name - comparison_operator = var.metric_alarms_by_multiple_dimensions.comparison_operator - evaluation_periods = var.metric_alarms_by_multiple_dimensions.evaluation_periods - threshold = var.metric_alarms_by_multiple_dimensions.threshold - statistic = var.metric_alarms_by_multiple_dimensions.statistic - period = var.metric_alarms_by_multiple_dimensions.period - actions_enabled = var.metric_alarms_by_multiple_dimensions.actions_enabled - dimensions = var.metric_alarms_by_multiple_dimensions.dimensions + alarm_name = local.multi_dimension_alarm_name + alarm_name_delimiter = "-" + comparison_operator = local.multi_dimension_metric_alarm.comparison_operator + evaluation_periods = local.multi_dimension_metric_alarm.evaluation_periods + threshold = local.multi_dimension_metric_alarm.threshold + statistic = local.multi_dimension_metric_alarm.statistic + period = local.multi_dimension_metric_alarm.period + actions_enabled = local.multi_dimension_metric_alarm.actions_enabled + dimensions = { + default = local.multi_dimension_metric_alarm.dimensions + } - metric_name = var.metric_alarms_by_multiple_dimensions.metric_name - namespace = var.metric_alarms_by_multiple_dimensions.namespace + metric_name = local.multi_dimension_metric_alarm.metric_name + namespace = local.multi_dimension_metric_alarm.namespace alarm_actions = var.alarm_actions ok_actions = var.ok_actions diff --git a/infrastructure/modules/cloudwatch-metric-alarm/tests/cloudwatch_metric_alarm.tftest.hcl b/infrastructure/modules/cloudwatch-metric-alarm/tests/cloudwatch_metric_alarm.tftest.hcl new file mode 100644 index 00000000..ca6f2297 --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/tests/cloudwatch_metric_alarm.tftest.hcl @@ -0,0 +1,176 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "111111111111" + arn = "arn:aws:iam::111111111111:role/mock" + user_id = "AIDAMOCK" + } + } + + mock_data "aws_iam_session_context" { + defaults = { + issuer_arn = "arn:aws:iam::111111111111:role/mock" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } +} + +variables { + service = "bcss" + project = "bcss" + environment = "test" + stack = "account" + name = "oracle-licence" + label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] +} + +run "single_metric_alarm_only" { + command = apply + + variables { + metric_alarm = { + metric_name = "ExampleMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 1 + } + } + + assert { + condition = output.cloudwatch_metric_alarm_id != null + error_message = "A configured single metric alarm must return its alarm ID." + } + + assert { + condition = output.cloudwatch_metric_alarms_by_multiple_dimensions_ids == {} + error_message = "The unused multi-dimension alarm output must stay empty when only a single alarm is configured." + } + + assert { + condition = local.single_metric_alarm.period == 60 && local.single_metric_alarm.actions_enabled + error_message = "A single metric alarm must default to a 60-second period with actions enabled." + } + + assert { + condition = var.treat_missing_data == "missing" + error_message = "Missing data must default to missing when no value is supplied." + } +} + +run "multi_dimension_alarm_only" { + command = apply + + variables { + metric_alarms_by_multiple_dimensions = { + metric_name = "ExampleMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 1 + dimensions = { Family = "integration" } + } + } + + assert { + condition = length(output.cloudwatch_metric_alarms_by_multiple_dimensions_ids) == 1 + error_message = "A configured multi-dimension alarm must return one alarm ID." + } + + assert { + condition = output.cloudwatch_metric_alarm_id == null + error_message = "The unused single-alarm output must stay null when only a multi-dimension alarm is configured." + } + + assert { + condition = local.multi_dimension_metric_alarm.period == 60 && local.multi_dimension_metric_alarm.dimensions["Family"] == "integration" + error_message = "A multi-dimension alarm must preserve its dimensions and default to a 60-second period." + } +} + +run "both_alarm_types_allow_custom_periods_and_disabled_actions" { + command = plan + + variables { + metric_alarm = { + metric_name = "ShortWindowMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 1 + period = 300 + actions_enabled = false + } + + metric_alarms_by_multiple_dimensions = { + metric_name = "HourlyMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + threshold = 80 + period = 3600 + actions_enabled = false + dimensions = { Family = "integration" } + } + } + + assert { + condition = ( + var.metric_alarm != null && + var.metric_alarms_by_multiple_dimensions != null && + local.single_metric_alarm.period == 300 && + !local.single_metric_alarm.actions_enabled && + local.multi_dimension_metric_alarm.period == 3600 && + !local.multi_dimension_metric_alarm.actions_enabled + ) + error_message = "Both alarm inputs must support independent periods and disabled actions." + } +} + +run "ignore_missing_data_value_is_accepted" { + command = plan + + variables { + metric_alarm = { + metric_name = "ExampleMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 1 + } + treat_missing_data = "ignore" + } + + assert { + condition = var.treat_missing_data == "ignore" + error_message = "The CloudWatch-supported ignore value must be accepted for missing data." + } +} + +run "invalid_missing_data_value_is_rejected" { + command = plan + + variables { + metric_alarm = { + metric_name = "ExampleMetric" + namespace = "Example/Tests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + threshold = 1 + } + treat_missing_data = "ignoreMetricTime" + } + + expect_failures = [var.treat_missing_data] +} + +run "at_least_one_alarm_configuration_is_required" { + command = plan + + expect_failures = [check.at_least_one_alarm_configured] +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/variables.tf b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf index 3bb2285d..6f1f3a83 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/variables.tf +++ b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf @@ -77,11 +77,11 @@ variable "insufficient_data_actions" { variable "treat_missing_data" { type = string - default = "notBreaching" - description = "How to handle missing data points: 'notBreaching', 'breaching', 'missing', 'ignoreMetricTime'." + default = "missing" + description = "How to handle missing data points: 'notBreaching', 'breaching', 'missing', or 'ignore'." validation { - condition = contains(["notBreaching", "breaching", "missing", "ignoreMetricTime"], var.treat_missing_data) - error_message = "treat_missing_data must be one of: notBreaching, breaching, missing, ignoreMetricTime." + condition = contains(["notBreaching", "breaching", "missing", "ignore"], var.treat_missing_data) + error_message = "treat_missing_data must be one of: notBreaching, breaching, missing, ignore." } }