diff --git a/blueprints/README.md b/blueprints/README.md index b38370bb4..61323dab4 100644 --- a/blueprints/README.md +++ b/blueprints/README.md @@ -5,7 +5,7 @@ This section **[networking blueprints](./networking/)** that implement core patt Currently available blueprints: - **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), [Packer image builder](./cloud-operations/packer-image-builder), [On-prem SA key management](./cloud-operations/onprem-sa-key-management), [TCP healthcheck for unmanaged GCE instances](./cloud-operations/unmanaged-instances-healthcheck), [HTTP Load Balancer with Cloud Armor](./cloud-operations/glb_and_armor) -- **data solutions** - [GCE/GCS CMEK via centralized Cloud KMS](./data-solutions/gcs-to-bq-with-least-privileges/), [Cloud Storage to Bigquery with Cloud Dataflow with least privileges](./data-solutions/gcs-to-bq-with-least-privileges/), [Data Platform Foundations](./data-solutions/data-platform-foundations/), [SQL Server AlwaysOn availability groups blueprint](./data-solutions/sqlserver-alwayson), [Cloud SQL instance with multi-region read replicas](./data-solutions/cloudsql-multiregion/) +- **data solutions** - [GCE/GCS CMEK via centralized Cloud KMS](./data-solutions/gcs-to-bq-with-least-privileges/), [Cloud Storage to Bigquery with Cloud Dataflow with least privileges](./data-solutions/gcs-to-bq-with-least-privileges/), [Data Platform Foundations](./data-solutions/data-platform-foundations/), [SQL Server AlwaysOn availability groups blueprint](./data-solutions/sqlserver-alwayson), [Cloud SQL instance with multi-region read replicas](./data-solutions/cloudsql-multiregion/), [Cloud Composer version 2 private instance, supporting Shared VPC and external CMEK key](./data-solutions/composer-2/) - **factories** - [The why and the how of resource factories](./factories/README.md) - **GKE** - [GKE multitenant fleet](./gke/multitenant-fleet/), [Shared VPC with GKE support](./networking/shared-vpc-gke/), [Binary Authorization Pipeline](./gke/binauthz/), [Multi-cluster mesh on GKE (fleet API)](./gke/multi-cluster-mesh-gke-fleet-api/) - **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) diff --git a/blueprints/data-solutions/README.md b/blueprints/data-solutions/README.md index 4abebf9d9..44311b632 100644 --- a/blueprints/data-solutions/README.md +++ b/blueprints/data-solutions/README.md @@ -30,7 +30,7 @@ This [blueprint](./data-platform-foundations/) implements SQL Server Always On A ### Cloud SQL instance with multi-region read replicas - + This [blueprint](./cloudsql-multiregion/) creates a [Cloud SQL instance](https://cloud.google.com/sql) with multi-region read replicas as described in the [Cloud SQL for PostgreSQL disaster recovery](https://cloud.google.com/architecture/cloud-sql-postgres-disaster-recovery-complete-failover-fallback) article.
@@ -41,3 +41,10 @@ This [blueprint](./data-playground/) creates a [Vertex AI Notebook](https://cloud.google.com/vertex-ai/docs/workbench/introduction) running on a VPC with a private IP and a dedicated Service Account. A GCS bucket and a BigQuery dataset are created to store inputs and outputs of data experiments.
+ +### Cloud Composer version 2 private instance, supporting Shared VPC and external CMEK key + + +This [blueprint](./composer-2/) creates a [Cloud Composer](https://cloud.google.com/sql) version 2 instance on a VPC with a dedicated service account. The solution supports as inputs: a Shared VPC and Cloud KMS CMEK keys. +
\ No newline at end of file diff --git a/blueprints/data-solutions/composer-2/README.md b/blueprints/data-solutions/composer-2/README.md new file mode 100644 index 000000000..1c05ec6a7 --- /dev/null +++ b/blueprints/data-solutions/composer-2/README.md @@ -0,0 +1,106 @@ +# Cloud Composer version 2 private instance, supporting Shared VPC and external CMEK key + +This blueprint creates a Private instance of [Cloud Composer version 2](https://cloud.google.com/composer/docs/composer-2/composer-versioning-overview) on a VPC with a dedicated service account. + +The solution will use: + - Cloud Composer + - VPC with Private Service Access to deploy resources, if no Shared VPC configuration provided. + - Google Cloud NAT to access internet resources, if no Shared VPC configuration provided. + +The solution supports as inputs: + - Shared VPC + - Cloud KMS CMEK keys + +This is the high level diagram: + +![Cloud Composer 2 architecture overview](./diagram.png "Cloud Composer 2 architecture overview") + +# Requirements +This blueprint will deploy all its resources into the project defined by the project_id variable. Please note that we assume this project already exists. However, if you provide the appropriate values to the `project_create` variable, the project will be created as part of the deployment. + +If `project_create` is left to null, the identity performing the deployment needs the owner role on the project defined by the `project_id` variable. Otherwise, the identity performing the deployment needs `resourcemanager.projectCreator` on the resource hierarchy node specified by `project_create.parent` and `billing.user` on the billing account specified by `project_create.billing_account_id`. + +# Deployment +Run Terraform init: + +``` +$ terraform init +``` + +Configure the Terraform variable in your terraform.tfvars file. You need to spefify at least the following variables: + +``` +project_id = "lcaggioni-sandbox" +prefix = "lc" +``` + +You can run now: + +``` +$ terraform apply +``` + +You can now connect to your instance. + +# Customizations + +## Shared VPC +As is often the case in real-world configurations, this blueprint accepts as input an existing [`Shared-VPC`](https://cloud.google.com/vpc/docs/shared-vpc) via the `network_config` variable. + +Example: +``` +network_config = { + host_project = "PROJECT" + network_self_link = "projects/PROJECT/global/networks/VPC_NAME" + subnet_self_link = "projects/PROJECT/regions/REGION/subnetworks/VPC_NAME" + composer_secondary_ranges = { + pods = "pods" + services = "services" + } +} +``` + +Make sure that: +- The GKE API (`container.googleapis.com`) is enabled in the VPC host project. +- The subnet has secondary ranges configured with 2 ranges: + - pods: `/22` example: `10.10.8.0/22` + - services = `/24` example: 10.10.12.0/24` +- Firewall rules are set, as described in the [documentation](https://cloud.google.com/composer/docs/how-to/managing/configuring-private-ip#step_3_configure_firewall_rules) + +In order to run the example and deploy Cloud Composer on a shared VPC the identity running Terraform must have the following IAM role on the Shared VPC Host project. + - Compute Network Admin (roles/compute.networkAdmin) + - Compute Shared VPC Admin (roles/compute.xpnAdmin) + +## Encryption +As is often the case in real-world configurations, this blueprint accepts as input an existing [`Cloud KMS keys`](https://cloud.google.com/kms/docs/cmek) via the `service_encryption_keys` variable. + +Example: +``` +service_encryption_keys = { + `europe/west1` = `projects/PROJECT/locations/REGION/keyRings/KR_NAME/cryptoKeys/KEY_NAME` +} +``` + + +## Variables + +| name | description | type | required | default | +|---|---|:---:|:---:|:---:| +| [organization_domain](variables.tf#L51) | Organization domain. | string | ✓ | | +| [prefix](variables.tf#L56) | Unique prefix used for resource names. Not used for project if 'project_create' is null. | string | ✓ | | +| [project_id](variables.tf#L70) | Project id, references existing project if `project_create` is null. | string | ✓ | | +| [composer_config](variables.tf#L17) | Composer environemnt configuration. | object({…}) | | {…} | +| [groups](variables.tf#L29) | User groups. | map(string) | | {…} | +| [network_config](variables.tf#L37) | Shared VPC network configurations to use. If null networks will be created in projects with preconfigured values. | object({…}) | | null | +| [project_create](variables.tf#L61) | Provide values if project creation is needed, uses existing project if null. Parent is in 'folders/nnn' or 'organizations/nnn' format. | object({…}) | | null | +| [region](variables.tf#L75) | Region where instances will be deployed. | string | | "europe-west1" | +| [service_encryption_keys](variables.tf#L81) | Cloud KMS keys to use to encrypt resources. Provide a key for each reagion in use. | map(string) | | null | + +## Outputs + +| name | description | sensitive | +|---|---|:---:| +| [composer_airflow_uri](outputs.tf#L22) | The URI of the Apache Airflow Web UI hosted within the Cloud Composer environment.. | | +| [composer_dag_gcs](outputs.tf#L17) | The Cloud Storage prefix of the DAGs for the Cloud Composer environment. | | + + diff --git a/blueprints/data-solutions/composer-2/backend.tf.sample b/blueprints/data-solutions/composer-2/backend.tf.sample new file mode 100644 index 000000000..49a0883db --- /dev/null +++ b/blueprints/data-solutions/composer-2/backend.tf.sample @@ -0,0 +1,30 @@ +# 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. + +# The `impersonate_service_account` option require the identity launching terraform +# role `roles/iam.serviceAccountTokenCreator` on the Service Account specified. + +terraform { + backend "gcs" { + bucket = "BUCKET_NAME" + prefix = "PREFIX" + impersonate_service_account = "SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com" + } +} +provider "google" { + impersonate_service_account = "SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com" +} +provider "google-beta" { + impersonate_service_account = "SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com" +} \ No newline at end of file diff --git a/blueprints/data-solutions/composer-2/composer.tf b/blueprints/data-solutions/composer-2/composer.tf new file mode 100644 index 000000000..28a9fffc1 --- /dev/null +++ b/blueprints/data-solutions/composer-2/composer.tf @@ -0,0 +1,96 @@ +/** + * 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 + * + * 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 "comp-sa" { + source = "../../../modules/iam-service-account" + project_id = module.project.project_id + prefix = var.prefix + name = "cmp" + display_name = "Composer service account" + iam = { + "roles/iam.serviceAccountTokenCreator" = [local.groups_iam.data-engineers] + } +} + +resource "google_composer_environment" "env" { + name = "${var.prefix}-composer" + project = module.project.project_id + region = var.region + config { + software_config { + image_version = var.composer_config.image_version + } + workloads_config { + scheduler { + cpu = 0.5 + memory_gb = 1.875 + storage_gb = 1 + count = 1 + } + web_server { + cpu = 0.5 + memory_gb = 1.875 + storage_gb = 1 + } + worker { + cpu = 0.5 + memory_gb = 1.875 + storage_gb = 1 + min_count = 1 + max_count = 3 + } + } + environment_size = var.composer_config.environment_size + + node_config { + network = local.orch_vpc + subnetwork = local.orch_subnet + service_account = module.comp-sa.email + enable_ip_masq_agent = "true" + tags = ["composer-worker"] + ip_allocation_policy { + cluster_secondary_range_name = try( + var.network_config.composer_secondary_ranges.pods, "pods" + ) + services_secondary_range_name = try( + var.network_config.composer_secondary_ranges.services, "services" + ) + } + } + private_environment_config { + enable_private_endpoint = "true" + cloud_sql_ipv4_cidr_block = try( + var.network_config.composer_ip_ranges.cloudsql, "10.20.10.0/24" + ) + master_ipv4_cidr_block = try( + var.network_config.composer_ip_ranges.gke_master, "10.20.11.0/28" + ) + } + dynamic "encryption_config" { + for_each = ( + try(lookup(var.service_encryption_keys, var.region, null) != null, false) + ? { 1 = 1 } + : {} + ) + content { + kms_key_name = try(lookup(var.service_encryption_keys, var.region, null), null) + } + } + } + depends_on = [ + google_project_iam_member.shared_vpc, + ] +} diff --git a/blueprints/data-solutions/composer-2/diagram.png b/blueprints/data-solutions/composer-2/diagram.png new file mode 100644 index 000000000..b8ffc12ef Binary files /dev/null and b/blueprints/data-solutions/composer-2/diagram.png differ diff --git a/blueprints/data-solutions/composer-2/main.tf b/blueprints/data-solutions/composer-2/main.tf new file mode 100644 index 000000000..7bb2ea8c3 --- /dev/null +++ b/blueprints/data-solutions/composer-2/main.tf @@ -0,0 +1,159 @@ +/** + * 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 + * + * 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 { + iam = { + "roles/composer.worker" = [ + module.comp-sa.iam_email + ] + "roles/composer.ServiceAgentV2Ext" = [ + "serviceAccount:${module.project.service_accounts.robots.composer}" + ] + } + + _shared_vpc_bindings = { + "roles/compute.networkUser" = [ + "prj-cloudservices", "prj-robot-gke" + ] + "roles/composer.sharedVpcAgent" = [ + "prj-robot-cs" + ] + "roles/container.hostServiceAgentUser" = [ + "prj-robot-gke" + ] + } + shared_vpc_role_members = { + prj-cloudservices = "serviceAccount:${module.project.service_accounts.cloud_services}" + prj-robot-gke = "serviceAccount:${module.project.service_accounts.robots.container-engine}" + prj-robot-cs = "serviceAccount:${module.project.service_accounts.robots.composer}" + } + # reassemble in a format suitable for for_each + shared_vpc_bindings_map = { + for binding in flatten([ + for role, members in local._shared_vpc_bindings : [ + for member in members : { role = role, member = member } + ] + ]) : "${binding.role}-${binding.member}" => binding + } + + shared_vpc_project = try(var.network_config.host_project, null) + use_shared_vpc = var.network_config != null + + vpc_self_link = ( + local.use_shared_vpc + ? var.network_config.network_self_link + : module.vpc.0.self_link + ) + + orch_subnet = ( + local.use_shared_vpc + ? var.network_config.subnet_self_link + : values(module.vpc.0.subnet_self_links)[0] + ) + + orch_vpc = ( + local.use_shared_vpc + ? var.network_config.network_self_link + : module.vpc.0.self_link + ) + groups = { + for k, v in var.groups : k => "${v}@${var.organization_domain}" + } + groups_iam = { + for k, v in local.groups : k => "group:${v}" + } +} + +module "project" { + source = "../../../modules/project" + name = var.project_id + parent = try(var.project_create.parent, null) + billing_account = try(var.project_create.billing_account_id, null) + project_create = var.project_create != null + prefix = var.project_create == null ? null : var.prefix + iam = var.project_create != null ? local.iam : {} + iam_additive = var.project_create == null ? local.iam : {} + services = [ + "cloudkms.googleapis.com", + "container.googleapis.com", + "containerregistry.googleapis.com", + "composer.googleapis.com", + "compute.googleapis.com", + "iap.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "networkmanagement.googleapis.com", + "servicenetworking.googleapis.com", + "storage.googleapis.com", + "storage-component.googleapis.com", + ] + + shared_vpc_service_config = local.shared_vpc_project == null ? null : { + attach = true + host_project = local.shared_vpc_project + service_identity_iam = {} + } + + service_encryption_key_ids = { + composer = [try(lookup(var.service_encryption_keys, var.region, null), null)] + } + + service_config = { + disable_on_destroy = false, disable_dependent_services = false + } +} + +module "vpc" { + source = "../../../modules/net-vpc" + count = local.use_shared_vpc ? 0 : 1 + project_id = module.project.project_id + name = "vpc" + subnets = [ + { + ip_cidr_range = "10.0.0.0/20" + name = "subnet" + region = var.region + secondary_ip_range = { + pods = "10.10.8.0/22" + services = "10.10.12.0/24" + } + } + ] +} + +module "firewall" { + source = "../../../modules/net-vpc-firewall" + count = local.use_shared_vpc ? 0 : 1 + project_id = module.project.project_id + network = module.vpc.0.name + admin_ranges = ["10.0.0.0/20"] +} + +module "nat" { + source = "../../../modules/net-cloudnat" + count = local.use_shared_vpc ? 0 : 1 + project_id = module.project.project_id + region = var.region + name = "${var.prefix}-default" + router_network = module.vpc.0.name +} + +resource "google_project_iam_member" "shared_vpc" { + for_each = local.use_shared_vpc ? local.shared_vpc_bindings_map : {} + project = var.network_config.host_project + role = each.value.role + member = lookup(local.shared_vpc_role_members, each.value.member) +} diff --git a/blueprints/data-solutions/composer-2/outputs.tf b/blueprints/data-solutions/composer-2/outputs.tf new file mode 100644 index 000000000..a2943006e --- /dev/null +++ b/blueprints/data-solutions/composer-2/outputs.tf @@ -0,0 +1,25 @@ +/** + * 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 + * + * 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 "composer_dag_gcs" { + description = "The Cloud Storage prefix of the DAGs for the Cloud Composer environment." + value = google_composer_environment.env.config[0].dag_gcs_prefix +} + +output "composer_airflow_uri" { + description = "The URI of the Apache Airflow Web UI hosted within the Cloud Composer environment.." + value = google_composer_environment.env.config[0].airflow_uri +} diff --git a/blueprints/data-solutions/composer-2/variables.tf b/blueprints/data-solutions/composer-2/variables.tf new file mode 100644 index 000000000..36ba2edf1 --- /dev/null +++ b/blueprints/data-solutions/composer-2/variables.tf @@ -0,0 +1,85 @@ +/** + * 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 + * + * 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 "composer_config" { + description = "Composer environemnt configuration." + type = object({ + environment_size = string + image_version = string + }) + default = { + environment_size = "ENVIRONMENT_SIZE_SMALL" + image_version = "composer-2-airflow-2" + } +} + +variable "groups" { + description = "User groups." + type = map(string) + default = { + data-engineers = "gcp-data-engineers" + } +} + +variable "network_config" { + description = "Shared VPC network configurations to use. If null networks will be created in projects with preconfigured values." + type = object({ + host_project = string + network_self_link = string + subnet_self_link = string + composer_secondary_ranges = object({ + pods = string + services = string + }) + }) + default = null +} + +variable "organization_domain" { + description = "Organization domain." + type = string +} + +variable "prefix" { + description = "Unique prefix used for resource names. Not used for project if 'project_create' is null." + type = string +} + +variable "project_create" { + description = "Provide values if project creation is needed, uses existing project if null. Parent is in 'folders/nnn' or 'organizations/nnn' format." + type = object({ + billing_account_id = string + parent = string + }) + default = null +} + +variable "project_id" { + description = "Project id, references existing project if `project_create` is null." + type = string +} + +variable "region" { + description = "Region where instances will be deployed." + type = string + default = "europe-west1" +} + +variable "service_encryption_keys" { + description = "Cloud KMS keys to use to encrypt resources. Provide a key for each reagion in use." + type = map(string) + default = null +} diff --git a/tests/blueprints/data_solutions/composer_2/__init__.py b/tests/blueprints/data_solutions/composer_2/__init__.py new file mode 100644 index 000000000..6d6d1266c --- /dev/null +++ b/tests/blueprints/data_solutions/composer_2/__init__.py @@ -0,0 +1,13 @@ +# 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 +# +# 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/blueprints/data_solutions/composer_2/fixture/main.tf b/tests/blueprints/data_solutions/composer_2/fixture/main.tf new file mode 100644 index 000000000..384a069a8 --- /dev/null +++ b/tests/blueprints/data_solutions/composer_2/fixture/main.tf @@ -0,0 +1,27 @@ +/** + * 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 + * + * 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 = "../../../../../blueprints/data-solutions/composer-2/" + project_id = "project" + + organization_domain = "example.com" + project_create = { + billing_account_id = "123456-123456-123456" + parent = "folders/12345678" + } + prefix = "prefix" +} diff --git a/tests/blueprints/data_solutions/composer_2/test_plan.py b/tests/blueprints/data_solutions/composer_2/test_plan.py new file mode 100644 index 000000000..017d9979f --- /dev/null +++ b/tests/blueprints/data_solutions/composer_2/test_plan.py @@ -0,0 +1,19 @@ +# 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 +# +# 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. + +def test_resources(e2e_plan_runner): + "Test that plan works and the numbers of resources is as expected." + modules, resources = e2e_plan_runner() + assert len(modules) == 5 + assert len(resources) == 28