diff --git a/modules/net-lb-app-int-cross-region/README.md b/modules/net-lb-app-int-cross-region/README.md index 003861e63b..e49fa2bd28 100644 --- a/modules/net-lb-app-int-cross-region/README.md +++ b/modules/net-lb-app-int-cross-region/README.md @@ -19,6 +19,7 @@ Due to the complexity of the underlying resources, changes to the configuration - [Private Service Connect NEG creation](#private-service-connect-neg-creation) - [URL Map](#url-map) - [Complex example](#complex-example) +- [Recipes](#recipes) - [Files](#files) - [Variables](#variables) - [Outputs](#outputs) @@ -721,6 +722,10 @@ module "ilb-l7" { +## Recipes + +- [Cross-region internal Application Load Balancer with VM instance group backends](https://github.com/GoogleCloudPlatform/cloud-foundation-fabric/blob/master/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns) + ## Files | name | description | resources | diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/README.md b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/README.md new file mode 100644 index 0000000000..1bdd5a9a63 --- /dev/null +++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/README.md @@ -0,0 +1,175 @@ +# Cross-region internal Application Load Balancer with VM instance group backends + +This recipe shows an actual usage scenario for the [cross-region internal application load balancer](../README.md) by implementing the [example provided in the GCP documentation](https://cloud.google.com/load-balancing/docs/l7-internal/setting-up-l7-cross-reg-internal). + +
+ +
+ + +- [Prerequisites](#prerequisites) + - [Proxy-only global subnets](#proxy-only-global-subnets) + - [Firewall rules](#firewall-rules) +- [Variable configuration](#variable-configuration) + - [VPC configuration options](#vpc-configuration-options) + - [Instance configuration options](#instance-configuration-options) + - [DNS configuration](#dns-configuration) +- [Testing](#testing) +- [Files](#files) +- [Variables](#variables) +- [Outputs](#outputs) + + +## Prerequisites + +To run this recipe, you need + +- an existing GCP project with the `compute` API enabled +- the `roles/compute.admin` role or equivalent (e.g. `roles/editor`) assigned on the project +- an existing VPC in the same project +- one regular subnet per region where you want to deploy the load balancer in the same VPC +- an organization policy configuration that allows creation of internal application load balancer (the default configuration is fine) +- access to the Docker Registry from the instances (e.g. via Cloud NAT) + +### Proxy-only global subnets + +The load balancer needs one proxy-only global subnet in each of its regions. If the subnets already exist the load balancer will consume them. If you need to create them, either do that manually or configure the module to do it for you as explained in the [Variable configuration](#variable-configuration) section below. + +### Firewall rules + +For the load balancer to work you need to allow ingress to the instances from the health check ranges, and from the load balancer proxy ranges. You can create firewall rules manually or configure the module to do it for you as explained in the [Variable configuration](#variable-configuration) section below. + +## Variable configuration + +With all the requirements in place, the only variables that are needed are those that configure the project and VPC details. Note that you need to use ids or self links in the VPC configuration not names (Shared VPC configurations are also supported). + +This is a simple minimal configuration: + +```tfvars +project_id = "my-project" +vpc_config = { + network = "projects/my-project/global/networks/test" + subnets = { + europe-west1 = "projects/my-project/regions/europe-west1/subnetworks/default" + europe-west8 = "projects/my-project/regions/europe-west8/subnetworks/default", + } +} +# tftest modules=5 resources=15 +``` + +### VPC configuration options + +The VPC configuration also allows creating instances in different subnets, and auto-creation of proxy subnets and firewall rules. This is a complete configuration with all options. + +```tfvars +project_id = "my-project" +vpc_config = { + network = "projects/my-project/global/networks/test" + subnets = { + europe-west1 = "projects/my-project/regions/europe-west1/subnetworks/default" + europe-west8 = "projects/my-project/regions/europe-west8/subnetworks/default", + } + # only specify this to use different subnets for instances + subnets_instances = { + europe-west1 = "projects/my-project/regions/europe-west1/subnetworks/vms" + europe-west8 = "projects/my-project/regions/europe-west8/subnetworks/vms", + } + # create proxy subnets + proxy_subnets_config = { + europe-west1 = "172.16.193.0/24" + europe-west8 = "172.16.192.0/24" + } + # create firewall rules + firewall_config = { + proxy_subnet_ranges = [ + "172.16.193.0/24", + "172.16.192.0/24" + ] + enable_health_check = true + enable_iap_ssh = true + } +} +# tftest skip +``` + +### Instance configuration options + +The instance type and the number of zones can be configured via the `instances_config` variable: + +```tfvars +project_id = "my-project" +vpc_config = { + network = "projects/my-project/global/networks/test" + subnets = { + europe-west1 = "projects/my-project/regions/europe-west1/subnetworks/default" + europe-west8 = "projects/my-project/regions/europe-west8/subnetworks/default", + } + instances_config = { + # both attributes are optional + machine_type = "e2-small" + zones = ["b", "c"] + } +} +# tftest modules=5 resources=15 +``` + +### DNS configuration + +The DNS zone used for the load balancer record can be configured via the `dns_config` variable: + +```tfvars +project_id = "my-project" +vpc_config = { + network = "projects/my-project/global/networks/test" + subnets = { + europe-west1 = "projects/my-project/regions/europe-west1/subnetworks/default" + europe-west8 = "projects/my-project/regions/europe-west8/subnetworks/default", + } + dns_config = { + # all attributes are optional + client_networks = [ + "projects/my-project/global/networks/test", + "projects/my-other-project/global/networks/test" + ] + domain = "foo.example." + hostname = "lb-test" + } +} +# tftest modules=5 resources=15 +``` + +## Testing + +To test the load balancer behaviour, you can simply disable the service on the backend instances by connecting via SSH and issuing the `sudo systemctl stop nginx` command. + +If the backends are unhealthy and the necessary firewall rules are in place, check that the Docker containers have started successfully on the instances by connecting via SSH and issuing the `sudo systemctl status nginx` command. + + + +## Files + +| name | description | modules | +|---|---|---| +| [instances.tf](./instances.tf) | Instance-related locals and resources. |compute-vm
· iam-service-account
|
+| [main.tf](./main.tf) | Load balancer and VPC resources. | dns
· net-lb-app-int-cross-region
· net-vpc
· net-vpc-firewall
|
+| [outputs.tf](./outputs.tf) | Module outputs. | |
+| [region_shortnames.tf](./region_shortnames.tf) | Region shortnames via locals. | |
+| [variables.tf](./variables.tf) | Module variables. | |
+
+## Variables
+
+| name | description | type | required | default |
+|---|---|:---:|:---:|:---:|
+| [project_id](variables.tf#L49) | Project used to create resources. | string
| ✓ | |
+| [vpc_config](variables.tf#L55) | VPC configuration for load balancer and instances. Subnets are keyed by region. | object({…})
| ✓ | |
+| [dns_config](variables.tf#L17) | DNS configuration. | object({…})
| | {}
|
+| [instances_config](variables.tf#L28) | Configuration for instances. | object({…})
| | {}
|
+| [prefix](variables.tf#L42) | Prefix used for resource names. | string
| | "lb-xr-00"
|
+
+## Outputs
+
+| name | description | sensitive |
+|---|---|:---:|
+| [instances](outputs.tf#L17) | Instances details. | |
+| [lb](outputs.tf#L34) | Load balancer details. | |
+
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/instances.tf b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/instances.tf
new file mode 100644
index 0000000000..df1f794798
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/instances.tf
@@ -0,0 +1,77 @@
+/**
+ * Copyright 2024 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.
+ */
+
+# tfdoc:file:description Instance-related locals and resources.
+
+locals {
+ # use lb subnets for instances if instance subnets are not defined
+ subnets_instances = coalesce(
+ var.vpc_config.subnets_instances, var.vpc_config.subnets
+ )
+ # derive instance names/attributes from permutation of regions and zones
+ instances = {
+ for t in setproduct(local.regions, var.instances_config.zones) :
+ "${var.prefix}-${local.region_shortnames[t.0]}-${t.1}" => {
+ region = t.0
+ zone = "${t.0}-${t.1}"
+ }
+ }
+}
+
+module "instance-sa" {
+ source = "../../iam-service-account"
+ project_id = var.project_id
+ name = "vm-default"
+ prefix = var.prefix
+ display_name = "Cross-region LB instances service account"
+ iam_project_roles = {
+ (var.project_id) = [
+ "roles/logging.logWriter",
+ "roles/monitoring.metricWriter"
+ ]
+ }
+}
+
+module "instances" {
+ source = "../../compute-vm"
+ for_each = local.instances
+ project_id = var.project_id
+ zone = each.value.zone
+ name = each.key
+ instance_type = var.instances_config.machine_type
+ boot_disk = {
+ initialize_params = {
+ image = "projects/cos-cloud/global/images/family/cos-stable"
+ }
+ }
+ network_interfaces = [{
+ network = var.vpc_config.network
+ subnetwork = local.subnets_instances[each.value.region]
+ }]
+ tags = [var.prefix]
+ metadata = {
+ user-data = file("${path.module}/nginx-cloud-config.yaml")
+ }
+ service_account = {
+ email = module.instance-sa.email
+ }
+ group = {
+ named_ports = {
+ http = 80
+ https = 443
+ }
+ }
+}
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/main.tf b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/main.tf
new file mode 100644
index 0000000000..237eb606bc
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/main.tf
@@ -0,0 +1,134 @@
+/**
+ * Copyright 2024 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.
+ */
+
+# tfdoc:file:description Load balancer and VPC resources.
+
+locals {
+ # define regions for both instances and lb for easy access
+ regions = keys(var.vpc_config.subnets)
+}
+
+module "vpc" {
+ source = "../../net-vpc"
+ count = var.vpc_config.proxy_subnets_config == null ? 0 : 1
+ project_id = regex("projects/([^/]+)/", var.vpc_config.network)[0]
+ name = regex("global/networks/([^/]+)$", var.vpc_config.network)[0]
+ vpc_create = false
+ subnets_proxy_only = [
+ for k, v in var.vpc_config.proxy_subnets_config : {
+ ip_cidr_range = v
+ name = "${var.prefix}-proxy-${local.region_shortnames[k]}"
+ region = k
+ active = true
+ global = true
+ }
+ ]
+}
+
+module "load-balancer" {
+ source = "../../net-lb-app-int-cross-region"
+ name = var.prefix
+ project_id = var.project_id
+ backend_service_configs = {
+ default = {
+ port_name = "http"
+ backends = [
+ for k, v in module.instances : { group = v.group.id }
+ ]
+ }
+ }
+ vpc_config = {
+ network = var.vpc_config.network
+ subnetworks = var.vpc_config.subnets
+ }
+ depends_on = [module.vpc]
+}
+
+module "dns" {
+ source = "../../dns"
+ project_id = var.project_id
+ name = var.prefix
+ zone_config = {
+ domain = var.dns_config.domain
+ private = {
+ client_networks = (
+ var.dns_config.client_networks != null
+ ? var.dns_config.client_networks
+ : [var.vpc_config.network]
+ )
+ }
+ }
+ recordsets = {
+ "A ${coalesce(var.dns_config.hostname, var.prefix)}" = {
+ geo_routing = [
+ for k in local.regions : {
+ location = k
+ health_checked_targets = [
+ {
+ load_balancer_type = "globalL7ilb"
+ ip_address = module.load-balancer.addresses[k]
+ port = "80"
+ ip_protocol = "tcp"
+ network_url = var.vpc_config.network
+ project = var.project_id
+ }
+ ] }
+ ]
+ }
+ }
+}
+
+module "firewall" {
+ source = "../../net-vpc-firewall"
+ count = var.vpc_config.firewall_config == null ? 0 : 1
+ project_id = var.project_id
+ network = var.vpc_config.network
+ default_rules_config = { disabled = true }
+ ingress_rules = merge(
+ {
+ "ingress-${var.prefix}-proxies" = {
+ description = "Allow load balancer proxy traffic to instances."
+ source_ranges = var.vpc_config.firewall_config.proxy_subnet_ranges
+ targets = [var.prefix]
+ rules = [{ protocol = "tcp", ports = [80, 443] }]
+ }
+ },
+ var.vpc_config.firewall_config.client_allowed_ranges == null ? {} : {
+ "ingress-${var.prefix}-http-clients" = {
+ description = "Allow client HTTP traffic to instances."
+ source_ranges = var.vpc_config.firewall_config.client_allowed_ranges
+ targets = [var.prefix]
+ rules = [{ protocol = "tcp", ports = [80, 443] }]
+ }
+ },
+ var.vpc_config.firewall_config.enable_health_check != true ? {} : {
+ "ingress-${var.prefix}-health-checks" = {
+ description = "Allow health check traffic to instances."
+ source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
+ targets = [var.prefix]
+ rules = [{ protocol = "tcp", ports = [80, 443] }]
+ }
+ },
+ var.vpc_config.firewall_config.enable_iap_ssh != true ? {} : {
+ "ingress-${var.prefix}-iap-ssh" = {
+ description = "Allow SSH traffic to instances via IAP."
+ source_ranges = ["35.235.240.0/20"]
+ targets = [var.prefix]
+ rules = [{ protocol = "tcp", ports = [22] }]
+ }
+ }
+ )
+}
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/nginx-cloud-config.yaml b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/nginx-cloud-config.yaml
new file mode 100644
index 0000000000..7817eb606b
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/nginx-cloud-config.yaml
@@ -0,0 +1,79 @@
+#cloud-config
+
+# Copyright 2022 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.
+
+# https://hub.docker.com/r/nginx/nginx/
+# https://nginx.io/manual/toc/#installation
+
+users:
+ - name: nginx
+ uid: 2000
+
+write_files:
+ - path: /var/lib/docker/daemon.json
+ permissions: 0644
+ owner: root
+ content: |
+ {
+ "live-restore": true,
+ "storage-driver": "overlay2",
+ "log-opts": {
+ "max-size": "1024m"
+ }
+ }
+
+ - path: /etc/nginx/nginx.conf
+ permissions: 0644
+ owner: root
+ content: |
+ server {
+ listen 80;
+ listen 8080;
+ listen [::]:80;
+ listen [::]:8080;
+
+ location / {
+ default_type text/plain;
+ expires -1;
+ return 200 'Server address: $server_addr:$server_port\nRemote address: $remote_addr\nServer name: $hostname\nDate: $time_local\nURI: $request_uri\nRequest ID: $request_id\n';
+ }
+ }
+
+ # nginx container service
+ - path: /etc/systemd/system/nginx.service
+ permissions: 0644
+ owner: root
+ content: |
+ [Unit]
+ Description=Start nginx container
+ After=gcr-online.target docker.socket
+ Wants=gcr-online.target docker.socket docker-events-collector.service
+ [Service]
+ Environment="HOME=/home/nginx"
+ ExecStartPre=/usr/bin/docker-credential-gcr configure-docker
+ ExecStart=/usr/bin/docker run --rm --name=nginx \
+ --network host \
+ -v /etc/nginx:/etc/nginx/conf.d \
+ nginxdemos/hello:plain-text
+ ExecStop=/usr/bin/docker stop nginx
+
+bootcmd:
+ - systemctl start node-problem-detector
+
+runcmd:
+ - iptables -I INPUT 1 -p tcp -m tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT
+ - iptables -I INPUT 1 -p tcp -m tcp --dport 8080 -m state --state NEW,ESTABLISHED -j ACCEPT
+ - systemctl daemon-reload
+ - systemctl start nginx
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/outputs.tf b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/outputs.tf
new file mode 100644
index 0000000000..f0f70880a8
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/outputs.tf
@@ -0,0 +1,39 @@
+/**
+ * Copyright 2024 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 "instances" {
+ description = "Instances details."
+ value = {
+ addresses = {
+ for k, v in module.instances : k => v.internal_ip
+ }
+ commands = {
+ for k, v in module.instances : k => (
+ "gssh ${nonsensitive(v.instance.name)} --project ${var.project_id} --zone ${nonsensitive(v.instance.zone)}"
+ )
+ }
+ groups = {
+ for k, v in module.instances : k => v.group.id
+ }
+ }
+}
+
+output "lb" {
+ description = "Load balancer details."
+ value = {
+ addresses = module.load-balancer.addresses
+ }
+}
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/region_shortnames.tf b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/region_shortnames.tf
new file mode 100644
index 0000000000..0d7e4ad112
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/region_shortnames.tf
@@ -0,0 +1,43 @@
+/**
+ * Copyright 2024 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.
+ */
+
+# tfdoc:file:description Region shortnames via locals.
+
+# adapted from FAST networking stages
+
+locals {
+ # used when the first character would not work
+ _region_cardinal = {
+ southeast = "se"
+ }
+ _region_geo = {
+ australia = "o"
+ }
+ # split in [geo, cardinal, number] tokens
+ _region_tokens = {
+ for v in local.regions : v => regexall("(?:[a-z]+)|(?:[0-9]+)", v)
+ }
+ region_shortnames = {
+ for k, v in local._region_tokens : k => join("", [
+ # first token via geo alias map or first character
+ lookup(local._region_geo, v.0, substr(v.0, 0, 1)),
+ # first token via cardinal alias map or first character
+ lookup(local._region_cardinal, v.1, substr(v.1, 0, 1)),
+ # region number as is
+ v.2
+ ])
+ }
+}
diff --git a/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/variables.tf b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/variables.tf
new file mode 100644
index 0000000000..e9cef8d68b
--- /dev/null
+++ b/modules/net-lb-app-int-cross-region/recipe-cross-reg-int-app-lb-vm-dns/variables.tf
@@ -0,0 +1,103 @@
+/**
+ * Copyright 2024 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 "dns_config" {
+ description = "DNS configuration."
+ type = object({
+ client_networks = optional(list(string))
+ domain = optional(string, "gce.example.")
+ hostname = optional(string)
+ })
+ nullable = false
+ default = {}
+}
+
+variable "instances_config" {
+ description = "Configuration for instances."
+ type = object({
+ machine_type = optional(string, "e2-micro")
+ zones = optional(list(string), ["b"])
+ })
+ nullable = false
+ default = {}
+ validation {
+ condition = length(var.instances_config.zones) > 0
+ error_message = "At least one zone is required for instances."
+ }
+}
+
+variable "prefix" {
+ description = "Prefix used for resource names."
+ type = string
+ nullable = false
+ default = "lb-xr-00"
+}
+
+variable "project_id" {
+ description = "Project used to create resources."
+ type = string
+ nullable = false
+}
+
+variable "vpc_config" {
+ description = "VPC configuration for load balancer and instances. Subnets are keyed by region."
+ type = object({
+ network = string
+ subnets = map(string)
+ subnets_instances = optional(map(string))
+ firewall_config = optional(object({
+ proxy_subnet_ranges = list(string)
+ client_allowed_ranges = optional(list(string))
+ enable_health_check = optional(bool, true)
+ enable_iap_ssh = optional(bool, false)
+ }))
+ proxy_subnets_config = optional(map(string))
+ })
+ nullable = false
+ validation {
+ condition = try(regex("/", var.vpc_config.network), null) != null
+ error_message = "Network must be a network id or self link, not a name."
+ }
+ validation {
+ condition = alltrue([
+ for k, v in var.vpc_config.subnets : try(regex("/", v), null) != null
+ ])
+ error_message = "Subnet values must be ids or self links, not names."
+ }
+ validation {
+ condition = (
+ var.vpc_config.subnets_instances == null
+ ||
+ keys(var.vpc_config.subnets) == keys(coalesce(var.vpc_config.subnets_instances, {}))
+ )
+ error_message = "Instance subnet regions must match load balancer regions if defined."
+ }
+ validation {
+ condition = (
+ var.vpc_config.proxy_subnets_config == null
+ ||
+ keys(var.vpc_config.subnets) == keys(coalesce(var.vpc_config.proxy_subnets_config, {}))
+ )
+ error_message = "Proxy subnet regions must match load balancer regions if defined."
+ }
+ validation {
+ condition = alltrue([
+ for k, v in coalesce(var.vpc_config.subnets_instances, {}) :
+ try(regex("/", v), null) != null
+ ])
+ error_message = "Instance subnet values must be ids or self links, not names."
+ }
+}
diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py
index bc6834249a..5833f9c6a9 100644
--- a/tests/examples/conftest.py
+++ b/tests/examples/conftest.py
@@ -1,4 +1,4 @@
-# Copyright 2023 Google LLC
+# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -25,7 +25,8 @@
FILE_TEST_RE = re.compile(r'# tftest-file +id=([\w_.-]+) +path=([\S]+)')
FIXTURE_TEST_RE = re.compile(r'# tftest-fixture +id=([\w_.-]+)')
-Example = collections.namedtuple('Example', 'name code module files fixtures')
+Example = collections.namedtuple('Example',
+ 'name code module files fixtures type')
File = collections.namedtuple('File', 'path content')
@@ -74,9 +75,11 @@ def pytest_generate_tests(metafunc, test_group='example',
index += 1
code = child.children[0].children
tftest_tag = get_tftest_directive(code)
+ if tftest_tag is None:
+ continue
if tftest_tag and not filter_tests(tftest_tag):
continue
- if child.lang == 'hcl':
+ if child.lang in ('hcl', 'tfvars'):
path = module.relative_to(FABRIC_ROOT)
name = f'{path}:{last_header}'
if index > 1:
@@ -88,7 +91,8 @@ def pytest_generate_tests(metafunc, test_group='example',
# see: https://pytest-xdist.readthedocs.io/en/latest/distribution.html
marks = [pytest.mark.xdist_group("serial")
] if 'serial' in tftest_tag else []
- example = Example(name, code, path, files[last_header], fixtures)
+ example = Example(name, code, path, files[last_header], fixtures,
+ child.lang)
examples.append(pytest.param(example, marks=marks))
elif isinstance(child, marko.block.Heading):
last_header = child.children[0].children
diff --git a/tests/examples/test_plan.py b/tests/examples/test_plan.py
index 345d155094..f618d7c549 100644
--- a/tests/examples/test_plan.py
+++ b/tests/examples/test_plan.py
@@ -1,4 +1,4 @@
-# Copyright 2023 Google LLC
+# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,6 +15,8 @@
import re
import subprocess
import yaml
+import shutil
+import tempfile
from pathlib import Path
BASE_PATH = Path(__file__).parent
@@ -47,49 +49,59 @@ def prepare_files(example, test_path, files, fixtures):
destination.write_text(example.fixtures[f])
-def test_example(plan_validator, tmp_path, example):
+def test_example(plan_validator, example):
if match := COUNT_TEST_RE.search(example.code):
- (tmp_path / 'fabric').symlink_to(BASE_PATH.parents[1])
- (tmp_path / 'variables.tf').symlink_to(BASE_PATH / 'variables.tf')
- (tmp_path / 'main.tf').write_text(example.code)
- assets_path = BASE_PATH.parent / str(example.module).replace('-',
- '_') / 'assets'
- if assets_path.exists():
- (tmp_path / 'assets').symlink_to(assets_path)
+ # for tfvars-based tests, create the temporary directory with the
+ # same parent as the original module
+ directory = example.module.parent if example.type == 'tfvars' else None
+ prefix = f'pytest-{example.module.name}'
+ with tempfile.TemporaryDirectory(prefix=prefix, dir=directory) as tmp_path:
+ tmp_path = Path(tmp_path)
+ tf_var_files = []
+ if example.type == 'hcl':
+ (tmp_path / 'fabric').symlink_to(BASE_PATH.parents[1])
+ (tmp_path / 'variables.tf').symlink_to(BASE_PATH / 'variables.tf')
+ (tmp_path / 'main.tf').write_text(example.code)
+ assets_path = (BASE_PATH.parent /
+ str(example.module).replace('-', '_') / 'assets')
+ if assets_path.exists():
+ (tmp_path / 'assets').symlink_to(assets_path)
- expected_modules = int(match.group('modules'))
- expected_resources = int(match.group('resources'))
+ prepare_files(example, tmp_path, match.group('files'),
+ match.group('fixtures'))
+ elif example.type == 'tfvars':
+ (tmp_path / 'terraform.auto.tfvars').write_text(example.code)
+ shutil.copytree(example.module, tmp_path, dirs_exist_ok=True)
+ tf_var_files = [(tmp_path / 'terraform.auto.tfvars').resolve()]
- prepare_files(example, tmp_path, match.group('files'),
- match.group('fixtures'))
+ expected_modules = int(match.group('modules'))
+ expected_resources = int(match.group('resources'))
- inventory = []
- if match.group('inventory') is not None:
- python_test_path = str(example.module).replace('-', '_')
- inventory = BASE_PATH.parent / python_test_path / 'examples'
- inventory = inventory / match.group('inventory')
+ inventory = []
+ if match.group('inventory') is not None:
+ python_test_path = str(example.module).replace('-', '_')
+ inventory = BASE_PATH.parent / python_test_path / 'examples'
+ inventory = inventory / match.group('inventory')
- # TODO: force plan_validator to never copy files (we're already
- # running from a temp dir)
- summary = plan_validator(module_path=tmp_path, inventory_paths=inventory,
- tf_var_files=[])
+ summary = plan_validator(module_path=tmp_path, inventory_paths=inventory,
+ tf_var_files=tf_var_files)
- print('\n')
- print(yaml.dump({'values': summary.values}))
- print(yaml.dump({'counts': summary.counts}))
- print(yaml.dump({'outputs': summary.outputs}))
+ print('\n')
+ print(yaml.dump({'values': summary.values}))
+ print(yaml.dump({'counts': summary.counts}))
+ print(yaml.dump({'outputs': summary.outputs}))
- counts = summary.counts
- num_modules, num_resources = counts['modules'], counts['resources']
- assert expected_modules == num_modules, 'wrong number of modules'
- assert expected_resources == num_resources, 'wrong number of resources'
+ counts = summary.counts
+ num_modules, num_resources = counts['modules'], counts['resources']
+ assert expected_modules == num_modules, 'wrong number of modules'
+ assert expected_resources == num_resources, 'wrong number of resources'
- # TODO(jccb): this should probably be done in check_documentation
- # but we already have all the data here.
- result = subprocess.run(
- 'terraform fmt -check -diff -no-color main.tf'.split(), cwd=tmp_path,
- stdout=subprocess.PIPE, encoding='utf-8')
- assert result.returncode == 0, f'terraform code not formatted correctly\n{result.stdout}'
+ # TODO(jccb): this should probably be done in check_documentation
+ # but we already have all the data here.
+ result = subprocess.run(
+ 'terraform fmt -check -diff -no-color main.tf'.split(), cwd=tmp_path,
+ stdout=subprocess.PIPE, encoding='utf-8')
+ assert result.returncode == 0, f'terraform code not formatted correctly\n{result.stdout}'
else:
assert False, "can't find tftest directive"
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 93b853c3f0..50b6d8ad6a 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -1,4 +1,4 @@
-# Copyright 2023 Google LLC
+# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -34,10 +34,9 @@
def _prepare_root_module(path):
"""Context manager to prepare a terraform module to be tested.
- If the TFTEST_COPY environment variable is set, `path` is copied to
- a temporary directory and a few terraform files (e.g.
- terraform.tfvars) are deleted to ensure a clean test environment.
- Otherwise, `path` is simply returned untouched.
+ `path` is copied to a temporary directory and a few terraform files
+ (e.g. terraform.tfvars) are deleted to ensure a clean test
+ environment.
"""
# if we're copying the module, we might as well ignore files and
# directories that are automatically read by terraform. Useful
@@ -50,31 +49,18 @@ def _prepare_root_module(path):
'.terraform.lock.hcl',
'terraform.tfvars', '.terraform')
- if os.environ.get('TFTEST_COPY'):
- # if the TFTEST_COPY is set, create temp dir and copy the root
- # module there
- with tempfile.TemporaryDirectory(dir=path.parent) as tmp_path:
- tmp_path = Path(tmp_path)
-
- # Running tests in a copy made with symlinks=True makes them run
- # ~20% slower than when run in a copy made with symlinks=False.
- shutil.copytree(path, tmp_path, dirs_exist_ok=True, symlinks=False,
- ignore=ignore_patterns)
- lockfile = _REPO_ROOT / 'tools' / 'lockfile' / '.terraform.lock.hcl'
- if lockfile.exists():
- shutil.copy(lockfile, tmp_path / '.terraform.lock.hcl')
-
- yield tmp_path
- else:
- # check if any ignore_patterns files are present in path
- if unwanted_files := ignore_patterns(path, os.listdir(path=path)):
- # prevent shooting yourself in the foot (unexpected test results) when ignored files are present
- raise RuntimeError(
- f'Test in path {path} contains {", ".join(unwanted_files)} which may affect '
- f'test results. Please run tests with TFTEST_COPY=1 environment variable'
- )
- # if TFTEST_COPY is not set, just return the same path
- yield path
+ with tempfile.TemporaryDirectory(dir=path.parent) as tmp_path:
+ tmp_path = Path(tmp_path)
+
+ # Running tests in a copy made with symlinks=True makes them run
+ # ~20% slower than when run in a copy made with symlinks=False.
+ shutil.copytree(path, tmp_path, dirs_exist_ok=True, symlinks=False,
+ ignore=ignore_patterns)
+ lockfile = _REPO_ROOT / 'tools' / 'lockfile' / '.terraform.lock.hcl'
+ if lockfile.exists():
+ shutil.copy(lockfile, tmp_path / '.terraform.lock.hcl')
+
+ yield tmp_path
def plan_summary(module_path, basedir, tf_var_files=None, extra_files=None,
diff --git a/tools/tfdoc.py b/tools/tfdoc.py
index 9bcfa30fdb..742260b12b 100755
--- a/tools/tfdoc.py
+++ b/tools/tfdoc.py
@@ -49,16 +49,15 @@
import click
import marko
-# manipulate path to import COUNT_TEST_RE from tests/examples/test_plan.py
-REPO_ROOT = os.path.dirname(os.path.dirname(__file__))
-sys.path.append(os.path.join(REPO_ROOT, 'tests'))
-
-from examples.test_plan import COUNT_TEST_RE
-
__version__ = '2.1.0'
+# COUNT_TEST_RE copied from tests/examples/test_plan.py
+COUNT_TEST_RE = re.compile(
+ r'# tftest +modules=(?P