From c0aab69bb7997f693e1ed1d6742f9e4aadebfe91 Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 09:22:40 +0200 Subject: [PATCH 01/42] feat: Decenrtalized firewall management example added. --- CHANGELOG.md | 1 + README.md | 2 +- modules/net-vpc-firewall-yaml/README.md | 39 +++-- modules/net-vpc-firewall-yaml/main.tf | 17 ++- modules/net-vpc-firewall-yaml/outputs.tf | 8 +- modules/net-vpc-firewall-yaml/variables.tf | 6 +- networking/README.md | 5 +- networking/decentralized-firewall/README.md | 28 ++++ .../decentralized-firewall/backend.tf.sample | 20 +++ networking/decentralized-firewall/diagram.png | Bin 0 -> 70256 bytes .../firewall/common/common-egress.yaml | 43 ++++++ .../firewall/common/iap-access.yaml | 23 +++ .../firewall/common/lb-access.yaml | 24 ++++ .../firewall/dev/app-1/app1-rules.yaml | 33 +++++ .../firewall/dev/app-2/app2-rules.yaml | 31 ++++ .../firewall/prod/app-1/app1-rules.yaml | 32 +++++ networking/decentralized-firewall/main.tf | 136 ++++++++++++++++++ networking/decentralized-firewall/outputs.tf | 53 +++++++ .../decentralized-firewall/variables.tf | 53 +++++++ 19 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 networking/decentralized-firewall/README.md create mode 100644 networking/decentralized-firewall/backend.tf.sample create mode 100644 networking/decentralized-firewall/diagram.png create mode 100644 networking/decentralized-firewall/firewall/common/common-egress.yaml create mode 100644 networking/decentralized-firewall/firewall/common/iap-access.yaml create mode 100644 networking/decentralized-firewall/firewall/common/lb-access.yaml create mode 100644 networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml create mode 100644 networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml create mode 100644 networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml create mode 100644 networking/decentralized-firewall/main.tf create mode 100644 networking/decentralized-firewall/outputs.tf create mode 100644 networking/decentralized-firewall/variables.tf diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0118241..69ae04144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - add support for VPC-SC perimeters in Data Foundation end to end example - fix `vpc-sc` module - new networking example showing how to use [Private Service Connect to call a Cloud Function from on-premises](networking/private-cloud-function-from-onprem/) +- new networking example showing how to organize [decentralized firewall](networking/decentralized-firewall/) management on GCP ## [5.0.0] - 2021-06-17 diff --git a/README.md b/README.md index df419fcae..b51700b04 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The examples in this repository are split in several main sections: **foundation 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) +- **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), [decentralized firewall](./networking/decentralized-firewall) - **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), [Granular Cloud DNS IAM for Shared VPC](./cloud-operations/dns-shared-vpc), [Compute Engine quota monitoring](./cloud-operations/quota-monitoring), [Scheduled Cloud Asset Inventory Export to Bigquery](./cloud-operations/scheduled-asset-inventory-export-bq) - **third party solutions** - [OpenShift cluster on Shared VPC](./third-party-solutions/openshift) diff --git a/modules/net-vpc-firewall-yaml/README.md b/modules/net-vpc-firewall-yaml/README.md index 955080922..bf87557a6 100644 --- a/modules/net-vpc-firewall-yaml/README.md +++ b/modules/net-vpc-firewall-yaml/README.md @@ -4,7 +4,7 @@ This module allows creation and management of different types of firewall rules Yaml abstraction for FW rules can simplify users onboarding and also makes rules definition simpler and clearer comparing to HCL. -Nested folder structure for yaml configurations is supported, which allows better and structured code management. +Nested folder structure for yaml configurations is supported, which allows better and structured code management for multiple teams and environments. ## Example @@ -12,20 +12,29 @@ Nested folder structure for yaml configurations is supported, which allows bette ```hcl module "prod-firewall" { - source = "./modules/net-vpc-firewall-yaml" - project_id = "my-prod-project" - network = "my-prod-network" - config_path = "./prod" + source = "./modules/net-vpc-firewall-yaml" + + project_id = "my-prod-project" + network = "my-prod-network" + config_directories = [ + "./prod", + "./common" + ] + log_config = { metadata = "INCLUDE_ALL_METADATA" } } module "dev-firewall" { - source = "./modules/net-vpc-firewall-yaml" - project_id = "my-dev-project" - network = "my-dev-network" - config_path = "./dev" + source = "./modules/net-vpc-firewall-yaml" + + project_id = "my-dev-project" + network = "my-dev-network" + config_directories = [ + "./prod", + "./common" + ] } # tftest:skip ``` @@ -33,9 +42,11 @@ module "dev-firewall" { ### Configuration Structure ```bash +├── common +│ ├── default-egress.yaml +│   ├── lb-rules.yaml +│   └── iap-ingress.yaml ├── dev -│   ├── core -│   │   └── common-rules.yaml │   ├── team-a │   │   ├── databases.yaml │   │   └── webb-app-a.yaml @@ -43,8 +54,6 @@ module "dev-firewall" { │   ├── backend.yaml │   └── frontend.yaml └── prod - ├── core - │   └── common-rules.yaml ├── team-a │   ├── databases.yaml │   └── webb-app-a.yaml @@ -63,7 +72,7 @@ rule-name: # descriptive name, naming convention is adjusted by the module - ports: ['443', '80'] # ports for a specific protocol, keep empty list `[]` for all ports protocol: tcp # protocol, put `all` for any protocol direction: EGRESS # EGRESS or INGRESS - disabled: false # `false` or `true`, FW rule is disabled when `true`, default value is `true` + disabled: false # `false` or `true`, FW rule is disabled when `true`, default value is `false` priority: 1000 # rule priority value, default value is 1000 source_ranges: # list of source ranges, should be specified only for `INGRESS` rule - 0.0.0.0/0 @@ -131,7 +140,7 @@ web-app-a-ingress: | name | description | type | required | default | |---|---|:---: |:---:|:---:| -| config_path | Path to a folder where firewall configs are stored in yaml format. Folder may include subfolders with configuration files. Files suffix must be `.yaml` | string | ✓ | | +| config_directories | List of paths to folders where firewall configs are stored in yaml format. Folder may include subfolders with configuration files. Files suffix must be `.yaml` | list(string) | ✓ | | | network | Name of the network this set of firewall rules applies to. | string | ✓ | | | project_id | Project Id. | string | ✓ | | | *log_config* | Log configuration. Possible values for `metadata` are `EXCLUDE_ALL_METADATA` and `INCLUDE_ALL_METADATA`. Set to `null` for disabling firewall logging. | object({...}) | | null | diff --git a/modules/net-vpc-firewall-yaml/main.tf b/modules/net-vpc-firewall-yaml/main.tf index e401f3b96..ab19b23a7 100644 --- a/modules/net-vpc-firewall-yaml/main.tf +++ b/modules/net-vpc-firewall-yaml/main.tf @@ -15,10 +15,23 @@ */ locals { + firewall_rule_files = flatten( + [ + for config_path in var.config_directories : + concat( + [ + for config_file in fileset("${path.root}/${config_path}", "**/*.yaml") : + "${path.root}/${config_path}/${config_file}" + ] + ) + + ] + ) + firewall_rules = merge( [ - for config_file in fileset("${path.root}/${var.config_path}", "**/*.yaml") : - try(yamldecode(file("${path.root}/${var.config_path}/${config_file}")), {}) + for config_file in local.firewall_rule_files : + try(yamldecode(file(config_file)), {}) ]... ) } diff --git a/modules/net-vpc-firewall-yaml/outputs.tf b/modules/net-vpc-firewall-yaml/outputs.tf index 63c3d0c85..d964b5a99 100644 --- a/modules/net-vpc-firewall-yaml/outputs.tf +++ b/modules/net-vpc-firewall-yaml/outputs.tf @@ -18,7 +18,7 @@ output "ingress_allow_rules" { description = "Ingress rules with allow blocks." value = [ for rule in google_compute_firewall.rules : - rule.name if rule.direction == "INGRESS" && length(rule.allow) > 0 + rule if rule.direction == "INGRESS" && length(rule.allow) > 0 ] } @@ -26,7 +26,7 @@ output "ingress_deny_rules" { description = "Ingress rules with deny blocks." value = [ for rule in google_compute_firewall.rules : - rule.name if rule.direction == "INGRESS" && length(rule.deny) > 0 + rule if rule.direction == "INGRESS" && length(rule.deny) > 0 ] } @@ -34,7 +34,7 @@ output "egress_allow_rules" { description = "Egress rules with allow blocks." value = [ for rule in google_compute_firewall.rules : - rule.name if rule.direction == "EGRESS" && length(rule.allow) > 0 + rule if rule.direction == "EGRESS" && length(rule.allow) > 0 ] } @@ -42,6 +42,6 @@ output "egress_deny_rules" { description = "Egress rules with allow blocks." value = [ for rule in google_compute_firewall.rules : - rule.name if rule.direction == "EGRESS" && length(rule.deny) > 0 + rule if rule.direction == "EGRESS" && length(rule.deny) > 0 ] } diff --git a/modules/net-vpc-firewall-yaml/variables.tf b/modules/net-vpc-firewall-yaml/variables.tf index 0d5d4da31..d54d5a356 100644 --- a/modules/net-vpc-firewall-yaml/variables.tf +++ b/modules/net-vpc-firewall-yaml/variables.tf @@ -24,9 +24,9 @@ variable "project_id" { type = string } -variable "config_path" { - description = "Path to a folder where firewall configs are stored in yaml format. Folder may include subfolders with configuration files. Files suffix must be `.yaml`" - type = string +variable "config_directories" { + description = "List of paths to folders where firewall configs are stored in yaml format. Folder may include subfolders with configuration files. Files suffix must be `.yaml`" + type = list(string) } variable "log_config" { diff --git a/networking/README.md b/networking/README.md index 5a1489da2..dcd948f47 100644 --- a/networking/README.md +++ b/networking/README.md @@ -40,4 +40,7 @@ It is meant to be used as a starting point for most Shared VPC configurations, a ### Calling a private Cloud Function from On-premises - This [example](./private-cloud-function-from-onprem/) shows how to invoke a [private Google Cloud Function](https://cloud.google.com/functions/docs/networking/network-settings) from the on-prem environment via a [Private Service Connect endpoint](https://cloud.google.com/vpc/docs/private-service-connect#benefits-apis). \ No newline at end of file + This [example](./private-cloud-function-from-onprem/) shows how to invoke a [private Google Cloud Function](https://cloud.google.com/functions/docs/networking/network-settings) from the on-prem environment via a [Private Service Connect endpoint](https://cloud.google.com/vpc/docs/private-service-connect#benefits-apis). + +### Decentralized firewall management + This [example](./decentralized-firewall/) shows how a decentralized firewall management can be organized using [firewall-yaml](../modules/net-vpc-firewall-yaml) module. diff --git a/networking/decentralized-firewall/README.md b/networking/decentralized-firewall/README.md new file mode 100644 index 000000000..d55d9044b --- /dev/null +++ b/networking/decentralized-firewall/README.md @@ -0,0 +1,28 @@ +# Decentralized firewall management + +This sample shows how a decentralized firewall management can be organized using [firewall-yaml](../../modules/net-vpc-firewall-yaml) module. + +This approach is a good fit when Shared VPCs are used across multiple application/infrastructure teams. A centrall repository keeps environment/team specific folders with firewall definitions in `yaml` format. This is the high level diagram: + +![High-level diagram](diagram.png "High-level diagram") + + +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| billing_account_id | Billing account id used as default for new projects. | string | ✓ | | +| prefix | Prefix used for resources that need unique names. | string | ✓ | | +| root_node | Hierarchy node where projects will be created, 'organizations/org_id' or 'folders/folder_id'. | string | ✓ | | +| *ip_ranges* | Subnet IP CIDR ranges. | map(string) | | ... | +| *project_services* | Service APIs enabled by default in new projects. | list(string) | | ... | +| *region* | Region used. | string | | europe-west1 | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| fw_rules | Firewall rules. | | +| projects | Project ids. | | +| vpc | Shared VPCs. | | + diff --git a/networking/decentralized-firewall/backend.tf.sample b/networking/decentralized-firewall/backend.tf.sample new file mode 100644 index 000000000..99f84b17c --- /dev/null +++ b/networking/decentralized-firewall/backend.tf.sample @@ -0,0 +1,20 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + + +terraform { + backend "gcs" { + bucket = "" + } +} diff --git a/networking/decentralized-firewall/diagram.png b/networking/decentralized-firewall/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..bf655309ea3901836a5623a10b2964e39761cb73 GIT binary patch literal 70256 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 literal 0 HcmV?d00001 diff --git a/networking/decentralized-firewall/firewall/common/common-egress.yaml b/networking/decentralized-firewall/firewall/common/common-egress.yaml new file mode 100644 index 000000000..c958bf81d --- /dev/null +++ b/networking/decentralized-firewall/firewall/common/common-egress.yaml @@ -0,0 +1,43 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Deny all egress (egress traffic is allowed by default) +deny-all: + deny: + - ports: [] + protocol: all + direction: EGRESS + priority: 65535 + destination_ranges: + - 0.0.0.0/0 + +# Allow access to GCP APIs via Private Google Access +# https://cloud.google.com/vpc/docs/access-apis-external-ip#config +gcp-pga-apis: + allow: + - ports: [443] + protocol: tcp + direction: EGRESS + priority: 500 + destination_ranges: + - 199.36.153.8/30 + +# Allow egress to internal networks +internal-egress: + allow: + - ports: [] + protocol: tcp + direction: EGRESS + destination_ranges: + - 10.0.0.0/16 \ No newline at end of file diff --git a/networking/decentralized-firewall/firewall/common/iap-access.yaml b/networking/decentralized-firewall/firewall/common/iap-access.yaml new file mode 100644 index 000000000..04a9a0cf9 --- /dev/null +++ b/networking/decentralized-firewall/firewall/common/iap-access.yaml @@ -0,0 +1,23 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Access via SSH from IAP to all instancess https://cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule +iap-ssh-access: + allow: + - ports: [22] + protocol: tcp + direction: INGRESS + priority: 1001 + source_ranges: + - 35.235.240.0/20 diff --git a/networking/decentralized-firewall/firewall/common/lb-access.yaml b/networking/decentralized-firewall/firewall/common/lb-access.yaml new file mode 100644 index 000000000..f151ef7a3 --- /dev/null +++ b/networking/decentralized-firewall/firewall/common/lb-access.yaml @@ -0,0 +1,24 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Access from GCP LBs https://cloud.google.com/load-balancing/docs/https/#firewall_rules +lb-health-checks: + allow: + - ports: [] + protocol: tcp + direction: INGRESS + priority: 1001 + source_ranges: + - 35.191.0.0/16 + - 130.211.0.0/22 \ No newline at end of file diff --git a/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml b/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml new file mode 100644 index 000000000..ca12f2724 --- /dev/null +++ b/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml @@ -0,0 +1,33 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Allow traffic from the frontend VMs +app1-backend: + allow: + - ports: ['443', '80'] + protocol: tcp + direction: INGRESS + source_tags: ['app1-frontend'] + target_tags: ['app1-backend'] + +# Allow traffic to MySQL Servers from App1 backend +app1-db: + allow: + - ports: ['3306'] + protocol: tcp + direction: INGRESS + source_tags: ['app1-backend'] + target_tags: ['mysql-server'] + + diff --git a/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml b/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml new file mode 100644 index 000000000..d15912bde --- /dev/null +++ b/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml @@ -0,0 +1,31 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Allow traffic from app1 frontend +app2-backend: + allow: + - ports: ['443', '80'] + protocol: tcp + direction: INGRESS + source_tags: ['app1-frontend'] + target_tags: ['app2-backend'] + +# Allow traffic to MySQL servers from App2 backend +app2-db: + allow: + - ports: ['3306'] + protocol: tcp + direction: INGRESS + source_tags: ['app2-backend'] + target_tags: ['mysql-server'] \ No newline at end of file diff --git a/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml b/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml new file mode 100644 index 000000000..541cbf88b --- /dev/null +++ b/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml @@ -0,0 +1,32 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +# Allow traffic from the frontend VMs +app1-backend: + allow: + - ports: ['443', '80'] + protocol: tcp + direction: INGRESS + source_tags: ['app1-frontend'] + target_tags: ['app1-backend'] + +# Allow traffic to MySQL Servers from App1 backend +app1-db: + allow: + - ports: ['3306'] + protocol: tcp + direction: INGRESS + source_tags: ['app1-backend'] + target_tags: ['mysql-server'] + diff --git a/networking/decentralized-firewall/main.tf b/networking/decentralized-firewall/main.tf new file mode 100644 index 000000000..74c719344 --- /dev/null +++ b/networking/decentralized-firewall/main.tf @@ -0,0 +1,136 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +############################################################################### +# Shared VPC Host projects # +############################################################################### + +module "project-host-prod" { + source = "../../modules/project" + parent = var.root_node + billing_account = var.billing_account_id + prefix = var.prefix + name = "prod-host" + services = var.project_services + + shared_vpc_host_config = { + enabled = true + service_projects = [] + } +} + +module "project-host-dev" { + source = "../../modules/project" + parent = var.root_node + billing_account = var.billing_account_id + prefix = var.prefix + name = "dev-host" + services = var.project_services + + shared_vpc_host_config = { + enabled = true + service_projects = [] + } +} + +################################################################################ +# Networking # +################################################################################ + +module "vpc-prod" { + source = "../../modules/net-vpc" + project_id = module.project-host-prod.project_id + name = "prod-vpc" + subnets = [ + { + ip_cidr_range = var.ip_ranges.prod + name = "prod" + region = var.region + secondary_ip_range = {} + } + ] +} + +module "vpc-dev" { + source = "../../modules/net-vpc" + project_id = module.project-host-dev.project_id + name = "dev-vpc" + subnets = [ + { + ip_cidr_range = var.ip_ranges.dev + name = "dev" + region = var.region + secondary_ip_range = {} + } + ] +} + +############################################################################### +# Private Google Access DNS # +############################################################################### + +module "dns-api-prod" { + source = "../../modules/dns" + project_id = module.project-host-prod.project_id + type = "private" + name = "googleapis" + domain = "googleapis.com." + client_networks = [module.vpc-prod.self_link] + recordsets = [ + { name = "*", type = "CNAME", ttl = 300, records = ["private.googleapis.com."] }, + ] +} + +module "dns-api-dev" { + source = "../../modules/dns" + project_id = module.project-host-dev.project_id + type = "private" + name = "googleapis" + domain = "googleapis.com." + client_networks = [module.vpc-dev.self_link] + recordsets = [ + { name = "*", type = "CNAME", ttl = 300, records = ["private.googleapis.com."] }, + ] +} + +############################################################################### +# Distributed Firewall # +############################################################################### + +module "vpc-firewall-prod" { + source = "../../modules/net-vpc-firewall-yaml" + + project_id = module.project-host-prod.project_id + network = module.vpc-prod.name + config_directories = [ + "./firewall/common", + "./firewall/prod" + ] + + # Enable Firewall Logging for the production fwl rules + log_config = { + metadata = "INCLUDE_ALL_METADATA" + } +} + +module "vpc-firewall-dev" { + source = "../../modules/net-vpc-firewall-yaml" + + project_id = module.project-host-dev.project_id + network = module.vpc-dev.name + config_directories = [ + "./firewall/common", + "./firewall/dev" + ] +} diff --git a/networking/decentralized-firewall/outputs.tf b/networking/decentralized-firewall/outputs.tf new file mode 100644 index 000000000..f744821f5 --- /dev/null +++ b/networking/decentralized-firewall/outputs.tf @@ -0,0 +1,53 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +output "projects" { + description = "Project ids." + value = { + prod-host = module.project-host-prod.project_id + dev-host = module.project-host-dev.project_id + } +} + +output "vpc" { + description = "Shared VPCs." + value = { + prod = { + name = module.vpc-prod.name + subnets = module.vpc-prod.subnet_ips + } + dev = { + name = module.vpc-dev.name + subnets = module.vpc-dev.subnet_ips + } + } +} + +output "fw_rules" { + description = "Firewall rules." + value = { + prod = { + ingress_allow_rules = module.vpc-firewall-prod.ingress_allow_rules + ingress_deny_rules = module.vpc-firewall-prod.ingress_deny_rules + egress_allow_rules = module.vpc-firewall-prod.egress_allow_rules + egress_deny_rules = module.vpc-firewall-prod.egress_deny_rules + } + dev = { + ingress_allow_rules = module.vpc-firewall-dev.ingress_allow_rules + ingress_deny_rules = module.vpc-firewall-dev.ingress_deny_rules + egress_allow_rules = module.vpc-firewall-dev.egress_allow_rules + egress_deny_rules = module.vpc-firewall-dev.egress_deny_rules + } + } +} diff --git a/networking/decentralized-firewall/variables.tf b/networking/decentralized-firewall/variables.tf new file mode 100644 index 000000000..6e71fbc37 --- /dev/null +++ b/networking/decentralized-firewall/variables.tf @@ -0,0 +1,53 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +variable "billing_account_id" { + description = "Billing account id used as default for new projects." + type = string +} + +variable "prefix" { + description = "Prefix used for resources that need unique names." + type = string +} + +variable "region" { + description = "Region used." + type = string + default = "europe-west1" +} + +variable "root_node" { + description = "Hierarchy node where projects will be created, 'organizations/org_id' or 'folders/folder_id'." + type = string +} + +variable "ip_ranges" { + description = "Subnet IP CIDR ranges." + type = map(string) + default = { + prod = "10.0.16.0/24" + dev = "10.0.32.0/24" + } +} + +variable "project_services" { + description = "Service APIs enabled by default in new projects." + type = list(string) + default = [ + "container.googleapis.com", + "dns.googleapis.com", + "stackdriver.googleapis.com", + ] +} From 9b30503ab73c31776a7ebdbc346b913a3f43841a Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 09:28:16 +0200 Subject: [PATCH 02/42] Formatting --- .../decentralized-firewall/firewall/common/common-egress.yaml | 2 +- .../decentralized-firewall/firewall/common/iap-access.yaml | 1 + .../decentralized-firewall/firewall/common/lb-access.yaml | 2 +- .../decentralized-firewall/firewall/dev/app-1/app1-rules.yaml | 2 -- .../decentralized-firewall/firewall/dev/app-2/app2-rules.yaml | 2 +- .../decentralized-firewall/firewall/prod/app-1/app1-rules.yaml | 1 - 6 files changed, 4 insertions(+), 6 deletions(-) diff --git a/networking/decentralized-firewall/firewall/common/common-egress.yaml b/networking/decentralized-firewall/firewall/common/common-egress.yaml index c958bf81d..716c1498b 100644 --- a/networking/decentralized-firewall/firewall/common/common-egress.yaml +++ b/networking/decentralized-firewall/firewall/common/common-egress.yaml @@ -40,4 +40,4 @@ internal-egress: protocol: tcp direction: EGRESS destination_ranges: - - 10.0.0.0/16 \ No newline at end of file + - 10.0.0.0/16 diff --git a/networking/decentralized-firewall/firewall/common/iap-access.yaml b/networking/decentralized-firewall/firewall/common/iap-access.yaml index 04a9a0cf9..931a180e4 100644 --- a/networking/decentralized-firewall/firewall/common/iap-access.yaml +++ b/networking/decentralized-firewall/firewall/common/iap-access.yaml @@ -21,3 +21,4 @@ iap-ssh-access: priority: 1001 source_ranges: - 35.235.240.0/20 + \ No newline at end of file diff --git a/networking/decentralized-firewall/firewall/common/lb-access.yaml b/networking/decentralized-firewall/firewall/common/lb-access.yaml index f151ef7a3..975d3ca05 100644 --- a/networking/decentralized-firewall/firewall/common/lb-access.yaml +++ b/networking/decentralized-firewall/firewall/common/lb-access.yaml @@ -21,4 +21,4 @@ lb-health-checks: priority: 1001 source_ranges: - 35.191.0.0/16 - - 130.211.0.0/22 \ No newline at end of file + - 130.211.0.0/22 diff --git a/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml b/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml index ca12f2724..9a26650be 100644 --- a/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml +++ b/networking/decentralized-firewall/firewall/dev/app-1/app1-rules.yaml @@ -29,5 +29,3 @@ app1-db: direction: INGRESS source_tags: ['app1-backend'] target_tags: ['mysql-server'] - - diff --git a/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml b/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml index d15912bde..d7b79b636 100644 --- a/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml +++ b/networking/decentralized-firewall/firewall/dev/app-2/app2-rules.yaml @@ -28,4 +28,4 @@ app2-db: protocol: tcp direction: INGRESS source_tags: ['app2-backend'] - target_tags: ['mysql-server'] \ No newline at end of file + target_tags: ['mysql-server'] diff --git a/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml b/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml index 541cbf88b..9a26650be 100644 --- a/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml +++ b/networking/decentralized-firewall/firewall/prod/app-1/app1-rules.yaml @@ -29,4 +29,3 @@ app1-db: direction: INGRESS source_tags: ['app1-backend'] target_tags: ['mysql-server'] - From 75ef6dd0ec83f755a3dadb200de88483833f44ca Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 09:32:53 +0200 Subject: [PATCH 03/42] Fix decentralized-fwl example docs --- networking/decentralized-firewall/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/networking/decentralized-firewall/README.md b/networking/decentralized-firewall/README.md index d55d9044b..3ea255ea3 100644 --- a/networking/decentralized-firewall/README.md +++ b/networking/decentralized-firewall/README.md @@ -15,7 +15,7 @@ This approach is a good fit when Shared VPCs are used across multiple applicatio | prefix | Prefix used for resources that need unique names. | string | ✓ | | | root_node | Hierarchy node where projects will be created, 'organizations/org_id' or 'folders/folder_id'. | string | ✓ | | | *ip_ranges* | Subnet IP CIDR ranges. | map(string) | | ... | -| *project_services* | Service APIs enabled by default in new projects. | list(string) | | ... | +| *project_services* | Service APIs enabled by default in new projects. | list(string) | | ... | | *region* | Region used. | string | | europe-west1 | ## Outputs From 830e464e2ed43e0ade5366e412b8bbb0ccc4970f Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 09:50:33 +0200 Subject: [PATCH 04/42] Update firewall-yaml test fixture --- tests/modules/net_vpc_firewall_yaml/fixture/main.tf | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/modules/net_vpc_firewall_yaml/fixture/main.tf b/tests/modules/net_vpc_firewall_yaml/fixture/main.tf index 7db37c77c..4dcc9b7c2 100644 --- a/tests/modules/net_vpc_firewall_yaml/fixture/main.tf +++ b/tests/modules/net_vpc_firewall_yaml/fixture/main.tf @@ -15,9 +15,11 @@ */ module "firewall" { - source = "../../../../modules/net-vpc-firewall-yaml" - project_id = "my-project" - network = "my-network" - config_path = "./rules" - log_config = var.log_config + source = "../../../../modules/net-vpc-firewall-yaml" + project_id = "my-project" + network = "my-network" + config_directories = [ + "./rules" + ] + log_config = var.log_config } From 77f9d9dad9301f0cf04741eab8fa657efca6ce68 Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 10:07:55 +0200 Subject: [PATCH 05/42] Fix networking examples readme --- networking/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/networking/README.md b/networking/README.md index dcd948f47..ba3f63e7f 100644 --- a/networking/README.md +++ b/networking/README.md @@ -43,4 +43,5 @@ It is meant to be used as a starting point for most Shared VPC configurations, a This [example](./private-cloud-function-from-onprem/) shows how to invoke a [private Google Cloud Function](https://cloud.google.com/functions/docs/networking/network-settings) from the on-prem environment via a [Private Service Connect endpoint](https://cloud.google.com/vpc/docs/private-service-connect#benefits-apis). ### Decentralized firewall management + This [example](./decentralized-firewall/) shows how a decentralized firewall management can be organized using [firewall-yaml](../modules/net-vpc-firewall-yaml) module. From 4d6586b7f31cd01210a6986c0f6b072da8141b34 Mon Sep 17 00:00:00 2001 From: averbukh Date: Mon, 26 Jul 2021 22:45:52 +0200 Subject: [PATCH 06/42] minor fixed to readme --- CHANGELOG.md | 4 ++-- networking/README.md | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ae04144..cc7ba4559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ All notable changes to this project will be documented in this file. - add support for CMEK keys in Data Foundation end to end example - add support for VPC-SC perimeters in Data Foundation end to end example - fix `vpc-sc` module -- new networking example showing how to use [Private Service Connect to call a Cloud Function from on-premises](networking/private-cloud-function-from-onprem/) -- new networking example showing how to organize [decentralized firewall](networking/decentralized-firewall/) management on GCP +- new networking example showing how to use [Private Service Connect to call a Cloud Function from on-premises](./networking/private-cloud-function-from-onprem/) +- new networking example showing how to organize [decentralized firewall](./networking/decentralized-firewall/) management on GCP ## [5.0.0] - 2021-06-17 diff --git a/networking/README.md b/networking/README.md index ba3f63e7f..00611913b 100644 --- a/networking/README.md +++ b/networking/README.md @@ -37,11 +37,14 @@ It is meant to be used as a starting point for most Shared VPC configurations, a ### ILB as next hop This [example](./ilb-next-hop/) allows testing [ILB as next hop](https://cloud.google.com/load-balancing/docs/internal/ilb-next-hop-overview) using simple Linux gateway VMS between two VPCs, to emulate virtual appliances. An optional additional ILB can be enabled to test multiple load balancer configurations and hashing. +
### Calling a private Cloud Function from On-premises This [example](./private-cloud-function-from-onprem/) shows how to invoke a [private Google Cloud Function](https://cloud.google.com/functions/docs/networking/network-settings) from the on-prem environment via a [Private Service Connect endpoint](https://cloud.google.com/vpc/docs/private-service-connect#benefits-apis). +
### Decentralized firewall management This [example](./decentralized-firewall/) shows how a decentralized firewall management can be organized using [firewall-yaml](../modules/net-vpc-firewall-yaml) module. +
From 07a70daab9ee49bab3e74ba147e9dfc0e2ab5d56 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 27 Jul 2021 16:44:46 +0200 Subject: [PATCH 07/42] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b51700b04..4cb79901e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The examples in this repository are split in several main sections: **foundation 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), [decentralized firewall](./networking/decentralized-firewall) +- **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), [PSC for on-premises Cloud Function invocation](./networking/private-cloud-function-from-onprem/), [decentralized firewall](./networking/decentralized-firewall) - **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), [Granular Cloud DNS IAM for Shared VPC](./cloud-operations/dns-shared-vpc), [Compute Engine quota monitoring](./cloud-operations/quota-monitoring), [Scheduled Cloud Asset Inventory Export to Bigquery](./cloud-operations/scheduled-asset-inventory-export-bq) - **third party solutions** - [OpenShift cluster on Shared VPC](./third-party-solutions/openshift) From d745fd03dd4d52d62fce94cad2cd0efcda50cf40 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Tue, 27 Jul 2021 16:46:56 +0200 Subject: [PATCH 08/42] Update README.md --- networking/decentralized-firewall/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/networking/decentralized-firewall/README.md b/networking/decentralized-firewall/README.md index 3ea255ea3..d718c1d3a 100644 --- a/networking/decentralized-firewall/README.md +++ b/networking/decentralized-firewall/README.md @@ -1,8 +1,8 @@ # Decentralized firewall management -This sample shows how a decentralized firewall management can be organized using [firewall-yaml](../../modules/net-vpc-firewall-yaml) module. +This sample shows how a decentralized firewall management can be organized using the [firewall-yaml](../../modules/net-vpc-firewall-yaml) module. -This approach is a good fit when Shared VPCs are used across multiple application/infrastructure teams. A centrall repository keeps environment/team specific folders with firewall definitions in `yaml` format. This is the high level diagram: +This approach is a good fit when Shared VPCs are used across multiple application/infrastructure teams. A central repository keeps environment/team specific folders with firewall definitions in `yaml` format. This is the high level diagram: ![High-level diagram](diagram.png "High-level diagram") From 804ce9bdc4ca24218e9835c8924bd50dc675295d Mon Sep 17 00:00:00 2001 From: sly92 Date: Wed, 28 Jul 2021 18:08:51 +0200 Subject: [PATCH 09/42] Add new variable for flexibility between project id & name (#287) * add new variable for flexibility between project id & name * remove random feature & use new variable * remove project_id variable & use local descriptive name variable * fix bad project_id output & avoid confusing name * update readme * Update main.tf Co-authored-by: slaheddine_bejaoui.ext Co-authored-by: Ludovico Magnocavallo --- modules/project/README.md | 1 + modules/project/main.tf | 4 +++- modules/project/variables.tf | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/project/README.md b/modules/project/README.md index 697f9d25a..03ec1ef95 100644 --- a/modules/project/README.md +++ b/modules/project/README.md @@ -183,6 +183,7 @@ module "project" { | *billing_account* | Billing account id. | string | | null | | *contacts* | List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES | map(list(string)) | | {} | | *custom_roles* | Map of role name => list of permissions to create in this project. | map(list(string)) | | {} | +| *descriptive_name* | Name of the project name. Used for project name instead of `name` variable | string | | null | | *group_iam* | Authoritative IAM binding for organization groups, in {GROUP_EMAIL => [ROLES]} format. Group emails need to be static. Can be used in combination with the `iam` variable. | map(list(string)) | | {} | | *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | *iam_additive* | IAM additive bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | diff --git a/modules/project/main.tf b/modules/project/main.tf index e17f6f0a9..dbc0de9f5 100644 --- a/modules/project/main.tf +++ b/modules/project/main.tf @@ -15,6 +15,7 @@ */ locals { + descriptive_name = var.descriptive_name != null ? var.descriptive_name : "${local.prefix}${var.name}" group_iam_roles = distinct(flatten(values(var.group_iam))) group_iam = { for r in local.group_iam_roles : r => [ @@ -75,6 +76,7 @@ locals { ]) } + data "google_project" "project" { count = var.project_create ? 0 : 1 project_id = "${local.prefix}${var.name}" @@ -85,7 +87,7 @@ resource "google_project" "project" { org_id = local.parent_type == "organizations" ? local.parent_id : null folder_id = local.parent_type == "folders" ? local.parent_id : null project_id = "${local.prefix}${var.name}" - name = "${local.prefix}${var.name}" + name = "${local.descriptive_name}" billing_account = var.billing_account auto_create_network = var.auto_create_network labels = var.labels diff --git a/modules/project/variables.tf b/modules/project/variables.tf index d4f917b33..a72e4d108 100644 --- a/modules/project/variables.tf +++ b/modules/project/variables.tf @@ -215,3 +215,9 @@ variable "service_perimeter_bridges" { type = list(string) default = null } + +variable "descriptive_name" { + description = "Name of the project name. Used for project name instead of `name` variable" + type = string + default = null +} From 83e469b07fa720bb1fe3191f5ed03679715d0824 Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Wed, 28 Jul 2021 18:47:53 +0200 Subject: [PATCH 10/42] Update main.tf --- modules/project/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/project/main.tf b/modules/project/main.tf index dbc0de9f5..7e7285bbd 100644 --- a/modules/project/main.tf +++ b/modules/project/main.tf @@ -87,7 +87,7 @@ resource "google_project" "project" { org_id = local.parent_type == "organizations" ? local.parent_id : null folder_id = local.parent_type == "folders" ? local.parent_id : null project_id = "${local.prefix}${var.name}" - name = "${local.descriptive_name}" + name = local.descriptive_name billing_account = var.billing_account auto_create_network = var.auto_create_network labels = var.labels From 7792b913648164430a113f878c957efaceb187e5 Mon Sep 17 00:00:00 2001 From: averbukh Date: Wed, 28 Jul 2021 22:14:14 +0200 Subject: [PATCH 11/42] Add basic tests for decentralized firewall example --- networking/decentralized-firewall/main.tf | 8 +++--- .../decentralized_firewall/__init__.py | 13 +++++++++ .../decentralized_firewall/fixture/main.tf | 22 +++++++++++++++ .../fixture/variables.tf | 28 +++++++++++++++++++ .../decentralized_firewall/test_plan.py | 28 +++++++++++++++++++ 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/networking/decentralized_firewall/__init__.py create mode 100644 tests/networking/decentralized_firewall/fixture/main.tf create mode 100644 tests/networking/decentralized_firewall/fixture/variables.tf create mode 100644 tests/networking/decentralized_firewall/test_plan.py diff --git a/networking/decentralized-firewall/main.tf b/networking/decentralized-firewall/main.tf index 74c719344..2502d41f6 100644 --- a/networking/decentralized-firewall/main.tf +++ b/networking/decentralized-firewall/main.tf @@ -114,8 +114,8 @@ module "vpc-firewall-prod" { project_id = module.project-host-prod.project_id network = module.vpc-prod.name config_directories = [ - "./firewall/common", - "./firewall/prod" + "${path.module}/firewall/common", + "${path.module}/firewall/prod" ] # Enable Firewall Logging for the production fwl rules @@ -130,7 +130,7 @@ module "vpc-firewall-dev" { project_id = module.project-host-dev.project_id network = module.vpc-dev.name config_directories = [ - "./firewall/common", - "./firewall/dev" + "${path.module}/firewall/common", + "${path.module}/firewall/dev" ] } diff --git a/tests/networking/decentralized_firewall/__init__.py b/tests/networking/decentralized_firewall/__init__.py new file mode 100644 index 000000000..d46dbae5e --- /dev/null +++ b/tests/networking/decentralized_firewall/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/networking/decentralized_firewall/fixture/main.tf b/tests/networking/decentralized_firewall/fixture/main.tf new file mode 100644 index 000000000..9bef2d737 --- /dev/null +++ b/tests/networking/decentralized_firewall/fixture/main.tf @@ -0,0 +1,22 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "test" { + source = "../../../../networking/decentralized-firewall" + billing_account_id = var.billing_account_id + prefix = var.prefix + root_node = var.root_node +} diff --git a/tests/networking/decentralized_firewall/fixture/variables.tf b/tests/networking/decentralized_firewall/fixture/variables.tf new file mode 100644 index 000000000..9646fe1b7 --- /dev/null +++ b/tests/networking/decentralized_firewall/fixture/variables.tf @@ -0,0 +1,28 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +variable "billing_account_id" { + type = string + default = "ABCDE-12345-ABCDE" +} + +variable "prefix" { + type = string + default = "test" +} + +variable "root_node" { + type = string + default = "organizations/0123456789" +} diff --git a/tests/networking/decentralized_firewall/test_plan.py b/tests/networking/decentralized_firewall/test_plan.py new file mode 100644 index 000000000..5183e2a90 --- /dev/null +++ b/tests/networking/decentralized_firewall/test_plan.py @@ -0,0 +1,28 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import pytest + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixture") + + +def test_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) == 8 + assert len(resources) == 50 + assert modules == "something" From 3a834235549ce4ade070b71f9ddd42840a1df36f Mon Sep 17 00:00:00 2001 From: averbukh Date: Wed, 28 Jul 2021 22:25:26 +0200 Subject: [PATCH 12/42] Cleaning up test for decentralized fwl --- tests/networking/decentralized_firewall/test_plan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/networking/decentralized_firewall/test_plan.py b/tests/networking/decentralized_firewall/test_plan.py index 5183e2a90..cb1764a9d 100644 --- a/tests/networking/decentralized_firewall/test_plan.py +++ b/tests/networking/decentralized_firewall/test_plan.py @@ -25,4 +25,3 @@ def test_resources(e2e_plan_runner): modules, resources = e2e_plan_runner(FIXTURES_DIR) assert len(modules) == 8 assert len(resources) == 50 - assert modules == "something" From a1008a83a8401d7ea41f16522337f939acbdfa2a Mon Sep 17 00:00:00 2001 From: averbukh Date: Thu, 29 Jul 2021 11:54:26 +0200 Subject: [PATCH 13/42] Fix firewall-yaml readme --- modules/net-vpc-firewall-yaml/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/net-vpc-firewall-yaml/README.md b/modules/net-vpc-firewall-yaml/README.md index bf87557a6..2d4ab90e7 100644 --- a/modules/net-vpc-firewall-yaml/README.md +++ b/modules/net-vpc-firewall-yaml/README.md @@ -4,7 +4,7 @@ This module allows creation and management of different types of firewall rules Yaml abstraction for FW rules can simplify users onboarding and also makes rules definition simpler and clearer comparing to HCL. -Nested folder structure for yaml configurations is supported, which allows better and structured code management for multiple teams and environments. +Nested folder structure for yaml configurations is supported, which allows better and structured code management for multiple teams and environments. ## Example @@ -32,7 +32,7 @@ module "dev-firewall" { project_id = "my-dev-project" network = "my-dev-network" config_directories = [ - "./prod", + "./dev", "./common" ] } From 2ab061baa90ad2d93350562229e326b558724545 Mon Sep 17 00:00:00 2001 From: averbukh Date: Fri, 30 Jul 2021 01:16:47 +0200 Subject: [PATCH 14/42] Note hierarcicall FW rules in the readme --- networking/decentralized-firewall/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/networking/decentralized-firewall/README.md b/networking/decentralized-firewall/README.md index d718c1d3a..8a4c0066e 100644 --- a/networking/decentralized-firewall/README.md +++ b/networking/decentralized-firewall/README.md @@ -2,7 +2,12 @@ This sample shows how a decentralized firewall management can be organized using the [firewall-yaml](../../modules/net-vpc-firewall-yaml) module. -This approach is a good fit when Shared VPCs are used across multiple application/infrastructure teams. A central repository keeps environment/team specific folders with firewall definitions in `yaml` format. This is the high level diagram: +This approach is a good fit when Shared VPCs are used across multiple application/infrastructure teams. A central repository keeps environment/team specific folders with firewall definitions in `yaml` format. + +In the current example multiple teams can define their [VPC Firewall Rules](https://cloud.google.com/vpc/docs/firewalls) for [dev](./firewall/dev) and [prod](./firewall/prod) environments using team specific subfolders. Rules defined in the [common](./firewall/common) folder are applied to both dev and prod environments. +> **_NOTE:_** Common rules are meant to be used for situations where [hierarchical rules](https://cloud.google.com/vpc/docs/firewall-policies) do not map precisely to requirements (e.g. SA, etc.) + +This is the high level diagram: ![High-level diagram](diagram.png "High-level diagram") From 202892b344cdc6b91d1330e18e8cdf716fadfaff Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Fri, 30 Jul 2021 12:07:17 +0200 Subject: [PATCH 15/42] Add support for lifecycle_rule in gcs module (#288) (#289) * Add support for lifecycle_rule in gcs module (#288) Co-authored-by: Ludovico Magnocavallo * fix docs * rename unrelated interconnect module tests * fix doc example test Co-authored-by: iury <1934268+IuryAlves@users.noreply.github.com> --- CHANGELOG.md | 1 + modules/gcs/README.md | 35 +++++++++++++++++++ modules/gcs/main.tf | 21 +++++++++++ modules/gcs/variables.tf | 22 ++++++++++++ .../__init__.py | 0 .../fixture/main.tf | 0 .../fixture/variables.tf | 0 .../test_plan.py | 0 8 files changed, 79 insertions(+) rename tests/modules/{net-interconnect-attachment-direct => net_interconnect_attachment_direct}/__init__.py (100%) rename tests/modules/{net-interconnect-attachment-direct => net_interconnect_attachment_direct}/fixture/main.tf (100%) rename tests/modules/{net-interconnect-attachment-direct => net_interconnect_attachment_direct}/fixture/variables.tf (100%) rename tests/modules/{net-interconnect-attachment-direct => net_interconnect_attachment_direct}/test_plan.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7ba4559..18e67a6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- add support for `lifecycle_rule` in gcs module - create `pubsub` service identity if service is enabled - support for creation of GKE Autopilot clusters - add support for CMEK keys in Data Foundation end to end example diff --git a/modules/gcs/README.md b/modules/gcs/README.md index ff27a5b63..095bb1059 100644 --- a/modules/gcs/README.md +++ b/modules/gcs/README.md @@ -60,6 +60,40 @@ module "bucket" { # tftest:modules=1:resources=2 ``` +### Example with lifecycle rule + +```hcl +module "bucket" { + source = "./modules/gcs" + project_id = "myproject" + prefix = "test" + name = "my-bucket" + + iam = { + "roles/storage.admin" = ["group:storage@example.com"] + } + + lifecycle_rule = { + action = { + type = "SetStorageClass" + storage_class = "STANDARD" + } + condition = { + age = 30 + created_before = null + with_state = null + matches_storage_class = null + num_newer_versions = null + custom_time_before = null + days_since_custom_time = null + days_since_noncurrent_time = null + noncurrent_time_before = null + } + } +} +# tftest:modules=1:resources=2 +``` + ## Variables @@ -72,6 +106,7 @@ module "bucket" { | *force_destroy* | Optional map to set force destroy keyed by name, defaults to false. | bool | | false | | *iam* | IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | *labels* | Labels to be attached to all buckets. | map(string) | | {} | +| *lifecycle_rule* | Bucket lifecycle rule | object({...}) | | null | | *location* | Bucket location. | string | | EU | | *logging_config* | Bucket logging configuration. | object({...}) | | null | | *prefix* | Prefix used to generate the bucket name. | string | | null | diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index 0b86f770b..59cbb1350 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -71,6 +71,27 @@ resource "google_storage_bucket" "bucket" { max_age_seconds = max(3600, var.cors.max_age_seconds) } } + + dynamic lifecycle_rule { + for_each = var.lifecycle_rule == null ? [] : [""] + content { + action { + type = var.lifecycle_rule.action["type"] + storage_class = var.lifecycle_rule.action["storage_class"] + } + condition { + age = var.lifecycle_rule.condition["age"] + created_before = var.lifecycle_rule.condition["created_before"] + with_state = var.lifecycle_rule.condition["with_state"] + matches_storage_class = var.lifecycle_rule.condition["matches_storage_class"] + num_newer_versions = var.lifecycle_rule.condition["num_newer_versions"] + custom_time_before = var.lifecycle_rule.condition["custom_time_before"] + days_since_custom_time = var.lifecycle_rule.condition["days_since_custom_time"] + days_since_noncurrent_time = var.lifecycle_rule.condition["days_since_noncurrent_time"] + noncurrent_time_before = var.lifecycle_rule.condition["noncurrent_time_before"] + } + } + } } resource "google_storage_bucket_iam_binding" "bindings" { diff --git a/modules/gcs/variables.tf b/modules/gcs/variables.tf index 268acfb35..cfb5e573f 100644 --- a/modules/gcs/variables.tf +++ b/modules/gcs/variables.tf @@ -110,3 +110,25 @@ variable "cors" { }) default = null } + +variable "lifecycle_rule" { + description = "Bucket lifecycle rule" + type = object({ + action = object({ + type = string + storage_class = string + }) + condition = object({ + age = number + created_before = string + with_state = string + matches_storage_class = list(string) + num_newer_versions = string + custom_time_before = string + days_since_custom_time = string + days_since_noncurrent_time = string + noncurrent_time_before = string + }) + }) + default = null +} diff --git a/tests/modules/net-interconnect-attachment-direct/__init__.py b/tests/modules/net_interconnect_attachment_direct/__init__.py similarity index 100% rename from tests/modules/net-interconnect-attachment-direct/__init__.py rename to tests/modules/net_interconnect_attachment_direct/__init__.py diff --git a/tests/modules/net-interconnect-attachment-direct/fixture/main.tf b/tests/modules/net_interconnect_attachment_direct/fixture/main.tf similarity index 100% rename from tests/modules/net-interconnect-attachment-direct/fixture/main.tf rename to tests/modules/net_interconnect_attachment_direct/fixture/main.tf diff --git a/tests/modules/net-interconnect-attachment-direct/fixture/variables.tf b/tests/modules/net_interconnect_attachment_direct/fixture/variables.tf similarity index 100% rename from tests/modules/net-interconnect-attachment-direct/fixture/variables.tf rename to tests/modules/net_interconnect_attachment_direct/fixture/variables.tf diff --git a/tests/modules/net-interconnect-attachment-direct/test_plan.py b/tests/modules/net_interconnect_attachment_direct/test_plan.py similarity index 100% rename from tests/modules/net-interconnect-attachment-direct/test_plan.py rename to tests/modules/net_interconnect_attachment_direct/test_plan.py From af34a3bc97ec06c4f342f68b041758fbf45655a3 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Fri, 30 Jul 2021 15:05:01 +0200 Subject: [PATCH 16/42] Update CHANGELOG.md --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e67a6a7..4c4f2a971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + +## [5.1.0] - 2021-08-30 + - add support for `lifecycle_rule` in gcs module - create `pubsub` service identity if service is enabled - support for creation of GKE Autopilot clusters @@ -332,7 +335,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/v5.0.0...HEAD +[Unreleased]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v5.1.0...HEAD +[5.1.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v5.0.0...v5.1.0 [5.0.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v4.9.0...v5.0.0 [4.9.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v4.8.0...v4.9.0 [4.8.0]: https://github.com/terraform-google-modules/cloud-foundation-fabric/compare/v4.7.0...v4.8.0 From 24b5e03c80ea2192356e3300045708cc91765267 Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Wed, 4 Aug 2021 17:09:44 +0200 Subject: [PATCH 17/42] initial scaffold for an Apigee tf module --- modules/apigee-x-instance/README.md | 30 ++++++++++ modules/apigee-x-instance/main.tf | 14 +++++ modules/apigee-x-instance/outputs.tf | 4 ++ modules/apigee-x-instance/variables.tf | 29 ++++++++++ modules/apigee/README.md | 68 ++++++++++++++++++++++ modules/apigee/main.tf | 72 +++++++++++++++++++++++ modules/apigee/outputs.tf | 14 +++++ modules/apigee/variables.tf | 79 ++++++++++++++++++++++++++ 8 files changed, 310 insertions(+) create mode 100644 modules/apigee-x-instance/README.md create mode 100644 modules/apigee-x-instance/main.tf create mode 100644 modules/apigee-x-instance/outputs.tf create mode 100644 modules/apigee-x-instance/variables.tf create mode 100644 modules/apigee/README.md create mode 100644 modules/apigee/main.tf create mode 100644 modules/apigee/outputs.tf create mode 100644 modules/apigee/variables.tf diff --git a/modules/apigee-x-instance/README.md b/modules/apigee-x-instance/README.md new file mode 100644 index 000000000..90d29a68f --- /dev/null +++ b/modules/apigee-x-instance/README.md @@ -0,0 +1,30 @@ +# Apigee Module + +This module allows managing a single Apigee X instance and its environment attachments. + +## TODO + +- [ ] N/A + +## Examples + +### Apigee X Evaluation Instance + +```hcl +module "apigee-x-instance" { + source = "./modules/apigee-x-instance" + name = "my-us-instance" + region = "us-central1" + cidr_mask = 22 + + apigee_org_id = "my-project" + apigee_environments = [ + "eval1", + "eval2" + ] +} +# tftest:modules=1:resources=3 +``` + + + diff --git a/modules/apigee-x-instance/main.tf b/modules/apigee-x-instance/main.tf new file mode 100644 index 000000000..0e5f6fbd1 --- /dev/null +++ b/modules/apigee-x-instance/main.tf @@ -0,0 +1,14 @@ +resource "google_apigee_instance" "apigee_instance" { + org_id = var.apigee_org_id + name = var.name + location = var.region + peering_cidr_range = "SLASH_${var.cidr_mask}" + #disk_encryption_key_name = google_kms_crypto_key.apigee_key.id +} + + +resource "google_apigee_instance_attachment" "apigee_instance_attchment" { + for_each = toset(var.apigee_environments) + instance_id = google_apigee_instance.apigee_instance.id + environment = each.key +} diff --git a/modules/apigee-x-instance/outputs.tf b/modules/apigee-x-instance/outputs.tf new file mode 100644 index 000000000..be9531336 --- /dev/null +++ b/modules/apigee-x-instance/outputs.tf @@ -0,0 +1,4 @@ +output "endpoint" { + description = "Internal endpoint of the Apigee instance." + value = google_apigee_instance.apigee_instance.host +} \ No newline at end of file diff --git a/modules/apigee-x-instance/variables.tf b/modules/apigee-x-instance/variables.tf new file mode 100644 index 000000000..9bc09f1f9 --- /dev/null +++ b/modules/apigee-x-instance/variables.tf @@ -0,0 +1,29 @@ +variable "name" { + description = "Apigee instance name." + type = string +} + +variable "apigee_org_id" { + description = "Apigee Organization ID" + type = string +} + +variable "apigee_environments" { + description = "Apigee Environment Names." + type = list(string) + default = [] +} + +variable "cidr_mask" { + description = "CIDR mask for the Apigee instance" + type = number + validation { + condition = contains([16, 20, 22], var.cidr_mask) + error_message = "Allowed Values for cidr_mask [16, 20, 22]." + } +} + +variable "region" { + description = "Compute region." + type = string +} diff --git a/modules/apigee/README.md b/modules/apigee/README.md new file mode 100644 index 000000000..f8be0b244 --- /dev/null +++ b/modules/apigee/README.md @@ -0,0 +1,68 @@ +# Apigee Module + +This module allows managing a single Apigee organization and its environments and environmentgrous. + +## TODO + +- [ ] N/A + +## Examples + +### Apigee X Evaluation Organization + +```hcl +module "apigee" { + source = "./modules/apigee" + project_id = "my-project" + analytics_region = "us-central1" + runtime_type = "CLOUD" + peering_network = "my-vpc" + peering_range = "10.0.0.0/16" + apigee_environments = [ + "eval1", + "eval2" + ] + apigee_envgroups = { + eval = { + environments = [ + "eval1", + "eval2" + ] + hostnames = [ + "eval.api.example.com" + ] + } + } +} +# tftest:modules=1:resources=10 +``` + +### Apigee hybrid Evaluation Organization + +```hcl +module "apigee" { + source = "./modules/apigee" + project_id = "my-project" + analytics_region = "us-central1" + runtime_type = "HYBRID" + apigee_environments = [ + "eval1", + "eval2" + ] + apigee_envgroups = { + eval = { + environments = [ + "eval1", + "eval2" + ] + hostnames = [ + "eval.api.example.com" + ] + } + } +} +# tftest:modules=1:resources=6 +``` + + + diff --git a/modules/apigee/main.tf b/modules/apigee/main.tf new file mode 100644 index 000000000..c1c3b6602 --- /dev/null +++ b/modules/apigee/main.tf @@ -0,0 +1,72 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + env_envgroup_pairs = flatten([ + for eg_name, eg in var.apigee_envgroups: [ + for e in eg.environments : { + envgroup = eg_name + env = e + } + ] + ]) +} + +resource "google_apigee_organization" "apigee_org" { + project_id = var.project_id + analytics_region = var.analytics_region + display_name = var.display_name + description = var.description + runtime_type = var.runtime_type + authorized_network = var.peering_network +} + +resource "google_apigee_environment" "apigee_env" { + for_each = toset(var.apigee_environments) + org_id = google_apigee_organization.apigee_org.id + name = each.key +} + +resource "google_apigee_envgroup" "apigee_envgroup" { + for_each = var.apigee_envgroups + org_id = google_apigee_organization.apigee_org.id + name = each.key + hostnames = each.value.hostnames +} + +resource "google_apigee_envgroup_attachment" "env_to_envgroup_attachment" { + for_each = { for pair in local.env_envgroup_pairs : "${pair.envgroup}-${pair.env}" => pair } + envgroup_id = google_apigee_envgroup.apigee_envgroup[each.value.envgroup].id + environment = google_apigee_environment.apigee_env[each.value.env].name +} + +resource "google_compute_global_address" "apigee_peering_range" { + count = var.peering_range == null ? 0 : 1 + project = var.project_id + name = "${var.project_id}-apigee-peering" + purpose = "VPC_PEERING" + address_type = "INTERNAL" + address = split("/", var.peering_range)[0] + prefix_length = split("/", var.peering_range)[1] + network = var.peering_network +} + +resource "google_service_networking_connection" "apigee_vpc_connection" { + count = var.peering_network == null ? 0 : 1 + network = "projects/${var.project_id}/global/networks/${var.peering_network}" + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.apigee_peering_range.0.name] +} diff --git a/modules/apigee/outputs.tf b/modules/apigee/outputs.tf new file mode 100644 index 000000000..5db63b341 --- /dev/null +++ b/modules/apigee/outputs.tf @@ -0,0 +1,14 @@ +output "subscription_type" { + description = "Apigee subscription type." + value = google_apigee_organization.apigee_org.subscription_type +} + +output "org_ca_certificate" { + description = "Apigee organization CA certificate." + value = google_apigee_organization.apigee_org.ca_certificate +} + +output "org_id" { + description = "Apigee Organization ID." + value = google_apigee_organization.apigee_org.id +} \ No newline at end of file diff --git a/modules/apigee/variables.tf b/modules/apigee/variables.tf new file mode 100644 index 000000000..3f4cd9921 --- /dev/null +++ b/modules/apigee/variables.tf @@ -0,0 +1,79 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + description = "Project ID to host this Apigee organization (will also become the Apigee Org name)." + type = string +} + +variable "analytics_region" { + description = "Analytics Region for the Apgiee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli." + type = string + default = "us-central1" +} + +variable "display_name" { + description = "Display Name of the Apigee Organization." + type = string + default = null +} + +variable "description" { + description = "Description of the Apigee Organization." + type = string + default = "Apigee Organization created by tf module" +} + +variable "runtime_type" { + type = string + + validation { + condition = contains(["CLOUD", "HYBRID"], var.runtime_type) + error_message = "Allowed values for runtime_type \"CLOUD\" or \"HYBRID\"." + } +} + +variable "peering_network" { + description = "VPC Network used for peering Apigee (Used in Apigee X only)." + type = string + default = null + + # validation { + # condition = var.runtime_type == "CLOUD" ? var.peering_vpc != null : true + # error_message = "A peering_vpc must be provided for Apigee Organizations of runtime_type \"CLOUD\"." + # } +} + +variable "peering_range" { + description = "RFC1919 CIDR range used for peering the Apigee tennant project. Min size for trial is /22 min size for PAID is /20" + type = string + default = null +} + +variable "apigee_environments" { + description = "Apigee Environment Names." + type = list(string) + default = [] +} + +variable "apigee_envgroups" { + description = "Apigee Environment Groups." + type = map(object({ + environments = list(string) + hostnames = list(string) + })) + default = {} +} From 9c4bb0562f4d2cf9f3d2bff5d210d0dec29bb6be Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 11:57:22 +0200 Subject: [PATCH 18/42] apigee module without service networking --- .../{apigee => apigee-organization}/README.md | 8 +-- .../{apigee => apigee-organization}/main.tf | 28 ++------ modules/apigee-organization/outputs.tf | 35 ++++++++++ .../variables.tf | 66 ++++++++----------- modules/apigee-x-instance/README.md | 2 +- modules/apigee-x-instance/main.tf | 17 ++++- modules/apigee-x-instance/outputs.tf | 27 +++++++- modules/apigee-x-instance/variables.tf | 51 ++++++++++---- modules/apigee/outputs.tf | 14 ---- 9 files changed, 151 insertions(+), 97 deletions(-) rename modules/{apigee => apigee-organization}/README.md (88%) rename modules/{apigee => apigee-organization}/main.tf (64%) create mode 100644 modules/apigee-organization/outputs.tf rename modules/{apigee => apigee-organization}/variables.tf (76%) delete mode 100644 modules/apigee/outputs.tf diff --git a/modules/apigee/README.md b/modules/apigee-organization/README.md similarity index 88% rename from modules/apigee/README.md rename to modules/apigee-organization/README.md index f8be0b244..becd82aff 100644 --- a/modules/apigee/README.md +++ b/modules/apigee-organization/README.md @@ -1,6 +1,6 @@ -# Apigee Module +# Google Apigee Organization Module -This module allows managing a single Apigee organization and its environments and environmentgrous. +This module allows managing a single Apigee organization and its environments and environmentgroups. ## TODO @@ -16,7 +16,7 @@ module "apigee" { project_id = "my-project" analytics_region = "us-central1" runtime_type = "CLOUD" - peering_network = "my-vpc" + authorized_network = "my-vpc" peering_range = "10.0.0.0/16" apigee_environments = [ "eval1", @@ -34,7 +34,7 @@ module "apigee" { } } } -# tftest:modules=1:resources=10 +# tftest:modules=1:resources=6 ``` ### Apigee hybrid Evaluation Organization diff --git a/modules/apigee/main.tf b/modules/apigee-organization/main.tf similarity index 64% rename from modules/apigee/main.tf rename to modules/apigee-organization/main.tf index c1c3b6602..66eaae529 100644 --- a/modules/apigee/main.tf +++ b/modules/apigee-organization/main.tf @@ -16,10 +16,10 @@ locals { env_envgroup_pairs = flatten([ - for eg_name, eg in var.apigee_envgroups: [ + for eg_name, eg in var.apigee_envgroups : [ for e in eg.environments : { - envgroup = eg_name - env = e + envgroup = eg_name + env = e } ] ]) @@ -31,7 +31,7 @@ resource "google_apigee_organization" "apigee_org" { display_name = var.display_name description = var.description runtime_type = var.runtime_type - authorized_network = var.peering_network + authorized_network = var.authorized_network } resource "google_apigee_environment" "apigee_env" { @@ -51,22 +51,4 @@ resource "google_apigee_envgroup_attachment" "env_to_envgroup_attachment" { for_each = { for pair in local.env_envgroup_pairs : "${pair.envgroup}-${pair.env}" => pair } envgroup_id = google_apigee_envgroup.apigee_envgroup[each.value.envgroup].id environment = google_apigee_environment.apigee_env[each.value.env].name -} - -resource "google_compute_global_address" "apigee_peering_range" { - count = var.peering_range == null ? 0 : 1 - project = var.project_id - name = "${var.project_id}-apigee-peering" - purpose = "VPC_PEERING" - address_type = "INTERNAL" - address = split("/", var.peering_range)[0] - prefix_length = split("/", var.peering_range)[1] - network = var.peering_network -} - -resource "google_service_networking_connection" "apigee_vpc_connection" { - count = var.peering_network == null ? 0 : 1 - network = "projects/${var.project_id}/global/networks/${var.peering_network}" - service = "servicenetworking.googleapis.com" - reserved_peering_ranges = [google_compute_global_address.apigee_peering_range.0.name] -} +} \ No newline at end of file diff --git a/modules/apigee-organization/outputs.tf b/modules/apigee-organization/outputs.tf new file mode 100644 index 000000000..6ff012500 --- /dev/null +++ b/modules/apigee-organization/outputs.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "org" { + description = "Apigee Organization." + value = google_apigee_organization.apigee_org +} + +output "org_ca_certificate" { + description = "Apigee organization CA certificate." + value = google_apigee_organization.apigee_org.ca_certificate +} + +output "org_id" { + description = "Apigee Organization ID." + value = google_apigee_organization.apigee_org.id +} + +output "subscription_type" { + description = "Apigee subscription type." + value = google_apigee_organization.apigee_org.subscription_type +} diff --git a/modules/apigee/variables.tf b/modules/apigee-organization/variables.tf similarity index 76% rename from modules/apigee/variables.tf rename to modules/apigee-organization/variables.tf index 3f4cd9921..1bec6f1fc 100644 --- a/modules/apigee/variables.tf +++ b/modules/apigee-organization/variables.tf @@ -14,15 +14,36 @@ * limitations under the License. */ -variable "project_id" { - description = "Project ID to host this Apigee organization (will also become the Apigee Org name)." - type = string +variable "authorized_network" { + description = "VPC network id (requires service network peering enabled (Used in Apigee X only)." + type = string + default = null } variable "analytics_region" { description = "Analytics Region for the Apgiee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli." type = string - default = "us-central1" +} + +variable "apigee_envgroups" { + description = "Apigee Environment Groups." + type = map(object({ + environments = list(string) + hostnames = list(string) + })) + default = {} +} + +variable "apigee_environments" { + description = "Apigee Environment Names." + type = list(string) + default = [] +} + +variable "description" { + description = "Description of the Apigee Organization." + type = string + default = "Apigee Organization created by tf module" } variable "display_name" { @@ -31,10 +52,9 @@ variable "display_name" { default = null } -variable "description" { - description = "Description of the Apigee Organization." - type = string - default = "Apigee Organization created by tf module" +variable "project_id" { + description = "Project ID to host this Apigee organization (will also become the Apigee Org name)." + type = string } variable "runtime_type" { @@ -46,34 +66,4 @@ variable "runtime_type" { } } -variable "peering_network" { - description = "VPC Network used for peering Apigee (Used in Apigee X only)." - type = string - default = null - # validation { - # condition = var.runtime_type == "CLOUD" ? var.peering_vpc != null : true - # error_message = "A peering_vpc must be provided for Apigee Organizations of runtime_type \"CLOUD\"." - # } -} - -variable "peering_range" { - description = "RFC1919 CIDR range used for peering the Apigee tennant project. Min size for trial is /22 min size for PAID is /20" - type = string - default = null -} - -variable "apigee_environments" { - description = "Apigee Environment Names." - type = list(string) - default = [] -} - -variable "apigee_envgroups" { - description = "Apigee Environment Groups." - type = map(object({ - environments = list(string) - hostnames = list(string) - })) - default = {} -} diff --git a/modules/apigee-x-instance/README.md b/modules/apigee-x-instance/README.md index 90d29a68f..caa8577a0 100644 --- a/modules/apigee-x-instance/README.md +++ b/modules/apigee-x-instance/README.md @@ -1,4 +1,4 @@ -# Apigee Module +# Google Apigee X Instance Module This module allows managing a single Apigee X instance and its environment attachments. diff --git a/modules/apigee-x-instance/main.tf b/modules/apigee-x-instance/main.tf index 0e5f6fbd1..82497b6fc 100644 --- a/modules/apigee-x-instance/main.tf +++ b/modules/apigee-x-instance/main.tf @@ -1,3 +1,19 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + resource "google_apigee_instance" "apigee_instance" { org_id = var.apigee_org_id name = var.name @@ -6,7 +22,6 @@ resource "google_apigee_instance" "apigee_instance" { #disk_encryption_key_name = google_kms_crypto_key.apigee_key.id } - resource "google_apigee_instance_attachment" "apigee_instance_attchment" { for_each = toset(var.apigee_environments) instance_id = google_apigee_instance.apigee_instance.id diff --git a/modules/apigee-x-instance/outputs.tf b/modules/apigee-x-instance/outputs.tf index be9531336..3d754d24a 100644 --- a/modules/apigee-x-instance/outputs.tf +++ b/modules/apigee-x-instance/outputs.tf @@ -1,4 +1,25 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * limitations under the License. + * See the License for the specific language governing permissions and + */ + +output "instance" { + description = "Apigee instance." + value = google_apigee_instance.apigee_instance +} + output "endpoint" { - description = "Internal endpoint of the Apigee instance." - value = google_apigee_instance.apigee_instance.host -} \ No newline at end of file + description = "Internal endpoint of the Apigee instance." + value = google_apigee_instance.apigee_instance.host +} diff --git a/modules/apigee-x-instance/variables.tf b/modules/apigee-x-instance/variables.tf index 9bc09f1f9..88047176f 100644 --- a/modules/apigee-x-instance/variables.tf +++ b/modules/apigee-x-instance/variables.tf @@ -1,28 +1,53 @@ -variable "name" { - description = "Apigee instance name." - type = string -} +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -variable "apigee_org_id" { - description = "Apigee Organization ID" - type = string +variable "apigee_envgroups" { + description = "Apigee Environment Groups." + type = map(object({ + environments = list(string) + hostnames = list(string) + })) + default = {} } variable "apigee_environments" { description = "Apigee Environment Names." - type = list(string) - default = [] + type = list(string) + default = [] +} + +variable "apigee_org_id" { + description = "Apigee Organization ID" + type = string } variable "cidr_mask" { - description = "CIDR mask for the Apigee instance" - type = number - validation { + description = "CIDR mask for the Apigee instance" + type = number + validation { condition = contains([16, 20, 22], var.cidr_mask) - error_message = "Allowed Values for cidr_mask [16, 20, 22]." + error_message = "Invalid CIDR mask; Allowed values for cidr_mask: [16, 20, 22]." } } +variable "name" { + description = "Apigee instance name." + type = string +} + variable "region" { description = "Compute region." type = string diff --git a/modules/apigee/outputs.tf b/modules/apigee/outputs.tf deleted file mode 100644 index 5db63b341..000000000 --- a/modules/apigee/outputs.tf +++ /dev/null @@ -1,14 +0,0 @@ -output "subscription_type" { - description = "Apigee subscription type." - value = google_apigee_organization.apigee_org.subscription_type -} - -output "org_ca_certificate" { - description = "Apigee organization CA certificate." - value = google_apigee_organization.apigee_org.ca_certificate -} - -output "org_id" { - description = "Apigee Organization ID." - value = google_apigee_organization.apigee_org.id -} \ No newline at end of file From e8b01064f6f80c3b24f4e70a3b333ac50df54753 Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 13:21:57 +0200 Subject: [PATCH 19/42] Apigee add paid only variables and examples --- modules/apigee-organization/README.md | 55 +++++++++++++++++++----- modules/apigee-organization/main.tf | 13 +++--- modules/apigee-organization/variables.tf | 6 +++ modules/apigee-x-instance/README.md | 25 +++++++++-- modules/apigee-x-instance/main.tf | 10 ++--- modules/apigee-x-instance/outputs.tf | 16 +++++-- modules/apigee-x-instance/variables.tf | 6 +++ 7 files changed, 103 insertions(+), 28 deletions(-) diff --git a/modules/apigee-organization/README.md b/modules/apigee-organization/README.md index becd82aff..b4b35d453 100644 --- a/modules/apigee-organization/README.md +++ b/modules/apigee-organization/README.md @@ -2,22 +2,17 @@ This module allows managing a single Apigee organization and its environments and environmentgroups. -## TODO - -- [ ] N/A - ## Examples ### Apigee X Evaluation Organization ```hcl -module "apigee" { - source = "./modules/apigee" +module "apigee-organization" { + source = "./modules/apigee-organization" project_id = "my-project" analytics_region = "us-central1" runtime_type = "CLOUD" authorized_network = "my-vpc" - peering_range = "10.0.0.0/16" apigee_environments = [ "eval1", "eval2" @@ -37,11 +32,51 @@ module "apigee" { # tftest:modules=1:resources=6 ``` -### Apigee hybrid Evaluation Organization +### Apigee X Paid Organization ```hcl -module "apigee" { - source = "./modules/apigee" +module "apigee-organization" { + source = "./modules/apigee-organization" + project_id = "my-project" + analytics_region = "us-central1" + runtime_type = "CLOUD" + authorized_network = "my-vpc" + database_encryption_key = "my-data-key" + apigee_environments = [ + "dev1", + "dev2", + "test1", + "test2" + ] + apigee_envgroups = { + dev = { + environments = [ + "dev1", + "dev2" + ] + hostnames = [ + "dev.api.example.com" + ] + } + test = { + environments = [ + "test1", + "test2" + ] + hostnames = [ + "test.api.example.com" + ] + } + } +} +# tftest:modules=1:resources=11 +``` + +### Apigee hybrid Organization + +```hcl +module "apigee-organization" { + source = "./modules/apigee-organization" project_id = "my-project" analytics_region = "us-central1" runtime_type = "HYBRID" diff --git a/modules/apigee-organization/main.tf b/modules/apigee-organization/main.tf index 66eaae529..b1c134814 100644 --- a/modules/apigee-organization/main.tf +++ b/modules/apigee-organization/main.tf @@ -26,12 +26,13 @@ locals { } resource "google_apigee_organization" "apigee_org" { - project_id = var.project_id - analytics_region = var.analytics_region - display_name = var.display_name - description = var.description - runtime_type = var.runtime_type - authorized_network = var.authorized_network + project_id = var.project_id + analytics_region = var.analytics_region + display_name = var.display_name + description = var.description + runtime_type = var.runtime_type + authorized_network = var.authorized_network + runtime_database_encryption_key_name = var.database_encryption_key } resource "google_apigee_environment" "apigee_env" { diff --git a/modules/apigee-organization/variables.tf b/modules/apigee-organization/variables.tf index 1bec6f1fc..5e792be51 100644 --- a/modules/apigee-organization/variables.tf +++ b/modules/apigee-organization/variables.tf @@ -40,6 +40,12 @@ variable "apigee_environments" { default = [] } +variable "database_encryption_key" { + description = "Cloud KMS key name used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only)." + type = string + default = null +} + variable "description" { description = "Description of the Apigee Organization." type = string diff --git a/modules/apigee-x-instance/README.md b/modules/apigee-x-instance/README.md index caa8577a0..e407a5f61 100644 --- a/modules/apigee-x-instance/README.md +++ b/modules/apigee-x-instance/README.md @@ -2,10 +2,6 @@ This module allows managing a single Apigee X instance and its environment attachments. -## TODO - -- [ ] N/A - ## Examples ### Apigee X Evaluation Instance @@ -26,5 +22,26 @@ module "apigee-x-instance" { # tftest:modules=1:resources=3 ``` +### Apigee X Paid Instance + +```hcl +module "apigee-x-instance" { + source = "./modules/apigee-x-instance" + name = "my-us-instance" + region = "us-central1" + cidr_mask = 16 + disk_encryption_key = "my-disk-key" + + apigee_org_id = "my-project" + apigee_environments = [ + "dev1", + "dev2", + "test1", + "test2" + ] +} +# tftest:modules=1:resources=5 +``` + diff --git a/modules/apigee-x-instance/main.tf b/modules/apigee-x-instance/main.tf index 82497b6fc..9c3008283 100644 --- a/modules/apigee-x-instance/main.tf +++ b/modules/apigee-x-instance/main.tf @@ -15,11 +15,11 @@ */ resource "google_apigee_instance" "apigee_instance" { - org_id = var.apigee_org_id - name = var.name - location = var.region - peering_cidr_range = "SLASH_${var.cidr_mask}" - #disk_encryption_key_name = google_kms_crypto_key.apigee_key.id + org_id = var.apigee_org_id + name = var.name + location = var.region + peering_cidr_range = "SLASH_${var.cidr_mask}" + disk_encryption_key_name = var.disk_encryption_key } resource "google_apigee_instance_attachment" "apigee_instance_attchment" { diff --git a/modules/apigee-x-instance/outputs.tf b/modules/apigee-x-instance/outputs.tf index 3d754d24a..0f2d5d6bb 100644 --- a/modules/apigee-x-instance/outputs.tf +++ b/modules/apigee-x-instance/outputs.tf @@ -14,12 +14,22 @@ * See the License for the specific language governing permissions and */ +output "endpoint" { + description = "Internal endpoint of the Apigee instance." + value = google_apigee_instance.apigee_instance.host +} + +output "id" { + description = "Apigee instance ID." + value = google_apigee_instance.apigee_instance.id +} + output "instance" { description = "Apigee instance." value = google_apigee_instance.apigee_instance } -output "endpoint" { - description = "Internal endpoint of the Apigee instance." - value = google_apigee_instance.apigee_instance.host +output "port" { + description = "Port number of the internal endpoint of the Apigee instance." + value = google_apigee_instance.apigee_instance.port } diff --git a/modules/apigee-x-instance/variables.tf b/modules/apigee-x-instance/variables.tf index 88047176f..37d8a3f5a 100644 --- a/modules/apigee-x-instance/variables.tf +++ b/modules/apigee-x-instance/variables.tf @@ -43,6 +43,12 @@ variable "cidr_mask" { } } +variable "disk_encryption_key" { + description = "Customer Managed Encryption Key (CMEK) used for disk and volume encryption (required for PAID Apigee Orgs only)." + type = string + default = null +} + variable "name" { description = "Apigee instance name." type = string From cb6ded05284971688a1543b5adef439fa127806c Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 14:25:50 +0200 Subject: [PATCH 20/42] Adding TFDOC to Apigee modules --- modules/apigee-organization/README.md | 22 ++++++++++++++++++++++ modules/apigee-x-instance/README.md | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/modules/apigee-organization/README.md b/modules/apigee-organization/README.md index b4b35d453..669aa2b4a 100644 --- a/modules/apigee-organization/README.md +++ b/modules/apigee-organization/README.md @@ -100,4 +100,26 @@ module "apigee-organization" { ``` +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| analytics_region | Analytics Region for the Apgiee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli. | string | ✓ | | +| project_id | Project ID to host this Apigee organization (will also become the Apigee Org name). | string | ✓ | | +| runtime_type | None | string | ✓ | | +| *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | +| *apigee_environments* | Apigee Environment Names. | list(string) | | [] | +| *authorized_network* | VPC network id (requires service network peering enabled (Used in Apigee X only). | string | | null | +| *database_encryption_key* | Cloud KMS key name used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only). | string | | null | +| *description* | Description of the Apigee Organization. | string | | Apigee Organization created by tf module | +| *display_name* | Display Name of the Apigee Organization. | string | | null | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| org | Apigee Organization. | | +| org_ca_certificate | Apigee organization CA certificate. | | +| org_id | Apigee Organization ID. | | +| subscription_type | Apigee subscription type. | | diff --git a/modules/apigee-x-instance/README.md b/modules/apigee-x-instance/README.md index e407a5f61..6d162fb98 100644 --- a/modules/apigee-x-instance/README.md +++ b/modules/apigee-x-instance/README.md @@ -44,4 +44,24 @@ module "apigee-x-instance" { ``` +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| apigee_org_id | Apigee Organization ID | string | ✓ | | +| cidr_mask | CIDR mask for the Apigee instance | number | ✓ | | +| name | Apigee instance name. | string | ✓ | | +| region | Compute region. | string | ✓ | | +| *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | +| *apigee_environments* | Apigee Environment Names. | list(string) | | [] | +| *disk_encryption_key* | Customer Managed Encryption Key (CMEK) used for disk and volume encryption (required for PAID Apigee Orgs only). | string | | null | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| endpoint | Internal endpoint of the Apigee instance. | | +| id | Apigee instance ID. | | +| instance | Apigee instance. | | +| port | Port number of the internal endpoint of the Apigee instance. | | From ed6ebc0c823d91b07c3436b9fe07f8242aa1ceb8 Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 15:47:00 +0200 Subject: [PATCH 21/42] pytests for apigee modules --- tests/modules/apigee_organization/__init__.py | 13 +++++ .../apigee_organization/fixture/main.tf | 38 ++++++++++++++ .../apigee_organization/fixture/variables.tf | 25 ++++++++++ .../modules/apigee_organization/test_plan.py | 49 ++++++++++++++++++ tests/modules/apigee_x_instance/__init__.py | 13 +++++ .../modules/apigee_x_instance/fixture/main.tf | 28 +++++++++++ .../apigee_x_instance/fixture/variables.tf | 25 ++++++++++ tests/modules/apigee_x_instance/test_plan.py | 50 +++++++++++++++++++ 8 files changed, 241 insertions(+) create mode 100644 tests/modules/apigee_organization/__init__.py create mode 100644 tests/modules/apigee_organization/fixture/main.tf create mode 100644 tests/modules/apigee_organization/fixture/variables.tf create mode 100644 tests/modules/apigee_organization/test_plan.py create mode 100644 tests/modules/apigee_x_instance/__init__.py create mode 100644 tests/modules/apigee_x_instance/fixture/main.tf create mode 100644 tests/modules/apigee_x_instance/fixture/variables.tf create mode 100644 tests/modules/apigee_x_instance/test_plan.py diff --git a/tests/modules/apigee_organization/__init__.py b/tests/modules/apigee_organization/__init__.py new file mode 100644 index 000000000..d46dbae5e --- /dev/null +++ b/tests/modules/apigee_organization/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/modules/apigee_organization/fixture/main.tf b/tests/modules/apigee_organization/fixture/main.tf new file mode 100644 index 000000000..7f5aa1649 --- /dev/null +++ b/tests/modules/apigee_organization/fixture/main.tf @@ -0,0 +1,38 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "test" { + source = "../../../../modules/apigee-organization" + project_id = "my-project" + analytics_region = var.analytics_region + runtime_type = "CLOUD" + authorized_network = var.network + apigee_environments = [ + "eval1", + "eval2" + ] + apigee_envgroups = { + eval = { + environments = [ + "eval1", + "eval2" + ] + hostnames = [ + "eval.api.example.com" + ] + } + } +} diff --git a/tests/modules/apigee_organization/fixture/variables.tf b/tests/modules/apigee_organization/fixture/variables.tf new file mode 100644 index 000000000..3e9109347 --- /dev/null +++ b/tests/modules/apigee_organization/fixture/variables.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "analytics_region" { + type = string + default = "europe-west1" +} + +variable "network" { + type = string + default = "apigee-vpc" +} \ No newline at end of file diff --git a/tests/modules/apigee_organization/test_plan.py b/tests/modules/apigee_organization/test_plan.py new file mode 100644 index 000000000..680d3cab3 --- /dev/null +++ b/tests/modules/apigee_organization/test_plan.py @@ -0,0 +1,49 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import pytest + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') + + +@pytest.fixture +def resources(plan_runner): + _, resources = plan_runner(FIXTURES_DIR) + return resources + + +def test_resource_count(resources): + "Test number of resources created." + assert len(resources) == 6 + + +def test_envgroup_attachment(resources): + "Test Apigee Envgroup Attachments." + attachments = [r['values'] for r in resources if r['type'] + == 'google_apigee_envgroup_attachment'] + assert len(attachments) == 2 + assert set(a['environment'] for a in attachments) == set(['eval1', 'eval2']) + + +def test_envgroup(resources): + "Test env group." + envgroups = [r['values'] for r in resources if r['type'] + == 'google_apigee_envgroup'] + assert len(envgroups) == 1 + assert envgroups[0]['name'] == 'eval' + assert len(envgroups[0]['hostnames']) == 1 + assert envgroups[0]['hostnames'][0] == 'eval.api.example.com' diff --git a/tests/modules/apigee_x_instance/__init__.py b/tests/modules/apigee_x_instance/__init__.py new file mode 100644 index 000000000..d46dbae5e --- /dev/null +++ b/tests/modules/apigee_x_instance/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/modules/apigee_x_instance/fixture/main.tf b/tests/modules/apigee_x_instance/fixture/main.tf new file mode 100644 index 000000000..7a4b73b55 --- /dev/null +++ b/tests/modules/apigee_x_instance/fixture/main.tf @@ -0,0 +1,28 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "apigee-x-instance" { + source = "../../../../modules/apigee-x-instance" + name = var.name + region = var.region + cidr_mask = 22 + + apigee_org_id = "my-project" + apigee_environments = [ + "eval1", + "eval2" + ] +} \ No newline at end of file diff --git a/tests/modules/apigee_x_instance/fixture/variables.tf b/tests/modules/apigee_x_instance/fixture/variables.tf new file mode 100644 index 000000000..603ec5085 --- /dev/null +++ b/tests/modules/apigee_x_instance/fixture/variables.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "name" { + type = string + default = "my-test-instance" +} + +variable "region" { + type = string + default = "europe-west1" +} \ No newline at end of file diff --git a/tests/modules/apigee_x_instance/test_plan.py b/tests/modules/apigee_x_instance/test_plan.py new file mode 100644 index 000000000..4b3a9256d --- /dev/null +++ b/tests/modules/apigee_x_instance/test_plan.py @@ -0,0 +1,50 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import pytest + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') + + +@pytest.fixture +def resources(plan_runner): + _, resources = plan_runner(FIXTURES_DIR) + return resources + + +def test_resource_count(resources): + "Test number of resources created." + assert len(resources) == 3 + + +def test_instance_attachment(resources): + "Test Apigee Instance Attachments." + attachments = [r['values'] for r in resources if r['type'] + == 'google_apigee_instance_attachment'] + assert len(attachments) == 2 + assert set(a['environment'] for a in attachments) == set(['eval1', 'eval2']) + + +def test_instance(resources): + "Test Instance." + instances = [r['values'] for r in resources if r['type'] + == 'google_apigee_instance'] + assert len(instances) == 1 + assert instances[0]['peering_cidr_range'] == 'SLASH_22' + assert instances[0]['name'] == 'my-test-instance' + assert instances[0]['location'] == 'europe-west1' + From 109bd80f431846d9156a98f3457c67086ca1ad05 Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 15:53:44 +0200 Subject: [PATCH 22/42] Apigee module variable description fixes --- modules/apigee-organization/variables.tf | 6 +++--- modules/apigee-x-instance/variables.tf | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/apigee-organization/variables.tf b/modules/apigee-organization/variables.tf index 5e792be51..eff016c8c 100644 --- a/modules/apigee-organization/variables.tf +++ b/modules/apigee-organization/variables.tf @@ -15,13 +15,13 @@ */ variable "authorized_network" { - description = "VPC network id (requires service network peering enabled (Used in Apigee X only)." + description = "VPC network self link (requires service network peering enabled (Used in Apigee X only)." type = string default = null } variable "analytics_region" { - description = "Analytics Region for the Apgiee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli." + description = "Analytics Region for the Apigee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli." type = string } @@ -41,7 +41,7 @@ variable "apigee_environments" { } variable "database_encryption_key" { - description = "Cloud KMS key name used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only)." + description = "Cloud KMS key self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only)." type = string default = null } diff --git a/modules/apigee-x-instance/variables.tf b/modules/apigee-x-instance/variables.tf index 37d8a3f5a..219ee7d6a 100644 --- a/modules/apigee-x-instance/variables.tf +++ b/modules/apigee-x-instance/variables.tf @@ -44,7 +44,7 @@ variable "cidr_mask" { } variable "disk_encryption_key" { - description = "Customer Managed Encryption Key (CMEK) used for disk and volume encryption (required for PAID Apigee Orgs only)." + description = "Customer Managed Encryption Key (CMEK) self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for disk and volume encryption (required for PAID Apigee Orgs only)." type = string default = null } From a0b3f2fb7fe0df37e117a0124c15acc25e6db076 Mon Sep 17 00:00:00 2001 From: Daniel Strebel Date: Thu, 5 Aug 2021 15:55:23 +0200 Subject: [PATCH 23/42] Apigee tfdoc update --- modules/apigee-organization/README.md | 6 +++--- modules/apigee-x-instance/README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/apigee-organization/README.md b/modules/apigee-organization/README.md index 669aa2b4a..b62950f40 100644 --- a/modules/apigee-organization/README.md +++ b/modules/apigee-organization/README.md @@ -104,13 +104,13 @@ module "apigee-organization" { | name | description | type | required | default | |---|---|:---: |:---:|:---:| -| analytics_region | Analytics Region for the Apgiee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli. | string | ✓ | | +| analytics_region | Analytics Region for the Apigee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli. | string | ✓ | | | project_id | Project ID to host this Apigee organization (will also become the Apigee Org name). | string | ✓ | | | runtime_type | None | string | ✓ | | | *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | | *apigee_environments* | Apigee Environment Names. | list(string) | | [] | -| *authorized_network* | VPC network id (requires service network peering enabled (Used in Apigee X only). | string | | null | -| *database_encryption_key* | Cloud KMS key name used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only). | string | | null | +| *authorized_network* | VPC network self link (requires service network peering enabled (Used in Apigee X only). | string | | null | +| *database_encryption_key* | Cloud KMS key self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only). | string | | null | | *description* | Description of the Apigee Organization. | string | | Apigee Organization created by tf module | | *display_name* | Display Name of the Apigee Organization. | string | | null | diff --git a/modules/apigee-x-instance/README.md b/modules/apigee-x-instance/README.md index 6d162fb98..371f8f0b7 100644 --- a/modules/apigee-x-instance/README.md +++ b/modules/apigee-x-instance/README.md @@ -54,7 +54,7 @@ module "apigee-x-instance" { | region | Compute region. | string | ✓ | | | *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | | *apigee_environments* | Apigee Environment Names. | list(string) | | [] | -| *disk_encryption_key* | Customer Managed Encryption Key (CMEK) used for disk and volume encryption (required for PAID Apigee Orgs only). | string | | null | +| *disk_encryption_key* | Customer Managed Encryption Key (CMEK) self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for disk and volume encryption (required for PAID Apigee Orgs only). | string | | null | ## Outputs From f181c5164d807ac24131164eb5d0b686d6f018c4 Mon Sep 17 00:00:00 2001 From: vanessabodard-voi <63779321+vanessabodard-voi@users.noreply.github.com> Date: Thu, 5 Aug 2021 18:03:54 +0200 Subject: [PATCH 24/42] Update list ==> tolist (#293) --- modules/dns/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/dns/main.tf b/modules/dns/main.tf index 8957b970f..738794c23 100644 --- a/modules/dns/main.tf +++ b/modules/dns/main.tf @@ -115,7 +115,7 @@ resource "google_dns_managed_zone" "public" { visibility = "public" dynamic "dnssec_config" { - for_each = var.dnssec_config == {} ? [] : list(var.dnssec_config) + for_each = var.dnssec_config == {} ? [] : tolist([var.dnssec_config]) iterator = config content { kind = lookup(config.value, "kind", "dns#managedZoneDnsSecConfig") From 75418bbbd00644554dff3b1fb9ea4fc14951d33d Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Aug 2021 10:40:58 +0200 Subject: [PATCH 25/42] Compute service account email statically Generate the service account output statically based on the service account name and parent project. This allows, among other things, to use service accounts as map keys (e.g. to be used in the `iam` argument in other modules). --- modules/iam-service-account/main.tf | 6 ++++-- modules/iam-service-account/outputs.tf | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/modules/iam-service-account/main.tf b/modules/iam-service-account/main.tf index 2095ec5a1..e5f4bb74c 100644 --- a/modules/iam-service-account/main.tf +++ b/modules/iam-service-account/main.tf @@ -56,8 +56,10 @@ locals { ? google_service_account_key.key["1"] : map("", null) , {}) - prefix = var.prefix != null ? "${var.prefix}-" : "" - resource_iam_email = "serviceAccount:${local.service_account.email}" + prefix = var.prefix != null ? "${var.prefix}-" : "" + resource_email_static = "${local.prefix}${var.name}@${var.project_id}.iam.gserviceaccount.com" + resource_iam_email_static = "serviceAccount:${local.resource_email_static}" + resource_iam_email = "serviceAccount:${local.service_account.email}" service_account = ( var.service_account_create ? try(google_service_account.service_account.0, null) diff --git a/modules/iam-service-account/outputs.tf b/modules/iam-service-account/outputs.tf index 642cbb89a..9b8f1ff55 100644 --- a/modules/iam-service-account/outputs.tf +++ b/modules/iam-service-account/outputs.tf @@ -21,12 +21,18 @@ output "service_account" { output "email" { description = "Service account email." - value = local.service_account.email + value = local.resource_email_static + depends_on = [ + local.service_account + ] } output "iam_email" { description = "IAM-format service account email." - value = local.resource_iam_email + value = local.resource_iam_email_static + depends_on = [ + local.service_account + ] } output "key" { From e4645afada2e3bd155bddb60aff977b6cd459447 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Tue, 10 Aug 2021 11:32:50 +0200 Subject: [PATCH 26/42] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4f2a971..b5dc6f32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- new `apigee-organization` and `apigee-x-instance` +- generate `email` and `iam_email` statically in the `iam-service-account` module ## [5.1.0] - 2021-08-30 From 4f91e3039bbb00cc118953d6046f3aafc723b77f Mon Sep 17 00:00:00 2001 From: Noah Dungey <51202769+noahdungey@users.noreply.github.com> Date: Wed, 11 Aug 2021 15:48:38 -0600 Subject: [PATCH 27/42] Fixed typo in max_delivery_attempts variable name. Fixed type in max_delivery_attempts variable name. --- modules/pubsub/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/pubsub/variables.tf b/modules/pubsub/variables.tf index 6657d4354..a52e4e2cd 100644 --- a/modules/pubsub/variables.tf +++ b/modules/pubsub/variables.tf @@ -18,7 +18,7 @@ variable "dead_letter_configs" { description = "Per-subscription dead letter policy configuration." type = map(object({ topic = string - max_delivery_attemps = number + max_delivery_attempts = number })) default = {} } From c2e23001b7e37bb6470c9076a9708afdac25a242 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Thu, 12 Aug 2021 09:53:48 +0200 Subject: [PATCH 28/42] Fix formatting --- modules/pubsub/README.md | 2 +- modules/pubsub/variables.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/pubsub/README.md b/modules/pubsub/README.md index 938f27166..0146b53e8 100644 --- a/modules/pubsub/README.md +++ b/modules/pubsub/README.md @@ -95,7 +95,7 @@ module "pubsub" { |---|---|:---: |:---:|:---:| | name | PubSub topic name. | string | ✓ | | | project_id | Project used for resources. | string | ✓ | | -| *dead_letter_configs* | Per-subscription dead letter policy configuration. | map(object({...})) | | {} | +| *dead_letter_configs* | Per-subscription dead letter policy configuration. | map(object({...})) | | {} | | *defaults* | Subscription defaults for options. | object({...}) | | ... | | *iam* | IAM bindings for topic in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | *kms_key* | KMS customer managed encryption key. | string | | null | diff --git a/modules/pubsub/variables.tf b/modules/pubsub/variables.tf index a52e4e2cd..5dabafa21 100644 --- a/modules/pubsub/variables.tf +++ b/modules/pubsub/variables.tf @@ -17,7 +17,7 @@ variable "dead_letter_configs" { description = "Per-subscription dead letter policy configuration." type = map(object({ - topic = string + topic = string max_delivery_attempts = number })) default = {} From cb7c65135ec9ccb31441fc30fa171bc8e3336bda Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Thu, 12 Aug 2021 17:30:53 +0200 Subject: [PATCH 29/42] Update CI processes (#296) - Upgrade to latest terraform version (1.0.4) - Remove tflint from linting pipeline (was not doing anything) - Add terraform fmt check to linting pipeline - Pass all code through terraform fmt --- .ci/cloudbuild.lint.yaml | 24 ++++++++++-- .ci/cloudbuild.test.environments.yaml | 2 +- .ci/cloudbuild.test.examples.yaml | 2 +- .ci/cloudbuild.test.modules.yaml | 2 +- .../01-environment/variables.tf | 4 +- foundations/environments/locals.tf | 2 +- foundations/environments/main.tf | 4 +- modules/apigee-organization/README.md | 2 +- modules/apigee-organization/variables.tf | 28 +++++++------- modules/apigee-x-instance/main.tf | 6 +-- modules/bigtable-instance/main.tf | 2 +- modules/cloud-config-container/instance.tf | 4 +- modules/cloud-function/main.tf | 6 +-- modules/cloud-function/outputs.tf | 4 +- modules/compute-mig/main.tf | 38 +++++++++---------- modules/datafusion/outputs.tf | 2 +- modules/endpoints/outputs.tf | 2 +- modules/folders-unit/locals.tf | 2 +- modules/gcs/main.tf | 10 ++--- modules/gke-nodepool/main.tf | 12 +++--- modules/kms/main.tf | 2 +- modules/net-ilb/main.tf | 16 ++++---- modules/net-vpc/versions.tf | 4 +- modules/net-vpn-ha/main.tf | 4 +- modules/project/main.tf | 2 +- modules/pubsub/main.tf | 10 ++--- modules/secret-manager/main.tf | 6 +-- modules/vpc-sc/versions.tf | 4 +- .../outputs.tf | 2 +- .../apigee_organization/fixture/main.tf | 10 ++--- .../modules/apigee_x_instance/fixture/main.tf | 10 ++--- .../fixture/variables.tf | 6 +-- .../hub_and_spoke_peering/fixture/main.tf | 2 +- .../openshift/tf/providers.tf | 2 +- tools/check_documentation.py | 2 +- 35 files changed, 128 insertions(+), 112 deletions(-) diff --git a/.ci/cloudbuild.lint.yaml b/.ci/cloudbuild.lint.yaml index d4e88d5b2..002536cd6 100644 --- a/.ci/cloudbuild.lint.yaml +++ b/.ci/cloudbuild.lint.yaml @@ -19,13 +19,25 @@ steps: args: - -c - | - python -m pip install --user --no-warn-script-location -r /workspace/tools/REQUIREMENTS.txt + python -m pip install --user --no-warn-script-location -r /workspace/tools/REQUIREMENTS.txt && + wget https://releases.hashicorp.com/terraform/${_TERRAFORM_VERSION}/terraform_${_TERRAFORM_VERSION}_linux_amd64.zip && + unzip terraform_${_TERRAFORM_VERSION}_linux_amd64.zip -d /builder/home/.local/bin && + rm terraform_${_TERRAFORM_VERSION}_linux_amd64.zip && + chmod 755 /builder/home/.local/bin/terraform && + mkdir -p /workspace/.terraform.d/plugin-cache - name: python:3-alpine id: boilerplate args: ["/workspace/tools/check_boilerplate.py", "/workspace"] - - name: wata727/tflint - id: lint - args: ["/workspace"] + - name: python:3-alpine + id: terraform-fmt-check + entrypoint: sh + args: + - -c + - | + terraform fmt -recursive -check /workspace/ + env: + - PATH=/usr/local/bin:/usr/bin:/bin:/builder/home/.local/bin + - TF_CLI_CONFIG_FILE=/workspace/.ci/.terraformrc - name: python:3-alpine id: documentation args: @@ -37,6 +49,10 @@ steps: "foundations", "networking", ] + +substitutions: + _TERRAFORM_VERSION: 1.0.4 + tags: - ci - lint diff --git a/.ci/cloudbuild.test.environments.yaml b/.ci/cloudbuild.test.environments.yaml index 4b2bdd6a6..9c5b2a6ff 100644 --- a/.ci/cloudbuild.test.environments.yaml +++ b/.ci/cloudbuild.test.environments.yaml @@ -39,7 +39,7 @@ steps: - TF_CLI_CONFIG_FILE=/workspace/.ci/.terraformrc substitutions: - _TERRAFORM_VERSION: 0.15.4 + _TERRAFORM_VERSION: 1.0.4 tags: - "ci" diff --git a/.ci/cloudbuild.test.examples.yaml b/.ci/cloudbuild.test.examples.yaml index 4b7186e51..83c7cb946 100644 --- a/.ci/cloudbuild.test.examples.yaml +++ b/.ci/cloudbuild.test.examples.yaml @@ -40,7 +40,7 @@ options: machineType: "N1_HIGHCPU_8" substitutions: - _TERRAFORM_VERSION: 0.15.4 + _TERRAFORM_VERSION: 1.0.4 tags: - "ci" diff --git a/.ci/cloudbuild.test.modules.yaml b/.ci/cloudbuild.test.modules.yaml index ed1e6cc23..1222f2a53 100644 --- a/.ci/cloudbuild.test.modules.yaml +++ b/.ci/cloudbuild.test.modules.yaml @@ -39,7 +39,7 @@ options: machineType: "N1_HIGHCPU_8" substitutions: - _TERRAFORM_VERSION: 0.15.4 + _TERRAFORM_VERSION: 1.0.4 tags: - "ci" diff --git a/data-solutions/data-platform-foundations/01-environment/variables.tf b/data-solutions/data-platform-foundations/01-environment/variables.tf index ec945a881..92ba230d7 100644 --- a/data-solutions/data-platform-foundations/01-environment/variables.tf +++ b/data-solutions/data-platform-foundations/01-environment/variables.tf @@ -71,6 +71,6 @@ variable "service_encryption_key_ids" { variable "service_perimeter_standard" { description = "VPC Service control standard perimeter name in the form of 'accessPolicies/ACCESS_POLICY_NAME/servicePerimeters/PERIMETER_NAME'. All projects will be added to the perimeter in enforced mode." - type = string - default = null + type = string + default = null } diff --git a/foundations/environments/locals.tf b/foundations/environments/locals.tf index 841fe152f..e18fc2acc 100644 --- a/foundations/environments/locals.tf +++ b/foundations/environments/locals.tf @@ -21,7 +21,7 @@ locals { var.iam_billing_config.target_org ? [] : ["roles/billing.user"] ) sa_billing_org_role = ( - ! var.iam_billing_config.target_org ? [] : ["roles/billing.user"] + !var.iam_billing_config.target_org ? [] : ["roles/billing.user"] ) sa_xpn_folder_role = ( local.sa_xpn_target_org ? [] : ["roles/compute.xpnAdmin"] diff --git a/foundations/environments/main.tf b/foundations/environments/main.tf index 4352f2f22..7174b2f5b 100644 --- a/foundations/environments/main.tf +++ b/foundations/environments/main.tf @@ -24,7 +24,7 @@ module "tf-project" { parent = var.root_node prefix = var.prefix billing_account = var.billing_account_id - iam_additive = { + iam_additive = { "roles/owner" = var.iam_terraform_owners } services = var.project_services @@ -158,7 +158,7 @@ module "sharedsvc-project" { parent = var.root_node prefix = var.prefix billing_account = var.billing_account_id - iam_additive = { + iam_additive = { "roles/owner" = var.iam_shared_owners } services = var.project_services diff --git a/modules/apigee-organization/README.md b/modules/apigee-organization/README.md index b62950f40..b725243f4 100644 --- a/modules/apigee-organization/README.md +++ b/modules/apigee-organization/README.md @@ -107,7 +107,7 @@ module "apigee-organization" { | analytics_region | Analytics Region for the Apigee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli. | string | ✓ | | | project_id | Project ID to host this Apigee organization (will also become the Apigee Org name). | string | ✓ | | | runtime_type | None | string | ✓ | | -| *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | +| *apigee_envgroups* | Apigee Environment Groups. | map(object({...})) | | {} | | *apigee_environments* | Apigee Environment Names. | list(string) | | [] | | *authorized_network* | VPC network self link (requires service network peering enabled (Used in Apigee X only). | string | | null | | *database_encryption_key* | Cloud KMS key self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only). | string | | null | diff --git a/modules/apigee-organization/variables.tf b/modules/apigee-organization/variables.tf index eff016c8c..8978542e7 100644 --- a/modules/apigee-organization/variables.tf +++ b/modules/apigee-organization/variables.tf @@ -16,46 +16,46 @@ variable "authorized_network" { description = "VPC network self link (requires service network peering enabled (Used in Apigee X only)." - type = string - default = null + type = string + default = null } variable "analytics_region" { description = "Analytics Region for the Apigee Organization (immutable). See https://cloud.google.com/apigee/docs/api-platform/get-started/install-cli." - type = string + type = string } variable "apigee_envgroups" { description = "Apigee Environment Groups." type = map(object({ - environments = list(string) - hostnames = list(string) + environments = list(string) + hostnames = list(string) })) default = {} } variable "apigee_environments" { description = "Apigee Environment Names." - type = list(string) - default = [] + type = list(string) + default = [] } variable "database_encryption_key" { description = "Cloud KMS key self link (e.g. `projects/foo/locations/us/keyRings/bar/cryptoKeys/baz`) used for encrypting the data that is stored and replicated across runtime instances (immutable, used in Apigee X only)." - type = string - default = null + type = string + default = null } variable "description" { description = "Description of the Apigee Organization." - type = string - default = "Apigee Organization created by tf module" + type = string + default = "Apigee Organization created by tf module" } variable "display_name" { description = "Display Name of the Apigee Organization." - type = string - default = null + type = string + default = null } variable "project_id" { @@ -64,7 +64,7 @@ variable "project_id" { } variable "runtime_type" { - type = string + type = string validation { condition = contains(["CLOUD", "HYBRID"], var.runtime_type) diff --git a/modules/apigee-x-instance/main.tf b/modules/apigee-x-instance/main.tf index 9c3008283..552d6d085 100644 --- a/modules/apigee-x-instance/main.tf +++ b/modules/apigee-x-instance/main.tf @@ -23,7 +23,7 @@ resource "google_apigee_instance" "apigee_instance" { } resource "google_apigee_instance_attachment" "apigee_instance_attchment" { - for_each = toset(var.apigee_environments) - instance_id = google_apigee_instance.apigee_instance.id - environment = each.key + for_each = toset(var.apigee_environments) + instance_id = google_apigee_instance.apigee_instance.id + environment = each.key } diff --git a/modules/bigtable-instance/main.tf b/modules/bigtable-instance/main.tf index 33c4806e3..f3081fca3 100644 --- a/modules/bigtable-instance/main.tf +++ b/modules/bigtable-instance/main.tf @@ -49,7 +49,7 @@ resource "google_bigtable_table" "default" { name = each.key split_keys = each.value.split_keys - dynamic column_family { + dynamic "column_family" { for_each = each.value.column_family != null ? [""] : [] content { diff --git a/modules/cloud-config-container/instance.tf b/modules/cloud-config-container/instance.tf index a947c4f67..447becf51 100644 --- a/modules/cloud-config-container/instance.tf +++ b/modules/cloud-config-container/instance.tf @@ -59,7 +59,7 @@ resource "google_compute_instance" "default" { user-data = local.cloud_config }) - dynamic attached_disk { + dynamic "attached_disk" { for_each = var.test_instance_defaults.disks iterator = disk content { @@ -84,7 +84,7 @@ resource "google_compute_instance" "default" { network_interface { network = var.test_instance.network subnetwork = var.test_instance.subnetwork - dynamic access_config { + dynamic "access_config" { for_each = var.test_instance_defaults.nat ? [""] : [] iterator = config content { diff --git a/modules/cloud-function/main.tf b/modules/cloud-function/main.tf index 52904093c..cbef1b477 100644 --- a/modules/cloud-function/main.tf +++ b/modules/cloud-function/main.tf @@ -78,12 +78,12 @@ resource "google_cloudfunctions_function" "function" { var.vpc_connector_config.egress_settings, null ) - dynamic event_trigger { + dynamic "event_trigger" { for_each = var.trigger_config == null ? [] : [""] content { event_type = var.trigger_config.event resource = var.trigger_config.resource - dynamic failure_policy { + dynamic "failure_policy" { for_each = var.trigger_config.retry == null ? [] : [""] content { retry = var.trigger_config.retry @@ -114,7 +114,7 @@ resource "google_storage_bucket" "bucket" { ) labels = var.labels - dynamic lifecycle_rule { + dynamic "lifecycle_rule" { for_each = var.bucket_config.lifecycle_delete_age == null ? [] : [""] content { action { type = "Delete" } diff --git a/modules/cloud-function/outputs.tf b/modules/cloud-function/outputs.tf index 593a607fd..0b625b12c 100644 --- a/modules/cloud-function/outputs.tf +++ b/modules/cloud-function/outputs.tf @@ -16,7 +16,7 @@ output "bucket" { description = "Bucket resource (only if auto-created)." - value = try( + value = try( var.bucket_config == null ? null : google_storage_bucket.bucket.0, null ) } @@ -38,7 +38,7 @@ output "function_name" { output "service_account" { description = "Service account resource." - value = try(google_service_account.service_account[0], null) + value = try(google_service_account.service_account[0], null) } output "service_account_email" { diff --git a/modules/compute-mig/main.tf b/modules/compute-mig/main.tf index 968d41ed8..75ab2d3dc 100644 --- a/modules/compute-mig/main.tf +++ b/modules/compute-mig/main.tf @@ -28,7 +28,7 @@ resource "google_compute_autoscaler" "default" { min_replicas = var.autoscaler_config.min_replicas cooldown_period = var.autoscaler_config.cooldown_period - dynamic cpu_utilization { + dynamic "cpu_utilization" { for_each = ( var.autoscaler_config.cpu_utilization_target == null ? [] : [""] ) @@ -37,7 +37,7 @@ resource "google_compute_autoscaler" "default" { } } - dynamic load_balancing_utilization { + dynamic "load_balancing_utilization" { for_each = ( var.autoscaler_config.load_balancing_utilization_target == null ? [] : [""] ) @@ -46,7 +46,7 @@ resource "google_compute_autoscaler" "default" { } } - dynamic metric { + dynamic "metric" { for_each = ( var.autoscaler_config.metric == null ? [] @@ -76,7 +76,7 @@ resource "google_compute_instance_group_manager" "default" { target_size = var.target_size target_pools = var.target_pools wait_for_instances = var.wait_for_instances - dynamic auto_healing_policies { + dynamic "auto_healing_policies" { for_each = var.auto_healing_policies == null ? [] : [var.auto_healing_policies] iterator = config content { @@ -84,7 +84,7 @@ resource "google_compute_instance_group_manager" "default" { initial_delay_sec = config.value.initial_delay_sec } } - dynamic update_policy { + dynamic "update_policy" { for_each = var.update_policy == null ? [] : [var.update_policy] iterator = config content { @@ -105,7 +105,7 @@ resource "google_compute_instance_group_manager" "default" { ) } } - dynamic named_port { + dynamic "named_port" { for_each = var.named_ports == null ? {} : var.named_ports iterator = config content { @@ -117,7 +117,7 @@ resource "google_compute_instance_group_manager" "default" { instance_template = var.default_version.instance_template name = var.default_version.name } - dynamic version { + dynamic "version" { for_each = var.versions == null ? {} : var.versions iterator = version content { @@ -150,7 +150,7 @@ resource "google_compute_region_autoscaler" "default" { min_replicas = var.autoscaler_config.min_replicas cooldown_period = var.autoscaler_config.cooldown_period - dynamic cpu_utilization { + dynamic "cpu_utilization" { for_each = ( var.autoscaler_config.cpu_utilization_target == null ? [] : [""] ) @@ -159,7 +159,7 @@ resource "google_compute_region_autoscaler" "default" { } } - dynamic load_balancing_utilization { + dynamic "load_balancing_utilization" { for_each = ( var.autoscaler_config.load_balancing_utilization_target == null ? [] : [""] ) @@ -168,7 +168,7 @@ resource "google_compute_region_autoscaler" "default" { } } - dynamic metric { + dynamic "metric" { for_each = ( var.autoscaler_config.metric == null ? [] @@ -198,7 +198,7 @@ resource "google_compute_region_instance_group_manager" "default" { target_size = var.target_size target_pools = var.target_pools wait_for_instances = var.wait_for_instances - dynamic auto_healing_policies { + dynamic "auto_healing_policies" { for_each = var.auto_healing_policies == null ? [] : [var.auto_healing_policies] iterator = config content { @@ -206,7 +206,7 @@ resource "google_compute_region_instance_group_manager" "default" { initial_delay_sec = config.value.initial_delay_sec } } - dynamic update_policy { + dynamic "update_policy" { for_each = var.update_policy == null ? [] : [var.update_policy] iterator = config content { @@ -227,7 +227,7 @@ resource "google_compute_region_instance_group_manager" "default" { ) } } - dynamic named_port { + dynamic "named_port" { for_each = var.named_ports == null ? {} : var.named_ports iterator = config content { @@ -239,7 +239,7 @@ resource "google_compute_region_instance_group_manager" "default" { instance_template = var.default_version.instance_template name = var.default_version.name } - dynamic version { + dynamic "version" { for_each = var.versions == null ? {} : var.versions iterator = version content { @@ -279,7 +279,7 @@ resource "google_compute_health_check" "http" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -309,7 +309,7 @@ resource "google_compute_health_check" "https" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -338,7 +338,7 @@ resource "google_compute_health_check" "tcp" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -367,7 +367,7 @@ resource "google_compute_health_check" "ssl" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -397,7 +397,7 @@ resource "google_compute_health_check" "http2" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true diff --git a/modules/datafusion/outputs.tf b/modules/datafusion/outputs.tf index 06023210d..9c626a72c 100644 --- a/modules/datafusion/outputs.tf +++ b/modules/datafusion/outputs.tf @@ -21,7 +21,7 @@ output "id" { output "ip_allocation" { description = "IP range reserved for Data Fusion instance in case of a private instance." - value = "${local.ip_allocation}" + value = local.ip_allocation } output "resource" { diff --git a/modules/endpoints/outputs.tf b/modules/endpoints/outputs.tf index 181e15fed..7878d026a 100644 --- a/modules/endpoints/outputs.tf +++ b/modules/endpoints/outputs.tf @@ -26,5 +26,5 @@ output "endpoints_service" { output "endpoints" { description = "A list of Endpoint objects." - value = google_endpoints_service.default.endpoints + value = google_endpoints_service.default.endpoints } diff --git a/modules/folders-unit/locals.tf b/modules/folders-unit/locals.tf index c7f4e2239..a275da1cc 100644 --- a/modules/folders-unit/locals.tf +++ b/modules/folders-unit/locals.tf @@ -40,7 +40,7 @@ locals { var.iam_billing_config.target_org ? [] : ["roles/billing.user"] ) sa_billing_org_roles = ( - ! var.iam_billing_config.target_org ? [] : ["roles/billing.user"] + !var.iam_billing_config.target_org ? [] : ["roles/billing.user"] ) sa_xpn_folder_roles = ( local.sa_xpn_target_org ? [] : ["roles/compute.xpnAdmin"] diff --git a/modules/gcs/main.tf b/modules/gcs/main.tf index 59cbb1350..26fb66277 100644 --- a/modules/gcs/main.tf +++ b/modules/gcs/main.tf @@ -38,7 +38,7 @@ resource "google_storage_bucket" "bucket" { storage_class = lower(var.storage_class) }) - dynamic encryption { + dynamic "encryption" { for_each = var.encryption_key == null ? [] : [""] content { @@ -46,7 +46,7 @@ resource "google_storage_bucket" "bucket" { } } - dynamic retention_policy { + dynamic "retention_policy" { for_each = var.retention_policy == null ? [] : [""] content { retention_period = var.retention_policy.retention_period @@ -54,7 +54,7 @@ resource "google_storage_bucket" "bucket" { } } - dynamic logging { + dynamic "logging" { for_each = var.logging_config == null ? [] : [""] content { log_bucket = var.logging_config.log_bucket @@ -62,7 +62,7 @@ resource "google_storage_bucket" "bucket" { } } - dynamic cors { + dynamic "cors" { for_each = var.cors == null ? [] : [""] content { origin = var.cors.origin @@ -72,7 +72,7 @@ resource "google_storage_bucket" "bucket" { } } - dynamic lifecycle_rule { + dynamic "lifecycle_rule" { for_each = var.lifecycle_rule == null ? [] : [""] content { action { diff --git a/modules/gke-nodepool/main.tf b/modules/gke-nodepool/main.tf index 750d606c6..ad6591dd5 100644 --- a/modules/gke-nodepool/main.tf +++ b/modules/gke-nodepool/main.tf @@ -91,7 +91,7 @@ resource "google_container_node_pool" "nodepool" { tags = var.node_tags boot_disk_kms_key = var.node_boot_disk_kms_key - dynamic guest_accelerator { + dynamic "guest_accelerator" { for_each = var.node_guest_accelerator iterator = config content { @@ -100,7 +100,7 @@ resource "google_container_node_pool" "nodepool" { } } - dynamic sandbox_config { + dynamic "sandbox_config" { for_each = ( var.node_sandbox_config != null ? [var.node_sandbox_config] @@ -112,7 +112,7 @@ resource "google_container_node_pool" "nodepool" { } } - dynamic shielded_instance_config { + dynamic "shielded_instance_config" { for_each = ( var.node_shielded_instance_config != null ? [var.node_shielded_instance_config] @@ -131,7 +131,7 @@ resource "google_container_node_pool" "nodepool" { } - dynamic autoscaling { + dynamic "autoscaling" { for_each = var.autoscaling_config != null ? [var.autoscaling_config] : [] iterator = config content { @@ -140,7 +140,7 @@ resource "google_container_node_pool" "nodepool" { } } - dynamic management { + dynamic "management" { for_each = var.management_config != null ? [var.management_config] : [] iterator = config content { @@ -149,7 +149,7 @@ resource "google_container_node_pool" "nodepool" { } } - dynamic upgrade_settings { + dynamic "upgrade_settings" { for_each = var.upgrade_config != null ? [var.upgrade_config] : [] iterator = config content { diff --git a/modules/kms/main.tf b/modules/kms/main.tf index 5c773556f..62ed33c53 100644 --- a/modules/kms/main.tf +++ b/modules/kms/main.tf @@ -64,7 +64,7 @@ resource "google_kms_crypto_key" "default" { rotation_period = try(each.value.rotation_period, null) labels = try(each.value.labels, null) purpose = try(local.key_purpose[each.key].purpose, null) - dynamic version_template { + dynamic "version_template" { for_each = local.key_purpose[each.key].version_template == null ? [] : [""] content { algorithm = local.key_purpose[each.key].version_template.algorithm diff --git a/modules/net-ilb/main.tf b/modules/net-ilb/main.tf index f1382ab02..329a56983 100644 --- a/modules/net-ilb/main.tf +++ b/modules/net-ilb/main.tf @@ -67,7 +67,7 @@ resource "google_compute_region_backend_service" "default" { timeout_sec = try(var.backend_config.timeout_sec, null) connection_draining_timeout_sec = try(var.backend_config.connection_draining_timeout_sec, null) - dynamic backend { + dynamic "backend" { for_each = { for b in var.backends : b.group => b } iterator = backend content { @@ -78,7 +78,7 @@ resource "google_compute_region_backend_service" "default" { } } - dynamic failover_policy { + dynamic "failover_policy" { for_each = var.failover_config == null ? [] : [var.failover_config] iterator = config content { @@ -97,7 +97,7 @@ resource "google_compute_instance_group" "unmanaged" { name = each.key description = "Terraform-managed." instances = each.value.instances - dynamic named_port { + dynamic "named_port" { for_each = each.value.named_ports != null ? each.value.named_ports : {} iterator = config content { @@ -131,7 +131,7 @@ resource "google_compute_health_check" "http" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -163,7 +163,7 @@ resource "google_compute_health_check" "https" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -194,7 +194,7 @@ resource "google_compute_health_check" "tcp" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -225,7 +225,7 @@ resource "google_compute_health_check" "ssl" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true @@ -257,7 +257,7 @@ resource "google_compute_health_check" "http2" { response = try(var.health_check_config.check.response, null) } - dynamic log_config { + dynamic "log_config" { for_each = try(var.health_check_config.logging, false) ? [""] : [] content { enable = true diff --git a/modules/net-vpc/versions.tf b/modules/net-vpc/versions.tf index 20c35afe3..04caecd4b 100644 --- a/modules/net-vpc/versions.tf +++ b/modules/net-vpc/versions.tf @@ -16,7 +16,7 @@ terraform { required_version = ">= 0.13.0" - required_providers { - google = ">= 3.45" + required_providers { + google = ">= 3.45" } } diff --git a/modules/net-vpn-ha/main.tf b/modules/net-vpn-ha/main.tf index 9b0016195..cb2ba6877 100644 --- a/modules/net-vpn-ha/main.tf +++ b/modules/net-vpn-ha/main.tf @@ -79,7 +79,7 @@ resource "google_compute_router" "router" { : var.router_advertise_config.groups ) ) - dynamic advertised_ip_ranges { + dynamic "advertised_ip_ranges" { for_each = ( var.router_advertise_config == null ? {} : ( var.router_advertise_config.mode != "CUSTOM" @@ -122,7 +122,7 @@ resource "google_compute_router_peer" "bgp_peer" { : each.value.bgp_peer_options.advertise_groups ) ) - dynamic advertised_ip_ranges { + dynamic "advertised_ip_ranges" { for_each = ( each.value.bgp_peer_options == null ? {} : ( each.value.bgp_peer_options.advertise_mode != "CUSTOM" diff --git a/modules/project/main.tf b/modules/project/main.tf index 7e7285bbd..d52c8e87e 100644 --- a/modules/project/main.tf +++ b/modules/project/main.tf @@ -16,7 +16,7 @@ locals { descriptive_name = var.descriptive_name != null ? var.descriptive_name : "${local.prefix}${var.name}" - group_iam_roles = distinct(flatten(values(var.group_iam))) + group_iam_roles = distinct(flatten(values(var.group_iam))) group_iam = { for r in local.group_iam_roles : r => [ for k, v in var.group_iam : "group:${k}" if try(index(v, r), null) != null diff --git a/modules/pubsub/main.tf b/modules/pubsub/main.tf index d65756365..f66c73245 100644 --- a/modules/pubsub/main.tf +++ b/modules/pubsub/main.tf @@ -41,7 +41,7 @@ resource "google_pubsub_topic" "default" { kms_key_name = var.kms_key labels = var.labels - dynamic message_storage_policy { + dynamic "message_storage_policy" { for_each = length(var.regions) > 0 ? [var.regions] : [] content { allowed_persistence_regions = var.regions @@ -67,14 +67,14 @@ resource "google_pubsub_subscription" "default" { message_retention_duration = each.value.options.message_retention_duration retain_acked_messages = each.value.options.retain_acked_messages - dynamic expiration_policy { + dynamic "expiration_policy" { for_each = each.value.options.expiration_policy_ttl == null ? [] : [""] content { ttl = each.value.options.expiration_policy_ttl } } - dynamic dead_letter_policy { + dynamic "dead_letter_policy" { for_each = try(var.dead_letter_configs[each.key], null) == null ? [] : [""] content { dead_letter_topic = var.dead_letter_configs[each.key].topic @@ -82,12 +82,12 @@ resource "google_pubsub_subscription" "default" { } } - dynamic push_config { + dynamic "push_config" { for_each = try(var.push_configs[each.key], null) == null ? [] : [""] content { push_endpoint = var.push_configs[each.key].endpoint attributes = var.push_configs[each.key].attributes - dynamic oidc_token { + dynamic "oidc_token" { for_each = ( local.oidc_config[each.key] == null ? [] : [""] ) diff --git a/modules/secret-manager/main.tf b/modules/secret-manager/main.tf index 696331a81..6b6154e6e 100644 --- a/modules/secret-manager/main.tf +++ b/modules/secret-manager/main.tf @@ -42,19 +42,19 @@ resource "google_secret_manager_secret" "default" { secret_id = each.key labels = lookup(var.labels, each.key, null) - dynamic replication { + dynamic "replication" { for_each = each.value == null ? [""] : [] content { automatic = true } } - dynamic replication { + dynamic "replication" { for_each = each.value == null ? [] : [each.value] iterator = locations content { user_managed { - dynamic replicas { + dynamic "replicas" { for_each = locations.value iterator = location content { diff --git a/modules/vpc-sc/versions.tf b/modules/vpc-sc/versions.tf index 6ecbc64ee..2146648bd 100644 --- a/modules/vpc-sc/versions.tf +++ b/modules/vpc-sc/versions.tf @@ -17,6 +17,6 @@ terraform { required_version = ">= 0.12.6" required_providers { - google = ">= 3.62" - } + google = ">= 3.62" + } } diff --git a/networking/private-cloud-function-from-onprem/outputs.tf b/networking/private-cloud-function-from-onprem/outputs.tf index 2c52e5809..76a2fc680 100644 --- a/networking/private-cloud-function-from-onprem/outputs.tf +++ b/networking/private-cloud-function-from-onprem/outputs.tf @@ -16,5 +16,5 @@ output "function_url" { description = "URL of the Cloud Function." - value = module.function-hello.function.https_trigger_url + value = module.function-hello.function.https_trigger_url } \ No newline at end of file diff --git a/tests/modules/apigee_organization/fixture/main.tf b/tests/modules/apigee_organization/fixture/main.tf index 7f5aa1649..e3b60b6d9 100644 --- a/tests/modules/apigee_organization/fixture/main.tf +++ b/tests/modules/apigee_organization/fixture/main.tf @@ -15,10 +15,10 @@ */ module "test" { - source = "../../../../modules/apigee-organization" - project_id = "my-project" - analytics_region = var.analytics_region - runtime_type = "CLOUD" + source = "../../../../modules/apigee-organization" + project_id = "my-project" + analytics_region = var.analytics_region + runtime_type = "CLOUD" authorized_network = var.network apigee_environments = [ "eval1", @@ -30,7 +30,7 @@ module "test" { "eval1", "eval2" ] - hostnames = [ + hostnames = [ "eval.api.example.com" ] } diff --git a/tests/modules/apigee_x_instance/fixture/main.tf b/tests/modules/apigee_x_instance/fixture/main.tf index 7a4b73b55..9915ef207 100644 --- a/tests/modules/apigee_x_instance/fixture/main.tf +++ b/tests/modules/apigee_x_instance/fixture/main.tf @@ -15,12 +15,12 @@ */ module "apigee-x-instance" { - source = "../../../../modules/apigee-x-instance" - name = var.name - region = var.region - cidr_mask = 22 + source = "../../../../modules/apigee-x-instance" + name = var.name + region = var.region + cidr_mask = 22 - apigee_org_id = "my-project" + apigee_org_id = "my-project" apigee_environments = [ "eval1", "eval2" diff --git a/tests/modules/net_interconnect_attachment_direct/fixture/variables.tf b/tests/modules/net_interconnect_attachment_direct/fixture/variables.tf index 16353cbbb..08126b344 100644 --- a/tests/modules/net_interconnect_attachment_direct/fixture/variables.tf +++ b/tests/modules/net_interconnect_attachment_direct/fixture/variables.tf @@ -81,9 +81,9 @@ variable "router_config" { }) default = { - description = null - asn = 64514 - advertise_config = null + description = null + asn = 64514 + advertise_config = null } } diff --git a/tests/networking/hub_and_spoke_peering/fixture/main.tf b/tests/networking/hub_and_spoke_peering/fixture/main.tf index 5df89997e..899a622e1 100644 --- a/tests/networking/hub_and_spoke_peering/fixture/main.tf +++ b/tests/networking/hub_and_spoke_peering/fixture/main.tf @@ -15,7 +15,7 @@ */ module "test" { - source = "../../../../networking/hub-and-spoke-peering" + source = "../../../../networking/hub-and-spoke-peering" project_create = { billing_account = "123456-123456-123456" oslogin = true diff --git a/third-party-solutions/openshift/tf/providers.tf b/third-party-solutions/openshift/tf/providers.tf index 11735b9f7..52f428818 100644 --- a/third-party-solutions/openshift/tf/providers.tf +++ b/third-party-solutions/openshift/tf/providers.tf @@ -15,7 +15,7 @@ */ # pinning to avoid some weird issues we had with the following version - + terraform { required_providers { google = { diff --git a/tools/check_documentation.py b/tools/check_documentation.py index 5f03abf9f..667b2dd5b 100755 --- a/tools/check_documentation.py +++ b/tools/check_documentation.py @@ -31,7 +31,7 @@ class DocState(enum.Enum): UNKNOWN = 4 def __str__(self): - return {1: '✗', 2: '✓', 3: '✗', 4: '?'}[self.value] + return {1: '✗', 2: '✓', 3: '!', 4: '?'}[self.value] def check_path(pathname): From 3a8a040ff3efcec38c423d5249625ed2d87ab261 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Thu, 12 Aug 2021 19:43:09 +0200 Subject: [PATCH 30/42] Billing budget module --- modules/billing-budget/README.md | 95 +++++++++++++++++++ modules/billing-budget/main.tf | 78 +++++++++++++++ modules/billing-budget/outputs.tf | 25 +++++ modules/billing-budget/variables.tf | 85 +++++++++++++++++ modules/billing-budget/versions.tf | 23 +++++ tests/modules/billing_budget/__init__.py | 13 +++ tests/modules/billing_budget/fixture/main.tf | 29 ++++++ .../billing_budget/fixture/variables.tf | 61 ++++++++++++ tests/modules/billing_budget/test_plan.py | 63 ++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 modules/billing-budget/README.md create mode 100644 modules/billing-budget/main.tf create mode 100644 modules/billing-budget/outputs.tf create mode 100644 modules/billing-budget/variables.tf create mode 100644 modules/billing-budget/versions.tf create mode 100644 tests/modules/billing_budget/__init__.py create mode 100644 tests/modules/billing_budget/fixture/main.tf create mode 100644 tests/modules/billing_budget/fixture/variables.tf create mode 100644 tests/modules/billing_budget/test_plan.py diff --git a/modules/billing-budget/README.md b/modules/billing-budget/README.md new file mode 100644 index 000000000..4b441a6f0 --- /dev/null +++ b/modules/billing-budget/README.md @@ -0,0 +1,95 @@ +# Google Cloud Billing Budget Module + +This module allows creating a Cloud Billing budget for a set of services and projects. + +To create billing budgets you need one of the following IAM roles on the target billing account: + +* Billing Account Administrator +* Billing Account Costs Manager + +## Examples + +### Simple email notification + +Send a notification to an email when a set of projects reach $100 of spend. + +```hcl +resource "google_monitoring_notification_channel" "channel" { + display_name = "$100 spend channel" + type = "email" + project = var.project_id + labels = { + email_address = "user@example.com" + } +} + +module "budget" { + source = "./modules/billing-budget" + billing_account = var.billing_account_id + name = "$100 budget" + amount = 100 + thresholds = { + current = [0.5, 0.75, 1.0] + forecasted = [1.0] + } + projects = [ + "projects/123456789000", + "projects/123456789111" + ] + notification_channels = [ + google_monitoring_notification_channel.channel.id + ] +} +# tftest:modules=1:resources=1 +``` + +### Pubsub notification + +Send a notification to a PubSub topic the total spend of a billing account reaches the previous month's spend. + + +```hcl +module "budget" { + source = "./modules/billing-budget" + billing_account = var.billing_account_id + name = "previous period budget" + amount = 0 + thresholds = { + current = [1.0] + forecasted = [] + } + pubsub_topic = module.pubsub.id +} + +module "pubsub" { + source = "./modules/pubsub" + project_id = var.project_id + name = "budget-topic" +} + +# tftest:modules=2:resources=2 +``` + + +## Variables + +| name | description | type | required | default | +|---|---|:---: |:---:|:---:| +| billing_account | Billing account id. | string | ✓ | | +| name | Budget name. | string | ✓ | | +| thresholds | None | object({...}) | ✓ | | +| *amount* | Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend. | number | | 0 | +| *credit_treatment* | How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported | string | | ... | +| *notification_channels* | Monitoring notification channels (up to 5) where to send updates. | list(string) | | null | +| *notify_default_recipients* | Notify Billing Account Administrators and Billing Account Users IAM roles for the target account. | bool | | false | +| *projects* | List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account. | list(string) | | null | +| *pubsub_topic* | The ID of the Cloud Pub/Sub topic where budget related messages will be published. | string | | null | +| *services* | List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services. | list(string) | | null | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| budget | Budget resource. | | +| id | Budget ID. | | + diff --git a/modules/billing-budget/main.tf b/modules/billing-budget/main.tf new file mode 100644 index 000000000..6f41250ec --- /dev/null +++ b/modules/billing-budget/main.tf @@ -0,0 +1,78 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + spend_basis = { + current = "CURRENT_SPEND" + forecasted = "FORECASTED_SPEND" + } + threshold_pairs = flatten([ + for type, values in var.thresholds : [ + for value in values : { + spend_basis = local.spend_basis[type] + threshold_percent = value + } + ] + ]) +} + +resource "google_billing_budget" "budget" { + billing_account = var.billing_account + display_name = var.name + + budget_filter { + projects = var.projects + credit_types_treatment = var.credit_treatment + services = var.services + } + + dynamic "amount" { + for_each = var.amount == 0 ? [1] : [] + content { + last_period_amount = true + } + } + + dynamic "amount" { + for_each = var.amount != 0 ? [1] : [] + content { + dynamic "specified_amount" { + for_each = var.amount != 0 ? [1] : [] + content { + units = var.amount + } + } + } + } + + dynamic "threshold_rules" { + for_each = local.threshold_pairs + iterator = threshold + content { + threshold_percent = threshold.value.threshold_percent + spend_basis = threshold.value.spend_basis + } + } + + all_updates_rule { + monitoring_notification_channels = var.notification_channels + pubsub_topic = var.pubsub_topic + # disable_default_iam_recipients can only be set if + # monitoring_notification_channels is nonempty + disable_default_iam_recipients = try(length(var.notification_channels), 0) > 0 && !var.notify_default_recipients + schema_version = "1.0" + } +} diff --git a/modules/billing-budget/outputs.tf b/modules/billing-budget/outputs.tf new file mode 100644 index 000000000..9f2dd4ffb --- /dev/null +++ b/modules/billing-budget/outputs.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "budget" { + description = "Budget resource." + value = google_billing_budget.budget +} + +output "id" { + description = "Budget ID." + value = google_billing_budget.budget.id +} diff --git a/modules/billing-budget/variables.tf b/modules/billing-budget/variables.tf new file mode 100644 index 000000000..5debe5cef --- /dev/null +++ b/modules/billing-budget/variables.tf @@ -0,0 +1,85 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "amount" { + description = "Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend." + type = number + default = 0 +} + +variable "billing_account" { + description = "Billing account id." + type = string +} + +variable "credit_treatment" { + description = "How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported" + type = string + default = "INCLUDE_ALL_CREDITS" + validation { + condition = ( + var.credit_treatment == "INCLUDE_ALL_CREDITS" || + var.credit_treatment == "EXCLUDE_ALL_CREDITS" + ) + error_message = "Argument credit_treatment must be INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS." + } +} + +variable "name" { + description = "Budget name." + type = string +} + +variable "notification_channels" { + description = "Monitoring notification channels (up to 5) where to send updates." + type = list(string) + default = null +} + +variable "notify_default_recipients" { + description = "Notify Billing Account Administrators and Billing Account Users IAM roles for the target account." + type = bool + default = false +} + +variable "projects" { + description = "List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account." + type = list(string) + default = null +} + +variable "pubsub_topic" { + description = "The ID of the Cloud Pub/Sub topic where budget related messages will be published." + type = string + default = null +} + +variable "services" { + description = "List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services." + type = list(string) + default = null +} + +variable "thresholds" { + type = object({ + current = list(number) + forecasted = list(number) + }) + validation { + condition = length(var.thresholds.current) > 0 || length(var.thresholds.forecasted) > 0 + error_message = "Must specify at least one budget threshold." + } +} diff --git a/modules/billing-budget/versions.tf b/modules/billing-budget/versions.tf new file mode 100644 index 000000000..968f411ef --- /dev/null +++ b/modules/billing-budget/versions.tf @@ -0,0 +1,23 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = ">= 0.13.0" + required_providers { + google = ">= 3.79.0" + google-beta = ">= 3.79.0" + } +} diff --git a/tests/modules/billing_budget/__init__.py b/tests/modules/billing_budget/__init__.py new file mode 100644 index 000000000..d46dbae5e --- /dev/null +++ b/tests/modules/billing_budget/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/modules/billing_budget/fixture/main.tf b/tests/modules/billing_budget/fixture/main.tf new file mode 100644 index 000000000..b8fcbd295 --- /dev/null +++ b/tests/modules/billing_budget/fixture/main.tf @@ -0,0 +1,29 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "budget" { + source = "../../../../modules/billing-budget" + billing_account = "123456-123456-123456" + name = "my budget" + projects = var.projects + services = var.services + notify_default_recipients = var.notify_default_recipients + amount = var.amount + credit_treatment = var.credit_treatment + pubsub_topic = var.pubsub_topic + notification_channels = var.notification_channels + thresholds = var.thresholds +} diff --git a/tests/modules/billing_budget/fixture/variables.tf b/tests/modules/billing_budget/fixture/variables.tf new file mode 100644 index 000000000..5466b9214 --- /dev/null +++ b/tests/modules/billing_budget/fixture/variables.tf @@ -0,0 +1,61 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "amount" { + type = number + default = 0 +} + +variable "credit_treatment" { + type = string + default = "INCLUDE_ALL_CREDITS" +} + +variable "notification_channels" { + type = list(string) + default = null +} + +variable "notify_default_recipients" { + type = bool + default = false +} + +variable "projects" { + type = list(string) + default = null +} + +variable "pubsub_topic" { + type = string + default = null +} + +variable "services" { + type = list(string) + default = null +} + +variable "thresholds" { + type = object({ + current = list(number) + forecasted = list(number) + }) + default = { + current = [0.5, 1.0] + forecasted = [1.0] + } +} diff --git a/tests/modules/billing_budget/test_plan.py b/tests/modules/billing_budget/test_plan.py new file mode 100644 index 000000000..9f9bf8fb1 --- /dev/null +++ b/tests/modules/billing_budget/test_plan.py @@ -0,0 +1,63 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import pytest + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') + + +def test_resource_count(plan_runner): + "Test number of resources created." + _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic') + assert len(resources) == 1 + resource = resources[0] + assert resource['values']['all_updates_rule'] == [ + {'disable_default_iam_recipients': False, + 'monitoring_notification_channels': None, + 'pubsub_topic': 'topic', + 'schema_version': '1.0'} + ] + + _, resources = plan_runner(FIXTURES_DIR, notification_channels='["channel"]') + assert len(resources) == 1 + resource = resources[0] + assert resource['values']['all_updates_rule'] == [ + {'disable_default_iam_recipients': True, + 'monitoring_notification_channels': ['channel'], + 'pubsub_topic': None, + 'schema_version': '1.0'} + ] + + +def test_absolute_amount(plan_runner): + "Test absolute amount budget." + _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic', amount="100") + assert len(resources) == 1 + resource = resources[0] + + amount = resource['values']['amount'][0] + assert amount['last_period_amount'] is None + assert amount['specified_amount'] == [{'nanos': None, 'units': '100'}] + + assert resource['values']['threshold_rules'] == [ + {'spend_basis': 'CURRENT_SPEND', + 'threshold_percent': 0.5}, + {'spend_basis': 'CURRENT_SPEND', + 'threshold_percent': 1}, + {'spend_basis': 'FORECASTED_SPEND', + 'threshold_percent': 1} + ] From 1b80085c9becf90883df42ab9e79ff58da628edd Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Fri, 13 Aug 2021 11:56:10 +0200 Subject: [PATCH 31/42] Create email notification channels automatically --- modules/billing-budget/README.md | 21 +++++++------------ modules/billing-budget/main.tf | 19 ++++++++++++++++- modules/billing-budget/variables.tf | 11 +++++++++- tests/modules/billing_budget/fixture/main.tf | 1 + .../billing_budget/fixture/variables.tf | 8 +++++++ tests/modules/billing_budget/test_plan.py | 10 +++++++-- 6 files changed, 52 insertions(+), 18 deletions(-) diff --git a/modules/billing-budget/README.md b/modules/billing-budget/README.md index 4b441a6f0..2b1eaced9 100644 --- a/modules/billing-budget/README.md +++ b/modules/billing-budget/README.md @@ -14,15 +14,6 @@ To create billing budgets you need one of the following IAM roles on the target Send a notification to an email when a set of projects reach $100 of spend. ```hcl -resource "google_monitoring_notification_channel" "channel" { - display_name = "$100 spend channel" - type = "email" - project = var.project_id - labels = { - email_address = "user@example.com" - } -} - module "budget" { source = "./modules/billing-budget" billing_account = var.billing_account_id @@ -36,11 +27,12 @@ module "budget" { "projects/123456789000", "projects/123456789111" ] - notification_channels = [ - google_monitoring_notification_channel.channel.id - ] + email_recipients = { + project_id = "my-project" + emails = ["user@example.com"] + } } -# tftest:modules=1:resources=1 +# tftest:modules=1:resources=2 ``` ### Pubsub notification @@ -80,7 +72,8 @@ module "pubsub" { | thresholds | None | object({...}) | ✓ | | | *amount* | Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend. | number | | 0 | | *credit_treatment* | How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported | string | | ... | -| *notification_channels* | Monitoring notification channels (up to 5) where to send updates. | list(string) | | null | +| *email_recipients* | Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project. | object({...}) | | null | +| *notification_channels* | Monitoring notification channels where to send updates. | list(string) | | null | | *notify_default_recipients* | Notify Billing Account Administrators and Billing Account Users IAM roles for the target account. | bool | | false | | *projects* | List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account. | list(string) | | null | | *pubsub_topic* | The ID of the Cloud Pub/Sub topic where budget related messages will be published. | string | | null | diff --git a/modules/billing-budget/main.tf b/modules/billing-budget/main.tf index 6f41250ec..739dcedd4 100644 --- a/modules/billing-budget/main.tf +++ b/modules/billing-budget/main.tf @@ -27,8 +27,25 @@ locals { } ] ]) + + notification_channels = concat( + [for channel in google_monitoring_notification_channel.email_channels : channel.id], + coalesce(var.notification_channels, []) + ) } +resource "google_monitoring_notification_channel" "email_channels" { + for_each = toset(try(var.email_recipients.emails, [])) + display_name = "${var.name} budget email notification (${each.value})" + type = "email" + project = var.email_recipients.project_id + labels = { + email_address = each.value + } + user_labels = {} +} + + resource "google_billing_budget" "budget" { billing_account = var.billing_account display_name = var.name @@ -68,7 +85,7 @@ resource "google_billing_budget" "budget" { } all_updates_rule { - monitoring_notification_channels = var.notification_channels + monitoring_notification_channels = local.notification_channels pubsub_topic = var.pubsub_topic # disable_default_iam_recipients can only be set if # monitoring_notification_channels is nonempty diff --git a/modules/billing-budget/variables.tf b/modules/billing-budget/variables.tf index 5debe5cef..3125d37d3 100644 --- a/modules/billing-budget/variables.tf +++ b/modules/billing-budget/variables.tf @@ -38,13 +38,22 @@ variable "credit_treatment" { } } +variable "email_recipients" { + description = "Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project." + type = object({ + project_id = string + emails = list(string) + }) + default = null +} + variable "name" { description = "Budget name." type = string } variable "notification_channels" { - description = "Monitoring notification channels (up to 5) where to send updates." + description = "Monitoring notification channels where to send updates." type = list(string) default = null } diff --git a/tests/modules/billing_budget/fixture/main.tf b/tests/modules/billing_budget/fixture/main.tf index b8fcbd295..91c05e4a6 100644 --- a/tests/modules/billing_budget/fixture/main.tf +++ b/tests/modules/billing_budget/fixture/main.tf @@ -26,4 +26,5 @@ module "budget" { pubsub_topic = var.pubsub_topic notification_channels = var.notification_channels thresholds = var.thresholds + email_recipients = var.email_recipients } diff --git a/tests/modules/billing_budget/fixture/variables.tf b/tests/modules/billing_budget/fixture/variables.tf index 5466b9214..6eb8e4e39 100644 --- a/tests/modules/billing_budget/fixture/variables.tf +++ b/tests/modules/billing_budget/fixture/variables.tf @@ -24,6 +24,14 @@ variable "credit_treatment" { default = "INCLUDE_ALL_CREDITS" } +variable "email_recipients" { + type = object({ + project_id = string + emails = list(string) + }) + default = null +} + variable "notification_channels" { type = list(string) default = null diff --git a/tests/modules/billing_budget/test_plan.py b/tests/modules/billing_budget/test_plan.py index 9f9bf8fb1..5692bf0a5 100644 --- a/tests/modules/billing_budget/test_plan.py +++ b/tests/modules/billing_budget/test_plan.py @@ -20,18 +20,19 @@ import pytest FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture') -def test_resource_count(plan_runner): +def test_pubsub(plan_runner): "Test number of resources created." _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic') assert len(resources) == 1 resource = resources[0] assert resource['values']['all_updates_rule'] == [ {'disable_default_iam_recipients': False, - 'monitoring_notification_channels': None, + 'monitoring_notification_channels': [], 'pubsub_topic': 'topic', 'schema_version': '1.0'} ] +def test_channel(plan_runner): _, resources = plan_runner(FIXTURES_DIR, notification_channels='["channel"]') assert len(resources) == 1 resource = resources[0] @@ -42,6 +43,11 @@ def test_resource_count(plan_runner): 'schema_version': '1.0'} ] +def test_emails(plan_runner): + email_recipients = '{project_id = "project", emails = ["a@b.com", "c@d.com"]}' + _, resources = plan_runner(FIXTURES_DIR, email_recipients=email_recipients) + assert len(resources) == 3 + def test_absolute_amount(plan_runner): "Test absolute amount budget." From 5f97d68c0b0bea1c2f8380fa8ad930a181007837 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Fri, 13 Aug 2021 12:33:44 +0200 Subject: [PATCH 32/42] Update readmes --- CHANGELOG.md | 1 + README.md | 6 +++--- modules/README.md | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5dc6f32d..0283331fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - new `apigee-organization` and `apigee-x-instance` - generate `email` and `iam_email` statically in the `iam-service-account` module +- new `billing-budget` module ## [5.1.0] - 2021-08-30 diff --git a/README.md b/README.md index 4cb79901e..3cc9315fa 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,12 @@ The current list of modules supports most of the core foundational and networkin Currently available modules: -- **foundational** - [folder](./modules/folder), [organization](./modules/organization), [project](./modules/project), [service accounts](./modules/iam-service-account) +- **foundational** - [folder](./modules/folder), [organization](./modules/organization), [project](./modules/project), [service accounts](./modules/iam-service-account), [logging bucket](./modules/logging-bucket) - **networking** - [VPC](./modules/net-vpc), [VPC firewall](./modules/net-vpc-firewall), [VPC peering](./modules/net-vpc-peering), [VPN static](./modules/net-vpn-static), [VPN dynamic](./modules/net-vpn-dynamic), [VPN HA](./modules/net-vpn-ha), [NAT](./modules/net-cloudnat), [address reservation](./modules/net-address), [DNS](./modules/dns), [L4 ILB](./modules/net-ilb), [Service Directory](./modules/service-directory), [Cloud Endpoints](./modules/cloudenpoints) - **compute** - [VM/VM group](./modules/compute-vm), [MIG](./modules/compute-mig), [GKE cluster](./modules/gke-cluster), [GKE nodepool](./modules/gke-nodepool), [COS container](./modules/cos-container) (coredns, mysql, onprem, squid) - **data** - [GCS](./modules/gcs), [BigQuery dataset](./modules/bigquery-dataset), [Pub/Sub](./modules/pubsub), [Datafusion](./modules/datafusion), [Bigtable instance](./modules/bigtable-instance) -- **development** - [Cloud Source Repository](./modules/source-repository), [Container Registry](./modules/container-registry), [Artifact Registry](./modules/artifact-registry) +- **development** - [Cloud Source Repository](./modules/source-repository), [Container Registry](./modules/container-registry), [Artifact Registry](./modules/artifact-registry), [Apigee Organization](./modules/apigee-organization), [Apigee X Instance](./modules/apigee-x-instance) - **security** - [KMS](./modules/kms), [SecretManager](./modules/secret-manager), [VPC Service Control](./modules/vpc-sc) -- **serverless** - [Cloud Functions](./modules/cloud-function) +- **serverless** - [Cloud Function](./modules/cloud-function) For more information and usage examples see each module's README file. diff --git a/modules/README.md b/modules/README.md index a86196f51..7b229e255 100644 --- a/modules/README.md +++ b/modules/README.md @@ -14,6 +14,7 @@ Specific modules also offer support for non-authoritative bindings (e.g. `google - [organization](./organization) - [project](./project) - [service account](./iam-service-account) +- [logging bucket](./logging-bucket) ## Networking modules @@ -52,6 +53,8 @@ Specific modules also offer support for non-authoritative bindings (e.g. `google - [Artifact Registry](./artifact-registry) - [Container Registry](./container-registry) - [Source Repository](./source-repository) +- [Apigee Organization](./apigee-organization) +- [Apigee X Insntace](./apigee-x-instance) ## Security From 658eb3b297d7e5a19cc54fa900b0c54eaa3daa65 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Fri, 13 Aug 2021 12:35:12 +0200 Subject: [PATCH 33/42] Fix typo --- modules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/README.md b/modules/README.md index 7b229e255..2e12ef3ae 100644 --- a/modules/README.md +++ b/modules/README.md @@ -54,7 +54,7 @@ Specific modules also offer support for non-authoritative bindings (e.g. `google - [Container Registry](./container-registry) - [Source Repository](./source-repository) - [Apigee Organization](./apigee-organization) -- [Apigee X Insntace](./apigee-x-instance) +- [Apigee X Instance](./apigee-x-instance) ## Security From bc95150bde370bb2f41b754bf107dfd2acddba75 Mon Sep 17 00:00:00 2001 From: lcaggio Date: Wed, 18 Aug 2021 17:41:28 +0200 Subject: [PATCH 34/42] Update README (#298) Fix missing '-' in gcloud command. --- networking/onprem-google-access-dns/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/networking/onprem-google-access-dns/README.md b/networking/onprem-google-access-dns/README.md index 3e8c19bd3..df679fae7 100644 --- a/networking/onprem-google-access-dns/README.md +++ b/networking/onprem-google-access-dns/README.md @@ -33,7 +33,7 @@ The Cloud DNS inbound policy reserves an IP address in the VPC, which is used by Run this gcloud command to (find out the address assigned to the inbound forwarder)[https://cloud.google.com/dns/docs/policies#list-in-entrypoints]: ```bash -gcloud compute addresses list -project [your project id] +gcloud compute addresses list --project [your project id] ``` In the list of addresses, look for the address with purpose `DNS_RESOLVER` in the subnet `to-onprem-default`. If its IP address is `10.0.0.2` it matches the default value in the Terraform `forwarder_address` variable, which means you're all set. If it's different, proceed to the next step. From 7b01f3dc08644b0a1e922918a0a7c9c0282b72b4 Mon Sep 17 00:00:00 2001 From: sruffilli Date: Wed, 25 Aug 2021 16:54:05 +0200 Subject: [PATCH 35/42] Updated README.md for net-vpc module (#299) * Updated README.md for net-vpc module Added a recommendation to avoid setting up shared vpcs using the module for prod usage. * Update README.md Co-authored-by: Ludovico Magnocavallo --- modules/net-vpc/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/net-vpc/README.md b/modules/net-vpc/README.md index fb6a6a5d4..6be63a8fc 100644 --- a/modules/net-vpc/README.md +++ b/modules/net-vpc/README.md @@ -74,6 +74,8 @@ module "vpc-spoke-1" { ### Shared VPC +[Shared VPC](https://cloud.google.com/vpc/docs/shared-vpc) is a project-level functionality which enables a project to share its VPCs with other projects. The `shared_vpc_host` variable is here to help with rapid prototyping, we recommend leveraging the project module for production usage. + ```hcl locals { service_project_1 = { From b2d42511e5259f289f32fa2ff921bb3402369e48 Mon Sep 17 00:00:00 2001 From: lcaggio Date: Wed, 1 Sep 2021 11:17:06 +0200 Subject: [PATCH 36/42] Fix example --- .../scheduled-asset-inventory-export-bq/main.tf | 11 +++++++++-- .../scheduled-asset-inventory-export-bq/variables.tf | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cloud-operations/scheduled-asset-inventory-export-bq/main.tf b/cloud-operations/scheduled-asset-inventory-export-bq/main.tf index c11b2c1b4..b79d8c4f4 100644 --- a/cloud-operations/scheduled-asset-inventory-export-bq/main.tf +++ b/cloud-operations/scheduled-asset-inventory-export-bq/main.tf @@ -22,7 +22,7 @@ module "project" { source = "../../modules/project" name = var.project_id parent = var.root_node - billing_account = var.billing_account + billing_account = try(var.billing_account, null) project_create = var.project_create services = [ "bigquery.googleapis.com", @@ -33,6 +33,11 @@ module "project" { "cloudscheduler.googleapis.com", "pubsub.googleapis.com" ] + iam = { + "roles/resourcemanager.projectIamAdmin" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] + "roles/bigquery.dataEditor" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] + "roles/bigquery.user" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] + } } module "service-account" { @@ -40,7 +45,9 @@ module "service-account" { project_id = module.project.project_id name = "${var.name}-cf" iam_project_roles = { - (var.project_id) = ["roles/cloudasset.viewer"] + (var.project_id) = [ + "roles/cloudasset.owner", + ] } } diff --git a/cloud-operations/scheduled-asset-inventory-export-bq/variables.tf b/cloud-operations/scheduled-asset-inventory-export-bq/variables.tf index ab89f77e0..6f8217d33 100644 --- a/cloud-operations/scheduled-asset-inventory-export-bq/variables.tf +++ b/cloud-operations/scheduled-asset-inventory-export-bq/variables.tf @@ -17,6 +17,7 @@ variable "billing_account" { description = "Billing account id used as default for new projects." type = string + default = null } variable "bundle_path" { From 8a177b395fdd312351545848a56e278075eac47e Mon Sep 17 00:00:00 2001 From: lcaggio Date: Wed, 1 Sep 2021 11:25:45 +0200 Subject: [PATCH 37/42] Update changelog and fix tests. --- .../scheduled_asset_inventory_export_bq/test_plan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cloud_operations/scheduled_asset_inventory_export_bq/test_plan.py b/tests/cloud_operations/scheduled_asset_inventory_export_bq/test_plan.py index a80a3ac8d..de94c82d5 100644 --- a/tests/cloud_operations/scheduled_asset_inventory_export_bq/test_plan.py +++ b/tests/cloud_operations/scheduled_asset_inventory_export_bq/test_plan.py @@ -24,4 +24,4 @@ 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) == 5 - assert len(resources) == 20 + assert len(resources) == 23 From abe602934a6ce375efe91d5376420c11feae8862 Mon Sep 17 00:00:00 2001 From: lcaggio Date: Wed, 1 Sep 2021 11:26:39 +0200 Subject: [PATCH 38/42] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0283331fb..eb976dff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - new `apigee-organization` and `apigee-x-instance` - generate `email` and `iam_email` statically in the `iam-service-account` module - new `billing-budget` module +- fix `scheduled-asset-inventory-export-bq` module ## [5.1.0] - 2021-08-30 From 2fb8decab3ecb235ca0a8b2a28480cfd0999e716 Mon Sep 17 00:00:00 2001 From: lcaggio Date: Thu, 2 Sep 2021 09:38:33 +0200 Subject: [PATCH 39/42] Fix lint --- .../scheduled-asset-inventory-export-bq/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud-operations/scheduled-asset-inventory-export-bq/main.tf b/cloud-operations/scheduled-asset-inventory-export-bq/main.tf index b79d8c4f4..005c0fe38 100644 --- a/cloud-operations/scheduled-asset-inventory-export-bq/main.tf +++ b/cloud-operations/scheduled-asset-inventory-export-bq/main.tf @@ -34,9 +34,9 @@ module "project" { "pubsub.googleapis.com" ] iam = { - "roles/resourcemanager.projectIamAdmin" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] - "roles/bigquery.dataEditor" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] - "roles/bigquery.user" = [ "serviceAccount:${module.project.service_accounts.robots.cloudasset}" ] + "roles/resourcemanager.projectIamAdmin" = ["serviceAccount:${module.project.service_accounts.robots.cloudasset}"] + "roles/bigquery.dataEditor" = ["serviceAccount:${module.project.service_accounts.robots.cloudasset}"] + "roles/bigquery.user" = ["serviceAccount:${module.project.service_accounts.robots.cloudasset}"] } } From 7327b0214a9613be3f14559114a682a33b13ddae Mon Sep 17 00:00:00 2001 From: lcaggio Date: Thu, 2 Sep 2021 09:45:51 +0200 Subject: [PATCH 40/42] Fix documentation. --- cloud-operations/scheduled-asset-inventory-export-bq/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-operations/scheduled-asset-inventory-export-bq/README.md b/cloud-operations/scheduled-asset-inventory-export-bq/README.md index 44c6ecbb7..1abecdd94 100644 --- a/cloud-operations/scheduled-asset-inventory-export-bq/README.md +++ b/cloud-operations/scheduled-asset-inventory-export-bq/README.md @@ -43,9 +43,9 @@ You can also create a dashboard connecting [Datalab](https://datastudio.google.c | name | description | type | required | default | |---|---|:---: |:---:|:---:| -| billing_account | Billing account id used as default for new projects. | string | ✓ | | | cai_config | Cloud Asset inventory export config. | object({...}) | ✓ | | | project_id | Project id that references existing project. | string | ✓ | | +| *billing_account* | Billing account id used as default for new projects. | string | | null | | *bundle_path* | Path used to write the intermediate Cloud Function code bundle. | string | | ./bundle.zip | | *location* | Appe Engine location used in the example. | string | | europe-west | | *name* | Arbitrary string used to name created resources. | string | | asset-inventory | From ff254facd4bc232ed162c8786547082bf351cd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Taneli=20Lepp=C3=A4?= Date: Wed, 8 Sep 2021 12:49:52 +0200 Subject: [PATCH 41/42] Added decentralized firewall rule validator. --- .../validator/Dockerfile | 29 ++ .../validator/README.md | 80 ++++++ .../validator/action.yml | 44 +++ .../validator/firewallSchema.yaml | 32 +++ .../validator/firewallSchemaAutoApprove.yaml | 42 +++ .../validator/firewallSchemaSettings.yaml | 49 ++++ .../validator/requirements.txt | 16 ++ .../validator/validator.py | 261 ++++++++++++++++++ 8 files changed, 553 insertions(+) create mode 100644 networking/decentralized-firewall/validator/Dockerfile create mode 100644 networking/decentralized-firewall/validator/README.md create mode 100644 networking/decentralized-firewall/validator/action.yml create mode 100644 networking/decentralized-firewall/validator/firewallSchema.yaml create mode 100644 networking/decentralized-firewall/validator/firewallSchemaAutoApprove.yaml create mode 100644 networking/decentralized-firewall/validator/firewallSchemaSettings.yaml create mode 100644 networking/decentralized-firewall/validator/requirements.txt create mode 100644 networking/decentralized-firewall/validator/validator.py diff --git a/networking/decentralized-firewall/validator/Dockerfile b/networking/decentralized-firewall/validator/Dockerfile new file mode 100644 index 000000000..be4b22b47 --- /dev/null +++ b/networking/decentralized-firewall/validator/Dockerfile @@ -0,0 +1,29 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. +FROM python:3.9-slim + +RUN mkdir /validator +COPY requirements.txt /validator/requirements.txt +RUN pip install -r /validator/requirements.txt +COPY validator.py /validator/validator.py + +RUN mkdir /schemas +COPY firewallSchema.yaml /schemas/firewallSchema.yaml +COPY firewallSchemaAutoApprove.yaml /schemas/firewallAutoApprove.yaml +COPY firewallSchemaSettings.yaml /schemas/firewallSchemaSettings.yaml + +RUN mkdir /rules + +CMD ["/rules/**/*.yaml"] +ENTRYPOINT ["python3", "/validator/validator.py"] \ No newline at end of file diff --git a/networking/decentralized-firewall/validator/README.md b/networking/decentralized-firewall/validator/README.md new file mode 100644 index 000000000..fd5880370 --- /dev/null +++ b/networking/decentralized-firewall/validator/README.md @@ -0,0 +1,80 @@ +# Decentralized firewall validator + +The decentralized firewall validator is a Python scripts that utilizes [Yamale](https://github.com/23andMe/Yamale) schema +validation library to validate the configured firewall rules. + +## Configuring schemas + +There are three configuration files: +- [firewallSchema.yaml](firewallSchema.yaml), where the basic validation schema is configured +- [firewallSchemaAutoApprove.yaml](firewallSchemaAutoApprove.yaml), where the a different schema for auto-approval + can be configured (in case more validation is required than what is available in the schema settings) +- [firewallSchemaSettings.yaml](firewallSchemaSettings.yaml), configures list of allowed and approved + source and destination ranges, ports, network tags and service accounts. + +## Building the container + +You can build the container like this: + +```sh +docker build -t eu.gcr.io/YOUR-PROJECT/firewall-validator:latest . +docker push eu.gcr.io/YOUR-PROJECT/firewall-validator:latest +``` + +## Running the validator + +Example: + +```sh +docker run -v $(pwd)/firewall:/rules/ -t eu.gcr.io/YOUR-PROJECT/firewall-validator:latest +``` + +Output is JSON with keys `ok` and `errors` (if any were found). + +## Using as a GitHub action + +An `action.yml` is provided for this validator to be used as a GitHub action. + +Example of being used in a pipeline: + +```yaml + - uses: actions/checkout@v2 + + - name: Get changed files + if: ${{ github.event_name == 'pull_request' }} + id: changed-files + uses: tj-actions/changed-files@v1.1.2 + + - uses: ./.github/actions/validate-firewall + if: ${{ github.event_name == 'pull_request' }} + id: validation + with: + files: ${{ steps.changed-files.outputs.all_modified_files }} + + - uses: actions/github-script@v3 + if: ${{ github.event_name == 'pull_request' && steps.validation.outputs.ok != 'true' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + var comments = []; + var errors = JSON.parse(process.env.ERRORS); + for (const filename in errors) { + var fn = filename.replace('/github/workspace/', ''); + comments.push({ + path: fn, + body: "```\n" + errors[filename].join("\n") + "\n```\n", + position: 1, + }); + } + github.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + event: "REQUEST_CHANGES", + body: "Firewall rule validation failed.", + comments: comments, + }); + core.setFailed("Firewall validation failed"); + env: + ERRORS: '${{ steps.validation.outputs.errors }}' +``` diff --git a/networking/decentralized-firewall/validator/action.yml b/networking/decentralized-firewall/validator/action.yml new file mode 100644 index 000000000..d6e6177c5 --- /dev/null +++ b/networking/decentralized-firewall/validator/action.yml @@ -0,0 +1,44 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. +# +name: 'Validate firewall rules' +description: 'Validate firewall rule YAML files' +inputs: + files: + description: 'Files to scan (supports wildcards)' + required: false + default: '/github/workspace/firewall/**/*.yaml' + mode: + description: 'Mode (validate or approve)' + required: false + default: 'validate' + schema: + description: 'Schema' + required: false + default: '/schemas/firewallSchema.yaml' +outputs: + ok: + description: 'Validation successful' + errors: + description: 'Validation results' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.files }} + - "--mode" + - ${{ inputs.mode }} + - "--schema" + - ${{ inputs.schema }} + - "--github" \ No newline at end of file diff --git a/networking/decentralized-firewall/validator/firewallSchema.yaml b/networking/decentralized-firewall/validator/firewallSchema.yaml new file mode 100644 index 000000000..697d982a4 --- /dev/null +++ b/networking/decentralized-firewall/validator/firewallSchema.yaml @@ -0,0 +1,32 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +map(include('rule'), key=str(min=3, max=30)) +--- +rule: + disabled: bool(required=False) + deny: list(include('trafficSpec'), required=False) + allow: list(include('trafficSpec'), required=False) + direction: enum('ingress', 'INGRESS', 'egress', 'EGRESS') + priority: int(min=1, max=65535, required=False) + destination_ranges: list(netmask(type='destination'), max=256, required=False) + source_ranges: list(netmask(type='source'), max=256, required=False) + source_tags: list(networktag(), max=30, required=False) + target_tags: list(networktag(), max=70, required=False) + source_service_accounts: list(serviceaccount(), max=10, required=False) + target_service_account: list(serviceaccount(), max=10, required=False) +--- +trafficSpec: + ports: list(networkports()) + protocol: enum('all', 'tcp', 'udp', 'icmp', 'esp', 'ah', 'ipip', 'sctp') diff --git a/networking/decentralized-firewall/validator/firewallSchemaAutoApprove.yaml b/networking/decentralized-firewall/validator/firewallSchemaAutoApprove.yaml new file mode 100644 index 000000000..a5a425f3b --- /dev/null +++ b/networking/decentralized-firewall/validator/firewallSchemaAutoApprove.yaml @@ -0,0 +1,42 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. +map(include('ingress'), include('egress'), key=str(min=3, max=30)) +--- +ingress: + disabled: bool(required=False) + deny: list(include('trafficSpec'), required=False) + allow: list(include('trafficSpec'), required=False) + direction: enum('ingress', 'INGRESS') + priority: int(min=1, max=65535, required=False) + source_ranges: list(netmask(type='source'), max=256, required=False) + source_tags: list(networktag(), max=30, required=False) + target_tags: list(networktag(), max=70, required=False) + source_service_accounts: list(serviceaccount(), max=10, required=False) + target_service_account: list(serviceaccount(), max=10, required=False) +--- +egress: + disabled: bool(required=False) + deny: list(include('trafficSpec'), required=False) + allow: list(include('trafficSpec'), required=False) + direction: enum('egress', 'EGRESS') + priority: int(min=1, max=65535, required=False) + destination_ranges: list(netmask(type='destination'), max=256, required=False) + source_tags: list(networktag(), max=30, required=False) + target_tags: list(networktag(), max=70, required=False) + source_service_accounts: list(serviceaccount(), max=10, required=False) + target_service_account: list(serviceaccount(), max=10, required=False) +--- +trafficSpec: + ports: list() + protocol: enum('all', 'tcp', 'udp', 'icmp', 'esp', 'ah', 'ipip', 'sctp') diff --git a/networking/decentralized-firewall/validator/firewallSchemaSettings.yaml b/networking/decentralized-firewall/validator/firewallSchemaSettings.yaml new file mode 100644 index 000000000..77c5ec65f --- /dev/null +++ b/networking/decentralized-firewall/validator/firewallSchemaSettings.yaml @@ -0,0 +1,49 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +allowedPorts: +- ports: 22 # SSH + approved: false +- ports: 80 # HTTP + approved: true +- ports: 443 # HTTPS + approved: true +- ports: 3306 # MySQL + approved: false +- ports: 8000-8999 + approved: true + +allowedSourceRanges: +- cidr: 10.0.0.0/8 # Example on-premise range + approved: true +- cidr: 35.191.0.0/16 # Load balancing & health checks + approved: true +- cidr: 130.211.0.0/22 # Load balancing & health checks + approved: false +- cidr: 35.235.240.0/20 # IAP source range + approved: true + +allowedDestinationRanges: +- cidr: 10.0.0.0/8 + approved: true +- cidr: 0.0.0.0/0 + approved: false + +allowedNetworkTags: +- tag: '*' + approved: true + +allowedServiceAccounts: +- serviceAccount: '*' + approved: true \ No newline at end of file diff --git a/networking/decentralized-firewall/validator/requirements.txt b/networking/decentralized-firewall/validator/requirements.txt new file mode 100644 index 000000000..05fa91c40 --- /dev/null +++ b/networking/decentralized-firewall/validator/requirements.txt @@ -0,0 +1,16 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. +yamale~=3.0.0 +PyYAML~=5.4.0 +click~=7.1.0 \ No newline at end of file diff --git a/networking/decentralized-firewall/validator/validator.py b/networking/decentralized-firewall/validator/validator.py new file mode 100644 index 000000000..ca625882f --- /dev/null +++ b/networking/decentralized-firewall/validator/validator.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. + +import glob +import ipaddress +import json +import sys + +import click +import yaml +import yamale + +from fnmatch import fnmatch +from types import SimpleNamespace +from yamale.validators import DefaultValidators, Validator + + +class Netmask(Validator): + """ Custom netmask validator """ + tag = 'netmask' + settings = {} + mode = None + _type = None + + def __init__(self, *args, **kwargs): + self._type = kwargs.pop('type', 'source-or-dest') + super().__init__(*args, **kwargs) + + def fail(self, value): + dir_str = 'source or destination' + mode_str = 'allowed' + if self._type == 'source': + dir_str = 'source' + elif self._type == 'destination': + dir_str = 'destination' + if self.mode == 'approve': + mode_str = 'automatically approved' + return '\'%s\' is not an %s %s network.' % (value, mode_str, dir_str) + + def _is_valid(self, value): + is_ok = False + network = ipaddress.ip_network(value) + if self._type == 'source' or self._type == 'source-or-dest': + for ip_range in self.settings['allowedSourceRanges']: + allowed_network = ipaddress.ip_network(ip_range['cidr']) + if network.subnet_of(allowed_network): + if self.mode != 'approve' or ip_range['approved']: + is_ok = True + break + if self._type == 'destination' or self._type == 'source-or-dest': + for ip_range in self.settings['allowedDestinationRanges']: + allowed_network = ipaddress.ip_network(ip_range['cidr']) + if network.subnet_of(allowed_network): + if self.mode != 'approve' or ip_range['approved']: + is_ok = True + break + + return is_ok + + +class NetworkTag(Validator): + """ Custom network tag validator """ + tag = 'networktag' + settings = {} + mode = None + _type = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def fail(self, value): + mode_str = 'allowed' + if self.mode == 'approve': + mode_str = 'automatically approved' + return '\'%s\' is not an %s network tag.' % (value, mode_str) + + def _is_valid(self, value): + is_ok = False + for tag in self.settings['allowedNetworkTags']: + if fnmatch(value, tag['tag']): + if self.mode != 'approve' or tag['approved']: + is_ok = True + break + return is_ok + + +class ServiceAccount(Validator): + """ Custom service account validator """ + tag = 'serviceaccount' + settings = {} + mode = None + _type = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def fail(self, value): + mode_str = 'allowed' + if self.mode == 'approve': + mode_str = 'automatically approved' + return '\'%s\' is not an %s service account.' % (value, mode_str) + + def _is_valid(self, value): + is_ok = False + for sa in self.settings['allowedServiceAccounts']: + if fnmatch(value, sa['serviceAccount']): + if self.mode != 'approve' or sa['approved']: + is_ok = True + break + return is_ok + + +class NetworkPorts(Validator): + """ Custom ports validator """ + tag = 'networkports' + settings = {} + mode = None + _type = None + allowed_port_map = [] + approved_port_map = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for port in self.settings['allowedPorts']: + ports = self._process_port_definition(port['ports']) + self.allowed_port_map.extend(ports) + if port['approved']: + self.approved_port_map.extend(ports) + + def _process_port_definition(self, port_definition): + ports = [] + if not isinstance(port_definition, int) and '-' in port_definition: + start, end = port_definition.split('-', 2) + for port in range(int(start), int(end) + 1): + ports.append(int(port)) + else: + ports.append(int(port_definition)) + return ports + + def fail(self, value): + mode_str = 'allowed' + if self.mode == 'approve': + mode_str = 'automatically approved' + return '\'%s\' is not an %s IP port.' % (value, mode_str) + + def _is_valid(self, value): + ports = self._process_port_definition(value) + is_ok = True + for port in ports: + if self.mode == 'approve' and port not in self.approved_port_map: + is_ok = False + break + elif port not in self.allowed_port_map: + is_ok = False + break + + return is_ok + + +class FirewallValidator: + schema = None + settings = None + validators = None + + def __init__(self, settings, mode): + self.settings = settings + + self.validators = DefaultValidators.copy() + Netmask.settings = self.settings + Netmask.mode = mode + self.validators[Netmask.tag] = Netmask + + NetworkTag.settings = self.settings + NetworkTag.mode = mode + self.validators[NetworkTag.tag] = NetworkTag + + ServiceAccount.settings = self.settings + ServiceAccount.mode = mode + self.validators[ServiceAccount.tag] = ServiceAccount + + NetworkPorts.settings = self.settings + NetworkPorts.mode = mode + self.validators[NetworkPorts.tag] = NetworkPorts + + def set_schema_from_file(self, schema): + self.schema = yamale.make_schema(path=schema, validators=self.validators) + + def set_schema_from_string(self, schema): + self.schema = yamale.make_schema(content=schema, validators=self.validators) + + def validate_file(self, file): + print('Validating %s...' % (file), file=sys.stderr) + data = yamale.make_data(file) + yamale.validate(self.schema, data) + + +@click.command() +@click.argument('files') +@click.option('--schema', + default='/schemas/firewallSchema.yaml', + help='YAML schema file') +@click.option('--settings', + default='/schemas/firewallSchemaSettings.yaml', + help='schema configuration file') +@click.option('--mode', + default='validate', + help='select mode (validate or approve)') +@click.option('--github', + is_flag=True, + default=False, + help='output GitHub action compatible variables') +def main(**kwargs): + args = SimpleNamespace(**kwargs) + files = [args.files] + if '*' in args.files: + files = glob.glob(args.files, recursive=True) + + print('Arguments: %s' % (str(sys.argv)), file=sys.stderr) + + f = open(args.settings) + settings = yaml.load(f, Loader=yaml.SafeLoader) + + firewall_validator = FirewallValidator(settings, args.mode) + firewall_validator.set_schema_from_file(args.schema) + output = {'ok': True, 'errors': {}} + for file in files: + try: + firewall_validator.validate_file(file) + except yamale.yamale_error.YamaleError as e: + if file not in output['errors']: + output['errors'][file] = [] + output['ok'] = False + for result in e.results: + for err in result.errors: + output['errors'][file].append(err) + + if args.github: + print('::set-output name=ok::%s' % ('true' if output['ok'] else 'false')) + print('::set-output name=errors::%s' % (json.dumps(output['errors']))) + print(json.dumps(output), file=sys.stderr) + else: + print(json.dumps(output)) + if not output['ok'] and not args.github: + sys.exit(1) + + +if __name__ == '__main__': + main() From a1152003c60481bf6b13695e0adc1209c8f69906 Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Mon, 13 Sep 2021 17:34:20 +0200 Subject: [PATCH 42/42] Output role information from organization module --- CHANGELOG.md | 2 ++ modules/organization/README.md | 19 +++++++++++++++++++ modules/organization/outputs.tf | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb976dff9..c8bbd4e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + - new `apigee-organization` and `apigee-x-instance` - generate `email` and `iam_email` statically in the `iam-service-account` module - new `billing-budget` module - fix `scheduled-asset-inventory-export-bq` module +- output custom role information from the `organization` module ## [5.1.0] - 2021-08-30 diff --git a/modules/organization/README.md b/modules/organization/README.md index c912c517f..94e9ff25a 100644 --- a/modules/organization/README.md +++ b/modules/organization/README.md @@ -151,6 +151,23 @@ module "org" { # tftest:modules=5:resources=11 ``` +## Custom Roles +```hcl +module "org" { + source = "./modules/organization" + organization_id = var.organization_id + custom_roles = { + "myRole" = [ + "compute.instances.list", + ] + } + iam = { + (module.org.custom_role_id.myRole) = ["user:me@example.com"] + } +} +# tftest:modules=1:resources=2 +``` + ## Variables @@ -177,6 +194,8 @@ module "org" { | name | description | sensitive | |---|---|:---:| +| custom_role_id | Map of custom role IDs created in the organization. | | +| custom_roles | Map of custom roles resources created in the organization. | | | firewall_policies | Map of firewall policy resources created in the organization. | | | firewall_policy_id | Map of firewall policy ids created in the organization. | | | organization_id | Organization id dependent on module resources. | | diff --git a/modules/organization/outputs.tf b/modules/organization/outputs.tf index 1dd51ee8b..456befb66 100644 --- a/modules/organization/outputs.tf +++ b/modules/organization/outputs.tf @@ -50,3 +50,21 @@ output "sink_writer_identities" { for name, sink in google_logging_organization_sink.sink : name => sink.writer_identity } } + +output "custom_roles" { + description = "Map of custom roles resources created in the organization." + value = google_organization_iam_custom_role.roles +} + +output "custom_role_id" { + description = "Map of custom role IDs created in the organization." + value = { + for role_id, role in google_organization_iam_custom_role.roles : + # build the string manually so that role IDs can be used as map + # keys (useful for folder/organization/project-level iam bindings) + (role_id) => "${var.organization_id}/roles/${role_id}" + } + depends_on = [ + google_organization_iam_custom_role.roles + ] +}