diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index b8362aa..e2348aa 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -2,37 +2,20 @@ name: Release Drafter on: push: - # branches to consider in the event; optional, defaults to all branches: - - main - master - # pull_request event is required only for autolabeler - pull_request: - # Only following types are handled by the action, but one can default to all as well - types: [opened, reopened, synchronize] - # pull_request_target event is required for autolabeler to support PRs from forks - # pull_request_target: - # types: [opened, reopened, synchronize] - permissions: contents: read jobs: - update_release_draft: + release: permissions: - # write permission is required to create a github release contents: write - # write permission is required for autolabeler - # otherwise, read permission is required at least - pull-requests: write runs-on: ubuntu-latest steps: - # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v6 - # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml - # with: - # config-name: my-config.yml - # disable-autolabeler: true + with: + publish: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/modules/cost-alerts/context.tf b/modules/cost-alerts/context.tf new file mode 100644 index 0000000..c5bd0f5 --- /dev/null +++ b/modules/cost-alerts/context.tf @@ -0,0 +1,202 @@ +# +# ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf +# and then place it in your Terraform module to automatically get +# Cloud Posse's standard configuration inputs suitable for passing +# to Cloud Posse modules. +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "cloudposse/label/null" + version = "0.25.0" # requires Terraform >= 0.13.0 + + enabled = var.enabled + namespace = var.namespace + environment = var.environment + stage = var.stage + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + + context = var.context +} + +# Copy contents of cloudposse/terraform-null-label/variables.tf here + +variable "context" { + type = any + default = { + enabled = true + namespace = null + environment = null + stage = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "namespace" { + type = string + default = null + description = "Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp'" +} + +variable "environment" { + type = string + default = null + description = "Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT'" +} + +variable "stage" { + type = string + default = null + description = "Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release'" +} + +variable "name" { + type = string + default = null + description = "Solution name, e.g. 'app' or 'jenkins'" +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between `namespace`, `environment`, `stage`, `name` and `attributes`. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = "Additional attributes (e.g. `1`)" +} + +variable "tags" { + type = map(string) + default = {} + description = "Additional tags (e.g. `map('BusinessUnit','XYZ')`" +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = "Additional tags for appending to tags_as_list_of_maps. Not added to `tags`." +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The naming order of the id output and Name tag. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 5 elements, but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for default, which is `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + The letter case of output label values (also used in `tags` and `id`). + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} +#### End of copy of cloudposse/terraform-null-label/variables.tf diff --git a/modules/cost-alerts/main.tf b/modules/cost-alerts/main.tf new file mode 100644 index 0000000..aad780b --- /dev/null +++ b/modules/cost-alerts/main.tf @@ -0,0 +1,85 @@ +# ---- Anomaly detection --------------------------------------------------- + +resource "aws_ce_anomaly_monitor" "service" { + count = module.this.enabled ? 1 : 0 + + name = module.this.id + monitor_type = "DIMENSIONAL" + monitor_dimension = "SERVICE" + + tags = module.this.tags +} + +resource "aws_ce_anomaly_subscription" "email" { + count = module.this.enabled ? 1 : 0 + + name = module.this.id + frequency = "IMMEDIATE" + + monitor_arn_list = [ + aws_ce_anomaly_monitor.service[0].arn, + ] + + threshold_expression { + dimension { + key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE" + values = [tostring(var.anomaly_threshold_usd)] + match_options = ["GREATER_THAN_OR_EQUAL"] + } + } + + dynamic "subscriber" { + for_each = var.notification_emails + content { + type = "EMAIL" + address = subscriber.value + } + } + + tags = module.this.tags +} + +# ---- Daily budgets ------------------------------------------------------- + +module "budget_label" { + source = "cloudposse/label/null" + version = "0.25.0" + + for_each = module.this.enabled ? var.budgets : {} + + attributes = [each.key] + context = module.this.context +} + +resource "aws_budgets_budget" "daily" { + for_each = module.this.enabled ? var.budgets : {} + + name = module.budget_label[each.key].id + budget_type = "COST" + limit_amount = each.value.limit_amount + limit_unit = "USD" + time_unit = "DAILY" + + cost_filter { + name = "LinkedAccount" + values = [each.value.linked_account_id] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = var.notification_emails + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = var.notification_emails + } + + tags = module.budget_label[each.key].tags +} diff --git a/modules/cost-alerts/outputs.tf b/modules/cost-alerts/outputs.tf new file mode 100644 index 0000000..4ba22f3 --- /dev/null +++ b/modules/cost-alerts/outputs.tf @@ -0,0 +1,14 @@ +output "anomaly_monitor_arn" { + description = "ARN of the cost anomaly monitor." + value = try(aws_ce_anomaly_monitor.service[0].arn, null) +} + +output "anomaly_subscription_arn" { + description = "ARN of the cost anomaly subscription." + value = try(aws_ce_anomaly_subscription.email[0].arn, null) +} + +output "budget_names" { + description = "Map of budget key to AWS budget name." + value = { for k, b in aws_budgets_budget.daily : k => b.name } +} diff --git a/modules/cost-alerts/variables.tf b/modules/cost-alerts/variables.tf new file mode 100644 index 0000000..50d8e86 --- /dev/null +++ b/modules/cost-alerts/variables.tf @@ -0,0 +1,28 @@ +variable "notification_emails" { + type = list(string) + description = "Email addresses to subscribe to both anomaly alerts and budget notifications. Each subscriber must independently confirm their AWS subscription." + + validation { + condition = length(var.notification_emails) > 0 + error_message = "At least one notification email is required." + } +} + +variable "anomaly_threshold_usd" { + type = number + description = "Minimum total impact in USD for an anomaly to trigger a notification." + default = 5 +} + +variable "budgets" { + type = map(object({ + limit_amount = string + linked_account_id = string + })) + description = <<-EOT + Map of daily budgets to create. Key is a logical name used as a label attribute. + limit_amount is the dollar limit (string, AWS API requires string). + linked_account_id filters cost to a single linked account. + EOT + default = {} +} diff --git a/modules/cost-alerts/versions.tf b/modules/cost-alerts/versions.tf new file mode 100644 index 0000000..0659337 --- /dev/null +++ b/modules/cost-alerts/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_version = ">= 0.13.0" + + required_providers { + aws = ">= 4.40.0" + } +}