Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
202 changes: 202 additions & 0 deletions modules/cost-alerts/context.tf
Original file line number Diff line number Diff line change
@@ -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.<var>`,
# 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
85 changes: 85 additions & 0 deletions modules/cost-alerts/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions modules/cost-alerts/outputs.tf
Original file line number Diff line number Diff line change
@@ -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 }
}
28 changes: 28 additions & 0 deletions modules/cost-alerts/variables.tf
Original file line number Diff line number Diff line change
@@ -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 = {}
}
7 changes: 7 additions & 0 deletions modules/cost-alerts/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
terraform {
required_version = ">= 0.13.0"

required_providers {
aws = ">= 4.40.0"
}
}