From 3a8a040ff3efcec38c423d5249625ed2d87ab261 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Thu, 12 Aug 2021 19:43:09 +0200 Subject: [PATCH 1/2] Billing budget module --- modules/billing-budget/README.md | 95 +++++++++++++++++++ modules/billing-budget/main.tf | 78 +++++++++++++++ modules/billing-budget/outputs.tf | 25 +++++ modules/billing-budget/variables.tf | 85 +++++++++++++++++ modules/billing-budget/versions.tf | 23 +++++ tests/modules/billing_budget/__init__.py | 13 +++ tests/modules/billing_budget/fixture/main.tf | 29 ++++++ .../billing_budget/fixture/variables.tf | 61 ++++++++++++ tests/modules/billing_budget/test_plan.py | 63 ++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 modules/billing-budget/README.md create mode 100644 modules/billing-budget/main.tf create mode 100644 modules/billing-budget/outputs.tf create mode 100644 modules/billing-budget/variables.tf create mode 100644 modules/billing-budget/versions.tf create mode 100644 tests/modules/billing_budget/__init__.py create mode 100644 tests/modules/billing_budget/fixture/main.tf create mode 100644 tests/modules/billing_budget/fixture/variables.tf create mode 100644 tests/modules/billing_budget/test_plan.py diff --git a/modules/billing-budget/README.md b/modules/billing-budget/README.md new file mode 100644 index 00000000..4b441a6f --- /dev/null +++ b/modules/billing-budget/README.md @@ -0,0 +1,95 @@ +# Google Cloud Billing Budget Module + +This module allows creating a Cloud Billing budget for a set of services and projects. + +To create billing budgets you need one of the following IAM roles on the target billing account: + +* Billing Account Administrator +* Billing Account Costs Manager + +## Examples + +### Simple email notification + +Send a notification to an email when a set of projects reach $100 of spend. + +```hcl +resource "google_monitoring_notification_channel" "channel" { + display_name = "$100 spend channel" + type = "email" + project = var.project_id + labels = { + email_address = "user@example.com" + } +} + +module "budget" { + source = "./modules/billing-budget" + billing_account = var.billing_account_id + name = "$100 budget" + amount = 100 + thresholds = { + current = [0.5, 0.75, 1.0] + forecasted = [1.0] + } + projects = [ + "projects/123456789000", + "projects/123456789111" + ] + notification_channels = [ + google_monitoring_notification_channel.channel.id + ] +} +# tftest:modules=1:resources=1 +``` + +### Pubsub notification + +Send a notification to a PubSub topic the total spend of a billing account reaches the previous month's spend. + + +```hcl +module "budget" { + source = "./modules/billing-budget" + billing_account = var.billing_account_id + name = "previous period budget" + amount = 0 + thresholds = { + current = [1.0] + forecasted = [] + } + pubsub_topic = module.pubsub.id +} + +module "pubsub" { + source = "./modules/pubsub" + project_id = var.project_id + name = "budget-topic" +} + +# tftest:modules=2:resources=2 +``` + + +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| billing_account | Billing account id. | string | ✓ | | +| name | Budget name. | string | ✓ | | +| thresholds | None | object({...}) | ✓ | | +| *amount* | Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend. | number | | 0 | +| *credit_treatment* | How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported | string | | ... | +| *notification_channels* | Monitoring notification channels (up to 5) where to send updates. | list(string) | | null | +| *notify_default_recipients* | Notify Billing Account Administrators and Billing Account Users IAM roles for the target account. | bool | | false | +| *projects* | List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account. | list(string) | | null | +| *pubsub_topic* | The ID of the Cloud Pub/Sub topic where budget related messages will be published. | string | | null | +| *services* | List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services. | list(string) | | null | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| budget | Budget resource. | | +| id | Budget ID. | | + diff --git a/modules/billing-budget/main.tf b/modules/billing-budget/main.tf new file mode 100644 index 00000000..6f41250e --- /dev/null +++ b/modules/billing-budget/main.tf @@ -0,0 +1,78 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + spend_basis = { + current = "CURRENT_SPEND" + forecasted = "FORECASTED_SPEND" + } + threshold_pairs = flatten([ + for type, values in var.thresholds : [ + for value in values : { + spend_basis = local.spend_basis[type] + threshold_percent = value + } + ] + ]) +} + +resource "google_billing_budget" "budget" { + billing_account = var.billing_account + display_name = var.name + + budget_filter { + projects = var.projects + credit_types_treatment = var.credit_treatment + services = var.services + } + + dynamic "amount" { + for_each = var.amount == 0 ? [1] : [] + content { + last_period_amount = true + } + } + + dynamic "amount" { + for_each = var.amount != 0 ? [1] : [] + content { + dynamic "specified_amount" { + for_each = var.amount != 0 ? [1] : [] + content { + units = var.amount + } + } + } + } + + dynamic "threshold_rules" { + for_each = local.threshold_pairs + iterator = threshold + content { + threshold_percent = threshold.value.threshold_percent + spend_basis = threshold.value.spend_basis + } + } + + all_updates_rule { + monitoring_notification_channels = var.notification_channels + pubsub_topic = var.pubsub_topic + # disable_default_iam_recipients can only be set if + # monitoring_notification_channels is nonempty + disable_default_iam_recipients = try(length(var.notification_channels), 0) > 0 && !var.notify_default_recipients + schema_version = "1.0" + } +} diff --git a/modules/billing-budget/outputs.tf b/modules/billing-budget/outputs.tf new file mode 100644 index 00000000..9f2dd4ff --- /dev/null +++ b/modules/billing-budget/outputs.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "budget" { + description = "Budget resource." + value = google_billing_budget.budget +} + +output "id" { + description = "Budget ID." + value = google_billing_budget.budget.id +} diff --git a/modules/billing-budget/variables.tf b/modules/billing-budget/variables.tf new file mode 100644 index 00000000..5debe5ce --- /dev/null +++ b/modules/billing-budget/variables.tf @@ -0,0 +1,85 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "amount" { + description = "Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend." + type = number + default = 0 +} + +variable "billing_account" { + description = "Billing account id." + type = string +} + +variable "credit_treatment" { + description = "How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported" + type = string + default = "INCLUDE_ALL_CREDITS" + validation { + condition = ( + var.credit_treatment == "INCLUDE_ALL_CREDITS" || + var.credit_treatment == "EXCLUDE_ALL_CREDITS" + ) + error_message = "Argument credit_treatment must be INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS." + } +} + +variable "name" { + description = "Budget name." + type = string +} + +variable "notification_channels" { + description = "Monitoring notification channels (up to 5) where to send updates." + type = list(string) + default = null +} + +variable "notify_default_recipients" { + description = "Notify Billing Account Administrators and Billing Account Users IAM roles for the target account." + type = bool + default = false +} + +variable "projects" { + description = "List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account." + type = list(string) + default = null +} + +variable "pubsub_topic" { + description = "The ID of the Cloud Pub/Sub topic where budget related messages will be published." + type = string + default = null +} + +variable "services" { + description = "List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services." + type = list(string) + default = null +} + +variable "thresholds" { + type = object({ + current = list(number) + forecasted = list(number) + }) + validation { + condition = length(var.thresholds.current) > 0 || length(var.thresholds.forecasted) > 0 + error_message = "Must specify at least one budget threshold." + } +} diff --git a/modules/billing-budget/versions.tf b/modules/billing-budget/versions.tf new file mode 100644 index 00000000..968f411e --- /dev/null +++ b/modules/billing-budget/versions.tf @@ -0,0 +1,23 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = ">= 0.13.0" + required_providers { + google = ">= 3.79.0" + google-beta = ">= 3.79.0" + } +} diff --git a/tests/modules/billing_budget/__init__.py b/tests/modules/billing_budget/__init__.py new file mode 100644 index 00000000..d46dbae5 --- /dev/null +++ b/tests/modules/billing_budget/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/modules/billing_budget/fixture/main.tf b/tests/modules/billing_budget/fixture/main.tf new file mode 100644 index 00000000..b8fcbd29 --- /dev/null +++ b/tests/modules/billing_budget/fixture/main.tf @@ -0,0 +1,29 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "budget" { + source = "../../../../modules/billing-budget" + billing_account = "123456-123456-123456" + name = "my budget" + projects = var.projects + services = var.services + notify_default_recipients = var.notify_default_recipients + amount = var.amount + credit_treatment = var.credit_treatment + pubsub_topic = var.pubsub_topic + notification_channels = var.notification_channels + thresholds = var.thresholds +} diff --git a/tests/modules/billing_budget/fixture/variables.tf b/tests/modules/billing_budget/fixture/variables.tf new file mode 100644 index 00000000..5466b921 --- /dev/null +++ b/tests/modules/billing_budget/fixture/variables.tf @@ -0,0 +1,61 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "amount" { + type = number + default = 0 +} + +variable "credit_treatment" { + type = string + default = "INCLUDE_ALL_CREDITS" +} + +variable "notification_channels" { + type = list(string) + default = null +} + +variable "notify_default_recipients" { + type = bool + default = false +} + +variable "projects" { + type = list(string) + default = null +} + +variable "pubsub_topic" { + type = string + default = null +} + +variable "services" { + type = list(string) + default = null +} + +variable "thresholds" { + type = object({ + current = list(number) + forecasted = list(number) + }) + default = { + current = [0.5, 1.0] + forecasted = [1.0] + } +} diff --git a/tests/modules/billing_budget/test_plan.py b/tests/modules/billing_budget/test_plan.py new file mode 100644 index 00000000..9f9bf8fb --- /dev/null +++ b/tests/modules/billing_budget/test_plan.py @@ -0,0 +1,63 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import pytest + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') + + +def test_resource_count(plan_runner): + "Test number of resources created." + _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic') + assert len(resources) == 1 + resource = resources[0] + assert resource['values']['all_updates_rule'] == [ + {'disable_default_iam_recipients': False, + 'monitoring_notification_channels': None, + 'pubsub_topic': 'topic', + 'schema_version': '1.0'} + ] + + _, resources = plan_runner(FIXTURES_DIR, notification_channels='["channel"]') + assert len(resources) == 1 + resource = resources[0] + assert resource['values']['all_updates_rule'] == [ + {'disable_default_iam_recipients': True, + 'monitoring_notification_channels': ['channel'], + 'pubsub_topic': None, + 'schema_version': '1.0'} + ] + + +def test_absolute_amount(plan_runner): + "Test absolute amount budget." + _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic', amount="100") + assert len(resources) == 1 + resource = resources[0] + + amount = resource['values']['amount'][0] + assert amount['last_period_amount'] is None + assert amount['specified_amount'] == [{'nanos': None, 'units': '100'}] + + assert resource['values']['threshold_rules'] == [ + {'spend_basis': 'CURRENT_SPEND', + 'threshold_percent': 0.5}, + {'spend_basis': 'CURRENT_SPEND', + 'threshold_percent': 1}, + {'spend_basis': 'FORECASTED_SPEND', + 'threshold_percent': 1} + ] From 1b80085c9becf90883df42ab9e79ff58da628edd Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Fri, 13 Aug 2021 11:56:10 +0200 Subject: [PATCH 2/2] Create email notification channels automatically --- modules/billing-budget/README.md | 21 +++++++------------ modules/billing-budget/main.tf | 19 ++++++++++++++++- modules/billing-budget/variables.tf | 11 +++++++++- tests/modules/billing_budget/fixture/main.tf | 1 + .../billing_budget/fixture/variables.tf | 8 +++++++ tests/modules/billing_budget/test_plan.py | 10 +++++++-- 6 files changed, 52 insertions(+), 18 deletions(-) diff --git a/modules/billing-budget/README.md b/modules/billing-budget/README.md index 4b441a6f..2b1eaced 100644 --- a/modules/billing-budget/README.md +++ b/modules/billing-budget/README.md @@ -14,15 +14,6 @@ To create billing budgets you need one of the following IAM roles on the target Send a notification to an email when a set of projects reach $100 of spend. ```hcl -resource "google_monitoring_notification_channel" "channel" { - display_name = "$100 spend channel" - type = "email" - project = var.project_id - labels = { - email_address = "user@example.com" - } -} - module "budget" { source = "./modules/billing-budget" billing_account = var.billing_account_id @@ -36,11 +27,12 @@ module "budget" { "projects/123456789000", "projects/123456789111" ] - notification_channels = [ - google_monitoring_notification_channel.channel.id - ] + email_recipients = { + project_id = "my-project" + emails = ["user@example.com"] + } } -# tftest:modules=1:resources=1 +# tftest:modules=1:resources=2 ``` ### Pubsub notification @@ -80,7 +72,8 @@ module "pubsub" { | thresholds | None | object({...}) | ✓ | | | *amount* | Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend. | number | | 0 | | *credit_treatment* | How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported | string | | ... | -| *notification_channels* | Monitoring notification channels (up to 5) where to send updates. | list(string) | | null | +| *email_recipients* | Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project. | object({...}) | | null | +| *notification_channels* | Monitoring notification channels where to send updates. | list(string) | | null | | *notify_default_recipients* | Notify Billing Account Administrators and Billing Account Users IAM roles for the target account. | bool | | false | | *projects* | List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account. | list(string) | | null | | *pubsub_topic* | The ID of the Cloud Pub/Sub topic where budget related messages will be published. | string | | null | diff --git a/modules/billing-budget/main.tf b/modules/billing-budget/main.tf index 6f41250e..739dcedd 100644 --- a/modules/billing-budget/main.tf +++ b/modules/billing-budget/main.tf @@ -27,8 +27,25 @@ locals { } ] ]) + + notification_channels = concat( + [for channel in google_monitoring_notification_channel.email_channels : channel.id], + coalesce(var.notification_channels, []) + ) } +resource "google_monitoring_notification_channel" "email_channels" { + for_each = toset(try(var.email_recipients.emails, [])) + display_name = "${var.name} budget email notification (${each.value})" + type = "email" + project = var.email_recipients.project_id + labels = { + email_address = each.value + } + user_labels = {} +} + + resource "google_billing_budget" "budget" { billing_account = var.billing_account display_name = var.name @@ -68,7 +85,7 @@ resource "google_billing_budget" "budget" { } all_updates_rule { - monitoring_notification_channels = var.notification_channels + monitoring_notification_channels = local.notification_channels pubsub_topic = var.pubsub_topic # disable_default_iam_recipients can only be set if # monitoring_notification_channels is nonempty diff --git a/modules/billing-budget/variables.tf b/modules/billing-budget/variables.tf index 5debe5ce..3125d37d 100644 --- a/modules/billing-budget/variables.tf +++ b/modules/billing-budget/variables.tf @@ -38,13 +38,22 @@ variable "credit_treatment" { } } +variable "email_recipients" { + description = "Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project." + type = object({ + project_id = string + emails = list(string) + }) + default = null +} + variable "name" { description = "Budget name." type = string } variable "notification_channels" { - description = "Monitoring notification channels (up to 5) where to send updates." + description = "Monitoring notification channels where to send updates." type = list(string) default = null } diff --git a/tests/modules/billing_budget/fixture/main.tf b/tests/modules/billing_budget/fixture/main.tf index b8fcbd29..91c05e4a 100644 --- a/tests/modules/billing_budget/fixture/main.tf +++ b/tests/modules/billing_budget/fixture/main.tf @@ -26,4 +26,5 @@ module "budget" { pubsub_topic = var.pubsub_topic notification_channels = var.notification_channels thresholds = var.thresholds + email_recipients = var.email_recipients } diff --git a/tests/modules/billing_budget/fixture/variables.tf b/tests/modules/billing_budget/fixture/variables.tf index 5466b921..6eb8e4e3 100644 --- a/tests/modules/billing_budget/fixture/variables.tf +++ b/tests/modules/billing_budget/fixture/variables.tf @@ -24,6 +24,14 @@ variable "credit_treatment" { default = "INCLUDE_ALL_CREDITS" } +variable "email_recipients" { + type = object({ + project_id = string + emails = list(string) + }) + default = null +} + variable "notification_channels" { type = list(string) default = null diff --git a/tests/modules/billing_budget/test_plan.py b/tests/modules/billing_budget/test_plan.py index 9f9bf8fb..5692bf0a 100644 --- a/tests/modules/billing_budget/test_plan.py +++ b/tests/modules/billing_budget/test_plan.py @@ -20,18 +20,19 @@ import pytest FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') -def test_resource_count(plan_runner): +def test_pubsub(plan_runner): "Test number of resources created." _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic') assert len(resources) == 1 resource = resources[0] assert resource['values']['all_updates_rule'] == [ {'disable_default_iam_recipients': False, - 'monitoring_notification_channels': None, + 'monitoring_notification_channels': [], 'pubsub_topic': 'topic', 'schema_version': '1.0'} ] +def test_channel(plan_runner): _, resources = plan_runner(FIXTURES_DIR, notification_channels='["channel"]') assert len(resources) == 1 resource = resources[0] @@ -42,6 +43,11 @@ def test_resource_count(plan_runner): 'schema_version': '1.0'} ] +def test_emails(plan_runner): + email_recipients = '{project_id = "project", emails = ["a@b.com", "c@d.com"]}' + _, resources = plan_runner(FIXTURES_DIR, email_recipients=email_recipients) + assert len(resources) == 3 + def test_absolute_amount(plan_runner): "Test absolute amount budget."