diff --git a/modules/project-factory/README.md b/modules/project-factory/README.md index 6a6983ac..e38e411d 100644 --- a/modules/project-factory/README.md +++ b/modules/project-factory/README.md @@ -1,33 +1,57 @@ -# Project Factory +# Project and Folder Factory -This module implements in code the end-to-end project creation process for multiple projects via YAML data configurations. +This module implements end-to-end creation processes for a folder hierarchy, projects and billing budgets via YAML data configurations. It supports +- filesystem-driven folder hierarchy exposing the full configuration options available in the [folder module](../folder/) - multiple project creation and management exposing the full configuration options available in the [project module](../project/), including KMS key grants and VPC-SC perimeter membership - optional per-project [service account management](#service-accounts) including basic IAM grants - optional [billing budgets](#billing-budgets) factory and budget/project associations +- cross-referencing of hierarchy folders in projects - optional per-project IaC configuration (TODO) -The factory is implemented as a thin wrapping layer, so that no "magic" or hidden side effects are implemented in code, and debugging or integration of new features are simple. +The factory is implemented as a thin data translation layer for the underlying modules, so that no "magic" or hidden side effects are implemented in code, and debugging or integration of new features are simple. The code is meant to be executed by a high level service accounts with powerful permissions: -- Shared VPC connection if service project attachment is desired +- forlder admin permissions for the hierarchy - project creation on the nodes (folder or org) where projects will be defined +- Shared VPC connection if service project attachment is desired +- billing cost manager permissions to manage budgets and monitoring permissions if notifications should also be managed here -- [Leveraging data defaults, merges, optionals](#leveraging-data-defaults-merges-optionals) -- [Additional resources](#additional-resources) +- [Folder hierarchy](#folder-hierarchy) +- [Projects](#projects) + - [Leveraging project defaults, merges, optionals](#leveraging-project-defaults-merges-optionals) - [Service accounts](#service-accounts) - - [Billing budgets](#billing-budgets) +- [Billing budgets](#billing-budgets) - [Example](#example) - [Variables](#variables) - [Outputs](#outputs) - [Tests](#tests) -## Leveraging data defaults, merges, optionals +## Folder hierarchy + +The hierarchy supports up to three levels of folders, which are defined via filesystem directories each including a `_config.yaml` files detailing their attributes. + +The hierarchy factory is configured via the `factories_config.hierarchy` variable via one mandatory and one optional argument: + +- `factories_config.hierarchy.folders_data_path` is required to enable the hierarchy factory, and must be set to the path containing the YAML definitions +- `factories_config.hierarchy.parent_ids` is an optional map where keys are arbitrary and values are set to resource node ids + +Top-level folders in the filesystem hierarchy have no explicit parent, so their parent ids need to be provided in the YAML by either referencing the full id (e.g. `folders/12345678`) or by referencing a key in the `parent_ids` attribute described above. As a shortcut, a `default` key can be defined whose value is used for any top-level folder which does not directly provide a parent id. + +Filesystem directories can also contain project definitions in the same YAML format described below. This approach must be used with caution and is best adopted for stable scenarios, as problems in the filesystem hierarchy definitions might result in the project files not being read and the resources being deleted by Terraform. + +Refer to the [example](#example) below for actual examples of the YAML definitions. + +## Projects + +The project factory is configured via the `factories_config.projects_data_path` variable, and project files are also read from the hierarchy describe in the previous section when enabled. The YAML format mirrors the project module, refer to the [example](#example) below for actual examples of the YAML definitions. + +### Leveraging project defaults, merges, optionals In addition to the YAML-based project configurations, the factory accepts three additional sets of inputs via Terraform variables: @@ -37,8 +61,6 @@ In addition to the YAML-based project configurations, the factory accepts three Some examples on where to use each of the three sets are [provided below](#example). -## Additional resources - ### Service accounts Service accounts can be managed as part of each project's YAML configuration. This allows creation of default service accounts used for GCE instances, in firewall rules, or for application-level credentials without resorting to a separate Terraform configuration. @@ -59,9 +81,9 @@ service_accounts: Both the `display_name` and `iam_self_roles` attributes are optional. -### Billing budgets +## Billing budgets -The project factory integrates the billing budgets factory exposed by the `[`billing-account`](../billing-account/) module, and adds support for easy referencing budgets in project files. +The billing budgets factory integrates the `[`billing-account`](../billing-account/) module functionality, and adds support for easy referencing budgets in project files. To enable support for billing budgets, set the billing account id, optional notification channels, and the data folder for budgets in the `factories_config.budgets` variable, then create billing budgets using YAML definitions following the format described in the `billing-account` module. @@ -84,6 +106,8 @@ The example below shows how to use the billing budgets factory. ## Example +The module invocation using all optional features: + ```hcl module "project-factory" { source = "./fabric/modules/project-factory" @@ -122,12 +146,56 @@ module "project-factory" { } } } + hierarchy = { + folders_data_path = "data/hierarchy" + parent_ids = { + default = "folders/12345678" + } + } projects_data_path = "data/projects" } } -# tftest modules=8 resources=37 files=prj-app-1,prj-app-2,prj-app-3,budget-test-100 +# tftest modules=13 resources=48 files=prj-app-1,prj-app-2,prj-app-3,budget-test-100,h-0-0,h-1-0,h-0-1,h-1-1,h-1-1-p0 inventory=example.yaml ``` +A simple hierarchy of folders: + +```yaml +name: Foo (level 1) +iam: + roles/viewer: + - group:a@example.com +# tftest-file id=h-0-0 path=data/hierarchy/foo/_config.yaml +``` + +```yaml +name: Bar (level 1) +parent: folders/4567890 +# tftest-file id=h-1-0 path=data/hierarchy/bar/_config.yaml +``` + +```yaml +name: Foo Baz (level 2) +# tftest-file id=h-0-1 path=data/hierarchy/foo/baz/_config.yaml +``` + +```yaml +name: Bar Baz (level 2) +# tftest-file id=h-1-1 path=data/hierarchy/bar/baz/_config.yaml +``` + +One project defined within the folder hierarchy: + +```yaml +billing_account: 012345-67890A-BCDEF0 +services: + - container.googleapis.com + - storage.googleapis.com +# tftest-file id=h-1-1-p0 path=data/hierarchy/bar/baz/bar-baz-0.yaml +``` + +More traditional project definitions via the project factory data: + ```yaml # project app-1 billing_account: 012345-67890A-BCDEF0 @@ -206,6 +274,8 @@ services: # tftest-file id=prj-app-3 path=data/projects/prj-app-3.yaml ``` +And a billing budget: + ```yaml # billing budget test-100 display_name: 100 dollars in current spend @@ -226,12 +296,13 @@ update_rules: - billing-default # tftest-file id=budget-test-100 path=data/budgets/test-100.yaml ``` + ## Variables | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [factories_config](variables.tf#L91) | Path to folder with YAML resource description data files. | object({…}) | ✓ | | +| [factories_config](variables.tf#L91) | Path to folder with YAML resource description data files. | object({…}) | ✓ | | | [data_defaults](variables.tf#L17) | Optional default values used when corresponding project data from files are missing. | object({…}) | | {} | | [data_merges](variables.tf#L49) | Optional values that will be merged with corresponding data from files. Combines with `data_defaults`, file data, and `data_overrides`. | object({…}) | | {} | | [data_overrides](variables.tf#L69) | Optional values that override corresponding data from files. Takes precedence over file data and `data_defaults`. | object({…}) | | {} | @@ -240,8 +311,9 @@ update_rules: | name | description | sensitive | |---|---|:---:| -| [projects](outputs.tf#L17) | Project module outputs. | | -| [service_accounts](outputs.tf#L22) | Service account emails. | | +| [folders](outputs.tf#L17) | Folder ids. | | +| [projects](outputs.tf#L22) | Project module outputs. | | +| [service_accounts](outputs.tf#L27) | Service account emails. | | ## Tests diff --git a/modules/project-factory/factory-folders.tf b/modules/project-factory/factory-folders.tf new file mode 100644 index 00000000..99e91a47 --- /dev/null +++ b/modules/project-factory/factory-folders.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. + */ + +locals { + _folders_path = try( + pathexpand(var.factories_config.hierarchy.folders_data_path), null + ) + _folders = { + for f in local._hierarchy_files : dirname(f) => yamldecode(file( + "${coalesce(var.factories_config.hierarchy.folders_data_path, "-")}/${f}" + )) + } + _hierarchy_files = try( + fileset(local._folders_path, "**/_config.yaml"), + [] + ) + folders = { + for key, data in local._folders : key => merge(data, { + key = key + level = length(split("/", key)) + parent_key = dirname(key) + }) + } + hierarchy = merge( + try(var.factories_config.hierarchy.parent_ids, {}), + { for k, v in module.hierarchy-folder-lvl-1 : k => v.id }, + { for k, v in module.hierarchy-folder-lvl-2 : k => v.id }, + { for k, v in module.hierarchy-folder-lvl-3 : k => v.id }, + ) +} + +check "hierarchy-data" { + assert { + condition = ( + var.factories_config.hierarchy == null || + try(var.factories_config.hierarchy.parent_ids.default, null) != null + ) + error_message = "No default set for hierarchy parent ids." + } +} + +module "hierarchy-folder-lvl-1" { + source = "../folder" + for_each = { for k, v in local.folders : k => v if v.level == 1 } + parent = try( + # allow the YAML data to set the parent for this level + lookup( + var.factories_config.hierarchy.parent_ids, + each.value.parent, + # use the value as is if it's not in the parents map + each.value.parent + ), + # use the default value in the initial parents map + var.factories_config.hierarchy.parent_ids.default + # fail if we don't have an explicit parent + ) + name = each.value.name + iam = lookup(each.value, "iam", {}) + iam_bindings = lookup(each.value, "iam_bindings", {}) + iam_bindings_additive = lookup(each.value, "iam_bindings_additive", {}) + iam_by_principals = lookup(each.value, "iam_by_principals", {}) + org_policies = lookup(each.value, "org_policies", {}) + tag_bindings = lookup(each.value, "tag_bindings", {}) +} + +module "hierarchy-folder-lvl-2" { + source = "../folder" + for_each = { for k, v in local.folders : k => v if v.level == 2 } + parent = module.hierarchy-folder-lvl-1[each.value.parent_key].id + name = each.value.name + iam = lookup(each.value, "iam", {}) + iam_bindings = lookup(each.value, "iam_bindings", {}) + iam_bindings_additive = lookup(each.value, "iam_bindings_additive", {}) + iam_by_principals = lookup(each.value, "iam_by_principals", {}) + org_policies = lookup(each.value, "org_policies", {}) + tag_bindings = lookup(each.value, "tag_bindings", {}) +} + +module "hierarchy-folder-lvl-3" { + source = "../folder" + for_each = { for k, v in local.folders : k => v if v.level == 3 } + parent = module.hierarchy-folder-lvl-2[each.value.parent_key].id + name = each.value.name + iam = lookup(each.value, "iam", {}) + iam_bindings = lookup(each.value, "iam_bindings", {}) + iam_bindings_additive = lookup(each.value, "iam_bindings_additive", {}) + iam_by_principals = lookup(each.value, "iam_by_principals", {}) + org_policies = lookup(each.value, "org_policies", {}) + tag_bindings = lookup(each.value, "tag_bindings", {}) +} diff --git a/modules/project-factory/factory-projects.tf b/modules/project-factory/factory-projects.tf index 90e90a66..1c5c2f19 100644 --- a/modules/project-factory/factory-projects.tf +++ b/modules/project-factory/factory-projects.tf @@ -14,12 +14,23 @@ * limitations under the License. */ locals { + _hierarchy_projects = ( + { + for f in try(fileset(local._folders_path, "**/*.yaml"), []) : + basename(trimsuffix(f, ".yaml")) => merge( + { parent = dirname(f) }, + yamldecode(file("${local._folders_path}/${f}")) + ) + if !endswith(f, "/_config.yaml") + } + ) _project_path = try(pathexpand(var.factories_config.projects_data_path), null) - _projects = ( + _projects = merge( { for f in try(fileset(local._project_path, "**/*.yaml"), []) : trimsuffix(f, ".yaml") => yamldecode(file("${local._project_path}/${f}")) - } + }, + local._hierarchy_projects ) _project_budgets = flatten([ for k, v in local._projects : [ diff --git a/modules/project-factory/main.tf b/modules/project-factory/main.tf index d01471b5..2f440de6 100644 --- a/modules/project-factory/main.tf +++ b/modules/project-factory/main.tf @@ -15,11 +15,13 @@ */ module "projects" { - source = "../project" - for_each = local.projects - billing_account = each.value.billing_account - name = each.key - parent = try(each.value.parent, null) + source = "../project" + for_each = local.projects + billing_account = each.value.billing_account + name = each.key + parent = try( + lookup(local.hierarchy, each.value.parent, each.value.parent), null + ) prefix = each.value.prefix auto_create_network = try(each.value.auto_create_network, false) compute_metadata = try(each.value.compute_metadata, {}) diff --git a/modules/project-factory/outputs.tf b/modules/project-factory/outputs.tf index 2c15ec99..9f49ebe9 100644 --- a/modules/project-factory/outputs.tf +++ b/modules/project-factory/outputs.tf @@ -14,6 +14,11 @@ * limitations under the License. */ +output "folders" { + description = "Folder ids." + value = local.folders +} + output "projects" { description = "Project module outputs." value = module.projects diff --git a/modules/project-factory/variables.tf b/modules/project-factory/variables.tf index a0c85a0e..437ea7e1 100644 --- a/modules/project-factory/variables.tf +++ b/modules/project-factory/variables.tf @@ -1,5 +1,5 @@ /** - * 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. @@ -91,10 +91,15 @@ variable "data_overrides" { variable "factories_config" { description = "Path to folder with YAML resource description data files." type = object({ - projects_data_path = string + hierarchy = optional(object({ + folders_data_path = string + parent_ids = optional(map(string), {}) + })) + projects_data_path = optional(string) budgets = optional(object({ - billing_account = string - budgets_data_path = string + billing_account = string + budgets_data_path = string + # TODO: allow defining notification channels via YAML files notification_channels = optional(map(any), {}) })) }) diff --git a/tests/modules/project_factory/examples/example.yaml b/tests/modules/project_factory/examples/example.yaml index 1b3b50ad..3e14fc28 100644 --- a/tests/modules/project_factory/examples/example.yaml +++ b/tests/modules/project_factory/examples/example.yaml @@ -12,306 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -values: - module.project-factory.module.billing-account[0].google_billing_budget.default["test-100"]: - all_updates_rule: - - disable_default_iam_recipients: true - pubsub_topic: null - schema_version: '1.0' - amount: - - last_period_amount: null - specified_amount: - - nanos: null - units: '100' - billing_account: 123456-123456-123456 - budget_filter: - - calendar_period: null - credit_types_treatment: INCLUDE_ALL_CREDITS - custom_period: [] - projects: - - projects/test-pf-prj-app-1 - resource_ancestors: - - folders/1234567890 - display_name: 100 dollars in current spend - threshold_rules: - - spend_basis: CURRENT_SPEND - threshold_percent: 0.5 - - spend_basis: CURRENT_SPEND - threshold_percent: 0.75 - timeouts: null - module.project-factory.module.billing-account[0].google_monitoring_notification_channel.default["billing-default"]: - description: null - display_name: Budget email notification billing-default. - enabled: true - force_delete: false - labels: - email_address: gcp-billing-admins@example.com - project: foo-billing-audit - sensitive_labels: [] - timeouts: null - type: email - user_labels: null - module.project-factory.module.projects["prj-app-1"].data.google_storage_project_service_account.gcs_sa[0]: - project: test-pf-prj-app-1 - user_project: null - module.project-factory.module.projects["prj-app-1"].google_essential_contacts_contact.contact["admin@example.com"]: - email: admin@example.com - language_tag: en - notification_category_subscriptions: - - ALL - parent: projects/test-pf-prj-app-1 - timeouts: null - ? module.project-factory.module.projects["prj-app-1"].google_kms_crypto_key_iam_member.service_identity_cmek["compute.projects/kms-central-prj/locations/europe-west3/keyRings/my-keyring/cryptoKeys/europe3-gce"] - : condition: [] - crypto_key_id: projects/kms-central-prj/locations/europe-west3/keyRings/my-keyring/cryptoKeys/europe3-gce - role: roles/cloudkms.cryptoKeyEncrypterDecrypter - module.project-factory.module.projects["prj-app-1"].google_project.project[0]: - auto_create_network: false - billing_account: 012345-67890A-BCDEF0 - effective_labels: - app: app-1 - environment: test - team: foo - folder_id: '12345678' - labels: - app: app-1 - environment: test - team: foo - name: test-pf-prj-app-1 - org_id: null - project_id: test-pf-prj-app-1 - skip_delete: false - terraform_labels: - app: app-1 - environment: test - team: foo - timeouts: null - module.project-factory.module.projects["prj-app-1"].google_project_service.project_services["container.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-1 - service: container.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-1"].google_project_service.project_services["stackdriver.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-1 - service: stackdriver.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-1"].google_project_service.project_services["storage.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-1 - service: storage.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-2"].data.google_storage_project_service_account.gcs_sa[0]: - project: test-pf-prj-app-2 - user_project: null - module.project-factory.module.projects["prj-app-2"].google_compute_shared_vpc_service_project.shared_vpc_service[0]: - deletion_policy: null - host_project: foo-host - service_project: test-pf-prj-app-2 - timeouts: null - ? module.project-factory.module.projects["prj-app-2"].google_compute_subnetwork_iam_member.shared_vpc_host_robots["europe-west1:prod-default-ew1:cloudservices"] - : condition: [] - project: foo-host - region: europe-west1 - role: roles/compute.networkUser - subnetwork: prod-default-ew1 - ? module.project-factory.module.projects["prj-app-2"].google_compute_subnetwork_iam_member.shared_vpc_host_robots["europe-west1:prod-default-ew1:container-engine"] - : condition: [] - project: foo-host - region: europe-west1 - role: roles/compute.networkUser - subnetwork: prod-default-ew1 - ? module.project-factory.module.projects["prj-app-2"].google_compute_subnetwork_iam_member.shared_vpc_host_subnets_iam["europe-west1:prod-default-ew1:group:team-1@example.com"] - : condition: [] - member: group:team-1@example.com - project: foo-host - region: europe-west1 - role: roles/compute.networkUser - subnetwork: prod-default-ew1 - module.project-factory.module.projects["prj-app-2"].google_essential_contacts_contact.contact["admin@example.com"]: - email: admin@example.com - language_tag: en - notification_category_subscriptions: - - ALL - parent: projects/test-pf-prj-app-2 - timeouts: null - ? module.project-factory.module.projects["prj-app-2"].google_org_policy_policy.default["compute.restrictSharedVpcSubnetworks"] - : dry_run_spec: [] - name: projects/test-pf-prj-app-2/policies/compute.restrictSharedVpcSubnetworks - parent: projects/test-pf-prj-app-2 - spec: - - inherit_from_parent: null - reset: null - rules: - - allow_all: null - condition: [] - deny_all: null - enforce: null - values: - - allowed_values: - - projects/foo-host/regions/europe-west1/subnetworks/prod-default-ew1 - denied_values: null - timeouts: null - module.project-factory.module.projects["prj-app-2"].google_project.project[0]: - auto_create_network: false - billing_account: 123456-123456-123456 - effective_labels: - app: app-2 - environment: test - team: foo - folder_id: '12345678' - labels: - app: app-2 - environment: test - team: foo - name: test-pf-prj-app-2 - org_id: null - project_id: test-pf-prj-app-2 - skip_delete: false - terraform_labels: - app: app-2 - environment: test - team: foo - timeouts: null - ? module.project-factory.module.projects["prj-app-2"].google_project_iam_member.shared_vpc_host_robots["roles/container.hostServiceAgentUser:container-engine"] - : condition: [] - project: foo-host - role: roles/container.hostServiceAgentUser - ? module.project-factory.module.projects["prj-app-2"].google_project_iam_member.shared_vpc_host_robots["roles/vpcaccess.user:cloudrun"] - : condition: [] - project: foo-host - role: roles/vpcaccess.user - module.project-factory.module.projects["prj-app-2"].google_project_service.project_services["compute.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-2 - service: compute.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-2"].google_project_service.project_services["container.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-2 - service: container.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-2"].google_project_service.project_services["run.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-2 - service: run.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-2"].google_project_service.project_services["stackdriver.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-2 - service: stackdriver.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-2"].google_project_service.project_services["storage.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-2 - service: storage.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-3"].data.google_storage_project_service_account.gcs_sa[0]: - project: test-pf-prj-app-3 - user_project: null - module.project-factory.module.projects["prj-app-3"].google_essential_contacts_contact.contact["admin@example.com"]: - email: admin@example.com - language_tag: en - notification_category_subscriptions: - - ALL - parent: projects/test-pf-prj-app-3 - timeouts: null - module.project-factory.module.projects["prj-app-3"].google_project.project[0]: - auto_create_network: false - billing_account: 123456-123456-123456 - effective_labels: - environment: test - folder_id: '12345678' - labels: - environment: test - name: test-pf-prj-app-3 - org_id: null - project_id: test-pf-prj-app-3 - skip_delete: false - terraform_labels: - environment: test - timeouts: null - module.project-factory.module.projects["prj-app-3"].google_project_service.project_services["run.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-3 - service: run.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-3"].google_project_service.project_services["stackdriver.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-3 - service: stackdriver.googleapis.com - timeouts: null - module.project-factory.module.projects["prj-app-3"].google_project_service.project_services["storage.googleapis.com"]: - disable_dependent_services: false - disable_on_destroy: false - project: test-pf-prj-app-3 - service: storage.googleapis.com - timeouts: null - ? module.project-factory.module.service-accounts["prj-app-1/app-1-be"].google_project_iam_member.project-roles["my-host-project-roles/compute.networkUser"] - : condition: [] - project: my-host-project - role: roles/compute.networkUser - ? module.project-factory.module.service-accounts["prj-app-1/app-1-be"].google_project_iam_member.project-roles["test-pf-prj-app-1-roles/logging.logWriter"] - : condition: [] - project: test-pf-prj-app-1 - role: roles/logging.logWriter - ? module.project-factory.module.service-accounts["prj-app-1/app-1-be"].google_project_iam_member.project-roles["test-pf-prj-app-1-roles/monitoring.metricWriter"] - : condition: [] - project: test-pf-prj-app-1 - role: roles/monitoring.metricWriter - module.project-factory.module.service-accounts["prj-app-1/app-1-be"].google_service_account.service_account[0]: - account_id: app-1-be - create_ignore_already_exists: null - description: null - disabled: false - display_name: Terraform-managed. - project: test-pf-prj-app-1 - timeouts: null - ? module.project-factory.module.service-accounts["prj-app-1/app-1-fe"].google_project_iam_member.project-roles["my-host-project-roles/compute.networkUser"] - : condition: [] - project: my-host-project - role: roles/compute.networkUser - module.project-factory.module.service-accounts["prj-app-1/app-1-fe"].google_service_account.service_account[0]: - account_id: app-1-fe - create_ignore_already_exists: null - description: null - disabled: false - display_name: Test app 1 frontend. - project: test-pf-prj-app-1 - timeouts: null - module.project-factory.module.service-accounts["prj-app-2/app-2-be"].google_service_account.service_account[0]: - account_id: app-2-be - create_ignore_already_exists: null - description: null - disabled: false - display_name: Terraform-managed. - project: test-pf-prj-app-2 - timeouts: null - counts: google_billing_budget: 1 google_compute_shared_vpc_service_project: 1 google_compute_subnetwork_iam_member: 3 - google_essential_contacts_contact: 3 + google_essential_contacts_contact: 4 + google_folder: 4 + google_folder_iam_binding: 1 google_kms_crypto_key_iam_member: 1 google_monitoring_notification_channel: 1 google_org_policy_policy: 1 - google_project: 3 + google_project: 4 google_project_iam_member: 6 - google_project_service: 11 + google_project_service: 14 google_service_account: 3 - google_storage_project_service_account: 3 - modules: 8 - resources: 37 - -outputs: {} + google_storage_project_service_account: 4 + modules: 13 + resources: 48