From 5ae489f50d29126772ad056727763717f299fb24 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Sat, 29 Aug 2020 10:12:30 +0200 Subject: [PATCH 01/25] Add alias IP support in `compute-vm` (#127) * Add alias IP support in `compute-vm` * Fix tests * add end to end tests for data solutions examples and fix example errors * update changelog * add missing boilerplate Co-authored-by: Ludovico Magnocavallo --- CHANGELOG.md | 5 ++ .../asset-inventory-feed-remediation/main.tf | 7 +-- cloud-operations/dns-fine-grained-iam/main.tf | 14 +++--- .../cmek-via-centralized-kms/main.tf | 2 +- .../cmek-via-centralized-kms/variables.tf | 6 --- .../gcs-to-bq-with-dataflow/main.tf | 50 +++++++++---------- .../gcs-to-bq-with-dataflow/variables.tf | 6 --- modules/compute-vm/README.md | 30 ++++++----- modules/compute-vm/main.tf | 8 +++ modules/compute-vm/variables.tf | 6 ++- networking/hub-and-spoke-peering/main.tf | 10 ++-- networking/hub-and-spoke-vpn/main.tf | 12 +++-- networking/ilb-next-hop/gateways.tf | 6 ++- networking/ilb-next-hop/vms.tf | 10 ++-- networking/onprem-google-access-dns/main.tf | 8 +-- networking/shared-vpc-gke/main.tf | 7 +-- .../cmek_via_centralized_kms/__init__.py | 13 +++++ .../cmek_via_centralized_kms/fixture/main.tf | 21 ++++++++ .../fixture/variables.tf | 26 ++++++++++ .../cmek_via_centralized_kms/test_plan.py | 27 ++++++++++ .../gc_to_bq_with_dataflow/__init__.py | 13 +++++ .../gc_to_bq_with_dataflow/fixture/main.tf | 23 +++++++++ .../fixture/variables.tf | 36 +++++++++++++ .../gc_to_bq_with_dataflow/test_plan.py | 27 ++++++++++ tests/modules/compute_vm/fixture/variables.tf | 5 ++ .../compute_vm/test_plan_interfaces.py | 4 ++ 26 files changed, 300 insertions(+), 82 deletions(-) create mode 100644 tests/data_solutions/cmek_via_centralized_kms/__init__.py create mode 100644 tests/data_solutions/cmek_via_centralized_kms/fixture/main.tf create mode 100644 tests/data_solutions/cmek_via_centralized_kms/fixture/variables.tf create mode 100644 tests/data_solutions/cmek_via_centralized_kms/test_plan.py create mode 100644 tests/data_solutions/gc_to_bq_with_dataflow/__init__.py create mode 100644 tests/data_solutions/gc_to_bq_with_dataflow/fixture/main.tf create mode 100644 tests/data_solutions/gc_to_bq_with_dataflow/fixture/variables.tf create mode 100644 tests/data_solutions/gc_to_bq_with_dataflow/test_plan.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc68935..10798c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- add alias IP support in `cloud-vm` module +- add tests for `data-solutions` examples +- fix apply errors on dynamic resources in dataflow example + ## [3.1.1] - 2020-08-26 + - fix error in `project` module ## [3.1.0] - 2020-08-16 diff --git a/cloud-operations/asset-inventory-feed-remediation/main.tf b/cloud-operations/asset-inventory-feed-remediation/main.tf index 5c845086..0d6b29ef 100644 --- a/cloud-operations/asset-inventory-feed-remediation/main.tf +++ b/cloud-operations/asset-inventory-feed-remediation/main.tf @@ -108,10 +108,11 @@ module "simple-vm-example" { region = var.region name = var.name network_interfaces = [{ - network = module.vpc.self_link, - subnetwork = try(module.vpc.subnet_self_links["${var.region}/${var.name}-default"], ""), - nat = false, + network = module.vpc.self_link + subnetwork = try(module.vpc.subnet_self_links["${var.region}/${var.name}-default"], "") + nat = false addresses = null + alias_ips = null }] tags = ["${var.project_id}-test-feed", "shared-test-feed"] instance_count = 1 diff --git a/cloud-operations/dns-fine-grained-iam/main.tf b/cloud-operations/dns-fine-grained-iam/main.tf index 2bc0bbdf..35e59dc0 100644 --- a/cloud-operations/dns-fine-grained-iam/main.tf +++ b/cloud-operations/dns-fine-grained-iam/main.tf @@ -111,10 +111,11 @@ module "vm-ns-editor" { region = var.region name = "${var.name}-ns" network_interfaces = [{ - network = module.vpc.self_link, - subnetwork = module.vpc.subnet_self_links["${var.region}/${var.name}-default"], - nat = false, + network = module.vpc.self_link + subnetwork = module.vpc.subnet_self_links["${var.region}/${var.name}-default"] + nat = false addresses = null + alias_ips = null }] metadata = { startup-script = local.startup-script } service_account_create = true @@ -128,10 +129,11 @@ module "vm-svc-editor" { region = var.region name = "${var.name}-svc" network_interfaces = [{ - network = module.vpc.self_link, - subnetwork = module.vpc.subnet_self_links["${var.region}/${var.name}-default"], - nat = false, + network = module.vpc.self_link + subnetwork = module.vpc.subnet_self_links["${var.region}/${var.name}-default"] + nat = false addresses = null + alias_ips = null }] metadata = { startup-script = local.startup-script } service_account_create = true diff --git a/data-solutions/cmek-via-centralized-kms/main.tf b/data-solutions/cmek-via-centralized-kms/main.tf index 464f208f..08b7b7de 100644 --- a/data-solutions/cmek-via-centralized-kms/main.tf +++ b/data-solutions/cmek-via-centralized-kms/main.tf @@ -104,13 +104,13 @@ module "kms_vm_example" { source = "../../modules/compute-vm" project_id = module.project-service.project_id region = var.region - zone = var.zone name = "kms-vm" network_interfaces = [{ network = module.vpc.self_link, subnetwork = module.vpc.subnet_self_links["${var.region}/subnet"], nat = false, addresses = null + alias_ips = null }] attached_disks = [ { diff --git a/data-solutions/cmek-via-centralized-kms/variables.tf b/data-solutions/cmek-via-centralized-kms/variables.tf index fdc0ac94..04b70784 100644 --- a/data-solutions/cmek-via-centralized-kms/variables.tf +++ b/data-solutions/cmek-via-centralized-kms/variables.tf @@ -64,9 +64,3 @@ variable "vpc_ip_cidr_range" { type = string default = "10.0.0.0/20" } - -variable "zone" { - description = "The zone where resources will be deployed." - type = string - default = "europe-west1-b" -} diff --git a/data-solutions/gcs-to-bq-with-dataflow/main.tf b/data-solutions/gcs-to-bq-with-dataflow/main.tf index 84521c03..c573c7e7 100644 --- a/data-solutions/gcs-to-bq-with-dataflow/main.tf +++ b/data-solutions/gcs-to-bq-with-dataflow/main.tf @@ -61,9 +61,9 @@ module "service-account-bq" { project_id = module.project-service.project_id names = ["bq-test"] iam_project_roles = { - (module.project-service.project_id) = [ + (var.project_service_name) = [ "roles/logging.logWriter", - "roles/monitoring.metricWriter", + "roles/monitoring.metricWriter", "roles/bigquery.admin" ] } @@ -74,12 +74,12 @@ module "service-account-gce" { project_id = module.project-service.project_id names = ["gce-test"] iam_project_roles = { - (module.project-service.project_id) = [ + (var.project_service_name) = [ "roles/logging.logWriter", "roles/monitoring.metricWriter", "roles/dataflow.admin", "roles/iam.serviceAccountUser", - "roles/bigquery.dataOwner", + "roles/bigquery.dataOwner", "roles/bigquery.jobUser" # Needed to import data using 'bq' command ] } @@ -90,7 +90,7 @@ module "service-account-df" { project_id = module.project-service.project_id names = ["df-test"] iam_project_roles = { - (module.project-service.project_id) = [ + (var.project_service_name) = [ "roles/dataflow.worker", "roles/bigquery.dataOwner", "roles/bigquery.metadataViewer", @@ -143,7 +143,7 @@ module "kms" { #"serviceAccount:${module.project-service.service_accounts.default.bq}", "serviceAccount:${data.google_bigquery_default_service_account.bq_sa.email}", ] - }, + }, } } @@ -156,7 +156,7 @@ module "kms-regional" { } keys = { key-df = null } key_iam_roles = { - key-df = ["roles/cloudkms.cryptoKeyEncrypterDecrypter"] + key-df = ["roles/cloudkms.cryptoKeyEncrypterDecrypter"] } key_iam_members = { key-df = { @@ -164,7 +164,7 @@ module "kms-regional" { "serviceAccount:${module.project-service.service_accounts.robots.dataflow}", "serviceAccount:${module.project-service.service_accounts.robots.compute}", ] - } + } } } @@ -210,13 +210,13 @@ module "vm_example" { source = "../../modules/compute-vm" project_id = module.project-service.project_id region = var.region - zone = var.zone name = "vm-example" network_interfaces = [{ network = module.vpc.self_link, subnetwork = module.vpc.subnet_self_links["${var.region}/${var.vpc_subnet_name}"], nat = false, addresses = null + alias_ips = null }] attached_disks = [ { @@ -259,9 +259,9 @@ module "kms-gcs" { prefix = module.project-service.project_id names = ["data", "df-tmplocation"] iam_roles = { - data = ["roles/storage.admin","roles/storage.objectViewer"], + data = ["roles/storage.admin", "roles/storage.objectViewer"], df-tmplocation = ["roles/storage.admin"] - } + } iam_members = { data = { "roles/storage.admin" = [ @@ -269,22 +269,22 @@ module "kms-gcs" { ], "roles/storage.viewer" = [ "serviceAccount:${module.service-account-df.email}", - ], + ], }, df-tmplocation = { "roles/storage.admin" = [ "serviceAccount:${module.service-account-gce.email}", "serviceAccount:${module.service-account-df.email}", ] - } - } + } + } encryption_keys = { data = module.kms.keys.key-gcs.self_link, df-tmplocation = module.kms.keys.key-gcs.self_link, } force_destroy = { data = true, - df-tmplocation = true, + df-tmplocation = true, } } @@ -293,9 +293,9 @@ module "kms-gcs" { ############################################################################### module "bigquery-dataset" { - source = "../../modules/bigquery-dataset" - project_id = module.project-service.project_id - id = "bq_dataset" + source = "../../modules/bigquery-dataset" + project_id = module.project-service.project_id + id = "bq_dataset" access_roles = { reader-group = { role = "READER", type = "domain" } owner = { role = "OWNER", type = "user_by_email" } @@ -315,11 +315,11 @@ module "bigquery-dataset" { range = null # use start/end/interval for range time = null } - schema = file("schema_bq_import.json") + schema = file("${path.module}/schema_bq_import.json") options = { - clustering = null - expiration_time = null - encryption_key = module.kms.keys.key-bq.self_link + clustering = null + expiration_time = null + encryption_key = module.kms.keys.key-bq.self_link } }, df_import = { @@ -331,11 +331,11 @@ module "bigquery-dataset" { range = null # use start/end/interval for range time = null } - schema = file("schema_df_import.json") + schema = file("${path.module}/schema_df_import.json") options = { - clustering = null + clustering = null expiration_time = null - encryption_key = module.kms.keys.key-bq.self_link + encryption_key = module.kms.keys.key-bq.self_link } } } diff --git a/data-solutions/gcs-to-bq-with-dataflow/variables.tf b/data-solutions/gcs-to-bq-with-dataflow/variables.tf index a44f874a..be76a0e5 100644 --- a/data-solutions/gcs-to-bq-with-dataflow/variables.tf +++ b/data-solutions/gcs-to-bq-with-dataflow/variables.tf @@ -63,12 +63,6 @@ variable "vpc_ip_cidr_range" { default = "10.0.0.0/20" } -variable "zone" { - description = "The zone where resources will be deployed." - type = string - default = "europe-west1-b" -} - variable "ssh_source_ranges" { description = "IP CIDR ranges that will be allowed to connect via SSH to the onprem instance." type = list(string) diff --git a/modules/compute-vm/README.md b/modules/compute-vm/README.md index 128087c0..ed6513d9 100644 --- a/modules/compute-vm/README.md +++ b/modules/compute-vm/README.md @@ -20,10 +20,11 @@ module "simple-vm-example" { region = "europe-west1" name = "test" network_interfaces = [{ - network = local.network_self_link, - subnetwork = local.subnet_self_link, - nat = false, + network = local.network_self_link + subnetwork = local.subnet_self_link + nat = false addresses = null + alias_ips = null }] service_account_create = true instance_count = 1 @@ -41,10 +42,11 @@ module "kms-vm-example" { region = local.region name = "kms-test" network_interfaces = [{ - network = local.network_self_link, - subnetwork = local.subnet_self_link, - nat = false, + network = local.network_self_link + subnetwork = local.subnet_self_link + nat = false addresses = null + alias_ips = null }] attached_disks = [ { @@ -85,10 +87,11 @@ module "cos-test" { region = "europe-west1" name = "test" network_interfaces = [{ - network = local.network_self_link, - subnetwork = local.subnet_self_link, - nat = false, + network = local.network_self_link + subnetwork = local.subnet_self_link + nat = false addresses = null + alias_ips = null }] instance_count = 1 boot_disk = { @@ -115,10 +118,11 @@ module "instance-group" { region = "europe-west1" name = "ilb-test" network_interfaces = [{ - network = local.network_self_link, - subnetwork = local.subnetwork_self_link, - nat = false, + network = local.network_self_link + subnetwork = local.subnetwork_self_link + nat = false addresses = null + alias_ips = null }] boot_disk = { image = "projects/cos-cloud/global/images/family/cos-stable" @@ -141,7 +145,7 @@ module "instance-group" { | name | description | type | required | default | |---|---|:---: |:---:|:---:| | name | Instances base name. | string | ✓ | | -| network_interfaces | Network interfaces configuration. Use self links for Shared VPC, set addresses to null if not needed. | list(object({...})) | ✓ | | +| network_interfaces | Network interfaces configuration. Use self links for Shared VPC, set addresses and alias_ips to null if not needed. | list(object({...})) | ✓ | | | project_id | Project id. | string | ✓ | | | region | Compute region. | string | ✓ | | | *attached_disk_defaults* | Defaults for attached disks options. | object({...}) | | ... | diff --git a/modules/compute-vm/main.tf b/modules/compute-vm/main.tf index 834a1a60..90b88c0c 100644 --- a/modules/compute-vm/main.tf +++ b/modules/compute-vm/main.tf @@ -145,6 +145,14 @@ resource "google_compute_instance" "default" { ) } } + dynamic alias_ip_range { + for_each = config.value.alias_ips != null ? config.value.alias_ips : [] + iterator = alias_ips + content { + ip_cidr_range = alias_ips.value.ip_cidr_range + subnetwork_range_name = alias_ips.value.subnetwork_range_name + } + } } } diff --git a/modules/compute-vm/variables.tf b/modules/compute-vm/variables.tf index 61b3508d..709a7548 100644 --- a/modules/compute-vm/variables.tf +++ b/modules/compute-vm/variables.tf @@ -144,7 +144,7 @@ variable "name" { } variable "network_interfaces" { - description = "Network interfaces configuration. Use self links for Shared VPC, set addresses to null if not needed." + description = "Network interfaces configuration. Use self links for Shared VPC, set addresses and alias_ips to null if not needed." type = list(object({ nat = bool network = string @@ -153,6 +153,10 @@ variable "network_interfaces" { internal = list(string) external = list(string) }) + alias_ips = list(object({ + ip_cidr_range = string + subnetwork_range_name = string + })) })) } diff --git a/networking/hub-and-spoke-peering/main.tf b/networking/hub-and-spoke-peering/main.tf index 686ef547..f77d49f6 100644 --- a/networking/hub-and-spoke-peering/main.tf +++ b/networking/hub-and-spoke-peering/main.tf @@ -149,10 +149,11 @@ module "vm-spoke-1" { region = var.region name = "spoke-1-test" network_interfaces = [{ - network = module.vpc-spoke-1.self_link, + network = module.vpc-spoke-1.self_link subnetwork = module.vpc-spoke-1.subnet_self_links["${var.region}/spoke-1-default"] - nat = false, + nat = false addresses = null + alias_ips = null }] metadata = { startup-script = local.vm-startup-script } service_account = module.service-account-gce.email @@ -166,10 +167,11 @@ module "vm-spoke-2" { region = var.region name = "spoke-2-test" network_interfaces = [{ - network = module.vpc-spoke-2.self_link, + network = module.vpc-spoke-2.self_link subnetwork = module.vpc-spoke-2.subnet_self_links["${var.region}/spoke-2-default"] - nat = false, + nat = false addresses = null + alias_ips = null }] metadata = { startup-script = local.vm-startup-script } service_account = module.service-account-gce.email diff --git a/networking/hub-and-spoke-vpn/main.tf b/networking/hub-and-spoke-vpn/main.tf index 1cad68be..78eea3aa 100644 --- a/networking/hub-and-spoke-vpn/main.tf +++ b/networking/hub-and-spoke-vpn/main.tf @@ -249,10 +249,11 @@ module "vm-spoke-1" { region = var.regions.b name = "spoke-1-test" network_interfaces = [{ - network = module.vpc-spoke-1.self_link, + network = module.vpc-spoke-1.self_link subnetwork = module.vpc-spoke-1.subnet_self_links["${var.regions.b}/spoke-1-b"] - nat = false, + nat = false addresses = null + alias_ips = null }] tags = ["ssh"] metadata = { startup-script = local.vm-startup-script } @@ -264,10 +265,11 @@ module "vm-spoke-2" { region = var.regions.b name = "spoke-2-test" network_interfaces = [{ - network = module.vpc-spoke-2.self_link, - subnetwork = module.vpc-spoke-2.subnet_self_links["${var.regions.b}/spoke-2-b"], - nat = false, + network = module.vpc-spoke-2.self_link + subnetwork = module.vpc-spoke-2.subnet_self_links["${var.regions.b}/spoke-2-b"] + nat = false addresses = null + alias_ips = null }] tags = ["ssh"] metadata = { startup-script = local.vm-startup-script } diff --git a/networking/ilb-next-hop/gateways.tf b/networking/ilb-next-hop/gateways.tf index 7684e130..9a0da360 100644 --- a/networking/ilb-next-hop/gateways.tf +++ b/networking/ilb-next-hop/gateways.tf @@ -32,13 +32,15 @@ module "gw" { network = module.vpc-left.self_link subnetwork = values(module.vpc-left.subnet_self_links)[0], nat = false, - addresses = null + addresses = null, + alias_ips = null }, { network = module.vpc-right.self_link subnetwork = values(module.vpc-right.subnet_self_links)[0], nat = false, - addresses = null + addresses = null, + alias_ips = null } ] tags = ["ssh"] diff --git a/networking/ilb-next-hop/vms.tf b/networking/ilb-next-hop/vms.tf index 6a8f22a0..d3fbc0f8 100644 --- a/networking/ilb-next-hop/vms.tf +++ b/networking/ilb-next-hop/vms.tf @@ -31,9 +31,10 @@ module "vm-left" { network_interfaces = [ { network = module.vpc-left.self_link - subnetwork = values(module.vpc-left.subnet_self_links)[0], - nat = false, + subnetwork = values(module.vpc-left.subnet_self_links)[0] + nat = false addresses = null + alias_ips = null } ] tags = ["ssh"] @@ -56,9 +57,10 @@ module "vm-right" { network_interfaces = [ { network = module.vpc-right.self_link - subnetwork = values(module.vpc-right.subnet_self_links)[0], - nat = false, + subnetwork = values(module.vpc-right.subnet_self_links)[0] + nat = false addresses = null + alias_ips = null } ] tags = ["ssh"] diff --git a/networking/onprem-google-access-dns/main.tf b/networking/onprem-google-access-dns/main.tf index 1e29defa..2d803673 100644 --- a/networking/onprem-google-access-dns/main.tf +++ b/networking/onprem-google-access-dns/main.tf @@ -187,10 +187,11 @@ module "vm-test" { region = var.region name = "test" network_interfaces = [{ - network = module.vpc.self_link, + network = module.vpc.self_link subnetwork = module.vpc.subnet_self_links["${var.region}/subnet"] - nat = false, + nat = false addresses = null + alias_ips = null }] metadata = { startup-script = local.vm-startup-script } service_account = module.service-account-gce.email @@ -250,8 +251,9 @@ module "vm-onprem" { network_interfaces = [{ network = module.vpc.name subnetwork = module.vpc.subnet_self_links["${var.region}/subnet"] - nat = true, + nat = true addresses = null + alias_ips = null }] service_account = module.service-account-onprem.email service_account_scopes = ["https://www.googleapis.com/auth/cloud-platform"] diff --git a/networking/shared-vpc-gke/main.tf b/networking/shared-vpc-gke/main.tf index 00bd89cc..9beef9aa 100644 --- a/networking/shared-vpc-gke/main.tf +++ b/networking/shared-vpc-gke/main.tf @@ -182,10 +182,11 @@ module "vm-bastion" { region = var.region name = "bastion" network_interfaces = [{ - network = module.vpc-shared.self_link, - subnetwork = lookup(module.vpc-shared.subnet_self_links, "${var.region}/gce", null), - nat = false, + network = module.vpc-shared.self_link + subnetwork = lookup(module.vpc-shared.subnet_self_links, "${var.region}/gce", null) + nat = false addresses = null + alias_ips = null }] instance_count = 1 tags = ["ssh"] diff --git a/tests/data_solutions/cmek_via_centralized_kms/__init__.py b/tests/data_solutions/cmek_via_centralized_kms/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/tests/data_solutions/cmek_via_centralized_kms/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 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/data_solutions/cmek_via_centralized_kms/fixture/main.tf b/tests/data_solutions/cmek_via_centralized_kms/fixture/main.tf new file mode 100644 index 00000000..374460dc --- /dev/null +++ b/tests/data_solutions/cmek_via_centralized_kms/fixture/main.tf @@ -0,0 +1,21 @@ +/** + * Copyright 2020 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 "test" { + source = "../../../../data-solutions/cmek-via-centralized-kms/" + billing_account = var.billing_account + root_node = var.root_node +} diff --git a/tests/data_solutions/cmek_via_centralized_kms/fixture/variables.tf b/tests/data_solutions/cmek_via_centralized_kms/fixture/variables.tf new file mode 100644 index 00000000..764f047d --- /dev/null +++ b/tests/data_solutions/cmek_via_centralized_kms/fixture/variables.tf @@ -0,0 +1,26 @@ +/** + * Copyright 2020 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 "billing_account" { + type = string + default = "123456-123456-123456" +} + +variable "root_node" { + description = "The resource name of the parent Folder or Organization. Must be of the form folders/folder_id or organizations/org_id." + type = string + default = "folders/12345678" +} diff --git a/tests/data_solutions/cmek_via_centralized_kms/test_plan.py b/tests/data_solutions/cmek_via_centralized_kms/test_plan.py new file mode 100644 index 00000000..21514522 --- /dev/null +++ b/tests/data_solutions/cmek_via_centralized_kms/test_plan.py @@ -0,0 +1,27 @@ +# Copyright 2020 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_resources(e2e_plan_runner): + "Test that plan works and the numbers of resources is as expected." + modules, resources = e2e_plan_runner(FIXTURES_DIR) + assert len(modules) == 7 + assert len(resources) == 22 diff --git a/tests/data_solutions/gc_to_bq_with_dataflow/__init__.py b/tests/data_solutions/gc_to_bq_with_dataflow/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/tests/data_solutions/gc_to_bq_with_dataflow/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 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/data_solutions/gc_to_bq_with_dataflow/fixture/main.tf b/tests/data_solutions/gc_to_bq_with_dataflow/fixture/main.tf new file mode 100644 index 00000000..cf6ce48f --- /dev/null +++ b/tests/data_solutions/gc_to_bq_with_dataflow/fixture/main.tf @@ -0,0 +1,23 @@ +/** + * Copyright 2020 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 "test" { + source = "../../../../data-solutions/gcs-to-bq-with-dataflow/" + billing_account = var.billing_account + project_kms_name = var.project_kms_name + project_service_name = var.project_service_name + root_node = var.root_node +} diff --git a/tests/data_solutions/gc_to_bq_with_dataflow/fixture/variables.tf b/tests/data_solutions/gc_to_bq_with_dataflow/fixture/variables.tf new file mode 100644 index 00000000..0a2696ba --- /dev/null +++ b/tests/data_solutions/gc_to_bq_with_dataflow/fixture/variables.tf @@ -0,0 +1,36 @@ +/** + * Copyright 2020 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 "billing_account" { + type = string + default = "123456-123456-123456" +} + +variable "root_node" { + description = "The resource name of the parent Folder or Organization. Must be of the form folders/folder_id or organizations/org_id." + type = string + default = "folders/12345678" +} + +variable "project_service_name" { + type = string + default = "project-srv" +} + +variable "project_kms_name" { + type = string + default = "project-kms" +} diff --git a/tests/data_solutions/gc_to_bq_with_dataflow/test_plan.py b/tests/data_solutions/gc_to_bq_with_dataflow/test_plan.py new file mode 100644 index 00000000..1828f7f4 --- /dev/null +++ b/tests/data_solutions/gc_to_bq_with_dataflow/test_plan.py @@ -0,0 +1,27 @@ +# Copyright 2020 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_resources(e2e_plan_runner): + "Test that plan works and the numbers of resources is as expected." + modules, resources = e2e_plan_runner(FIXTURES_DIR) + assert len(modules) == 13 + assert len(resources) == 61 diff --git a/tests/modules/compute_vm/fixture/variables.tf b/tests/modules/compute_vm/fixture/variables.tf index b2a5c6ed..21603452 100644 --- a/tests/modules/compute_vm/fixture/variables.tf +++ b/tests/modules/compute_vm/fixture/variables.tf @@ -53,12 +53,17 @@ variable "network_interfaces" { internal = list(string) external = list(string) }) + alias_ips = list(object({ + ip_cidr_range = string + subnetwork_range_name = string + })) })) default = [{ network = "https://www.googleapis.com/compute/v1/projects/my-project/global/networks/default", subnetwork = "https://www.googleapis.com/compute/v1/projects/my-project/regions/europe-west1/subnetworks/default-default", nat = false, addresses = null + alias_ips = null }] } diff --git a/tests/modules/compute_vm/test_plan_interfaces.py b/tests/modules/compute_vm/test_plan_interfaces.py index 560be3cb..3711b481 100644 --- a/tests/modules/compute_vm/test_plan_interfaces.py +++ b/tests/modules/compute_vm/test_plan_interfaces.py @@ -26,6 +26,7 @@ def test_no_addresses(plan_runner): subnetwork = "https://www.googleapis.com/compute/v1/projects/my-project/regions/europe-west1/subnetworks/default-default", nat = false, addresses = {external=[], internal=[]} + alias_ips = null }] ''' _, resources = plan_runner( @@ -39,6 +40,7 @@ def test_internal_addresses(plan_runner): subnetwork = "https://www.googleapis.com/compute/v1/projects/my-project/regions/europe-west1/subnetworks/default-default", nat = false, addresses = {external=[], internal=["1.1.1.2", "1.1.1.3"]} + alias_ips = null }] ''' _, resources = plan_runner( @@ -53,6 +55,7 @@ def test_internal_addresses_nat(plan_runner): subnetwork = "https://www.googleapis.com/compute/v1/projects/my-project/regions/europe-west1/subnetworks/default-default", nat = true, addresses = {external=[], internal=["1.1.1.2", "1.1.1.3"]} + alias_ips = null }] ''' _, resources = plan_runner( @@ -67,6 +70,7 @@ def test_all_addresses(plan_runner): subnetwork = "https://www.googleapis.com/compute/v1/projects/my-project/regions/europe-west1/subnetworks/default-default", nat = true, addresses = {external=["2.2.2.2", "2.2.2.3"], internal=["1.1.1.2", "1.1.1.3"]} + alias_ips = null }] ''' _, resources = plan_runner( From 9c59a030522bc3c0ce3b8275e913ef120dbde22a Mon Sep 17 00:00:00 2001 From: vanessabodard-voi <63779321+vanessabodard-voi@users.noreply.github.com> Date: Sat, 29 Aug 2020 11:09:57 +0200 Subject: [PATCH 02/25] Add the option to not create a DNS managed zone (#126) * Add zone_create variable * Update readme * Update dns_keys --- modules/dns/README.md | 1 + modules/dns/main.tf | 25 +++++++++++++++++-------- modules/dns/variables.tf | 8 ++++++++ modules/organization/outputs.tf | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/modules/dns/README.md b/modules/dns/README.md index d391c4cf..1d0c4e0e 100644 --- a/modules/dns/README.md +++ b/modules/dns/README.md @@ -38,6 +38,7 @@ module "private-dns" { | *recordsets* | List of DNS record objects to manage. | list(object({...})) | | [] | | *service_directory_namespace* | Service directory namespace id (URL), only valid for 'service-directory' zone types. | string | | null | | *type* | Type of zone to create, valid values are 'public', 'private', 'forwarding', 'peering', 'service-directory'. | string | | private | +| *zone_create* | Create zone. When set to false, uses a data source to reference existing project. | bool | | true | ## Outputs diff --git a/modules/dns/main.tf b/modules/dns/main.tf index f29d2768..d7e98b52 100644 --- a/modules/dns/main.tf +++ b/modules/dns/main.tf @@ -19,10 +19,14 @@ locals { for record in var.recordsets : join("/", [record.name, record.type]) => record } - zone = try( - google_dns_managed_zone.non-public.0, try( - google_dns_managed_zone.public.0, null - ) + zone = ( + var.zone_create + ? try( + google_dns_managed_zone.non-public.0, try( + google_dns_managed_zone.public.0, null + ) + ) + : try(data.google_dns_managed_zone.public.0, null) ) dns_keys = try( data.google_dns_keys.dns_keys.0, null @@ -30,7 +34,7 @@ locals { } resource "google_dns_managed_zone" "non-public" { - count = var.type != "public" ? 1 : 0 + count = (var.zone_create && var.type != "public" ) ? 1 : 0 provider = google-beta project = var.project_id name = var.name @@ -89,8 +93,13 @@ resource "google_dns_managed_zone" "non-public" { } +data "google_dns_managed_zone" "public" { + count = var.zone_create ? 0 : 1 + name = var.name +} + resource "google_dns_managed_zone" "public" { - count = var.type == "public" ? 1 : 0 + count = (var.zone_create && var.type == "public" ) ? 1 : 0 project = var.project_id name = var.name dns_name = var.domain @@ -123,8 +132,8 @@ resource "google_dns_managed_zone" "public" { } data "google_dns_keys" "dns_keys" { - count = var.dnssec_config == {} || var.type != "public" ? 0 : 1 - managed_zone = google_dns_managed_zone.public.0.id + count = var.zone_create && ( var.dnssec_config == {} || var.type != "public" ) ? 0 : 1 + managed_zone = local.zone.id } resource "google_dns_record_set" "cloud-static-records" { diff --git a/modules/dns/variables.tf b/modules/dns/variables.tf index f38fb36a..43aad86b 100644 --- a/modules/dns/variables.tf +++ b/modules/dns/variables.tf @@ -98,3 +98,11 @@ variable "type" { type = string default = "private" } + +variable "zone_create" { + description = "Create zone. When set to false, uses a data source to reference existing zone." + type = bool + default = true +} + + diff --git a/modules/organization/outputs.tf b/modules/organization/outputs.tf index 2a829c4d..f33d54f1 100644 --- a/modules/organization/outputs.tf +++ b/modules/organization/outputs.tf @@ -18,7 +18,7 @@ output "org_id" { description = "Organization id dependent on module resources." value = var.org_id depends_on = [ - google_organization_iam_audit_config, + google_organization_iam_audit_config.config, google_organization_iam_binding.authoritative, google_organization_iam_custom_role.roles, google_organization_iam_member.additive, From 0389af7310e0646475810f4a7d1d16e2609b00b7 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Sat, 29 Aug 2020 11:10:37 +0200 Subject: [PATCH 03/25] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10798c4a..901af3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - add alias IP support in `cloud-vm` module - add tests for `data-solutions` examples - fix apply errors on dynamic resources in dataflow example +- make zone creation optional in `dns` module ## [3.1.1] - 2020-08-26 From 60c068ffea8b031f5837c09d5a1938f7e4c5dcb2 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Sat, 29 Aug 2020 11:15:42 +0200 Subject: [PATCH 04/25] update input variable tables in data solutions examples --- data-solutions/cmek-via-centralized-kms/README.md | 1 - data-solutions/gcs-to-bq-with-dataflow/README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/data-solutions/cmek-via-centralized-kms/README.md b/data-solutions/cmek-via-centralized-kms/README.md index 62b42210..03c32f27 100644 --- a/data-solutions/cmek-via-centralized-kms/README.md +++ b/data-solutions/cmek-via-centralized-kms/README.md @@ -44,7 +44,6 @@ This sample creates several distinct groups of resources: | *vpc_ip_cidr_range* | Ip range used in the subnet deployef in the Service Project. | string | | 10.0.0.0/20 | | *vpc_name* | Name of the VPC created in the Service Project. | string | | local | | *vpc_subnet_name* | Name of the subnet created in the Service Project. | string | | subnet | -| *zone* | The zone where resources will be deployed. | string | | europe-west1-b | ## Outputs diff --git a/data-solutions/gcs-to-bq-with-dataflow/README.md b/data-solutions/gcs-to-bq-with-dataflow/README.md index 23a4470d..da3c35c5 100644 --- a/data-solutions/gcs-to-bq-with-dataflow/README.md +++ b/data-solutions/gcs-to-bq-with-dataflow/README.md @@ -125,7 +125,6 @@ You can check data imported into Google BigQuery from the Google Cloud Console U | *vpc_ip_cidr_range* | Ip range used in the subnet deployef in the Service Project. | string | | 10.0.0.0/20 | | *vpc_name* | Name of the VPC created in the Service Project. | string | | local | | *vpc_subnet_name* | Name of the subnet created in the Service Project. | string | | subnet | -| *zone* | The zone where resources will be deployed. | string | | europe-west1-b | ## Outputs From 86bee0ff707fc0370d875b5516c70106ae889c1c Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Sat, 29 Aug 2020 11:16:42 +0200 Subject: [PATCH 05/25] update input variable table in dns module README --- modules/dns/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/dns/README.md b/modules/dns/README.md index 1d0c4e0e..2c00ebb1 100644 --- a/modules/dns/README.md +++ b/modules/dns/README.md @@ -38,7 +38,7 @@ module "private-dns" { | *recordsets* | List of DNS record objects to manage. | list(object({...})) | | [] | | *service_directory_namespace* | Service directory namespace id (URL), only valid for 'service-directory' zone types. | string | | null | | *type* | Type of zone to create, valid values are 'public', 'private', 'forwarding', 'peering', 'service-directory'. | string | | private | -| *zone_create* | Create zone. When set to false, uses a data source to reference existing project. | bool | | true | +| *zone_create* | Create zone. When set to false, uses a data source to reference existing zone. | bool | | true | ## Outputs From 088a7c569f32d7b561ef93655c07c1fb04ad52d1 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Sat, 29 Aug 2020 11:29:46 +0200 Subject: [PATCH 06/25] Quota monitor end to end example (#125) * working example, README missing * add missing boilerplate to outputs file * README * fix dynamic resources in IAM binding for_each * add tests * update input/output table in README * add example to READMEs --- README.md | 2 +- cloud-operations/README.md | 5 + cloud-operations/quota-monitoring/README.md | 42 ++++ .../quota-monitoring/backend.tf.sample | 23 ++ cloud-operations/quota-monitoring/cf/main.py | 201 ++++++++++++++++++ .../quota-monitoring/cf/requirements.txt | 3 + .../quota-monitoring/cloud-shell-readme.txt | 9 + cloud-operations/quota-monitoring/diagram.png | Bin 0 -> 48087 bytes .../quota-monitoring/explorer.png | Bin 0 -> 52935 bytes cloud-operations/quota-monitoring/main.tf | 142 +++++++++++++ cloud-operations/quota-monitoring/outputs.tf | 16 ++ .../quota-monitoring/variables.tf | 64 ++++++ .../quota_monitoring/__init__.py | 13 ++ .../quota_monitoring/fixture/cf/README | 0 .../quota_monitoring/fixture/main.tf | 22 ++ .../quota_monitoring/fixture/variables.tf | 38 ++++ .../quota_monitoring/test_plan.py | 27 +++ 17 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 cloud-operations/quota-monitoring/README.md create mode 100644 cloud-operations/quota-monitoring/backend.tf.sample create mode 100755 cloud-operations/quota-monitoring/cf/main.py create mode 100644 cloud-operations/quota-monitoring/cf/requirements.txt create mode 100644 cloud-operations/quota-monitoring/cloud-shell-readme.txt create mode 100644 cloud-operations/quota-monitoring/diagram.png create mode 100644 cloud-operations/quota-monitoring/explorer.png create mode 100644 cloud-operations/quota-monitoring/main.tf create mode 100644 cloud-operations/quota-monitoring/outputs.tf create mode 100644 cloud-operations/quota-monitoring/variables.tf create mode 100644 tests/cloud_operations/quota_monitoring/__init__.py create mode 100644 tests/cloud_operations/quota_monitoring/fixture/cf/README create mode 100644 tests/cloud_operations/quota_monitoring/fixture/main.tf create mode 100644 tests/cloud_operations/quota_monitoring/fixture/variables.tf create mode 100644 tests/cloud_operations/quota_monitoring/test_plan.py diff --git a/README.md b/README.md index b541bfcf..811bc844 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Currently available examples: - **foundations** - [single level hierarchy](./foundations/environments/) (environments), [multiple level hierarchy](./foundations/business-units/) (business units + environments) - **networking** - [hub and spoke via peering](./networking/hub-and-spoke-peering/), [hub and spoke via VPN](./networking/hub-and-spoke-vpn/), [DNS and Google Private Access for on-premises](./networking/onprem-google-access-dns/), [Shared VPC with GKE support](./networking/shared-vpc-gke/), [ILB as next hop](./networking/ilb-next-hop) - **data solutions** - [GCE/GCS CMEK via centralized Cloud KMS](./data-solutions/cmek-via-centralized-kms/), [Cloud Storage to Bigquery with Cloud Dataflow](./data-solutions/gcs-to-bq-with-dataflow/) -- **cloud operations** - [Resource tracking and remediation via Cloud Asset feeds](.//cloud-operations/asset-inventory-feed-remediation), [Granular Cloud DNS IAM via Service Directory](./cloud-operations/dns-fine-grained-iam) +- **cloud operations** - [Resource tracking and remediation via Cloud Asset feeds](.//cloud-operations/asset-inventory-feed-remediation), [Granular Cloud DNS IAM via Service Directory](./cloud-operations/dns-fine-grained-iam), [Compute Engine quota monitoring](./cloud-operations/quota-monitoring) For more information see the README files in the [foundations](./foundations/), [networking](./networking/), [data solutions](./data-solutions/) and [cloud operations](./cloud-operations/) folders. diff --git a/cloud-operations/README.md b/cloud-operations/README.md index 016a5f86..e887b417 100644 --- a/cloud-operations/README.md +++ b/cloud-operations/README.md @@ -16,3 +16,8 @@ The example's feed tracks changes to Google Compute instances, and the Cloud Fun
+## Compute Engine quota monitoring + + This [example](./quota-monitoring) shows a practical way of collecting and monitoring [Compute Engine resource quotas](https://cloud.google.com/compute/quotas) via Cloud Monitoring metrics as an alternative to the recently released [built-in quota metrics](https://cloud.google.com/monitoring/alerts/using-quota-metrics). A simple alert on quota thresholds is also part of the example. + +
diff --git a/cloud-operations/quota-monitoring/README.md b/cloud-operations/quota-monitoring/README.md new file mode 100644 index 00000000..a92abf68 --- /dev/null +++ b/cloud-operations/quota-monitoring/README.md @@ -0,0 +1,42 @@ +# Compute Engine quota monitoring + +This example improves on the [GCE quota exporter tool](https://github.com/GoogleCloudPlatform/professional-services/tree/master/tools/gce-quota-sync) (by the same author of this example), and shows a practical way of collecting and monitoring [Compute Engine resource quotas](https://cloud.google.com/compute/quotas) via Cloud Monitoring metrics as an alternative to the recently released [built-in quota metrics](https://cloud.google.com/monitoring/alerts/using-quota-metrics). + +Compared to the built-in metrics, it offers a simpler representation of quotas and quota ratios which is especially useful in charts, it allows filtering or combining quotas between different projects regardless of their monitoring workspace, and it creates a default alerting policy without the need to interact directly with the monitoring API. + +Regardless of its specific purpose, this example is also useful in showing how to manipulate and write time series to cloud monitoring. The resources it creates are shown in the high level diagram below: + +GCP resource diagram + +The solution is designed so that the Cloud Function arguments that control function execution (eg to set which project quotas to monitor) are defined in the Cloud Scheduler payload set in the PubSub message, so that a single function can be used for different configurations by creating more schedules. + +Quota time series are stored using a [custom metric](https://cloud.google.com/monitoring/custom-metrics) with the `custom.googleapis.com/quota/gce` type and [gauge kind](https://cloud.google.com/monitoring/api/v3/kinds-and-types#metric-kinds), tracking the ratio between quota and limit as double to aid in visualization and alerting. Labels are set with the quota name, project id (which may differ from the monitoring workspace projects), value, and limit. This is how they look like in the metrics explorer. + +GCP resource diagram + +The solution also creates a basic monitoring alert policy, to demonstrate how to raise alerts when any of the tracked quota ratios go over a predefined threshold. + +## Running the example + +Clone this repository or [open it in cloud shell](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2Fterraform-google-modules%2Fcloud-foundation-fabric&cloudshell_print=cloud-shell-readme.txt&cloudshell_working_dir=cloud-operations%2Fquota-monitoring), then go through the following steps to create resources: + +- `terraform init` +- `terraform apply -var project_id=my-project-id` + + +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| project_id | Project id that references existing project. | string | ✓ | | +| *bundle_path* | Path used to write the intermediate Cloud Function code bundle. | string | | ./bundle.zip | +| *name* | Arbitrary string used to name created resources. | string | | quota-monitor | +| *project_create* | Create project instead ofusing an existing one. | bool | | false | +| *quota_config* | Cloud function configuration. | object({...}) | | ... | +| *region* | Compute region used in the example. | string | | europe-west1 | +| *schedule_config* | Schedule timer configuration in crontab format | string | | 0 * * * * | + +## Outputs + + + diff --git a/cloud-operations/quota-monitoring/backend.tf.sample b/cloud-operations/quota-monitoring/backend.tf.sample new file mode 100644 index 00000000..528c4eb2 --- /dev/null +++ b/cloud-operations/quota-monitoring/backend.tf.sample @@ -0,0 +1,23 @@ +# Copyright 2019 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 +# +# https://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. + +# set a valid bucket below and rename this file to backend.tf + +terraform { + backend "gcs" { + bucket = "" + prefix = "fabric/operations/quota-monitoring" + } +} + diff --git a/cloud-operations/quota-monitoring/cf/main.py b/cloud-operations/quota-monitoring/cf/main.py new file mode 100755 index 00000000..a3ff4a09 --- /dev/null +++ b/cloud-operations/quota-monitoring/cf/main.py @@ -0,0 +1,201 @@ +#! /usr/bin/env python3 +# Copyright 2020 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. + +"""Sync GCE quota usage to Stackdriver for multiple projects. + +This tool fetches global and/or regional quotas from the GCE API for +multiple projects, and sends them to Stackdriver as custom metrics, where they +can be used to set alert policies or create charts. +""" + +import base64 +import datetime +import json +import logging +import os +import warnings + +import click + +from google.api_core.exceptions import GoogleAPIError +from google.cloud import monitoring_v3 + +import googleapiclient.discovery +import googleapiclient.errors + + +_BATCH_SIZE = 5 +_METRIC_KIND = monitoring_v3.enums.MetricDescriptor.MetricKind.GAUGE +_METRIC_TYPE = 'custom.googleapis.com/quota/gce' + + +def _add_series(project_id, series, client=None): + """Write metrics series to Stackdriver. + + Args: + project_id: series will be written to this project id's account + series: the time series to be written, as a list of + monitoring_v3.types.TimeSeries instances + client: optional monitoring_v3.MetricServiceClient will be used + instead of obtaining a new one + """ + client = client or monitoring_v3.MetricServiceClient() + project_name = client.project_path(project_id) + if isinstance(series, monitoring_v3.types.TimeSeries): + series = [series] + try: + client.create_time_series(project_name, series) + except GoogleAPIError as e: + raise RuntimeError('Error from monitoring API: %s' % e) + + +def _configure_logging(verbose=True): + """Basic logging configuration. + + Args: + verbose: enable verbose logging + """ + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level) + warnings.filterwarnings('ignore', r'.*end user credentials.*', UserWarning) + + +def _fetch_quotas(project, region='global', compute=None): + """Fetch GCE per - project or per - region quotas from the API. + + Args: + project: fetch global or regional quotas for this project id + region: which quotas to fetch, 'global' or region name + compute: optional instance of googleapiclient.discovery.build will be used + instead of obtaining a new one + """ + compute = compute or googleapiclient.discovery.build('compute', 'v1') + try: + if region != 'global': + req = compute.regions().get(project=project, region=region) + else: + req = compute.projects().get(project=project) + resp = req.execute() + return resp['quotas'] + except (GoogleAPIError, googleapiclient.errors.HttpError) as e: + logging.debug('API Error: %s', e, exc_info=True) + raise RuntimeError('Error fetching quota (project: %s, region: %s)' % + (project, region)) + + +def _get_series(metric_labels, value, metric_type=_METRIC_TYPE, dt=None): + """Create a Stackdriver monitoring time series from value and labels. + + Args: + metric_labels: dict with labels that will be used in the time series + value: time series value + metric_type: which metric is this series for + dt: datetime.datetime instance used for the series end time + """ + series = monitoring_v3.types.TimeSeries() + series.metric.type = metric_type + series.resource.type = 'global' + for label in metric_labels: + series.metric.labels[label] = metric_labels[label] + point = series.points.add() + point.value.double_value = value + point.interval.end_time.FromDatetime(dt or datetime.datetime.utcnow()) + return series + + +def _quota_to_series(project, region, quota): + """Convert API quota objects to Stackdriver monitoring time series. + + Args: + project: set in converted time series labels + region: set in converted time series labels + quota: quota object received from the GCE API + """ + labels = dict((k, str(v)) for k, v in quota.items()) + labels['project'] = project + labels['region'] = region + try: + value = quota['usage'] / float(quota['limit']) + except ZeroDivisionError: + value = 0 + return _get_series(labels, value) + + +@click.command() +@click.option('--monitoring-project', required=True, + help='monitoring project id') +@click.option('--gce-project', multiple=True, + help='project ids (multiple), defaults to monitoring project') +@click.option('--gce-region', multiple=True, + help='regions (multiple), defaults to "global"') +@click.option('--verbose', is_flag=True, help='Verbose output') +@click.argument('keywords', nargs=-1) +def main_cli(monitoring_project=None, gce_project=None, gce_region=None, + verbose=False, keywords=None): + """Fetch GCE quotas and writes them as custom metrics to Stackdriver. + + If KEYWORDS are specified as arguments, only quotas matching one of the + keywords will be stored in Stackdriver. + """ + try: + _main(monitoring_project, gce_project, gce_region, verbose, keywords) + except RuntimeError: + logging.exception('exception raised') + + +def main(event, context): + """Cloud Function entry point.""" + try: + data = json.loads(base64.b64decode(event['data']).decode('utf-8')) + _main(os.environ.get('GCP_PROJECT'), **data) + # uncomment once https://issuetracker.google.com/issues/155215191 is fixed + # except RuntimeError: + # raise + except Exception: + logging.exception('exception in cloud function entry point') + + +def _main(monitoring_project, gce_project=None, gce_region=None, verbose=False, + keywords=None): + """Module entry point used by cli and cloud function wrappers.""" + _configure_logging(verbose=verbose) + gce_projects = gce_project or [monitoring_project] + gce_regions = gce_region or ['global'] + keywords = set(keywords or []) + logging.debug('monitoring project %s', monitoring_project) + logging.debug('projects %s regions %s', gce_projects, gce_regions) + logging.debug('keywords %s', keywords) + quotas = [] + compute = googleapiclient.discovery.build( + 'compute', 'v1', cache_discovery=False) + for project in gce_projects: + logging.debug('project %s', project) + for region in gce_regions: + logging.debug('region %s', region) + for quota in _fetch_quotas(project, region, compute=compute): + if keywords and not any(k in quota['metric'] for k in keywords): + # logging.debug('skipping %s', quota) + continue + logging.debug('quota %s', quota) + quotas.append((project, region, quota)) + client, i = monitoring_v3.MetricServiceClient(), 0 + while i < len(quotas): + series = [_quota_to_series(*q) for q in quotas[i:i + _BATCH_SIZE]] + _add_series(monitoring_project, series, client) + i += _BATCH_SIZE + + +if __name__ == '__main__': + main_cli() diff --git a/cloud-operations/quota-monitoring/cf/requirements.txt b/cloud-operations/quota-monitoring/cf/requirements.txt new file mode 100644 index 00000000..b9c9e011 --- /dev/null +++ b/cloud-operations/quota-monitoring/cf/requirements.txt @@ -0,0 +1,3 @@ +Click>=7.0 +google-api-python-client>=1.10.1 +google-cloud-monitoring>=1.1.0 diff --git a/cloud-operations/quota-monitoring/cloud-shell-readme.txt b/cloud-operations/quota-monitoring/cloud-shell-readme.txt new file mode 100644 index 00000000..62e65af9 --- /dev/null +++ b/cloud-operations/quota-monitoring/cloud-shell-readme.txt @@ -0,0 +1,9 @@ + + +################################# Quickstart ################################# + +- terraform init +- terraform apply -var project_id=$GOOGLE_CLOUD_PROJECT + +Refer to the README.md file for more info and testing flow. + diff --git a/cloud-operations/quota-monitoring/diagram.png b/cloud-operations/quota-monitoring/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c68131f210b61aee798a6be9102845564d788e71 GIT binary patch literal 48087 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

d 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 Nsp0V00%gm<{{aHpFWvwE literal 0 HcmV?d00001 diff --git a/cloud-operations/quota-monitoring/explorer.png b/cloud-operations/quota-monitoring/explorer.png new file mode 100644 index 0000000000000000000000000000000000000000..80f50254146dcfca7970879cd704e76a114f388b GIT binary patch literal 52935 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 z z5#icV(6bK$C**Wiw|TNBM7m7@n^<6w%Bu@0-WuH?8=iHjLkbw~drAxaTgc@_Q$@}| z%izd_F7T6MM#28PudGp`oreT-(v1FQ^n9ka-}G9X?U9$xfvkQoPil74Pa1{<+l_S4 zN7~FtQ&g!+*B2rC-b@~jd8onvz51OD#x=3|bD$1_8UD1ba(7!nUVjHXy_Nt|!MacYxgi@JhkPc#pT@XB7#+aqpP${lRw-ex=p zK$wJkqW$cc$R1iFoQCrF{?^yFE?u5m?t44!x#+#KYuMmlln&9Y&g2u|ts zo>c6H){T@o!=Gb2EAF)JCgT(7pT7&4h+;xT1gz!i6S>TRcFWZ8ujC z%JFHk^S*4mRNdHeJDW%(KmLun_>2NNo~>FdPafm8_YL(CGmRVdW{kRn7ec&=47}?^ zTgg>@6n@m?=S0lw#WN{UUfBWayJbR%$wdJ%gTxO0VVP}+Veemwnl1T_8m2n$(Wm9L z4pHLii+-U+rxyGL4ZmK6QyHLQeTRDKKHp$(X!=N+2A{jp&fzj)B(BIqTp7GxCPm+l ze~w1X*GLF7XCP(yx(s-G^1vuUcmOW+f|NZn`_mrz*%C7Q?_}N#1Xv;480sY3uozw> ze0Cu}H^rdnp)`UG;KA)-rcw9{b1K51T-BG@xL`aGHoq(GWFSO~{w^5E`PNe#e$!em z!A>rxJ_A?e5eb0D?{7hX*QS7f7-JZqB4O3kAnPE1*vzQ!CUF7LCCQFVi^|rEVVK(S|f3XuOV%NsyYi7=Et)bgBs-r&$>*l zE<{i}eBJ`a4Xm9Q4V(SCsDhaZu8*WqMazY!in{;uI6OsF#&e zndqnOXRJzNAobmY-HuS=AYIp47hhl2;SR)BDN41%zR?ncx-VOBh^{sHtE7VE5-XKr z2QKGAusAj%j#lsty$0=T7tK=^rv5*LFF^-{%?aHjp5L6J(GAV34!}Xh&rbF!xAy<{ zNvIP*LP->FkWk~0(@XwAL%r7*O4q7Ws7K`hERY39TQ~F^@Okvyl!&;HpXJ6#dfp+B;OhTi2A=mJ6tWa`3GN8eL4>7S?Qv zGZ6rN>vAgR%(!RN!}sGLq!#^nq#SQuKM&>lIXJPutN}>{h&R2whVolD+FHso6jtD} zE$(MiU5h9@4r+s)BGuty27vX)HW=Lc{ozQcj2B2?5}y4by;FaC0gyg%K*QG%Ao92T z$Z`?!X(FBL#cQie>vmAUBB6gw7wSa8(CU093nWP=MzpdD)#a1;Tq{QJ{a^TBjSq^q zhXf|8k05dvm~YFLaw&<)kCn4|p*@m!JO0*uDdqs!5$U1p6GMCl9C0 zp8(^FKjp+c4iImbZw{n_>+=Q)e6HA|l|YQD9%w(QPen{{uCaK!>JrJkh2H zO@II04PJRb|2ElGkRIEc!GQ`ol^w>)hVKo=7B;~$4StEG9*vQ`CO`Q6ibZMY zT>0IpH+v>udJ5rzw8YQy$=E-QHRG}Lq;BrJglrQaDGL7f!jJ4}u+KAx*1_1oTfi)L zLo(*G4{ax|0Lo}DxnadUDqV{oh%zb1#qm^oov?q%Dxp}5|sPKDa7MM>R--#ckz@^Gr!r$c z4{iC3x)>6(TL8Lg4b^wiqmf`<780Ei0<0k)K<}sibw&n0w+Ui9!`Bbe93XJOq(@t= zoN?UPR(F6EVXM^S1%@CI&<#@?)@Z}w83;@l*EuQu>we4@=j8LA9yNCS` zh=jXn`K>Q{w!)A#4*^?q=-m+gSvce00LfyJR{2WbErpu1}R3-tN%o4288_ttiOw6bITUN@qPM;S>rX{V5MtO@fifsU|1<^>tXzZ&%Z>AQj7GYCS`XZ7ELUcZf~zLC+> z=A~@QN(!9PtI#ZlniX}_>4jyUECHFExI#ytQssq)eSeLClD{=$DXxL&FqpmmT zH_MlVb*qy1K&~kTisGkJC*(fWElf-x$FTda4QZwgQa3WiHd&j92x z6+tG*5-b<@Z@^?W_?FV}xfld*odcVU=VAIbVtxi!xVVCLfV?LNL-2Q#{({_;U8Ua- z5zCNPl>cI%5e$bNUXM)sR1$2-`*B)N=-2vNMEeJshn(8K#V;j9+sVVw39Ntr`u~_u z+m7s8fO>u$YU_L^);*n&zNy&IkPt*YLRwO@00lg=uZ2S|$P61TF7O5@q z8r~d30Ym#bBOzG`0reVG$HhUc+-49Y$dto$>L`aeHOO?zhhSw{2F(r39=Nul%gl@> zKnnI$r4Ot}ff`OtkmqI=uSC+Kp! zt3HYt&=-p?zi07S16hjlR0Kmqto?rN4b=XcJ1?HSh_W0y%)9`ewxP+@G3SMKvqJL)f3a_t$GSrG+ z#7lZEgYtFLF9)*8YuRH~RXx%JGeDCSg7iVs$M*+pEB8VY7~dFEO+&f*_j~tcedZuv zm5=??my^jb4Lvs>o*;+rsTL5?qDs2-uBF6&#>ky`mJ2LK2zu;Z{;}Slfxgf0WtY)x zV&zlB>H`jnM@Wl_#7}Y4{6n0y?>-YuC~S$wBmQDQgr&rIaOilDmA29SIW8U7p>F{i zleH=w?B7iSr&gTuNQnY(cTkrLGV1sUij^pfHs#yR&b&+bxvp!ZToHEUWk3?Aq5u#~3|Z#I zqC9fiaDq#mdXy*d}g=N!#D0 zJ<(Izj=uF*LI}LI$=0DI^}+0TcZWpzaI*f996Wv-Nf!I$<;eBp?y0v6NwpP-snXa(|AjyE?U{DJ>*RamN$vbD zZtmTr3j_Xz@mCM}tK=|6vfyI^GajEp2hD`ECy5-wxt%BTo#dbuO6VTYj19FbJYHcJ z_EMb+oxx$gaUkD1l@HrSW3PgxKbJMsZ*8d$vl9=yp}--h>~NYjq8yy&l6vd$X@*i~ z^x48s+u4tC8RWES4ds@Q+Fg68LtB>SvSyRC*0Lu#@C_$MsE(w(a~WVuwpr(sc4+IK z`gRwKuc?5U!(MC&eh#+zF><5A*s+d#c9;`aeE1#Tfyc(Z9eR68J_Iwwn;;G)`S``6U4}=kemq@TsF%B!>>TwV1WwuJ@Ag$9} z7ym_bJ%%**m_31B1olgUrF8;nyMlLV^^N=Q`@=8}Vt3<2>5rY>0Mw~GvNz6Yk4;IJ zr#q(_$Vi@hzjWFxZ+B5`3PEk>{!?E^J92M8yJ2c~#5vT3%y%S$QU`0Bv0r7(8M2hI z6_9_Y5Sf5qaKtCiJy-;mcsNfzJIO19?y14L7EbCFR6 z^+aXvbomxPAJ=9cMi=Oi|CFXQztQoy#IXHNoUDeyakA07hTkw|5}#;gvv{RfKbSpS zNEhwnsOOKw{ghQ|e)$FcK@-{Vr8(~lF~-J3(yM+>#D{tUJ&AFBa_jvzR8N&Pc2;&jWY3i9S0 zMejK`jFimpxrDXldbCWdL=20E=mRCm^U*fnV}yws$*$7km|U5!!|Cd5#)T7l4$QNZ zEsis)h!)ra-LBfvhdSt8vx&!%-ZjpP(&Gjgt|G^^eb-)oBUp~kBUjsQdN{{4(#siy zlmCkLRR)>hYf@I&2_#quMu5ZgZ>lqSc}OdKk2K$`xSxjY0a z4NpP#hN?H8OZ*BGe0%xJZ-yTBHv8fuW?Ox*2MP-Y*f*>;C1g9eI0>-Xq@vCe9h)6R zY-tY=2>HK8gZ-^V_XCL)$A*eJOfOCKV>n2F2N>2>wTP>Tw_Mul^}+y1#sdT2>09Gx zoJuVRiixDNwkgs>)CgRe>4qW>>E^~)kt6tjYalC|FAJ>D^G zU0)I$(~Myo06HcukL|VxeN72a*q@3!I7@3L1)s}vA9AbZ)@sgv;haF6vCuII8oK@< zKl1Z)1UdgtS_lBqBkeQbrE+z=0kP8P&c*uA&rb-s-3FQj>J2ZEeYBa^qP{9SpHMSj1V2BT4;K!VpE-NAO!m?Xmg{lumn$|}@B2z&s z;JVVOPL66}L`i-X`8+azrS4&uZJ{KGwV68Y8NfHF4-1i&Pvp%sNbkZ92+M%42PW2l zK%m5V^y0vObkF$J#byP!Ve1a8N1V@Wo5{^4#L9~Ro^`*?7vTOn5$FegXRTHVC4_6$ z63=t~rV1U8sy$=Uj+9ownt@LzxC;N4;5D?=_di4efxn6p3zD4&Ubt-|HH@jn*1c#D za;%v^`jQbn5P1;}_;oVS;ODMr^@5j4bwdB~WyJYC;x`qI#lJxCBvZBL(Ci4g9_-(? z$ji6I{&d>EUffrOO&1MHGLQHNTIjW9!9!$&4IROB2c5cS`Ks6V#%1|sV}bF0Lf zfm`YB0-}xf6V{OS`0>;^prr2O^#NUUTn9Xc$jjWcc}Te%B+Se4MTA@0$*=ooV5 zG-8)lk!7lVi^Zw()kwrv)>YDD6?3?Xx;m{E9_MoUII1!ZOVEw8M_%?8wqIWAY93;T zLL7R?+oa$Hdsm&k?mh$9)5~(SXG9>*5R2jN5+qz6bmYct_=s382>z(il``W^-(gh9 z5we;kgGb<85%dLI&6{o{$DDS2mk-FV6M55KLm$*|R(btKh@txLiWV7ZIaB^F8fRmn z81%|kjmVy9-Wk)I^ZYfzIWvi;h};mbz5vw2HSl2O1G&=mScCtC)3)`N9UDZ6m$7%+ zP~xo_TXxYHQq&MjOn@{X(H8#(VfursBq9M~+yDRMQ3D&01HLm`ieLzv;EXb~c1d&n zH{RYls>-!{7ghu*r9(tIq*Ka8w}jFl5)x99qKidIigd#i zfBZY2l@RtcY1TjU;y;M7aLCa8!71RAptezYYVaF!Z-4~-OSbfH1gTavHgpuNU@c(A zBVc&o2+=>L;KmVDl2!I+FOX-xf&d=x$PEB-2xik!XG2%`gk7giUr2`fT%9C z7hwvP8?gw=v80vy2d9(Igwfs1Wh*!dOcymcG18m!p8cK7no#FVF6nh=f}3XM!ckFC zp>tURXc@}aPDUU~;058w=vt>ifAzy#3dq2~&cFdIhQy_%^rPh;2f(EHWo$k7pJPi3 zp#(-}(jx(83R3DCOyd0$Y=Rg9z{3^lCFJ=(0)CaThWzD!=q~Vk@jh7@AZ~r?Ac{(k zrs)22bWJhA*%z>vkc7NsftnO4qKJb3{x`S;_dl3Y1pCP2%0Y(YzZ6Uzkhg7MkT?Gq z1oPJ%jQM|QtB^VHkCyDeV4r{f`u}uwuWyhG6JJ$ETJ621tIgAgM(8Ipo6QpjWXJ!N zGZ4YI{GIASE5y#2>y-yP1EX(Zip+eTC(3;fTrmzs!HQ;v%3{z8`Ygf=MvmB~a7Rm7 zqQ;FwjcYBA*9x2lNJ<=Qb_a}cp#!1lwC1M{j;E9 z#oEK{Ap18Y4S9L}uj|Ucg1kRV{GY%6*VS7}*XMfaYF(BEvwJ@j-$^$t>hcAj7%%XN zIi$VbG5FhGix{xEK$t|w&=<7_>swn6KMvg8TT9T9F-@1mI(>A?-15;yegG^4finkM zym0w-xIFNVZHiFp|mvW<$2_>rt1{DfWe;uE*k3MWy_di)tdfNo&p^h$#Yzf)41xjylKbgzEL;LNx6pS=X`c@b^-*tF% z#z7FO@#;{%mk2oYdtZ=btQvjB3d{%65qdGVu|Ke%VeIN34Mm|9pOT}rTUH?KamX#T z>3)!B$!k>Xo?LXTu2efkq%#vUL%3^S;{jeIT76oYYwm!W^dO{3J^6yoj;EleFKI;A9v2Z^?2v=vODgnPwDD21xBt*fNu(7tYTLF{*gvW z9oP|f0?@LUVS1Cc`C6W_N1nCa2H_VYSiIzL+o~q@Tz^g{Z(hIzk>XpU$^_M=5@Y?= ztzO{sjo-Jhz5jk8-tztJOW;iSL9=Ockk_;!pEmL)C6!D1Er4KCKh0#^`E+%aOgp>r zOM+&?gfvG==Jl;dwvQyC(y@t9M24D_#{cZ1>Mio7!5Po*06^T}_~-;aNi}@Q7yyKe zs@UpRH$=b>;?1a3%pSs)Ts6PtIp6QmU<54BgX{r)fD3@Mri>ScPzZ(4#T5);r#hG_A?x+U-Xz={)hzB`d>dhKL*j>cu#7cl#`QPW!vTD zO{Dfs$*ENi_4RueURm3|78~%dn@bG0Q&YVmdhI@iz9(J%38xf?|(4 z!e2K}FvABOG`jh)JaOdZ&t1v~4mZ$m;$K?^vLS0xj{ulx&t9M{GM>AG;Xrk%fazQH z3^e!~vLJ;YuG`4W+hyJsR1V4*7ON4FDCwc?_-WAbh| zX=kF}n1N5rvOvtdrrVSEj#N>)aE?l!`S%m`(JoG%>~MFj!A~iJq1ro#P9+a63jaYu znEi43l8XXGiT~jO2-bl~L_IN-B`*5YUY-F(0(+b!E;1g7m00%nneFY6*3(6YE|$(e z{f+y_ZZbM(7a43Ry?1~}ikPI?iY@>@7IbuH+l z{axo@r3X#kMQ#&AJ2Gx^leNkBSjd!v&WbsiK#~j0xfQ=rB!2y($ovGTqoYH>HG*eP z)>9jZ?nnoBEeOR2+5?`H_>^}ETzhw9^w)!Mw4<<}iQqc;>feM=748bVNR8JBN6fB( zCWtgx;O74$00U-$8q_xtMPMKd>oJ`FXNo{3r$(A#=qk{EP!IRwp?ll;2aeyG`K616 zke6LGPiN|w*xKWXukkXo4S3$dIux^0!#rx2GZ4Q<6Lseb1MP&@^kmL!zbAG%{0jAI zBK|bXXV#V@nk^3;E|qWDl)dAG!9w)w7G773c=DOf)Oe=ez5l&E1AQgRsL>|;6dlFh z3n(IGGs%bic%^A{k$Z(Rvu;mgq9XXkp1~X#EAi6Q-f4~;4&Q5gPfQ-6=wCawRQK!k z$g;$N?EWvV#*(+qL+wL=iLJd>&l_r76Du>Cn0)d&J*_Fp_V;DnhLto#@=fppp2-wx zV9Kv-*?oMKgDIv%rGOu#za~D7Of1x6B=q8Z`7A6|UWVeYuFHNH7MoeZlU)mchhY)! znO}7F{j!_1>%ZWlR(5xzJ+PUEasR`WcovzAJF}*5@e;m?uKDuq|1eoN*_nEs_EBbD z2F&a~eE!IELv|pV{J1T&hOc=bA^pP@b>#~4`A%Ee9`4e1;l!?+=;SVd1AhTIOnnC zD|&aQBoQ2pD+UtC3qFUE5-GY_>$({jxj5BBp(WDneJ2(0x0%k#x8QGDr?t2l^Buz` zZNU__Tj<5tCa~qt&^)5kD8#Vl%bhYHEi#!f^_gWv_Mv+5+fC*jI(^}gEg3~kh+Y5Y zuTsVL>hjd&esrI`L;SmqQyQz1S@f1E@BWQa(T5ukc23w<-;tY6D`Ib8bDB4$iRSgq zc}6rE0oIslk&7|792X_dq>eb=fcfYhEs=WG?Ta+3X6Tza0lI#BVHH-JJWR=BC>f21 zP1?HO<9A=>++}k4z`S{LqJEnGR#vK1kn+UaJ-$({U?XqJQ4h!xmJ1vH_U?N9`AoE(qX;R%bBq zx!Jz~&dosx4{ty!&hhdSR1F*uJDqLF9F!M45z$K=A>1OTK|SZ6P@|0o+-@4SKV5T~ zX!J(+3wL9q1e?$JcsL1oQ(qX8oQ-ZQ&=g>JApv{ce`~}L!Wl?IOaQFmvv~Z@9y+Pi zAsGx{RK=ie6TjYi43Uz2egcjSt_ar*F?jDR&Dla~+X!h+20YRblmJna0=38~`PU*A zn0uq(zU~Sh3i*h>b9@0Xpb7{~vA`@u0$u^XrHe=#4n#A_poE;b?RYc-W)2WE+>d$( zm9GX67U%`DVV`w${^>>m_Z*5a&b-Q$fu-+&@VDOc)%dU9Cub{$Q1Vl~r#)@6h0h`f z;@jj!2)0K0JY~&$PQIYBKzh))7ESg1@@`v&I&1V<5s+`#Kq^r={VMz#V00z+Lz&>l zZ-Yp36Q*X!U{6VvZ*&JMg7b30=5I1tNB>&>&EwsoSo!{dFcFIBpMlEle67+pf?nqR z6S1fHzwhDK_N>z^OPY&XltZ775XAPb81=w{nFVBGPb$8VmF{Gd)(|PfJ zQmU}DFshhHUs&{wlo{ez14mgPF_m9AIFmgbi`ar<|AhnIm*T44P_@=pSc5RzgTA;Q zc$i=*DJKEt#U&_8u8CWYfcc*@xM&HSvIscXJ{~~j02aNr2&IeParKWwth_reku0{b zi0TXCkq0oQxj@tPI=+Z6n z0jG-7hxvZ_XT8*)J8ec>-L{@l#2_X%@1RPe$;O%d$I#~3afIZofE?uiMff^~zy`NP zfSVNb?|wW}ziYf0b@Zsl=v&f)I3jybWM45?VIs-F+jk0Y>Qg!bjuX zPc9V9K|0LxRXJp4bWyI@cIeIJw zVO-!40W*F(Ja7&X5j~E^Dm^`Y{W*95dW{$l-emlJvFuMz0`VDxPwHXArm`wE+2wJ9 zw3!V;%|SioM|yEFxaTcLX=1`Pb%_Q6Is(>vhC z@U5Xb?MbEA0|B&9GY?lb<5C|@Oixdr^DjgsAABa|@_ARDl+t7aF*p1a!$pHsahK<- z;B9{23DQRY1eBL+A}#`{FS&i#g=%imFG@U*e#t0S}5rH3p#Tw6lGnP??2O+Ikn}Sm( zFmAna-KX0}o{O5eHs4-M=zk(-d6MK@fmW9&H0}}Grb`%Mq03CP-f=dq7t|>eJ}kw3 zo{{ZrTrQVPh0c~njYzX{ZowH2Oyn3;$PPg#qv-6kJ2RiVjN%57 zcrr9ICd#|3{t7$kE!a^DD^!AB1QrAeZR7$Om~t~VU|%T=4R>o4QGxh0QPGoWV7klDMl@-p zXVw5CyB4zxdwO06!_o#?Smbsm)nr?d3>Z{!tY-S}T;yI>E&*I;v+)2U6G91_Wo60jV(47%qidExoo70OygBT z9c;aQZt0J#EcH#5o3yjhqKa=NqUiCjRfqHx7S(&;-> ztwDSx)?SR65*Ugp!gg!dw@+1;oXJ;Otx2O!rPp~TQu@BDuIsapgU8vJI?s`&Q`AMuR8xxVhmtNuh4> z#Smx0PoT-^aZE4QigWACGZ-HBsna-AXd3QOa&!fr@=1$wlZ}{U7H>ku;qsi=U_2Iz z1Q2cWUuz#s=_ZNLi<8G=#PbizKQ@8OS6LxhX3_ML`DY}0V#uN1yg|&$Uh8De$D3x@ z)|}(n#6u4%zPfa>N|{AUgfigDiIWP|a;;qc&Y)9{@A+e;=}1LeaTn6pBG1- zPG<_m%H94Nm-7?6*bQY)018`oGhu9boDhZiJoQoaDnv|`N@G*Uih%H^9>HQGLslm_ z6d8H}vol3FOmS`+)o&kaYF&kQ03c+`hpsSjlG4x&URAWo@|dLmUiAfts;d2EPGCu< zU(Z}a#=-w);npbk%B@K0F^=OD1lN#Z2%or;{TREbhJnCvq4LbVDh+kB;5c>Ll@PB} z#41$#F>knMQ@EDNmiBG>5HCr_S+!!=DEU;iYH63*4t;j+AmwiZkXCLqC$-z=urxDR{W}X zVX-Oes||!f&w%f^Bs~UxQHkBB2%}qDuD1o(%An7(vC~xcn*OHG_FkE460LPh zJnkl&JW5_2T;=W_L~t7kE1-81nov8}HG)LIz!6|CkSy?Awp z%nMxZj95}lOl&G5y?j~B^iil#yi(opD{wbU@ zOk=+yVD=(w(7;;s#1?s`Fae7jEvDC8 zKU765d;tqJgES%2wymc(3RO1GP_Y}fNtvYUcWh$>YD8fdn%-^Wxj8#e+FnYo5w#<1 z)RR=?P1iJ#v0ZyY>eIz^&paV!z8wXz$Up< z$hIr=F+Frd8a+_MvRN1gDsz%a5>Id3v9^sH3MpShe86Q>XMX)MauJo{( zzsUecevZhm>0-)v#j|3BWVci8=M#-B;$eg5j?Lz6h>w7Mt1DP5&~B|lNe}VHH-C2o&zmy0NzhmA7iv2?y8>rh6+rsoA~3AnRw+q@GfnJ{_ehxdowuTPA8OG}i^ za_#>Kek(=M<)xtv6pd4cl#<5bcNM-kG722{V=k9R1n-fJMv`4>bR%0((nY*iII^a) zqhe!?pS1ag3oD{EFvB&)kL+ewqR9szo8EANRc7DWAsY2YAGUGZyI1K>Sa`A_^Hi!W zxn1CVL6tUyWaXSlNcLkRcr)=^MoS;b)6#FZ%9^r1-@DQH2v@z86t{7XC9IIF8GHRL zvC9E!X=*%#Ygqj1{u>!_1sn^G>zAd$0H?s{eXzSx?i9*8T=GR1EdZY_dv%H(^ZW_ zD%rRoSr+Lv*2bGbDHypMdU}oJINR~M_6I$1Is6HZmG2>Jn4-rq)qvvVAQIx@Dh%1--;il>0z zIUTvSpFjueVFA-MPM!ebJ>ql)Z4HeCfQ1XaK2B#~_}VTVE8P6O_WSV4%iD{rK(I%< zquV1swp~;U`M4UFD!rm5PY!P@n9dSDI!qYgnGJOI`5zI-!ZM!7SJ5GNwSFG%ak;vNtN}V{DhXILauE^| z>eV&-xzN!+X^g;~#<;+OG?z{#nv>|)-#_{GK8*tvuF&Z z-#R_Q6yH4QoQZSJLjFPWQ(7W+O{fFbj`NfVZFO}X*!e1Xx~7Jg5&(uHEKU0>y;;*)0i z44qTi5ryN_vo0>Oz$Xx3C3)>Z$f}+2@F5VpW2qe zWnDT1;|r^C2a827oCzv)*UL)%I*&uy?GO4I1t|mXp~2#7D4r&VF(_(Q)Mt^3nwMV> zYTV!Kd$3HVDh*^IVP7NWA4bI{EYv&(<N8!JqoG*mgeUCw&er^Ba^8c0^y1@fG|*$8XpQCGZIu64G9Nm1tvZr{+f&vlCX1$OS+8h0v}5P*87` zsrUw>Pc0XX>#HuGU+4~4<&?iV7Z&&GB;Ndmd5MW_#9FI{?)cj<@pWrA%x~sYB!Qrl z#+1)hyxohnw*`Hui1()LT<5)*^}w9dC35S0?fTQbjNh$5Z36vDbLjUDATEEtFtn1w ztCJNtTdz`ZVYL0XiF#`q+o#c-(E!0pFPNa+0Pz-&AIXy{T!A2YT6F60 zQftr&p5nKXS0Mk$3OzT!0c+*lgaq8-XIId(&c1|=ub?XX#K}`}2#sN5#c@a#yx!&@ zfvc9rs3w(;22NF3Um41j(f&0KuC7->*SIK(U*=Q5*-;^Vrt!@J+=1VN$ES}r5C=-+ z4w_dR&yVgBjJ4NG+O$2TiMrtu^i>0E)fR*2014R*z|=m)d`XnL%xn@m4v7OV6xm+} z(TY>9GlY^z5AGd)UErJNl)gK9XWc~UL-wHWYW?{rvdu>k8?q&*rRgQB@@cPO=8Lgg zh`HBF-H7Ny;enJaouj-CvJX9WW+EyZme-BOUDRe0w#^dUC5g`eK76imf;u|lZZnri0MC8N08DwIBw zeYV9dkexm!v89mqoR2npdHWK-Yef%oH+vqT$LPRmhhmdVLsFlRH2KiY#+t-`2#oFW z6;J5v>wlD%M{6`bJnh-(G*Mx}CGX|uy?^aWT^|0+*T zPL3g`$>(L~_w9oX2N%*Hk~1*6%+Aq1(z3lgTx8#UhSBjOqy0hGh)I<_$+_wUnt)e1 z4}_fmH`@S%6@FPS0J9%hX)iI7-HRA1msfG%pvY$SM|GHjE3iQVk<9^_r}}&#ndtpi zkV?4=JNm!4wW4t#$@=yrF8QFbVr$zkyM21rSB7mr`$2Y8~rU0>|>#O6gGpwGLfiKHX#4l0~ttM1P3z8MkCm#HHgS-%cS>tQDe3v@TtW$6`&WHBMa-wO9`Fdi%3#>LcE|L05M-aU(jDsxg{j zVxzkH!nXC6-_K2NJtXFMLK!F_DJ(3ki6U@Q{o0%{w9)v2*H57yrMRDY6o*&cegMwVZ@YsnNG8U^d!h>gw~!}`5$k}_BM`wmd}#9ezH^1_BCNS<9j z)|~kPkqdIOLCkRHw8|kW`hP+X#iV0l<~=0*sH4>TkVW2Vfoae=>?kS;#5IE@I=lZT zfa#$*PSoH$Jv)HRZ6r{+jf?3L_@l_L$SHN;SCSxhNBksxC9hUlOmH??#!}Zsa7wHrTDVp zR3TDq;Y_0+^TbUqk#o&iJK===dk#yp&}wc5{#Q$#msTC(<0m;^w})_VzRL0D5YYId z@m#$o;JkVr%Es0pN78UDH{SbWj*0igXAA9l1gs3ZGWhqT@)3@>;E`UUz zm)(I-8gJ=~Ss#DjuU2mx$=~fiaG*0oUEY+cP`kO19B^aQ_FMq3i|O1 z7+)}a(@dT- z&V4JFlk5A;L@FgtV78XyQVpxFNWQk~RsRcyCrg?*H7|aNKdZueV|LB)o!7P@x4Dqs zC-lH9nPrQ)R`Q+k88>DRe#6|=&$#vJeDAW&svE~H44-Z#bq?wcBhG_yQil?6t9po z^TI~eH`~d?v*J0$1$?V53IBeZ0yB=KN z6a+Vr*)08Xh5l%=9V7e_SN`j|ab@&aCZF$~`L5jnDvs3`8n_(4(P@Jpi=F=ctZ#31 zgMuMtB>sk6es5e^+3Oi}{WIGj^+E{=UDXTsAKvxq7^g$*$uOXJgewAY#QrD?vNom)G1Fda8Y zC*>`Ven*L1CTHwj55t<_XNq|}mG3V5MXOs!aiT=TP$%x@neAB_EUEw?D;+Q8_UEVXC_u6_O zN5D?j3oBL8`!<5FAFb4K?iB%!sC^G^bDoL)x&)EW0qbTj_eBG2linza_WT-prkVfD zICrT;cN4R1Qom5k74gL8$)w2$`#U_o&vG*0I*5~@fb8R0y=B&^4^fr&4ojmDf?qMjv zVb4XXAJu7Wdpk#RQFrlWcb`_=DGM)c@pD%USsri+I%_37lY7=PD4X(>%bNlR>K`VX3agkLwp7ohFX2$cr zJnzAq*lg<6y$wy;cpKVxH(FDR666FFlkW6?Pjc#vEUy~PLtS2-X9$4Kg^vKm4BPx# z>GpTKysnQ`2;Ki-oXY2vws6t|Uw7DLj=El-*6u6O`h?v38|@F++eU=azbQ)2JruM4 zC?!%QYGrC}`h%r((Nav+q9DM$KiB^Lqm)$U6VJPQJ@44u_&PjnfSugD?43KmqI`8! zI96-yAs^lhYp&G>7c~!I>=U%W<(sqE&Qp#rxpeP+ibxC+vwrsRyfM4DhOJ8e^3x8j zxK>_Cf49h~^fN@Gss${KaiotTjI18VXe#%0*riy1xQrrjasE9*UB9;D;N7BPRF)@7 znr|UZ*KMs4W%ll+L3Lrd=*}yyj?W$v^esGf$~+`Dh;EEim9wrCN7op|@?QQLUi3e& zA1oC*74yo)$75l|lQ-~pV*(2=Nq%oG!zCU*il_Kdx6?J34LNRZQW4N=W80$M&phZC z^)`JgjUF0~NenQIP3)A*<0_+Tbg`-9;Jz~24X-@HeUpEmS{sR3Se_V3<0NYcIod{J zA25=$x%SrLYsLM>7D_m68i9~At35y?2P1Me+T(uaydoSadEvyB4BeG_r{=NVBE1%q zN79DNFWq0!6(@%4+fL@pUnP%WJ+@GKcaPUx{QG^cP!9pQbRL3mvk&5RANXlFTFlgw z?U*ceN6zz3QaqD3Z!z!Hv7cVQnt1IgZYHn)C2d+qAwgYciWrqAtsPvlWO>gAvDAI0 z3mqA+H{X`pI_im-qMuxPI3P32A)?sv5Iq&I{4OPA;T=1R%DMK^-KC;i7g>}(N&AiG z+~2aLM6>tZTitlq(?ti{<{J}}>D9py2da#&Q;+TJS3bwo95ULce;z;$G15(_;It{U zGPRdNf4#dQ@#$#81ieyqQ`P5AqCBjoGuE5x&Ia=-b~JEm(-Y?A<@G}M>s-vnjKGFT z)1XO2{%#7L=Ugv!J!a#nvZ|^^JgW-d>3oT?x(`{gfAVW%0JUc6rt zA|AS}dWO~YbvBiE9=h0|UQFXqt6%4}Hd$U>xr-f*npYX>jR=Q07bAxZZ+Guv=!0TuV<4*Z1*y4r4GD|`cXt?0e5hkZ#D|))sGLp@8M^H?B^f@N27U_cN<6Y9ccDBl)ANJKg6+bTB%igGN zA0KNUd?-F3S)b;$uL9*`&M8tWW1!DIVQWlEU?%ozW^FmF8Llot5g8}0)>u3=6TV6* zHX^(NRFODHwA5y1X4t`W9t=Er5uUq2fewqN81#}TePH_I`R;ToX^*g zW(S3!?%g}PEwkQwqSAhdvAacV46=tWgQ&GfK(5gIHd>Rx>i*F(r^(J`?|IAA>0i2U zRBzp?JWE+pY+9pHF^0X>ZJOZhiXICYW8l29K#Z_})ZF_9UWW z&@1u`NHzGJ)Gg(hVeoTc0|$p%{jjl{16}V|IE#kq*0#Sl1HAJZYKN1ANGCVjI$y%J1+IizwqFa=QDdhGYHd%Eu=VZm zmTJ5*7+D^2de1AZvs+cYNbjvk zc<3qGzW*LF9J$4+U3;eTx>MPe$L^K_iJ6uG4^F+-I=e4wdhbI7WmCcXlh6A$iz>Hn zr|#EjGEyf+Sm!h3^n4U?T#~cgy_On1naQp;n6EkAGHDaOyW}{X)OCrHp8M9Gq>YbVi`|C}w^m#|n**ZA-#V*tEHQ33mH&p(E5 z4An)EL}K_P`E1>|+oGvgls-lWaU4OqpU>)HzCeMzR9Xy{)r$*m6Q4KC|5m?A%FZ?e z3R1F}p2(%!&a%Qh&wR-I&b4XV=uS&f7~WWrz}w>s zv__!PHo5cljRMjTxX`S=QVWXVg!YdBX5<$94l+zu;Goes>$Kjz5=Pxvm8yn@y8JtE zhVO2{CuQVAj5gfm>B+X%waNgu_5ObSS`ZGNtHWeUMuXQ{64d&8k=l7)|39^JKOmwD zi~ti%qb6!&q}Cpbl=E$1)H zVo{#r-tr16hbG4_H+if)9!TM|1^v8Umwxe%29S_t#oR7fom4n!HFGJ*;TUEAL(oug zx83Ai5egrAA}0vQd+nx=EHqfq_$=l!YC!lHAkj;JLEivc+)_GyV+)28JQd19ZQ!9B zj<2f zANzJ#5KSI@f6E^GQzElMlnAx5pF@J-?vHHNwh4ervE`1jTTx1RNH~6!MQG2=DVi?a zDC>aG9>-dsMqUp4^7Narg|`ag=TDt_v+gk={lSaTLQ*p!i7CV2@PV)V@8;D^O;a~J z+IUThO@u$|C}rA9z^Q&C$C&= ztX_Afli*GtiD;f-#4NuNjAl8!R%NHHx#Hf!aCfll*mkSxeT*}KgH8BkQW|vhJsI1W z;oW+(>Pl?`zwBAXfjfQVa0Y5 zcHdT4IU<@G_1t%0)K%~IvvPB0AKc}x4vy1>je&y2u!malb-8}5kR+8?CeeTZ-$|CG zDgMRbGRved+^SaLcoK>Y#h8`rAbUl6g zbf;e+=3dVaFb8sxh<&5xOqL}}h4~Hrn2S47#)FHIrnu-2b6N&d636M!fPc%a3mZ^+ ze_kJb8vo$Mt4yR$?_!q)9y?b#A{DR#;#^ZwVp{~JHRBoOA@*ce&$$2_)fIP2JMS|V zy)9X#zkEXaHi3Q^KI06v{q>UaR|n`H^%Us6HE=5C*G|14%z%fF^MTnLvcNQVq9MIKprdIrw?;=wP@far(0vf%1jx=#=j>g%k_4DLIm z@NUp4ekbz@v*q!LC8W8jelHl0GZJvso0FK?)6_4+w>Baucny> zOlP-AiD0-ZFlud+1eEUw1Aabl@wpZQCHT`b{j80erZZ;=s6uO{D9fAgGJ9AMS)ZUY z_(qfdF_=r`E5~4@!kZ?r3F%d`t7}~jQI;ymL5lLyXt4a%$?hibyx_AUq&7{h)ME{d z+7Ds`_MvCqHbw<~sqnmU{&D-PQJt&#B6J$NICO7-zG$WTR}ABo7$(J%5+db;&!42x z>l1X{gR9b0+ehOf^6L}E=tNkC{bvj0P(|xqni10Y4>&AdsmZ4o zgxu%x6&JuWNq;(Mz1?a$%JUKd^@l+gx+-g@sEd4g6=cRT1I3M;ol^d{W+KnCQ0mCCy=4vTG%8d<(d5(_A^8ZD zQ0R*^8Q)sVCn^L4m`di)ZGCk1UaBnTNIj1&d}dzSoa#U^t<3-zk)@)TBaj;6%Sm_3^f~!tz(1L7u*t z()CDuY5lI#L@`XGf&S_zLw{dH*(~&4E)nRz(I|dmLXH2!C=cpGkSN-2f4#reGjN0aQMSj-lSt*G|TEszuhnTtYciZwkujEBy5gWyQWWxIbC zLAgFfz^|fQ*-p`xDb)Sl;O^pbxKN1t1p#!$_3-?*5#N>ag3uD5wd|Dg_jQG9f|YmL z*-m)eud*SlA~R6W%O<>LxlKEx(DGO!Q|i^vF6cvO?yakfkOR8ZQG)ZcYQ4HL(n-LS zI_3Vo+F`A)yAu2Y)4=+ z-7ah2`FWwxM|Pz1;*HNFZNlSpLtU*8t*hEnm?4U+j;c`hEf@wa)@A}elb_Buv8P$p z09MW(Jrm7KWMPAI9B<9e&#!T>p9f{O9L+T74N6bFfh$zs@qUAGjjC1tP-KMr&CtxV zVGMWAGq+b12Dik^6X4LNiJb9jd>Yn?!fpI%=F`pUTcd*=ne8k>#+sRnAT+Z46>%@^ z3T9-nVTF0g=L2I#3GE~N&Ojdr_I4f*P7OkfYcTuvBCU#nkbY1!t<*A{^Mv5sJ z#`wXBcz6IYXPx0ZdkMj`cL+jL%d3WcEwpi_Rjvc)bxHbMr5S56UbjWUj(F$xIw7K0F{}(0?P5_u!hlD3ayi=`-Tx{KxFaly zL64k|N5h-|WfX)vEqO$?IQj|4kztTO8El8#L!bnFt@lID@hw};|=oV53*x~(mHn7MomSqzk#jz(*e z?7rmPeaWy4H*BR5@>lDq;{*-xT}8j!uHjoPL@CB*2o_Xc{&O|Odb+%_wKZ#4#=<`4 z{Lw)}b({e8Eufj=&Q~G$-q;sYY85fjA_vZ((Pn|~=9nOFqa&_wyTt#gx z!>`TUbJu(|1kN=T3(@dpTyb^N)pB*S`0&JJga)DX165uL+N&VIiI-#D$K~imjL(x4 zVbH81?O$+Kp0f&K5bv*TfimIonwI}Mk9dlX)Dn9Bbs=KmJbAk5-(xBMpv~x8RA2S%B5^11bSC>}6`#vKk)0Vv7ZSE3d_O94XM)-G@d; z=o9ep^`97)m>SB28%5@$uN9)smu8pu5iVUF``+!j*k_@K{h1)JjO=Ye=UCx5aDDYA ziN?XEDZW?`_`gb+UvGgNR}dqT3{hKBT~h1e_jYC~N`pihun0Ot%*hiyngN zf;Za;gce9c351HV34KR;0YORZwAuVWz#;STmwBdYV; z^!poTKI0DNVStb1_PsnhkHZNs_O_XSWx`wF!ijoRW=BKc0dCMX%>QT=CTP;Kw09Levor+Q)-Z znh!rnEVwab6+glY+&NY*gh1O~HYxY1K&QT9$f&8U7K#p+T*P*^0C(ywy0bo|0Gu}F zT6;ubkSRyh!QQA*&LS>uM0optFR*0BTo1vqptlMD2aCCs@p{kvO%~+`R~989Pxi0ee@Gj|1Z!zH_om3XBS5UFt-ZP!m@~R!Fc~WOWEGfsFpVB z7Vh<>SH!1}Pi85c_^7sDdi~)PAQj2L<9iJl$eGB+xB77hX&@Z1n`HeZ29c9}7wTvq zoFarLh3E!O&Lr{RJ==KE@2=9I(juv?t(_P_dH#9l1bEAyGgRnlE5a!SNTtbdy$yc} z6>4FfUC6%^<8t%Pm5hsqh!4lr7w%RxS5i?xKA8dMgD>DnVi>gn@`)Q#AvS0^n(qM3 zBmna{Zb6rFg$pe|`xNtu%3MLJLu=a4EmTeQ$4YuS^`?s_16apD6`YM58sD zUgEN8ohv_(aT0+r#(ZFqCL~3vlkX36^dlrD5UNgsF6FIk|twJ?J9F zR$0B)dl};gSy#axzCzexer|5TVZC+uPPs0Q8*3!*U7)ZoDPZ|=dE2!s>Z_n{nxQ?A z+zqh1;OvnjeSn$p?izfU&-`l_^Q*qUS!L%Y#A`#*UPtjtDeG+SFt^IDJDqtO z<2XGc;;JWEMd{xu1Lho-XO#BqV1_1Pr|+Wn(_@Y=1;v0Sf$U8eNee%;v3Y-*(Ys+Q za@amLP~u77%GH^lYwJgE_ zm~x!Sf|f=W>0tgL+*gmvaaXF93pHbTbPBai5TfL9fs^q|xOHla>TDHZ&>+tRIsP?wnuwKKUx=co}gh5zW^_o-_6sC1ED!OcoKO<>}Y} z8=z;n1*CY4NE1+A54$>mtX08*XQiMorQiqni6(Y0zsXj5z=<1ZUAC0n(kdA3(N6w>;Urei z*IShuXHFGUqhCyt033Ju_c(y-&w!knXOnLqc-PUY!ia9`;x8mu8g&~-c|nL7&UOttechun#_VA5+o+rFs5_KU98+c+i){iioJWJB5kglS5YNKBHn;oR-R( zaGUk%*E;2`$`w2-?%#k0l&!eye)lr*N0EjA=%LDEOC_E{UFBPS*sc`fX}hjxz40Kp zg`6`l= zCUg%bW?UzVz4k9*1HHIgapn6}2_I>(y9710JY?IR*S4^2zV_!(4O@Z3_D_x`)V0~G zr(r^Q%>H4TzCrT!IfG3qOgR}*#P~H^Ib}WK(9ez*;_0tiQi%fS;)Us{E zv`+`b&Rx3yc)DjuL0_`qlWscM3c?^;PRZ_{Kp7>XNRUL?culwCaj1G&Q1DHsyz=|> zq~aBpmfzICTZ!d4dFB1xek;c7t~sX(0ETDCBu*cDf*3hlXI%R({8p$R}uMnLLw?CQbo8JBEs+#k2Kj@j4XLJ2?wY;RTxUUfU z1rwbM>~-VsXB*apq+N`u~!f)1mZyA^FaIC~NegVa^!9n}EVQ0~up_B~C*~sAG$MW)R z4RlOQWwKShB*PbH!#y*3jY|!-DDnK;{=eSNJRZuv{reKK#gMGomz1(iSwhw~VtAFa8 zIhXJGotg9aeBYnXQSq+&xrf_Ck&Vb_-t;VxV*40EFS=&;y91~HruGqYEq|Vq^$ZKe zlT%P{x%Xe59C}9TbP-ZJQ={IL$C9*4=*wWyr}h)HBbsve1$e{q4=0Dc=L$W%rx~La z;=iWmLVjpHkjKz(HJzDdsafPFI>uHQJRWomREGJ2tkroatp5Cr5T5@*Lv+u!UxbVM zG<{FyU$*?wd%(EQf{VOaf3v|Gx=pxHwnt3vBJ68Ir#gCMR7$O*Ol5xD&98D1==ZMu zsR7Oh_C`|q0aVT&NqU{nGXo>cXO`Ype5VVl+;@9@!WPx?%sgZIHg=VyP&SgB$Gl7K@C*>-F(8`-6y=<0?my$ z$Bs#x^$8#-Op|n4xLIGDhe?*H^H9&)FI8AiN80GIgwQF z2<%aK)f4&Y%FplfabFj%T9^I}TD$D+a^!aVqF(F@;mVXhw=1tE)c*(J<63eFur0YQI`5GBBTh*{YBP_v#)v7kLa_Nxi|ze=kiM#e{7ng8=vsA>o8GH zNf34+d}Vtl__H$Cj;B-&^LmU;=2w%~mm>{NC}!i=VCNZiA?BoQh%nSQHA?XOT7oj- zj`hP1We;lxrQ}~8Po8^x`HPB!L;_tNL4q$TEBm58htwzTpfA5nB5#cV8F$~T;C<1F zi2fL%@+-h=?3PLFwOu&#oSVmN)cwa!V6O&ImS(S-@hRBp=lgE`T)t!9RAQIdEg!$H zLT!nTAl0HDg9Yd>(kHi+mhk@lyB_8oXAMMC(24{R>~W;zvNlCZ(V)&{P{OBl+2^un z?;s=yALSBnY&}pQpRb!!yfhr%zo$Fg$a{xLB7+2d-k8tH9SvqV=C&MIl-n2&PvH-{ zr1Px5CdYQq@*5^N_T&#qI+~yiyocRu7R&)s{<3n+;=7 zTUqr*7901jo`+h1iQrWt@wQ#9p>Qc(^@PavhlXEL_MF7puPrwdZ)GbsmwQY^XEnMh z3aG5^cOd3^201ye&20Z^9Iqbc=h^>!T}Q3e1b=vM;F}ECwt|Z}HLTJowuE zLRiudXU|BxQZL$ZGhtzCVBt^xHjFA(8U4^T4!K7I^E2yw~z7Bk!~J>&2j3M#15AxJb$4j)(m^7v5!MsiZ!eL;n%W} z*S9fulFs~iP^MzLrLEJ+FR*<#uQ=MwUP4Q*_k+(5m_9MWE&{Ko3aWVs3@W$nwHI)^ zn$g&SA9e+$Vlh=Pt1scGI#oARw8cb!J)+xW!71;RiciDmI&3oy!Y1-|Xe5ltaLM0E zsy<{NF1T0f>&y|J#v)i<%1Q9zs5g|b8R2y#y3IuI`cAABD^vEuyH4@QwB5qc#vd%o zJgv75+C2JnRB8&(pL?$yt&G-u+b3W!q(*3Ycx2Sx^c1#r%iG%eF?>@_LPxj`gXOz7 zTF6vc1G0G3xZCyS#>Bpq3TK!5>w0eE-EGc=H8HfFKiW?56wfCazL1G$zr5sCIk4)n zb&Y4Q_IP!e#P~kzEbEKdNHklpVceBbG5wZCIr;M5NJ}l>Wi$SQTOuI>ZF-4A=j>ylZ8_heYl@xYyIMnN@Kw2xjzLpi>RVzb8>N zJ1AZ3*va~xYyy<=)38EJ2c((wE5B8(3-Ib*iN+<$ou)#RwKqboG`}@7(SXp1hzLIP z{lzvag+r7NN53!f4#!5`4J8s{XW&iWzqKkLZz9Klr75Hz_nUpC*#)Af5WiB=QRL0J zuzwFfq@04k8h=)RiFib7g=|A2UV=b*27#9c7{jN6To=ko@yTDHUPEE+8)FMDaJg>? zgK#{=H`jO3{uy#TF&W5Y;ZWz>xuGdmz}3#GTvDJYq_07o9rdYo{Y{orgE!4WD3b01 zMIV&`IeL~||Mc%~08K>b)u#)0pd1@9zG0na%r?e_-Vgp=sUA$)gj~sI4FJ~f1a3@$ z{};eIQ11VQoGlWyL;1EQ^ZMOP*IO4p+kq#LfRUQPtmY(aoRBAVLL_@pC;UX zaBK3@sA(v|wiwC^29MfR!-)miNV6F>T5*eaZ-~oAu#xCo++|9+$?P!bG68n` z*AFY!kMR4J)D7n;lPDQkHTd9El()XM5=KXwTQ^Jr8A!;^C70&k^CcBG{hW*-mBrlG zKP8o5y7YOCbZ|C>JPu!3rw7M;9cpu6MG$&@Nd250 zjR#{IkzL2V#Qlk;;5q!7>IP~iSXw=<9fy3IN9|drOxS~B{W8{G!r~?eTn`HIHCxI~ z9vwQ%*jPvx_t46)xb9WW|9|0BPe-R764-Y^o_Rcyx;c#K*B#Nm%ku_+*^GcV8xM_W z8bDke`6zHvE5H{Xz3%{hg@HVsE4xlS3`H6WM`0^7fk=_RI_Fe#LPqwp+=tq#dKy@1 z=U*jXQNFgAj|6H4ehT{x;_7$Mp|ZV)3^XaQ+&F9P1RVYrB+&Yti2LA^!zlRlllPHM zh&VL>LtS1fZ7fWbCw~Fup7$W2+}0oqqhl@IkJM9-oSVN5I?!c|B4sK`Zo1AusO6il zul3}zIDvn0_PV=Cw7~>d2+9)e*c=Pb@MaM7Gz|Swb3jHq9CKkkP-RCS6wk(-GL|`b zGe}1n`4egO1b^GI6={c3=(~))auik?-B5l@MoteVAq|xs;4ww`9qUMojTM7#Xr@{& z&B@9q3TzIYlt7uF#Lt}a8gDmXXJ;RVXplIqAVvd-!%ex0i@5m>3bD5da<;>u_(IkP z7XqBQb{99aL5#nOi7!ZVcc6tM`p7G6!kEfHBer;Y;{9u1pONEZ5KN>BPD-%EZ}*03 zO6`0JWZJ%eaEJ-2_h|O>I8G-aV&-qB|za#Yqv9DPTG(xHyWLJH3qM8S)aAmw!KX zp(HOWzbLY4LUD1ZZ0=>-OU7NKYXct?Bo#8UG4i))G;DTPfvUQU+S)24CjeV zHY|gzB9kA_x|=Kz%#IDI;NJIZRu2k3Ajai?U3erv|@=SW!t5iRc zVWN>OKitpB$M=-xK=m+0gkMnfK>}U6!0wv&{hAg{mF-K&pzOIa5DX)>DuI|pQ18+kfIKxat%7o{KlVP8mX9O9 z@1!Pui&Xh#GWIX3Y)7#F=~g-gq5hine3!}+YFft6t*_B4lr)#Ie<0E->4K#EmbUI# z^$gz-WHVY&WU@m>zZdO2nu=d*gncd_4I$F1>X>14Y9Og^cyd&JnX@Y|fY^Kx-3w3O zus~`ISaWlDsS#hOkGr%GAFt`Gllk1u5AE%z5+OUpjD&<99~Z>$DBq33{#}iwbNWA0 zV;cw6(A^ybFZU6->IatFY+lrct0kF^0OfkPSd;8u2g^#5v`n0*27<^rn&kscrvSz* zYIliCL{CSed(?QZ@hPUKfr@TwDZZy!PESZlR*-ZTShxmQg~|FFDbXZUFJ!2?3$>M7 zZyi><<%Ym&a&8E!##;)_01U$j0K@Ixgm(L^R=p%9YrX#g2{eRGEy|=KU zQ}sGS5871ORfn=+y1ndoezk=*-uTs^d}9f<(gLq=n9~ zxj_aVtX@4m03G2owc^sezw(TX)y={qVyY@_S4CY9@Kt*Y&jAtR>rf89JbxNuD)KN< zInviy)4uOWjz{g(HA3*+-2L0mbtg*i9X`JT!2(~d#tQl^YI<|z_gtSRVV(=sO{v{h zeWOjCmxN{u{Hs#C%A-7q#x3V(+FNmL;@LI{7%;Uruoplx6uwi zMeJd=&gGII>uM=!zR{E&@Rq?8KKxD|bjL-@RjB^x?AngK+9TW2pAzKw_QkHDCZoCS z3vbSb_*pa0^ufAGxTi3LQ8sU0l(VF(bQ{rcB%dq$>`U8zd&GKkxs=k7LjITl$%heIDrP zK>ioH#;0mZU#2C=gB@q13mI%anZDPllJ9TI?b57U?wdC%RIGHItMorF-#ygU$yv^i zdP#P=ufYMyMPJ4v1|8FUA04b3?CLa@YRa*@r1R=(&+`)1BpOb4qEZ!K_)n@zrdum1{`f6nYoj!A8K z^Fc)VnLoSG%#Xw<)SFQrmvZPAwL3?2Bp)e+!Y@zJ5Q$r>6h-|+Mkym0ze{paz|pU` zDQ=Nj_$vJ?E&1)Q>S%867t^k!-d2;HU-ca6;llW(>ZkJFQ*TLYpdmkrIo@e^8Wk8a zd!5|wWMGxwsh7a5iaQ;YQUYw=`8Ze+(%nzXcPoWG-GNN)Y@MpB@=PAxJ>7_Xs`c?K z>+a?wnyx}SI@~HTd+D8V3LdAK16g{FaP=fkoNtWemMcOWAEWBr;8WW;6fUcmQODxR zVJU~-gT&NW+_ZY`%J4284O6t~oil@7r7x>gtdDZb|XSatg z&44|J{Et){em@vx3CV|3siXO|goVvxR#66fQJ1p3yDqY9cJxENr)1T{=;8+Q$u(tS zp`w#JZp%qMDtTOG!^@yadTgQ8kb(JGg_DIGem3&tY@xAVsjXVy))4 zJZojrneK*b$X7aqHRQlQ8*F+H?)YSJw$Oq!Yy!`6f73iT@0Ujs@&5ihnTY(356nHV z_9)_v?3RnoTOU9c#kJi$H+Ry8X~s0s4CKK#O8Q~_K~lsQupz{9*I@J_)KxtbT9(jQ zBqR=ClkAzG1l@X(UbJcUsa^X^+vsY$Pg;kJQjgA)%?Gx#;vSj5Q|Sz`(^$+aLSu7D z?FoaJT3wp*XtT%9O|XwQJCd3fAuA}=2bWin81S2Gn=x!HzAD;EWEN?A(ODD7pGx;C|HD+>1Zo3r>sl1Tvx~5F?V*rs zOE+J47U;cwL~nct>&BG0Dgeund-68PGE#XpC3jH;3^ZY%`JQXmYv1ZFT1!X08GF^N zc)@<)3ogMORY&f|o#+oBn(MrfAu>O23fG+?>uW5Evau3(QMfj4JzS!POyM;f7Hxg! zc`LW7s+(vmJUs0xz_n56OWJ`B3Vhw)=P=kUNt(mRvAIPeaWz%bG&)KOI%LJ$0-9E~=q*E0WJk_v z6#1*?82?RDhnBTtG*=#lqhcq+tC<>mu9x;vn6|r$Imy7Zg?#rHQB!Z$8Dr8B=N#RW zuC4JabwBQXKKYZ5PyWLDlX>?s6}RT-G1@=0BxAd1mpecBEkof&LJ#pH#T06uW(Dxq zkcfQ475A1Pnjap|1HC(ZCRBaBEIL_*HsLw`B<7ZX1TnW|mlaR|xObZ^hx}&)$Pv!s zv0WqW2?bx0MK!GBJxN{ilwxo1rt3_E`?QCu3?NzutQT3$Rl;w@i3fzoV-BiwgqK3{ zPm|Mhm3HmfXLxzSG4AX0l>$%#`joF%k}>{*+Hwr{{-`v0VVg@bZacE?82>0LHEc4= zMyZH8gH)L=s!m%e2mT9DCx+5Rs!XT+E)zj*CS_ z>svLEDwCcrOoCEn-bdK7Vz%^+w+BSd<_zU$esIrzr|W zI%3B=rFVCA9qFP!jTHI^E)`QQ?h=R_7A!=^4;yD>1sXxrhxNk$&A-uJi?JYt7|#@5 zLN0~=DC{O(IhV9PBpPG{GwUtL7(sRvV+&V(iCK+redY*lItedM=3YNW2_oj}*76}g z6$p2f1+Tpp9v??38Jv*ayz{WnTi+TO52pvAg?kbCBVdOp6$NkG;gYs>!&?Yi5FkZ$ zJe22EF{^uUV@ht#&~1nsWRPl9(gy7*CxuFa18I4hcS%vU2J|B=Qn{_(TM8pm2POUp z_f-XmIiiRMkKn~ha_eW|isXbuMkpfp9=ZL-2R>X;)G}LIhuCUX{fXdL% zuVQ9|>_{1s6{PE0i>xVDCL4(`ET*&8r-MmEF4A|IS3wNX9%r3Oy|QX zgCvAQ6shUXHpYkS(vTUp%knhk zD$@;MH~Z@1UTw@UOE_dDG;5;-HeHHG@cJ*mc#D#&DFy7t(`d8Gs7t(dVqNE}b|+4C)0UOw z-+1`zWo7KuVg5*Q(*=8xGRCW_yU!SYf%G$WF0P$`ES@NF#dt$H^h2n-G2_<&_RfUl zitpVndG%LgFyQw%85V-x@a?BBIsQEFij7vaPKRNl9%0f@Q~r z2`P&b*Tms>_e^~kz_g?f-KW|=)dr>Y&WDE5mqE)JdkE+`0ZFj&svv|tSSoPuJJaxiNFvh;d z(}a!mSv4lfhK7a{Ncb!U=(G+`r%YV;>$sPmgS?C?3+*!`HK5zZy{IZYH{iC#U_J}A zbE$r#y)Tm?VNQCq&Yu&RMjEYbzny%dxcgk~QPbOi?{`?hUE0ECu%7eZ%4@%C^o8EHT#3JlW@TD)HQendQWF7PWn z-^4u)vw$jfAG@Ub^+=AY__8PB9TGm*&2k6Vk-Pzv77w?ss%g_l&pO463WBd zF)>0ApqyWvQlpYf>{5-Sp5P4L&gE36env5O&QNdCr#3= zy{@E;a49~e;1%crhe08B^(42u5mf)ovX$vUG^^fzQ^4{v6mFC6F?(dm!&Cp~uM6w(yF)H|E?Ca~qYW+uwM7_gGB0d4#9*>j`-4AWghqa0rw^ z%OEM^ckp4728GJ&+9$}KPL#C{ta=(O(_M1msj8#+;n<7xyrIgW>Y>FzTAF;BK5rd9 z-EVMX3)@ZZP)6W7&>*uMybZlq9+xyRVxJa#@CBspxujOW#S}T)V5Gm$U8on9Ay4)p zL?#z{4Cy|B)=2EJC>2R^yG7ZhL*D9Qcyx5pNZi0HO_s z3-@>Th<9`gZf_=-Us|pVe*zd{+yyxF10?iL3xXJ`FJ3jx}eqgm0FwS~mQ6?ET`AO3qS^ z^ZE1HhRI6D_s%~uOdNc%U5kZK^`AtS3(o&-(WTpaZzy}H0=)nk2j8Wc;QQHa$e6l6 zR53X*8zSiMUVSF-iIr|joSdpvCiG?eo93drH#eMc7-ds<5Zx)u zBWI&?ciy5a>?h=KYj>#A@17GR@~(Q;uuNKK&r98QMv zW4sF~+@$ijI1FAoMPAGeygizUB=VYP2C3Q<<#&!w4Hx+3lW+BeAr6$M(j>=6Q$Xx8wXAZG&kGj~hr*ks%FkhOho zpU*H@&#?*^g{t5SY|?3knOf+zI_I!v)?b_G=IRnB*XZNcAv)T;e_017oM&pO*;TVp*7i44>%febA^h-ol{Yr{0SiVaHX zRcJ!v91&=)@t>hN(Z51-#-k>WdmR2H0lixEPFxK-Yt8&h)`tI+Yh#ZyATKr%ee{#4> z8W~p_If~3cU(+5u3>Sb+=GXqlsU`G-*0DWCmX@|}s44nx8GD+r4r{up{-XyA304Kc zc9LhT&fmQFRBJp^vG$%-AE{4idW`U!zN^q!-ee%pcBYAFCMKGk-(s*!u***#n+U$-W9gg?@^`P z3Za6JXI(qX3mT0Gecxu<7lqrqvo+eIqrVp1SZG$lVsXK5pDPW8%4z6OHJ`C*7ClUr zYR^b8gvZf=@B8x8kSEaoL&%z|GGZ-M!E0%~5Ly;y85JEink^hi-qO4x^l%cf779mh zSPM~l-O6INrwuP052@M58HmNk4*bnpD5Tng(bN~_9D`WGV}mU78lMNA)1D6E9}kXa zzIoEFPDitIck`YvU>L`+^Kvgcha?uTHoHQMcL|r{i`i@;3S3v0C}|3G-SAfH5QP|S z8Uu0E+7gn&_-9k$mFmSRbjZ&fbz4Qddeknn;>Wz_LY?t@2Gu;_sq=&<-vz52wdaB@ zw9S(PWm?%+RZr%AP{2h7t*#3&tZRNvF4ZNy#pi?kG5{W;^wKA!+ zhoPKY=dDN=)!9)q5h=4fw^-*aPFDXs7WxvNd!LBua$q6t%+LL&8ZzNx{I?-v>eRh1 zz_ikFnk}I%$V$Rn)&5O_iTY;=ru?5J7?j9=kYGajJtseN$}bDYRjlo&(%sY_m5AmO zl1=W)eLg)A!r7}Ev6Y67)MPYsikqhgzlG70ZQwOMx?Nt1cOs;2Gf^-MhcXI7=_U77 zD-qvD_}m)V=E<$aXeq`4EFBJ5x^(vqmaZcolkv`pYnF8~-4Yz<4M$|NT4kC%-YMAr z0t+?zzNFGGhE24_#(#DbdLeE?wUd(HnAR>x-Sc zHyQ;6PuVH@>n!mPUe&Qo$5v18Q;3r_{t>Z{6RXfXw^IJY{A+bWt8M(Y_AUv^CtMlU zCRHlIU|8%0Y@PV{)jcgAnHq0glQd6?o?B7cmMMdZJCj_5XW6vs^ReHcig<3@WM%IW zcj?0ajT9q6T9W{CVf&XN@zr*L9|m@9=h-b@MI+wAUC6M|FsT_)om& zx_9L8&r`TMGVhjTR=lI36u1`8)FoqmK%Z###I6TX5byd|1X*+jI@B4wp$>pG;U}{f z>o0|o_k|vK*uok#2${q*= Date: Sat, 29 Aug 2020 11:31:22 +0200 Subject: [PATCH 07/25] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901af3d5..fac96d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- add alias IP support in `cloud-vm` module +## [3.2.0] - 2020-08-29 + +- **incompatible change** aadd alias IP support in `cloud-vm` module - add tests for `data-solutions` examples - fix apply errors on dynamic resources in dataflow example - make zone creation optional in `dns` module +- new `quota-monitoring` end-to-end example in `cloud-operations` ## [3.1.1] - 2020-08-26 @@ -176,7 +179,8 @@ All notable changes to this project will be documented in this file. - merge development branch with suite of new modules and end-to-end examples -[Unreleased]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.1.1...HEAD +[Unreleased]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.1.1...v3.2.0 [3.1.1]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.1.0...v3.1.1 [3.1.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v2.8.0...v3.0.0 From 887348da48460c10e818f6b2a2eee35b95692157 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Sat, 29 Aug 2020 11:31:48 +0200 Subject: [PATCH 08/25] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac96d1c..a09919bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ## [3.2.0] - 2020-08-29 -- **incompatible change** aadd alias IP support in `cloud-vm` module +- **incompatible change** add alias IP support in `cloud-vm` module - add tests for `data-solutions` examples - fix apply errors on dynamic resources in dataflow example - make zone creation optional in `dns` module From 534662a41c4a642158442431e9b59be9e448d3c6 Mon Sep 17 00:00:00 2001 From: Lorenzo Caggioni Date: Mon, 31 Aug 2020 10:48:14 +0200 Subject: [PATCH 09/25] - Fix issue 128 --- CHANGELOG.md | 1 + data-solutions/gcs-to-bq-with-dataflow/main.tf | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09919bd..1291c2f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Fix GCS2BQ (issue: 128) ## [3.2.0] - 2020-08-29 diff --git a/data-solutions/gcs-to-bq-with-dataflow/main.tf b/data-solutions/gcs-to-bq-with-dataflow/main.tf index c573c7e7..39a9ffd3 100644 --- a/data-solutions/gcs-to-bq-with-dataflow/main.tf +++ b/data-solutions/gcs-to-bq-with-dataflow/main.tf @@ -301,7 +301,6 @@ module "bigquery-dataset" { owner = { role = "OWNER", type = "user_by_email" } } access_identities = { - reader-group = "caggioland.com" owner = module.service-account-bq.email } encryption_key = module.kms.keys.key-bq.self_link From 067f072c2626edc661dfa481a9f02fa1695692c5 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Mon, 31 Aug 2020 14:09:28 +0200 Subject: [PATCH 10/25] Make VPC creation optional in `net-vpc` module --- CHANGELOG.md | 1 + modules/net-vpc/README.md | 1 + modules/net-vpc/main.tf | 34 +++++++++++++++++++++++----------- modules/net-vpc/outputs.tf | 6 +++--- modules/net-vpc/variables.tf | 6 ++++++ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1291c2f1..28b20425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Fix GCS2BQ (issue: 128) +- make VPC creation optional in `net-vpc` module to allow managing a pre-existing VPC ## [3.2.0] - 2020-08-29 diff --git a/modules/net-vpc/README.md b/modules/net-vpc/README.md index 334c7fbb..29085729 100644 --- a/modules/net-vpc/README.md +++ b/modules/net-vpc/README.md @@ -127,6 +127,7 @@ module "vpc-host" { | *subnet_flow_logs* | Optional map of boolean to control flow logs (default is disabled), keyed by subnet 'region/name'. | map(bool) | | {} | | *subnet_private_access* | Optional map of boolean to control private Google access (default is enabled), keyed by subnet 'region/name'. | map(bool) | | {} | | *subnets* | The list of subnets being created | list(object({...})) | | [] | +| *vpc_create* | Create VPC. When set to false, uses a data source to reference existing VPC. | bool | | true | ## Outputs diff --git a/modules/net-vpc/main.tf b/modules/net-vpc/main.tf index 3c21f467..a3cfd72c 100644 --- a/modules/net-vpc/main.tf +++ b/modules/net-vpc/main.tf @@ -66,9 +66,21 @@ locals { for subnet in var.subnets : "${subnet.region}/${subnet.name}" => subnet } + network = ( + var.vpc_create + ? try(google_compute_network.network.0, null) + : try(data.google_compute_network.network.0, null) + ) +} + +data "google_compute_network" "network" { + count = var.vpc_create ? 0 : 1 + project = var.project_id + name = var.name } resource "google_compute_network" "network" { + count = var.vpc_create ? 1 : 0 project = var.project_id name = var.name description = var.description @@ -80,8 +92,8 @@ resource "google_compute_network" "network" { resource "google_compute_network_peering" "local" { provider = google-beta count = var.peering_config == null ? 0 : 1 - name = "${google_compute_network.network.name}-${local.peer_network}" - network = google_compute_network.network.self_link + name = "${local.network.name}-${local.peer_network}" + network = local.network.self_link peer_network = var.peering_config.peer_vpc_self_link export_custom_routes = var.peering_config.export_routes import_custom_routes = var.peering_config.import_routes @@ -90,9 +102,9 @@ resource "google_compute_network_peering" "local" { resource "google_compute_network_peering" "remote" { provider = google-beta count = var.peering_config == null ? 0 : 1 - name = "${local.peer_network}-${google_compute_network.network.name}" + name = "${local.peer_network}-${local.network.name}" network = var.peering_config.peer_vpc_self_link - peer_network = google_compute_network.network.self_link + peer_network = local.network.self_link export_custom_routes = var.peering_config.import_routes import_custom_routes = var.peering_config.export_routes depends_on = [google_compute_network_peering.local] @@ -101,7 +113,7 @@ resource "google_compute_network_peering" "remote" { resource "google_compute_shared_vpc_host_project" "shared_vpc_host" { count = var.shared_vpc_host ? 1 : 0 project = var.project_id - depends_on = [google_compute_network.network] + depends_on = [local.network] } resource "google_compute_shared_vpc_service_project" "service_projects" { @@ -118,7 +130,7 @@ resource "google_compute_shared_vpc_service_project" "service_projects" { resource "google_compute_subnetwork" "subnetwork" { for_each = local.subnets project = var.project_id - network = google_compute_network.network.name + network = local.network.name region = each.value.region name = each.value.name ip_cidr_range = each.value.ip_cidr_range @@ -153,7 +165,7 @@ resource "google_compute_subnetwork_iam_binding" "binding" { resource "google_compute_route" "gateway" { for_each = local.routes_gateway project = var.project_id - network = google_compute_network.network.name + network = local.network.name name = "${var.name}-${each.key}" description = "Terraform-managed." dest_range = each.value.dest_range @@ -165,7 +177,7 @@ resource "google_compute_route" "gateway" { resource "google_compute_route" "ilb" { for_each = local.routes_ilb project = var.project_id - network = google_compute_network.network.name + network = local.network.name name = "${var.name}-${each.key}" description = "Terraform-managed." dest_range = each.value.dest_range @@ -177,7 +189,7 @@ resource "google_compute_route" "ilb" { resource "google_compute_route" "instance" { for_each = local.routes_instance project = var.project_id - network = google_compute_network.network.name + network = local.network.name name = "${var.name}-${each.key}" description = "Terraform-managed." dest_range = each.value.dest_range @@ -191,7 +203,7 @@ resource "google_compute_route" "instance" { resource "google_compute_route" "ip" { for_each = local.routes_ip project = var.project_id - network = google_compute_network.network.name + network = local.network.name name = "${var.name}-${each.key}" description = "Terraform-managed." dest_range = each.value.dest_range @@ -203,7 +215,7 @@ resource "google_compute_route" "ip" { resource "google_compute_route" "vpn_tunnel" { for_each = local.routes_vpn_tunnel project = var.project_id - network = google_compute_network.network.name + network = local.network.name name = "${var.name}-${each.key}" description = "Terraform-managed." dest_range = each.value.dest_range diff --git a/modules/net-vpc/outputs.tf b/modules/net-vpc/outputs.tf index 64649135..5dfe4066 100644 --- a/modules/net-vpc/outputs.tf +++ b/modules/net-vpc/outputs.tf @@ -16,17 +16,17 @@ output "network" { description = "Network resource." - value = google_compute_network.network + value = local.network } output "name" { description = "The name of the VPC being created." - value = google_compute_network.network.name + value = local.network.name } output "self_link" { description = "The URI of the VPC being created." - value = google_compute_network.network.self_link + value = local.network.self_link } output "project_id" { diff --git a/modules/net-vpc/variables.tf b/modules/net-vpc/variables.tf index d06eb401..d9ea1ee2 100644 --- a/modules/net-vpc/variables.tf +++ b/modules/net-vpc/variables.tf @@ -143,3 +143,9 @@ variable "subnet_private_access" { type = map(bool) default = {} } + +variable "vpc_create" { + description = "Create VPC. When set to false, uses a data source to reference existing VPC." + type = bool + default = true +} From 21aee6f0aa1721cde154ceeeff915226d39a8392 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Mon, 31 Aug 2020 14:54:05 +0200 Subject: [PATCH 11/25] Reference VPC name from module variable --- modules/net-vpc/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/net-vpc/main.tf b/modules/net-vpc/main.tf index a3cfd72c..67bd102d 100644 --- a/modules/net-vpc/main.tf +++ b/modules/net-vpc/main.tf @@ -92,7 +92,7 @@ resource "google_compute_network" "network" { resource "google_compute_network_peering" "local" { provider = google-beta count = var.peering_config == null ? 0 : 1 - name = "${local.network.name}-${local.peer_network}" + name = "${var.name}-${local.peer_network}" network = local.network.self_link peer_network = var.peering_config.peer_vpc_self_link export_custom_routes = var.peering_config.export_routes @@ -102,7 +102,7 @@ resource "google_compute_network_peering" "local" { resource "google_compute_network_peering" "remote" { provider = google-beta count = var.peering_config == null ? 0 : 1 - name = "${local.peer_network}-${local.network.name}" + name = "${local.peer_network}-${var.name}" network = var.peering_config.peer_vpc_self_link peer_network = local.network.self_link export_custom_routes = var.peering_config.import_routes From 263c767a0a7beca697edecfac65e7f21825a4b33 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Mon, 31 Aug 2020 15:13:28 +0200 Subject: [PATCH 12/25] Update README.md --- cloud-operations/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-operations/README.md b/cloud-operations/README.md index e887b417..e1fa1382 100644 --- a/cloud-operations/README.md +++ b/cloud-operations/README.md @@ -12,7 +12,7 @@ The example's feed tracks changes to Google Compute instances, and the Cloud Fun ## Granular Cloud DNS IAM via Service Directory - This [example](./dns-fine-grained-iam) shows how to leverage Service Directory](https://cloud.google.com/blog/products/networking/introducing-service-directory) and Cloud DNS Service Directory private zones, to implement fine-grained IAM controls on DNS. The example creates a Service Directory namespace, a Cloud DNS private zone that uses it as its authoritative source, service accounts with different levels of permissions, and VMs to test them. + This [example](./dns-fine-grained-iam) shows how to leverage [Service Directory](https://cloud.google.com/blog/products/networking/introducing-service-directory) and Cloud DNS Service Directory private zones, to implement fine-grained IAM controls on DNS. The example creates a Service Directory namespace, a Cloud DNS private zone that uses it as its authoritative source, service accounts with different levels of permissions, and VMs to test them.
From 707e56763771f5da7db784255681e7634197689c Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 1 Sep 2020 07:50:04 +0200 Subject: [PATCH 13/25] implement cf fix for https://issuetracker.google.com/issues/155215191 --- cloud-operations/quota-monitoring/main.tf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cloud-operations/quota-monitoring/main.tf b/cloud-operations/quota-monitoring/main.tf index ca27b933..4369f761 100644 --- a/cloud-operations/quota-monitoring/main.tf +++ b/cloud-operations/quota-monitoring/main.tf @@ -66,6 +66,10 @@ module "cf" { source_dir = "cf" output_path = var.bundle_path } + environment_variables = { + USE_WORKER_V2 = "true" + PYTHON37_DRAIN_LOGS_ON_CRASH_WAIT_SEC = "5" + } service_account_create = true trigger_config = { event = "google.pubsub.topic.publish" From fe857ee96f31b86edb318b5b3a3eabe3b2e0bf7d Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 1 Sep 2020 07:52:48 +0200 Subject: [PATCH 14/25] implement cf fix for https://issuetracker.google.com/issues/155215191 --- cloud-operations/quota-monitoring/main.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cloud-operations/quota-monitoring/main.tf b/cloud-operations/quota-monitoring/main.tf index 4369f761..0727ae4b 100644 --- a/cloud-operations/quota-monitoring/main.tf +++ b/cloud-operations/quota-monitoring/main.tf @@ -66,6 +66,8 @@ module "cf" { source_dir = "cf" output_path = var.bundle_path } + # https://github.com/hashicorp/terraform-provider-archive/issues/40 + # https://issuetracker.google.com/issues/155215191 environment_variables = { USE_WORKER_V2 = "true" PYTHON37_DRAIN_LOGS_ON_CRASH_WAIT_SEC = "5" From 4626dafcc8409e46c44d7c1ace82bd3484facabe Mon Sep 17 00:00:00 2001 From: Aleksandr Averbukh Date: Tue, 1 Sep 2020 12:38:25 +0200 Subject: [PATCH 15/25] Make VPN Gateway creation optional for the module. --- CHANGELOG.md | 2 ++ modules/net-vpn-ha/README.md | 10 ++++++---- modules/net-vpn-ha/main.tf | 20 ++++++++++++-------- modules/net-vpn-ha/outputs.tf | 25 ++++++++++++++++++------- modules/net-vpn-ha/variables.tf | 16 ++++++++++++++-- 5 files changed, 52 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a5a7bf..b4e2805b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **incompatible change** make HA VPN Gateway creation optional for `net-vpn-ha` module. Now an existing HA VPN Gateway can be used. Updating to the new version of the module will cause VPN Gateway recreation which can be handled by `terraform state rm/terraform import` operations. + ## [3.1.0] - 2020-08-16 - **incompatible change** add support for specifying a different project id in the GKE cluster module; if using the `peering_config` variable, `peering_config.project_id` now needs to be explicitly set, a `null` value will reuse the `project_id` variable for the peering diff --git a/modules/net-vpn-ha/README.md b/modules/net-vpn-ha/README.md index 2357fb6b..959f7fa7 100644 --- a/modules/net-vpn-ha/README.md +++ b/modules/net-vpn-ha/README.md @@ -136,7 +136,7 @@ module "vpn_ha" { | name | description | type | required | default | |---|---|:---: |:---:|:---:| -| name | VPN gateway name, and prefix used for dependent resources. | string | ✓ | | +| name | VPN Gateway name (if an existing VPN Gateway is not used), and prefix used for dependent resources. | string | ✓ | | | network | VPC used for the gateway and routes. | string | ✓ | | | project_id | Project where resources will be created. | string | ✓ | | | region | Region used for resources. | string | ✓ | | @@ -146,16 +146,18 @@ module "vpn_ha" { | *router_advertise_config* | Router custom advertisement configuration, ip_ranges is a map of address ranges and descriptions. | object({...}) | | null | | *router_asn* | Router ASN used for auto-created router. | number | | 64514 | | *router_create* | Create router. | bool | | true | -| *router_name* | Router name used for auto created router, or to specify existing router to use. Leave blank to use VPN name for auto created router. | string | | | +| *router_name* | Router name used for auto created router, or to specify an existing router to use if `router_create` is set to `true`. Leave blank to use VPN name for auto created router. | string | | | | *tunnels* | VPN tunnel configurations, bgp_peer_options is usually null. | map(object({...})) | | {} | +| *vpn_gateway* | HA VPN Gateway Self Link for using an existing HA VPN Gateway, leave empty if `vpn_gateway_create` is set to `true`. | string | | null | +| *vpn_gateway_create* | Create HA VPN Gateway. | bool | | true | ## Outputs | name | description | sensitive | |---|---|:---:| | external_gateway | External VPN gateway resource. | | -| gateway | HA VPN gateway resource. | | -| name | VPN gateway name. | | +| gateway | VPN gateway resource (only if auto-created). | | +| name | VPN gateway name (only if auto-created). | | | random_secret | Generated secret. | ✓ | | router | Router resource (only if auto-created). | | | router_name | Router name. | | diff --git a/modules/net-vpn-ha/main.tf b/modules/net-vpn-ha/main.tf index 141bffc7..7f6e4fa0 100644 --- a/modules/net-vpn-ha/main.tf +++ b/modules/net-vpn-ha/main.tf @@ -27,11 +27,17 @@ locals { ? try(google_compute_router.router[0].name, null) : var.router_name ) + vpn_gateway = ( + var.vpn_gateway_create + ? try(google_compute_ha_vpn_gateway.ha_gateway[0].self_link, null) + : var.vpn_gateway + ) secret = random_id.secret.b64_url } resource "google_compute_ha_vpn_gateway" "ha_gateway" { provider = google-beta + count = var.vpn_gateway_create ? 1 : 0 name = var.name project = var.project_id region = var.region @@ -55,12 +61,11 @@ resource "google_compute_external_vpn_gateway" "external_gateway" { } resource "google_compute_router" "router" { - provider = google-beta - count = var.router_create ? 1 : 0 - name = var.router_name == "" ? "vpn-${var.name}" : var.router_name - project = var.project_id - region = var.region - network = var.network + count = var.router_create ? 1 : 0 + name = var.router_name == "" ? "vpn-${var.name}" : var.router_name + project = var.project_id + region = var.region + network = var.network bgp { advertise_mode = ( var.router_advertise_config == null @@ -135,7 +140,6 @@ resource "google_compute_router_peer" "bgp_peer" { } resource "google_compute_router_interface" "router_interface" { - provider = google-beta for_each = var.tunnels project = var.project_id region = var.region @@ -162,7 +166,7 @@ resource "google_compute_vpn_tunnel" "tunnels" { ? local.secret : each.value.shared_secret ) - vpn_gateway = google_compute_ha_vpn_gateway.ha_gateway.self_link + vpn_gateway = local.vpn_gateway } resource "random_id" "secret" { diff --git a/modules/net-vpn-ha/outputs.tf b/modules/net-vpn-ha/outputs.tf index 94269377..7227e8f3 100644 --- a/modules/net-vpn-ha/outputs.tf +++ b/modules/net-vpn-ha/outputs.tf @@ -1,4 +1,3 @@ - /** * Copyright 2019 Google LLC * @@ -16,8 +15,12 @@ */ output "gateway" { - description = "HA VPN gateway resource." - value = google_compute_ha_vpn_gateway.ha_gateway + description = "VPN gateway resource (only if auto-created)." + value = ( + var.vpn_gateway_create + ? google_compute_ha_vpn_gateway.ha_gateway[0] + : null + ) } output "external_gateway" { @@ -30,13 +33,21 @@ output "external_gateway" { } output "name" { - description = "VPN gateway name." - value = google_compute_ha_vpn_gateway.ha_gateway.name + description = "VPN gateway name (only if auto-created). " + value = ( + var.vpn_gateway_create + ? google_compute_ha_vpn_gateway.ha_gateway[0].name + : null + ) } output "router" { description = "Router resource (only if auto-created)." - value = var.router_name == "" ? google_compute_router.router[0] : null + value = ( + var.router_name == "" + ? google_compute_router.router[0] + : null + ) } output "router_name" { @@ -46,7 +57,7 @@ output "router_name" { output "self_link" { description = "HA VPN gateway self link." - value = google_compute_ha_vpn_gateway.ha_gateway.self_link + value = local.vpn_gateway } output "tunnels" { diff --git a/modules/net-vpn-ha/variables.tf b/modules/net-vpn-ha/variables.tf index 55f4ec89..81016add 100644 --- a/modules/net-vpn-ha/variables.tf +++ b/modules/net-vpn-ha/variables.tf @@ -15,10 +15,22 @@ */ variable "name" { - description = "VPN gateway name, and prefix used for dependent resources." + description = "VPN Gateway name (if an existing VPN Gateway is not used), and prefix used for dependent resources." type = string } +variable "vpn_gateway_create" { + description = "Create HA VPN Gateway." + type = bool + default = true +} + +variable "vpn_gateway" { + description = "HA VPN Gateway Self Link for using an existing HA VPN Gateway, leave empty if `vpn_gateway_create` is set to `true`." + type = string + default = null +} + variable "network" { description = "VPC used for the gateway and routes." type = string @@ -81,7 +93,7 @@ variable "router_create" { } variable "router_name" { - description = "Router name used for auto created router, or to specify existing router to use. Leave blank to use VPN name for auto created router." + description = "Router name used for auto created router, or to specify an existing router to use if `router_create` is set to `true`. Leave blank to use VPN name for auto created router." type = string default = "" } From 931af3e94359f5a7ffdf522043eeddb00a612062 Mon Sep 17 00:00:00 2001 From: Aleksandr Averbukh Date: Tue, 1 Sep 2020 14:18:37 +0200 Subject: [PATCH 16/25] Fix subnet logging test --- tests/modules/net_vpc/test_plan_subnets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/modules/net_vpc/test_plan_subnets.py b/tests/modules/net_vpc/test_plan_subnets.py index 473a1c34..37be4e5c 100644 --- a/tests/modules/net_vpc/test_plan_subnets.py +++ b/tests/modules/net_vpc/test_plan_subnets.py @@ -58,7 +58,8 @@ def test_subnet_log_configs(plan_runner): for r in resources: if r['type'] != 'google_compute_subnetwork': continue - flow_logs[r['values']['name']] = r['values']['log_config'] + flow_logs[r['values']['name']] = {key: r['values']['log_config'][key] for key in r['values']['log_config'].keys() + & {'aggregation_interval', 'flow_sampling', 'metadata'}} assert flow_logs == { # enable, override one default option 'a': [{ From aacb570ac8b14cff12efc6f3802fe2e37b91d4bc Mon Sep 17 00:00:00 2001 From: Aleksandr Averbukh Date: Tue, 1 Sep 2020 15:00:16 +0200 Subject: [PATCH 17/25] Fix list of log config attributes to be tested --- tests/modules/net_vpc/test_plan_subnets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/modules/net_vpc/test_plan_subnets.py b/tests/modules/net_vpc/test_plan_subnets.py index 37be4e5c..2d14f6f5 100644 --- a/tests/modules/net_vpc/test_plan_subnets.py +++ b/tests/modules/net_vpc/test_plan_subnets.py @@ -58,8 +58,9 @@ def test_subnet_log_configs(plan_runner): for r in resources: if r['type'] != 'google_compute_subnetwork': continue - flow_logs[r['values']['name']] = {key: r['values']['log_config'][key] for key in r['values']['log_config'].keys() - & {'aggregation_interval', 'flow_sampling', 'metadata'}} + flow_logs[r['values']['name']] = [{key: config[key] for key in config.keys() + & {'aggregation_interval', 'flow_sampling', 'metadata'}} + for config in r['values']['log_config']] assert flow_logs == { # enable, override one default option 'a': [{ From daf3dc41e7e314676214fccec9bf5a14631df060 Mon Sep 17 00:00:00 2001 From: vanessabodard-voi <63779321+vanessabodard-voi@users.noreply.github.com> Date: Tue, 1 Sep 2020 17:48:02 +0200 Subject: [PATCH 18/25] Add retention policy (#133) --- CHANGELOG.md | 1 + modules/gcs/README.md | 30 +++++++++++++++++++++++++++++- modules/gcs/main.tf | 9 +++++++++ modules/gcs/variables.tf | 6 ++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 946e8116..e13d944b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Fix GCS2BQ (issue: 128) - make VPC creation optional in `net-vpc` module to allow managing a pre-existing VPC +- add retention_policy in `gcs` module ## [3.2.0] - 2020-08-29 diff --git a/modules/gcs/README.md b/modules/gcs/README.md index 839faadd..1f31a40f 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -45,12 +45,39 @@ module "buckets" { iam_roles = { bucket-two = ["roles/storage.admin"] } - kms_keys = { + encryption_keys = { bucket-two = local.kms_key.self_link, } } ``` +### Example with retention policy + +```hcl +module "buckets" { + source = "./modules/gcs" + project_id = "myproject" + prefix = "test" + names = ["bucket-one", "bucket-two"] + bucket_policy_only = { + bucket-one = false + } + iam_members = { + bucket-two = { + "roles/storage.admin" = ["group:storage@example.com"] + } + } + iam_roles = { + bucket-two = ["roles/storage.admin"] + } + + retention_policies = { + bucket-one = { retention_period = 100 , is_locked = true} + bucket-two = { retention_period = 900 } + } +} +``` + ## Variables @@ -68,6 +95,7 @@ module "buckets" { | *prefix* | Prefix used to generate the bucket name. | string | | null | | *storage_class* | Bucket storage class. | string | | MULTI_REGIONAL | | *versioning* | Optional map to set versioning keyed by name, defaults to false. | map(bool) | | {} | +| *retention_policies* | Optional map to set up retention policy keyed by bucket name. | map(map(string)) | | {} | ## Outputs diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index f345ed3b..d2d8616a 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -37,6 +37,7 @@ locals { : join("-", [var.prefix, lower(var.location), ""]) ) kms_keys = { for name in var.names : name => lookup(var.encryption_keys, name, null) } + retention_policy = { for name in var.names : name => lookup(var.retention_policies, name, null) } } resource "google_storage_bucket" "buckets" { @@ -63,6 +64,14 @@ resource "google_storage_bucket" "buckets" { default_kms_key_name = local.kms_keys[each.key] } } + + dynamic retention_policy { + for_each = local.retention_policy[each.key] == null ? [] : [""] + content { + retention_period = local.retention_policy[each.key]["retention_period"] + is_locked = lookup(local.retention_policy[each.key], "is_locked", false) + } + } } resource "google_storage_bucket_iam_binding" "bindings" { diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index cdc63ecc..c3e3360f 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -83,3 +83,9 @@ variable "versioning" { type = map(bool) default = {} } + +variable "retention_policies" { + description = "Per-bucket retention policy." + type = map(map(string)) + default = {} +} From 0265ba0951471513b36508c04f22a0aadc2e841e Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 1 Sep 2020 18:49:21 +0200 Subject: [PATCH 19/25] Refactor net-address variables, add support for internal address purpose * add support for internal address purpose * update gcs module README * refactor net address module interface and add tests * add more examples in net-address README --- modules/gcs/README.md | 2 +- modules/net-address/README.md | 41 +++++++++-- modules/net-address/main.tf | 6 +- modules/net-address/outputs.tf | 1 - modules/net-address/variables.tf | 18 +++-- modules/net-address/versions.tf | 3 + tests/modules/net_address/__init__.py | 13 ++++ tests/modules/net_address/fixture/main.tf | 24 +++++++ tests/modules/net_address/fixture/outputs.tf | 19 +++++ .../modules/net_address/fixture/variables.tf | 47 +++++++++++++ tests/modules/net_address/test_plan.py | 70 +++++++++++++++++++ 11 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 tests/modules/net_address/__init__.py create mode 100644 tests/modules/net_address/fixture/main.tf create mode 100644 tests/modules/net_address/fixture/outputs.tf create mode 100644 tests/modules/net_address/fixture/variables.tf create mode 100644 tests/modules/net_address/test_plan.py diff --git a/modules/gcs/README.md b/modules/gcs/README.md index 1f31a40f..d5f793f5 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -93,9 +93,9 @@ module "buckets" { | *labels* | Labels to be attached to all buckets. | map(string) | | {} | | *location* | Bucket location. | string | | EU | | *prefix* | Prefix used to generate the bucket name. | string | | null | +| *retention_policies* | Per-bucket retention policy. | map(map(string)) | | {} | | *storage_class* | Bucket storage class. | string | | MULTI_REGIONAL | | *versioning* | Optional map to set versioning keyed by name, defaults to false. | map(bool) | | {} | -| *retention_policies* | Optional map to set up retention policy keyed by bucket name. | map(map(string)) | | {} | ## Outputs diff --git a/modules/net-address/README.md b/modules/net-address/README.md index 9c1169b5..eb3e1168 100644 --- a/modules/net-address/README.md +++ b/modules/net-address/README.md @@ -1,14 +1,46 @@ # Net Address Reservation Module -## Example +This module allows reserving Compute Engine external, global, and internal addresses. + +## Examples + +### External and global addresses ```hcl module "addresses" { source = "./modules/net-address" project_id = local.projects.host external_addresses = { - nat-1 = module.vpc.subnet_regions["default"], - vpn-remote = module.vpc.subnet_regions["default"], + nat-1 = var.region + vpn-remote = var.region + } + global_addresses = ["app-1", "app-2"] +} +``` + +### Internal addresses + +```hcl +module "addresses" { + source = "./modules/net-address" + project_id = local.projects.host + internal_addresses = { + ilb-1 = { + region = var.region + subnetwork = module.vpc.subnet_self_links["${var.region}-test"] + } + ilb-2 = { + region = var.region + subnetwork = module.vpc.subnet_self_links["${var.region}-test"] + } + } + # optional configuration + internal_addresses_config = { + ilb-1 = { + address = null + purpose = "SHARED_LOADBALANCER_VIP" + tier = null + } } } ``` @@ -21,9 +53,8 @@ module "addresses" { | project_id | Project where the addresses will be created. | string | ✓ | | | *external_addresses* | Map of external address regions, keyed by name. | map(string) | | {} | | *global_addresses* | List of global addresses to create. | list(string) | | [] | -| *internal_address_addresses* | Optional explicit addresses for internal addresses, keyed by name. | map(string) | | {} | -| *internal_address_tiers* | Optional network tiers for internal addresses, keyed by name. | map(string) | | {} | | *internal_addresses* | Map of internal addresses to create, keyed by name. | map(object({...})) | | {} | +| *internal_addresses_config* | Optional configuration for internal addresses, keyed by name. Unused options can be set to null. | map(object({...})) | | {} | ## Outputs diff --git a/modules/net-address/main.tf b/modules/net-address/main.tf index b752f2aa..ae43174e 100644 --- a/modules/net-address/main.tf +++ b/modules/net-address/main.tf @@ -31,6 +31,7 @@ resource "google_compute_address" "external" { } resource "google_compute_address" "internal" { + provider = google-beta for_each = var.internal_addresses project = var.project_id name = each.key @@ -38,7 +39,8 @@ resource "google_compute_address" "internal" { address_type = "INTERNAL" region = each.value.region subnetwork = each.value.subnetwork - address = lookup(var.internal_address_addresses, each.key, null) - network_tier = lookup(var.internal_address_tiers, each.key, null) + address = try(var.internal_addresses_config[each.key].address, null) + network_tier = try(var.internal_addresses_config[each.key].tier, null) + purpose = try(var.internal_addresses_config[each.key].purpose, null) # labels = lookup(var.internal_address_labels, each.key, {}) } diff --git a/modules/net-address/outputs.tf b/modules/net-address/outputs.tf index 7d26158a..188e88c1 100644 --- a/modules/net-address/outputs.tf +++ b/modules/net-address/outputs.tf @@ -31,7 +31,6 @@ output "global_addresses" { address.name => { address = address.address self_link = address.self_link - status = address.status } } } diff --git a/modules/net-address/variables.tf b/modules/net-address/variables.tf index 02b85f68..e5eda945 100644 --- a/modules/net-address/variables.tf +++ b/modules/net-address/variables.tf @@ -41,16 +41,14 @@ variable "internal_addresses" { default = {} } -variable "internal_address_addresses" { - description = "Optional explicit addresses for internal addresses, keyed by name." - type = map(string) - default = {} -} - -variable "internal_address_tiers" { - description = "Optional network tiers for internal addresses, keyed by name." - type = map(string) - default = {} +variable "internal_addresses_config" { + description = "Optional configuration for internal addresses, keyed by name. Unused options can be set to null." + type = map(object({ + address = string + purpose = string + tier = string + })) + default = {} } # variable "internal_address_labels" { diff --git a/modules/net-address/versions.tf b/modules/net-address/versions.tf index ce6918e0..ef2d3464 100644 --- a/modules/net-address/versions.tf +++ b/modules/net-address/versions.tf @@ -16,4 +16,7 @@ terraform { required_version = ">= 0.12.6" + required_providers { + google-beta = "~> 3.28.0" + } } diff --git a/tests/modules/net_address/__init__.py b/tests/modules/net_address/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/tests/modules/net_address/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 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/net_address/fixture/main.tf b/tests/modules/net_address/fixture/main.tf new file mode 100644 index 00000000..e10bf7d2 --- /dev/null +++ b/tests/modules/net_address/fixture/main.tf @@ -0,0 +1,24 @@ +/** + * Copyright 2020 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 "test" { + source = "../../../../modules/net-address" + external_addresses = var.external_addresses + global_addresses = var.global_addresses + internal_addresses = var.internal_addresses + internal_addresses_config = var.internal_addresses_config + project_id = var.project_id +} diff --git a/tests/modules/net_address/fixture/outputs.tf b/tests/modules/net_address/fixture/outputs.tf new file mode 100644 index 00000000..77b8211f --- /dev/null +++ b/tests/modules/net_address/fixture/outputs.tf @@ -0,0 +1,19 @@ +/** + * Copyright 2020 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 "module" { + value = module.test +} diff --git a/tests/modules/net_address/fixture/variables.tf b/tests/modules/net_address/fixture/variables.tf new file mode 100644 index 00000000..9d350819 --- /dev/null +++ b/tests/modules/net_address/fixture/variables.tf @@ -0,0 +1,47 @@ +/** + * Copyright 2020 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 "external_addresses" { + type = map(string) + default = {} +} + +variable "global_addresses" { + type = list(string) + default = [] +} + +variable "internal_addresses" { + type = map(object({ + region = string + subnetwork = string + })) + default = {} +} + +variable "internal_addresses_config" { + type = map(object({ + address = string + purpose = string + tier = string + })) + default = {} +} + +variable "project_id" { + type = string + default = "my-project" +} diff --git a/tests/modules/net_address/test_plan.py b/tests/modules/net_address/test_plan.py new file mode 100644 index 00000000..968f05dc --- /dev/null +++ b/tests/modules/net_address/test_plan.py @@ -0,0 +1,70 @@ +# Copyright 2020 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_external_addresses(plan_runner): + addresses = '{one = "europe-west1", two = "europe-west2"}' + _, resources = plan_runner(FIXTURES_DIR, external_addresses=addresses) + assert [r['values']['name'] for r in resources] == ['one', 'two'] + assert set(r['values']['address_type'] + for r in resources) == set(['EXTERNAL']) + assert [r['values']['region'] + for r in resources] == ['europe-west1', 'europe-west2'] + + +def test_global_addresses(plan_runner): + _, resources = plan_runner(FIXTURES_DIR, global_addresses='["one", "two"]') + assert [r['values']['name'] for r in resources] == ['one', 'two'] + assert set(r['values']['address_type'] for r in resources) == set([None]) + + +def test_internal_addresses(plan_runner): + addresses = ( + '{one = {region = "europe-west1", subnetwork = "foobar"}, ' + 'two = {region = "europe-west2", subnetwork = "foobarz"}}' + ) + _, resources = plan_runner(FIXTURES_DIR, internal_addresses=addresses) + assert [r['values']['name'] for r in resources] == ['one', 'two'] + assert set(r['values']['address_type'] + for r in resources) == set(['INTERNAL']) + assert [r['values']['region'] + for r in resources] == ['europe-west1', 'europe-west2'] + + +def test_internal_addresses_config(plan_runner): + addresses = ( + '{one = {region = "europe-west1", subnetwork = "foobar"}, ' + 'two = {region = "europe-west2", subnetwork = "foobarz"}}' + ) + config = ( + '{one = {address = "10.0.0.2", purpose = "SHARED_LOADBALANCER_VIP", ' + 'tier=null}}' + ) + _, resources = plan_runner(FIXTURES_DIR, + internal_addresses=addresses, + internal_addresses_config=config) + assert [r['values']['name'] for r in resources] == ['one', 'two'] + assert set(r['values']['address_type'] + for r in resources) == set(['INTERNAL']) + assert [r['values'].get('address') + for r in resources] == ['10.0.0.2', None] + assert [r['values'].get('purpose') + for r in resources] == ['SHARED_LOADBALANCER_VIP', None] From c1b3459fd7bbd600314b858ef5f96e885e0cb51f Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 1 Sep 2020 18:52:15 +0200 Subject: [PATCH 20/25] Update CHANGELOG.md --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e13d944b..69d95ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Fix GCS2BQ (issue: 128) + +## [3.3.0] - 2020-09-01 + +- remove extra readers in `gcs-to-bq-with-dataflow` example (issue: 128) - make VPC creation optional in `net-vpc` module to allow managing a pre-existing VPC +- make HA VPN gateway creation optional in `net-vpn-ha` module - add retention_policy in `gcs` module +- refactor `net-address` module variables, and add support for internal address `purpose` ## [3.2.0] - 2020-08-29 @@ -184,7 +189,8 @@ All notable changes to this project will be documented in this file. - merge development branch with suite of new modules and end-to-end examples -[Unreleased]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.2.0...HEAD +[Unreleased]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.3.0...HEAD +[3.3.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.2.0...v3.3.0 [3.2.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.1.1...v3.2.0 [3.1.1]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.1.0...v3.1.1 [3.1.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v3.0.0...v3.1.0 From e8c227fdd616a125df48ffbf1d68d9820b3de5e9 Mon Sep 17 00:00:00 2001 From: vanessabodard-voi <63779321+vanessabodard-voi@users.noreply.github.com> Date: Thu, 3 Sep 2020 19:06:35 +0200 Subject: [PATCH 21/25] Add bucket logging (#134) * Add logging * Improve syntax * Add example * Improve type for retention policy --- modules/gcs/README.md | 10 ++++++++-- modules/gcs/main.tf | 11 ++++++++++- modules/gcs/variables.tf | 14 +++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/modules/gcs/README.md b/modules/gcs/README.md index d5f793f5..fdba89e6 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -73,7 +73,12 @@ module "buckets" { retention_policies = { bucket-one = { retention_period = 100 , is_locked = true} - bucket-two = { retention_period = 900 } + bucket-two = { retention_period = 900 , is_locked = false} + } + + logging_config = { + bucket-one = { log_bucket = bucket_name_for_logging , log_object_prefix = null} + bucket-two = { log_bucket = bucket_name_for_logging , log_object_prefix = "logs_for_bucket_two"} } } ``` @@ -92,8 +97,9 @@ module "buckets" { | *iam_roles* | IAM roles keyed by bucket name. | map(list(string)) | | {} | | *labels* | Labels to be attached to all buckets. | map(string) | | {} | | *location* | Bucket location. | string | | EU | +| *logging* | Per-bucket logging. | map(object) | | {} | | *prefix* | Prefix used to generate the bucket name. | string | | null | -| *retention_policies* | Per-bucket retention policy. | map(map(string)) | | {} | +| *retention_policies* | Per-bucket retention policy. | map(object) | | {} | | *storage_class* | Bucket storage class. | string | | MULTI_REGIONAL | | *versioning* | Optional map to set versioning keyed by name, defaults to false. | map(bool) | | {} | diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index d2d8616a..44feda5f 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -38,6 +38,7 @@ locals { ) kms_keys = { for name in var.names : name => lookup(var.encryption_keys, name, null) } retention_policy = { for name in var.names : name => lookup(var.retention_policies, name, null) } + logging_config = { for name in var.names : name => lookup(var.logging_config, name, null) } } resource "google_storage_bucket" "buckets" { @@ -69,7 +70,15 @@ resource "google_storage_bucket" "buckets" { for_each = local.retention_policy[each.key] == null ? [] : [""] content { retention_period = local.retention_policy[each.key]["retention_period"] - is_locked = lookup(local.retention_policy[each.key], "is_locked", false) + is_locked = local.retention_policy[each.key]["is_locked"] + } + } + + dynamic logging { + for_each = local.logging_config[each.key] == null ? [] : [""] + content { + log_bucket = local.logging_config[each.key]["log_bucket"] + log_object_prefix = local.logging_config[each.key]["log_object_prefix"] } } } diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index c3e3360f..79038165 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -86,6 +86,18 @@ variable "versioning" { variable "retention_policies" { description = "Per-bucket retention policy." - type = map(map(string)) + type = map(object({ + retention_period = number + is_locked = bool + })) + default = {} +} + +variable "logging_config" { + description = "Per-bucket logging." + type = map(object({ + log_bucket = string + log_object_prefix = string + })) default = {} } From 9e32b32b3db6f5b8833af9d2606fb1a2d7f7ad40 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Thu, 3 Sep 2020 19:08:29 +0200 Subject: [PATCH 22/25] reformat GCS module and update README --- modules/gcs/README.md | 4 ++-- modules/gcs/main.tf | 16 +++++++++++----- modules/gcs/variables.tf | 16 ++++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/modules/gcs/README.md b/modules/gcs/README.md index fdba89e6..627ce93a 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -97,9 +97,9 @@ module "buckets" { | *iam_roles* | IAM roles keyed by bucket name. | map(list(string)) | | {} | | *labels* | Labels to be attached to all buckets. | map(string) | | {} | | *location* | Bucket location. | string | | EU | -| *logging* | Per-bucket logging. | map(object) | | {} | +| *logging_config* | Per-bucket logging. | map(object({...})) | | {} | | *prefix* | Prefix used to generate the bucket name. | string | | null | -| *retention_policies* | Per-bucket retention policy. | map(object) | | {} | +| *retention_policies* | Per-bucket retention policy. | map(object({...})) | | {} | | *storage_class* | Bucket storage class. | string | | MULTI_REGIONAL | | *versioning* | Optional map to set versioning keyed by name, defaults to false. | map(bool) | | {} | diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index 44feda5f..92a84107 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -36,9 +36,15 @@ locals { ? "" : join("-", [var.prefix, lower(var.location), ""]) ) - kms_keys = { for name in var.names : name => lookup(var.encryption_keys, name, null) } - retention_policy = { for name in var.names : name => lookup(var.retention_policies, name, null) } - logging_config = { for name in var.names : name => lookup(var.logging_config, name, null) } + kms_keys = { + for name in var.names : name => lookup(var.encryption_keys, name, null) + } + retention_policy = { + for name in var.names : name => lookup(var.retention_policies, name, null) + } + logging_config = { + for name in var.names : name => lookup(var.logging_config, name, null) + } } resource "google_storage_bucket" "buckets" { @@ -70,14 +76,14 @@ resource "google_storage_bucket" "buckets" { for_each = local.retention_policy[each.key] == null ? [] : [""] content { retention_period = local.retention_policy[each.key]["retention_period"] - is_locked = local.retention_policy[each.key]["is_locked"] + is_locked = local.retention_policy[each.key]["is_locked"] } } dynamic logging { for_each = local.logging_config[each.key] == null ? [] : [""] content { - log_bucket = local.logging_config[each.key]["log_bucket"] + log_bucket = local.logging_config[each.key]["log_bucket"] log_object_prefix = local.logging_config[each.key]["log_object_prefix"] } } diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index 79038165..5ea231b2 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -87,17 +87,17 @@ variable "versioning" { variable "retention_policies" { description = "Per-bucket retention policy." type = map(object({ - retention_period = number - is_locked = bool - })) - default = {} + retention_period = number + is_locked = bool + })) + default = {} } variable "logging_config" { description = "Per-bucket logging." type = map(object({ - log_bucket = string - log_object_prefix = string - })) - default = {} + log_bucket = string + log_object_prefix = string + })) + default = {} } From 120e1be1d9c13b3af4f8922a3109201ac2d811cf Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Thu, 3 Sep 2020 19:19:41 +0200 Subject: [PATCH 23/25] extend gcs module tests to cover new variables --- modules/gcs/variables.tf | 36 +++++++++++++------------- tests/modules/gcs/fixture/main.tf | 10 ++++--- tests/modules/gcs/fixture/variables.tf | 25 ++++++++++++++++++ tests/modules/gcs/test_plan.py | 12 +++++++++ 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index 5ea231b2..bd84dfc7 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -56,6 +56,15 @@ variable "location" { default = "EU" } +variable "logging_config" { + description = "Per-bucket logging." + type = map(object({ + log_bucket = string + log_object_prefix = string + })) + default = {} +} + variable "names" { description = "Bucket name suffixes." type = list(string) @@ -72,6 +81,15 @@ variable "project_id" { type = string } +variable "retention_policies" { + description = "Per-bucket retention policy." + type = map(object({ + retention_period = number + is_locked = bool + })) + default = {} +} + variable "storage_class" { description = "Bucket storage class." type = string @@ -83,21 +101,3 @@ variable "versioning" { type = map(bool) default = {} } - -variable "retention_policies" { - description = "Per-bucket retention policy." - type = map(object({ - retention_period = number - is_locked = bool - })) - default = {} -} - -variable "logging_config" { - description = "Per-bucket logging." - type = map(object({ - log_bucket = string - log_object_prefix = string - })) - default = {} -} diff --git a/tests/modules/gcs/fixture/main.tf b/tests/modules/gcs/fixture/main.tf index c0f4b4cb..00303368 100644 --- a/tests/modules/gcs/fixture/main.tf +++ b/tests/modules/gcs/fixture/main.tf @@ -17,12 +17,14 @@ module "test" { source = "../../../../modules/gcs" project_id = "my-project" - names = ["bucket-a", "bucket-b"] - prefix = var.prefix + bucket_policy_only = var.bucket_policy_only + force_destroy = var.force_destroy iam_members = var.iam_members iam_roles = var.iam_roles labels = var.labels - bucket_policy_only = var.bucket_policy_only - force_destroy = var.force_destroy + logging_config = var.logging_config + names = ["bucket-a", "bucket-b"] + prefix = var.prefix + retention_policies = var.retention_policies versioning = var.versioning } diff --git a/tests/modules/gcs/fixture/variables.tf b/tests/modules/gcs/fixture/variables.tf index 08e95396..ac8da00b 100644 --- a/tests/modules/gcs/fixture/variables.tf +++ b/tests/modules/gcs/fixture/variables.tf @@ -39,11 +39,36 @@ variable "labels" { default = { environment = "test" } } +variable "logging_config" { + type = map(object({ + log_bucket = string + log_object_prefix = string + })) + default = { + bucket-a = { log_bucket = "foo", log_object_prefix = null } + } +} + variable "prefix" { type = string default = null } +variable "project_id" { + type = string + default = "my-project" +} + +variable "retention_policies" { + type = map(object({ + retention_period = number + is_locked = bool + })) + default = { + bucket-b = { retention_period = 5, is_locked = false } + } +} + variable "storage_class" { type = string default = "MULTI_REGIONAL" diff --git a/tests/modules/gcs/test_plan.py b/tests/modules/gcs/test_plan.py index c2a59343..536aae97 100644 --- a/tests/modules/gcs/test_plan.py +++ b/tests/modules/gcs/test_plan.py @@ -55,6 +55,18 @@ def test_map_values(plan_runner): assert versioning == { 'bucket-a': [{'enabled': True}], 'bucket-b': [{'enabled': False}] } + logging_config = dict((r['values']['name'], r['values']['logging']) + for r in resources) + assert logging_config == { + 'bucket-a': [{'log_bucket': 'foo'}], + 'bucket-b': [] + } + retention_policies = dict((r['values']['name'], r['values']['retention_policy']) + for r in resources) + assert retention_policies == { + 'bucket-a': [], + 'bucket-b': [{'is_locked': False, 'retention_period': 5}] + } for r in resources: assert r['values']['labels'] == { 'environment': 'test', 'location': 'eu', From 0eaeea6251e2b9583998f5de9b558e39ff6cc6ad Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Thu, 3 Sep 2020 19:23:09 +0200 Subject: [PATCH 24/25] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d95ec9..2e8f9949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- add support for logging and better type for the `retention_policies` variable in `gcs` module + ## [3.3.0] - 2020-09-01 - remove extra readers in `gcs-to-bq-with-dataflow` example (issue: 128) From 435d64d81a105a1aecfa5db01c01641715f7e71a Mon Sep 17 00:00:00 2001 From: vanessabodard-voi <63779321+vanessabodard-voi@users.noreply.github.com> Date: Tue, 15 Sep 2020 19:33:40 +0200 Subject: [PATCH 25/25] Change bucket_policy_only into uniform_bucket_level_access in GCS module (#135) * Change bucket_policy_only into bucket_policy_only * Update changelog --- CHANGELOG.md | 1 + modules/folders-unit/main.tf | 2 +- modules/gcs/README.md | 2 +- modules/gcs/main.tf | 2 +- modules/gcs/variables.tf | 4 ++-- tests/modules/gcs/fixture/main.tf | 2 +- tests/modules/gcs/fixture/variables.tf | 2 +- tests/modules/gcs/test_plan.py | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e8f9949..674d323e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - add support for logging and better type for the `retention_policies` variable in `gcs` module +- **incompatible change** deprecate bucket_policy_only in favor of uniform_bucket_level_access in `gcs` module ## [3.3.0] - 2020-09-01 diff --git a/modules/folders-unit/main.tf b/modules/folders-unit/main.tf index ac012ab3..3034d20f 100644 --- a/modules/folders-unit/main.tf +++ b/modules/folders-unit/main.tf @@ -95,7 +95,7 @@ resource "google_storage_bucket" "tfstate" { location = var.gcs_defaults.location storage_class = var.gcs_defaults.storage_class force_destroy = false - bucket_policy_only = true + uniform_bucket_level_access = true versioning { enabled = true } diff --git a/modules/gcs/README.md b/modules/gcs/README.md index 627ce93a..672ec12e 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -90,7 +90,7 @@ module "buckets" { |---|---|:---: |:---:|:---:| | names | Bucket name suffixes. | list(string) | ✓ | | | project_id | Bucket project id. | string | ✓ | | -| *bucket_policy_only* | Optional map to disable object ACLS keyed by name, defaults to true. | map(bool) | | {} | +| *uniform_bucket_level_access* | Optional map to enable object ACLs keyed by name, defaults to true. | map(bool) | | {} | | *encryption_keys* | Per-bucket KMS keys that will be used for encryption. | map(string) | | {} | | *force_destroy* | Optional map to set force destroy keyed by name, defaults to false. | map(bool) | | {} | | *iam_members* | IAM members keyed by bucket name and role. | map(map(list(string))) | | {} | diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index 92a84107..c93734a6 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -54,7 +54,7 @@ resource "google_storage_bucket" "buckets" { location = var.location storage_class = var.storage_class force_destroy = lookup(var.force_destroy, each.key, false) - bucket_policy_only = lookup(var.bucket_policy_only, each.key, true) + uniform_bucket_level_access = lookup(var.uniform_bucket_level_access, each.key, true) versioning { enabled = lookup(var.versioning, each.key, false) } diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index bd84dfc7..19ada263 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -14,8 +14,8 @@ * limitations under the License. */ -variable "bucket_policy_only" { - description = "Optional map to disable object ACLS keyed by name, defaults to true." +variable "uniform_bucket_level_access" { + description = "Optional map to allow using object ACLs (false) or not (true, this is the recommended behavior) , defaults to true (which is the recommended practice, but not the behavior of storage API)." type = map(bool) default = {} } diff --git a/tests/modules/gcs/fixture/main.tf b/tests/modules/gcs/fixture/main.tf index 00303368..6275c616 100644 --- a/tests/modules/gcs/fixture/main.tf +++ b/tests/modules/gcs/fixture/main.tf @@ -17,7 +17,7 @@ module "test" { source = "../../../../modules/gcs" project_id = "my-project" - bucket_policy_only = var.bucket_policy_only + uniform_bucket_level_access = var.uniform_bucket_level_access force_destroy = var.force_destroy iam_members = var.iam_members iam_roles = var.iam_roles diff --git a/tests/modules/gcs/fixture/variables.tf b/tests/modules/gcs/fixture/variables.tf index ac8da00b..f79485f8 100644 --- a/tests/modules/gcs/fixture/variables.tf +++ b/tests/modules/gcs/fixture/variables.tf @@ -14,7 +14,7 @@ * limitations under the License. */ -variable "bucket_policy_only" { +variable "uniform_bucket_level_access" { type = map(bool) default = { bucket-a = false } } diff --git a/tests/modules/gcs/test_plan.py b/tests/modules/gcs/test_plan.py index 536aae97..051eb042 100644 --- a/tests/modules/gcs/test_plan.py +++ b/tests/modules/gcs/test_plan.py @@ -44,7 +44,7 @@ def test_prefix(plan_runner): def test_map_values(plan_runner): "Test that map values set the correct attributes on buckets." _, resources = plan_runner(FIXTURES_DIR) - bpo = dict((r['values']['name'], r['values']['bucket_policy_only']) + bpo = dict((r['values']['name'], r['values']['uniform_bucket_level_access']) for r in resources) assert bpo == {'bucket-a': False, 'bucket-b': True} force_destroy = dict((r['values']['name'], r['values']['force_destroy'])