diff --git a/modules/cloud-run-v2/README.md b/modules/cloud-run-v2/README.md index 3870a6a8d..3fa169e6e 100644 --- a/modules/cloud-run-v2/README.md +++ b/modules/cloud-run-v2/README.md @@ -10,16 +10,23 @@ Cloud Run Services and Jobs, with support for IAM roles and Eventarc trigger cre - [Direct VPC Egress](#direct-vpc-egress) - [VPC Access Connector](#vpc-access-connector) - [Using Customer-Managed Encryption Key](#using-customer-managed-encryption-key) +- [Deploying OpenTelemetry Collector sidecar](#deploying-opentelemetry-collector-sidecar) - [Eventarc triggers](#eventarc-triggers) - [PubSub](#pubsub) - [Audit logs](#audit-logs) - - [Using custom service accounts for triggers](#using-custom-service-accounts-for-triggers) + - [GCS bucket](#gcs-bucket) +- [Cloud Run Invoker IAM Disable](#cloud-run-invoker-iam-disable) - [Cloud Run Service Account](#cloud-run-service-account) + - [Workload and agent identities](#workload-and-agent-identities) - [Creating Cloud Run Jobs](#creating-cloud-run-jobs) - [Tag bindings](#tag-bindings) +- [IAP Configuration](#iap-configuration) +- [Adding GPUs](#adding-gpus) +- [Multi-Region Service](#multi-region-service) +- [Traffic Splitting and Revision Tagging](#traffic-splitting-and-revision-tagging) +- [Security Configurations](#security-configurations) - [Variables](#variables) - [Outputs](#outputs) -- [Fixtures](#fixtures) ## IAM and environment variables @@ -30,7 +37,7 @@ IAM bindings support the usual syntax. Container environment values can be decla module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region containers = { hello = { @@ -42,7 +49,7 @@ module "cloud_run" { env_from_key = { SECRET1 = { secret = module.secret-manager.secrets["credentials"].name - version = module.secret-manager.version_versions["credentials:v1"] + version = module.secret-manager.version_versions["credentials/v1"] } } } @@ -52,7 +59,7 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=2 resources=5 fixtures=fixtures/secret-credentials.tf inventory=service-iam-env.yaml e2e +# tftest fixtures=fixtures/secret-credentials.tf inventory=service-iam-env.yaml e2e skip-tofu ``` ## Mounting secrets as volumes @@ -61,7 +68,7 @@ module "cloud_run" { module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region containers = { hello = { @@ -82,7 +89,7 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=2 resources=4 fixtures=fixtures/secret-credentials.tf inventory=service-volume-secretes.yaml e2e +# tftest fixtures=fixtures/secret-credentials.tf inventory=service-volume-secretes.yaml e2e skip-tofu ``` ## Mounting GCS buckets @@ -91,7 +98,7 @@ module "cloud_run" { module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region containers = { hello = { @@ -101,7 +108,7 @@ module "cloud_run" { } } } - revision = { + service_config = { gen2_execution_environment = true } volumes = { @@ -128,7 +135,7 @@ module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" @@ -148,11 +155,12 @@ module "cloud_run" { ``` ## Direct VPC Egress + ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region containers = { hello = { @@ -160,18 +168,20 @@ module "cloud_run" { } } revision = { - gen2_execution_environment = true - max_instance_count = 20 vpc_access = { egress = "ALL_TRAFFIC" subnet = var.subnet.name tags = ["tag1", "tag2", "tag3"] } } + service_config = { + gen2_execution_environment = true + max_instance_count = 20 + } deletion_protection = false } # E2E test disabled due to b/332419038 -# tftest modules=1 resources=1 inventory=service-direct-vpc.yaml +# tftest inventory=service-direct-vpc.yaml ``` ## VPC Access Connector @@ -182,14 +192,14 @@ You can use an existing [VPC Access Connector](https://cloud.google.com/vpc/docs module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - region = var.region - name = "hello" + region = var.regions.secondary + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - revision = { + service_config = { vpc_access = { connector = google_vpc_access_connector.connector.id egress = "ALL_TRAFFIC" @@ -197,17 +207,17 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=1 resources=2 fixtures=fixtures/vpc-connector.tf inventory=service-vpc-access-connector.yaml e2e +# tftest fixtures=fixtures/vpc-connector.tf inventory=service-vpc-access-connector.yaml e2e ``` -If creation of the VPC Access Connector is required, use the `vpc_connector_create` variable which also supports optional attributes like number of instances, machine type, or throughput. The connector will be used automatically. +If creation of the VPC Access Connector is required, use the `vpc_connector_create` variable which also supports optional attributes like number of instances, machine type, or throughput. The connector will be used automatically by Cloud Run Service and Job. Worker Pool does not support connector. ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" @@ -223,7 +233,7 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=1 resources=2 inventory=service-vpc-access-connector-create.yaml e2e +# tftest inventory=service-vpc-access-connector-create.yaml e2e ``` Note that if you are using a Shared VPC for the connector, you need to specify a subnet and the host project if this is not where the Cloud Run service is deployed. @@ -233,7 +243,7 @@ module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = module.project-service.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" @@ -252,7 +262,7 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=4 resources=55 fixtures=fixtures/shared-vpc.tf inventory=service-vpc-access-connector-create-sharedvpc.yaml e2e +# tftest fixtures=fixtures/shared-vpc.tf inventory=service-vpc-access-connector-create-sharedvpc.yaml e2e ``` ## Using Customer-Managed Encryption Key @@ -294,7 +304,7 @@ module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = module.project.project_id region = var.region - name = "hello" + name = "example-hello" encryption_key = module.kms.keys.key-regional.id containers = { hello = { @@ -303,7 +313,249 @@ module "cloud_run" { } deletion_protection = false } -# tftest modules=3 resources=11 e2e +# tftest inventory=cmek.yaml e2e +``` + +## Deploying OpenTelemetry Collector sidecar + +```yaml +# Reference: https://cloud.google.com/stackdriver/docs/instrumentation/opentelemetry-collector-cloud-run#gotc-provided-config + +receivers: + # Open two OTLP servers: + # - On port 4317, open an OTLP GRPC server + # - On port 4318, open an OTLP HTTP server + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver/otlpreceiver + otlp: + protocols: + grpc: + endpoint: localhost:4317 + http: + cors: + # This effectively allows any origin + # to make requests to the HTTP server. + allowed_origins: + - http://* + - https://* + endpoint: localhost:4318 + + # Using the prometheus scraper, scrape the Collector's self metrics. + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/prometheusreceiver + # https://opentelemetry.io/docs/collector/internal-telemetry/ + prometheus/self-metrics: + config: + scrape_configs: + - job_name: otel-self-metrics + scrape_interval: 1m + static_configs: + - targets: + - localhost:8888 + +processors: + # The batch processor is in place to regulate both the number of requests + # being made and the size of those requests. + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/batchprocessor + batch: + send_batch_max_size: 200 + send_batch_size: 200 + timeout: 5s + + # The memorylimiter will check the memory usage of the collector process. + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/memorylimiterprocessor + memory_limiter: + check_interval: 1s + limit_percentage: 65 + spike_limit_percentage: 20 + + # The resourcedetection processor is configured to detect GCP resources. + # Resource attributes that represent the GCP resource the collector is + # running on will be attached to all telemetry that goes through this + # processor. + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/resourcedetectionprocessor + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/resourcedetectionprocessor#gcp-metadata + resourcedetection: + detectors: [gcp] + timeout: 10s + + # The transform/collision processor ensures that any attributes that may + # collide with the googlemanagedprometheus exporter's monitored resource + # construction are moved to a similar name that is not reserved. + transform/collision: + metric_statements: + - context: datapoint + statements: + - set(attributes["exported_location"], attributes["location"]) + - delete_key(attributes, "location") + - set(attributes["exported_cluster"], attributes["cluster"]) + - delete_key(attributes, "cluster") + - set(attributes["exported_namespace"], attributes["namespace"]) + - delete_key(attributes, "namespace") + - set(attributes["exported_job"], attributes["job"]) + - delete_key(attributes, "job") + - set(attributes["exported_instance"], attributes["instance"]) + - delete_key(attributes, "instance") + - set(attributes["exported_project_id"], attributes["project_id"]) + - delete_key(attributes, "project_id") + +exporters: + # The googlecloud exporter will export telemetry to different + # Google Cloud services: + # Logs -> Cloud Logging + # Metrics -> Cloud Monitoring + # Traces -> Cloud Trace + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/googlecloudexporter + googlecloud: + log: + default_log_name: opentelemetry-collector + + # The googlemanagedprometheus exporter will send metrics to + # Google Managed Service for Prometheus. + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/googlemanagedprometheusexporter + googlemanagedprometheus: + +extensions: + # Opens an endpoint on 13133 that can be used to check the + # status of the collector. Since this does not configure the + # `path` config value, the endpoint will default to `/`. + # + # When running on Cloud Run, this extension is required and not optional. + # In other environments it is recommended but may not be required for operation + # (i.e. in Container-Optimized OS or other GCE environments). + # + # Docs: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/healthcheckextension + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: + - health_check + pipelines: + logs: + receivers: + - otlp + processors: + - resourcedetection + - memory_limiter + - batch + exporters: + - googlecloud + metrics/otlp: + receivers: + - otlp + processors: + - transform/collision + - resourcedetection + - memory_limiter + - batch + exporters: + - googlemanagedprometheus + metrics/self-metrics: + receivers: + - prometheus/self-metrics + processors: + - resourcedetection + - memory_limiter + - batch + exporters: + - googlemanagedprometheus + traces: + receivers: + - otlp + processors: + - resourcedetection + - memory_limiter + - batch + exporters: + - googlecloud + telemetry: + metrics: + address: localhost:8888 + +# tftest-file id=otel-config path=config/otel-config.yaml +``` + +```hcl +module "secrets" { + source = "./fabric/modules/secret-manager" + project_id = var.project_id + secrets = { + otel-config = { + iam = { + "roles/secretmanager.secretAccessor" = [module.cloud_run.service_account_iam_email] + } + versions = { + v1 = { + data = file("${path.module}/config/otel-config.yaml") + } + } + } + } +} +module "cloud_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + region = var.region + name = "example-hello" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + ports = { + default = { + container_port = 3000 + } + } + depends_on = ["collector"] + } + collector = { + image = "us-docker.pkg.dev/cloud-ops-agents-artifacts/google-cloud-opentelemetry-collector/otelcol-google:0.122.1" + startup_probe = { + http_get = { + path = "/" + port = 13133 + } + timeout_seconds = 30 + period_seconds = 30 + } + liveness_probe = { + http_get = { + path = "/" + port = 13133 + } + timeout_seconds = 30 + period_seconds = 30 + } + volume_mounts = { + "otel-config" = "/etc/otelcol-google/" + } + } + } + volumes = { + otel-config = { + secret = { + name = "otel-config" + version = "1" + path = "config.yaml" + } + } + } + deletion_protection = false +} +# tftest files=otel-config inventory=service-otel-sidecar.yaml e2e skip-tofu ``` ## Eventarc triggers @@ -317,20 +569,22 @@ module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - eventarc_triggers = { - pubsub = { - topic-1 = module.pubsub.topic.name + service_config = { + eventarc_triggers = { + pubsub = { + topic-1 = module.pubsub.topic.name + } } } deletion_protection = false } -# tftest modules=2 resources=4 fixtures=fixtures/pubsub.tf inventory=service-eventarc-pubsub.yaml e2e +# tftest fixtures=fixtures/pubsub.tf inventory=service-eventarc-pubsub.yaml e2e ``` ### Audit logs @@ -342,123 +596,192 @@ module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - eventarc_triggers = { - audit_log = { - setiampolicy = { - method = "SetIamPolicy" - service = "cloudresourcemanager.googleapis.com" + service_config = { + eventarc_triggers = { + audit_log = { + setiampolicy = { + method = "SetIamPolicy" + service = "cloudresourcemanager.googleapis.com" + } } + service_account_email = module.iam-service-account.email } - service_account_create = true + } + iam = { + "roles/run.invoker" = [module.iam-service-account.iam_email] } deletion_protection = false + depends_on = [google_project_iam_member.eventarc_receiver] } -# tftest modules=1 resources=4 inventory=service-eventarc-auditlogs-sa-create.yaml -``` -### Using custom service accounts for triggers +resource "google_project_iam_member" "eventarc_receiver" { + project = var.project_id + role = "roles/eventarc.eventReceiver" + member = module.iam-service-account.iam_email +} +# tftest fixtures=fixtures/iam-service-account.tf inventory=service-eventarc-auditlogs-external-sa.yaml e2e +``` -By default `Compute default service account` is used to trigger Cloud Run. If you want to use custom Service Accounts you can either provide your own in `eventarc_triggers.service_account_email` or set `eventarc_triggers.service_account_create` to true and service account named `tf-cr-trigger-${var.name}` will be created with `roles/run.invoker` granted on this Cloud Run service. +### GCS bucket -Example using provided service account: +This deploys a Cloud Run service that will be triggered when files are uploaded to a GCS bucket. ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - eventarc_triggers = { - audit_log = { - setiampolicy = { - method = "SetIamPolicy" - service = "cloudresourcemanager.googleapis.com" + service_config = { + eventarc_triggers = { + storage = { + bucket-upload = { + bucket = module.gcs.name + path = "/webhook" # optional: URL path for the Cloud Run service + } } + service_account_email = module.iam-service-account.email } - service_account_email = "cloud-run-trigger@my-project.iam.gserviceaccount.com" } + deletion_protection = false + depends_on = [ + google_project_iam_member.gcs_pubsb_publisher, + google_project_iam_member.trigger_sa_event_receiver, + ] +} + +resource "google_project_iam_member" "trigger_sa_event_receiver" { + member = module.iam-service-account.iam_email + project = var.project_id + role = "roles/eventarc.eventReceiver" +} + +resource "google_project_iam_member" "gcs_pubsb_publisher" { + member = "serviceAccount:service-${var.project_number}@gs-project-accounts.iam.gserviceaccount.com" + project = var.project_id + role = "roles/pubsub.publisher" } -# tftest modules=1 resources=2 inventory=service-eventarc-auditlogs-external-sa.yaml + +# tftest fixtures=fixtures/gcs.tf,fixtures/iam-service-account.tf inventory=service-eventarc-storage.yaml e2e ``` -Example using automatically created service account: +## Cloud Run Invoker IAM Disable + +To disables IAM permission check for `run.routes.invoke` for callers of this service set the `invoker_iam_disabled` variable of the module to `true` (default `false`). There should be no requirement to pass the `roles/run.invoker` to the IAM block to enable public access. This allows for the org policy `domain restricted sharing` org policy remain enabled. ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - eventarc_triggers = { - pubsub = { - topic-1 = module.pubsub.topic.name - } - service_account_create = true + service_config = { + invoker_iam_disabled = true } deletion_protection = false } -# tftest modules=2 resources=6 fixtures=fixtures/pubsub.tf inventory=service-eventarc-pubsub-sa-create.yaml e2e +# tftest inventory=service-invoker-iam-disable.yaml e2e ``` ## Cloud Run Service Account -To use a custom service account managed by the module, set `service_account_create` to `true` and leave `service_account` set to `null` (default). +The module by default creates a service account that is associated with the Cloud Run instance. It grants the service account `roles/logging.logWriter` and `roles/monitoring.metricWriter` roles. + +To assign non-default roles, pass them as `service_account_config.roles`. ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - service_account_create = true - deletion_protection = false + service_account_config = { + roles = [ + "roles/logging.logWriter", + "roles/monitoring.metricWriter", + "roles/cloudsql.client", + "roles/cloudsql.instanceUser", + ] + } + deletion_protection = false } -# tftest modules=1 resources=2 inventory=service-sa-create.yaml e2e +# tftest inventory=service-sa-create.yaml e2e ``` -To use an externally managed service account, use its email in `service_account` and leave `service_account_create` to `false` (default). +To use externally managed service account, pass its email in `service_account_config.email` and set `service_account_config.email` to `false`. ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id region = var.region - name = "hello" + name = "example-hello" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" } } - service_account = module.iam-service-account.email + service_account_config = { + create = false + email = module.iam-service-account.email + } deletion_protection = false } -# tftest modules=2 resources=2 fixtures=fixtures/iam-service-account.tf inventory=service-external-sa.yaml e2e +# tftest fixtures=fixtures/iam-service-account.tf inventory=service-external-sa.yaml e2e +``` + +### Workload and agent identities + +Services can run under a Cloud Run managed identity instead of a service account, via `service_config.workload_identity_config`. This is what [Agent Platform](https://cloud.google.com/run/docs/ai/agent-platform-features) features build on: a workload with identity type `IDENTITY_TYPE_AGENT_IDENTITY` is assigned a system-managed SPIFFE identity and registered in the Agent Registry. + +When identity type is not `IDENTITY_TYPE_SERVICE_ACCOUNT` the module creates no service account and binds no roles, as there is no service account to bind them to. Cloud Run does not expose the resolved principal as an attribute, so roles for the managed identity need to be granted outside this module. + +```hcl +module "cloud_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + region = var.region + name = "example-agent" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + service_config = { + workload_identity_config = { + certificate_enabled = true + identity_type = "IDENTITY_TYPE_AGENT_IDENTITY" + } + } + deletion_protection = false +} +# tftest inventory=service-agent-identity.yaml ``` ## Creating Cloud Run Jobs -To create a job instead of service set `create_job` to `true`. Jobs support all functions above apart from triggers. +To create a job instead of service set `type` to `JOB`. Jobs support all functions above apart from triggers. Unsupported variables / attributes: @@ -470,13 +793,19 @@ Unsupported variables / attributes: - containers.resources.cpu_idle - containers.resources.startup_cpu_boost +Additional configuration can be passwed as `job_config`: + +- max_retries - maximum of retries per task +- task_count - desired number of tasks +- timeout - max allowed time per task, in seconds with up to nine fractional digits, ending with 's'. Example: `3.5s` + ```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region - create_job = true + type = "JOB" containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" @@ -492,19 +821,26 @@ module "cloud_run" { deletion_protection = false } -# tftest modules=1 resources=2 inventory=job-iam-env.yaml e2e +# tftest inventory=job-iam-env.yaml e2e ``` ## Tag bindings -Tag bindings are not yet supported for jobs. Refer to the [Creating and managing tags](https://cloud.google.com/resource-manager/docs/tags/tags-creating-and-managing) documentation for details on usage. +Tag bindings are not yet supported for Worker Pool. Refer to the [Creating and managing tags](https://cloud.google.com/resource-manager/docs/tags/tags-creating-and-managing) documentation for details on usage. ```hcl -module "org" { - source = "./fabric/modules/organization" - organization_id = var.organization_id +module "project" { + source = "./fabric/modules/project" + name = var.project_id + project_reuse = { + use_data_source = false + attributes = { + name = var.project_id + number = var.project_number + } + } tags = { - environment = { + run_environment = { description = "Environment specification." values = { dev = {} @@ -515,54 +851,271 @@ module "org" { } } +module "cloud_run_service" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "hello-service" + region = var.region + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + tag_bindings = { + env-sandbox = module.project.tag_values["run_environment/sandbox"].id + } + deletion_protection = false +} + +module "cloud_run_job" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "hello-job" + region = var.region + type = "JOB" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + tag_bindings = { + env-sandbox = module.project.tag_values["run_environment/sandbox"].id + } + deletion_protection = false +} + +# tftest inventory=tags.yaml e2e +``` + +## IAP Configuration + +IAP is only supported for service. Refer to the [Configure IAP directly on cloud run](https://cloud.google.com/run/docs/securing/identity-aware-proxy-cloud-run) documentation for details on usage. + +```hcl module "cloud_run" { source = "./fabric/modules/cloud-run-v2" project_id = var.project_id - name = "hello" + name = "example-hello" region = var.region containers = { hello = { image = "us-docker.pkg.dev/cloudrun/container/hello" - env = { - VAR1 = "VALUE1" - VAR2 = "VALUE2" + } + } + service_config = { + iap_config = { + iam = ["group:${var.group_email}"] + } + } + deletion_protection = false +} +# tftest inventory=iap.yaml e2e +``` + +## Adding GPUs + +GPU support is available for all types of Cloud Run resources: jobs, services and worker pools. + +```hcl +module "job" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "example-job" + region = var.region + revision = { + gpu_zonal_redundancy_disabled = true + node_selector = { + accelerator = "nvidia-l4" + } + } + type = "JOB" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + resources = { + limits = { + cpu = "4000m" + memory = "16Gi" + "nvidia.com/gpu" = "1" + } } } } - iam = { - "roles/run.invoker" = ["allUsers"] + deletion_protection = false +} +# tftest inventory=gpu-job.yaml +``` + +```hcl +module "service" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "service" + region = var.region + revision = { + gpu_zonal_redundancy_disabled = true + node_selector = { + accelerator = "nvidia-l4" + } } - tag_bindings = { - env-sandbox = module.org.tag_values["environment/sandbox"].id + service_config = { + gen2_execution_environment = true + } + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + resources = { + limits = { + cpu = "4000m" + memory = "16Gi" + "nvidia.com/gpu" = "1" + } + } + } + } + deletion_protection = false +} +# tftest inventory=gpu-service.yaml e2e +``` + +```hcl +module "worker" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "worker" + region = var.region + revision = { + gpu_zonal_redundancy_disabled = true + node_selector = { + accelerator = "nvidia-l4" + } + } + type = "WORKERPOOL" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + resources = { + limits = { + cpu = "4000m" + memory = "16Gi" + "nvidia.com/gpu" = "1" + } + } + } + } + deletion_protection = false +} +# tftest inventory=gpu-workerpool.yaml e2e +``` + +## Multi-Region Service + +```hcl +module "cloud_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + region = "global" + name = "example-mr" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + service_config = { + ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" + multi_region_settings = { + regions = ["europe-west8", "europe-west1"] + } } + deletion_protection = false } -# tftest modules=2 resources=7 +# tftest inventory=multiregion.yaml +``` + +## Traffic Splitting and Revision Tagging + +```hcl +module "cloud_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + region = var.region + name = "example-traffic" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + service_config = { + traffic = [ + { + percent = 90 + revision = "example-traffic-v1" + type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" + }, + { + percent = 10 + revision = "example-traffic-v2" + tag = "candidate" + type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" + } + ] + } + deletion_protection = false +} +# tftest inventory=traffic.yaml +``` + +## Security Configurations + +You can enable Binary Authorization to verify container image signatures and disable the default `*.a.run.app` URL when routing traffic exclusively through internal networks or Application Load Balancers: + +```hcl +module "cloud_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + region = var.region + name = "example-security" + containers = { + hello = { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + } + binary_authorization = { + use_default = true + } + service_config = { + default_uri_disabled = true + } + deletion_protection = false +} +# tftest inventory=security.yaml ``` ## Variables | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [name](variables.tf#L165) | Name used for Cloud Run service. | string | ✓ | | -| [project_id](variables.tf#L180) | Project id used for all resources. | string | ✓ | | -| [region](variables.tf#L185) | Region used for all resources. | string | ✓ | | -| [containers](variables.tf#L17) | Containers in name => attributes format. | map(object({…})) | | {} | -| [create_job](variables.tf#L77) | Create Cloud Run Job instead of Service. | bool | | false | -| [custom_audiences](variables.tf#L83) | Custom audiences for service. | list(string) | | null | -| [deletion_protection](variables.tf#L89) | Deletion protection setting for this Cloud Run service. | string | | null | -| [encryption_key](variables.tf#L95) | The full resource name of the Cloud KMS CryptoKey. | string | | null | -| [eventarc_triggers](variables.tf#L101) | Event arc triggers for different sources. | object({…}) | | {} | -| [iam](variables.tf#L119) | IAM bindings for Cloud Run service in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | -| [ingress](variables.tf#L125) | Ingress settings. | string | | null | -| [labels](variables.tf#L142) | Resource labels. | map(string) | | {} | -| [launch_stage](variables.tf#L148) | The launch stage as defined by Google Cloud Platform Launch Stages. | string | | null | -| [prefix](variables.tf#L170) | Optional prefix used for resource names. | string | | null | -| [revision](variables.tf#L190) | Revision template configurations. | object({…}) | | {} | -| [service_account](variables.tf#L228) | Service account email. Unused if service account is auto-created. | string | | null | -| [service_account_create](variables.tf#L234) | Auto-create service account. | bool | | false | -| [tag_bindings](variables.tf#L240) | Tag bindings for this service, in key => tag value id format. | map(string) | | {} | -| [volumes](variables.tf#L247) | Named volumes in containers in name => attributes format. | map(object({…})) | | {} | -| [vpc_connector_create](variables-vpcconnector.tf#L17) | Populate this to create a Serverless VPC Access connector. | object({…}) | | null | +| [name](variables.tf#L192) | Name used for Cloud Run service. | string | ✓ | | +| [project_id](variables.tf#L197) | Project id used for all resources. | string | ✓ | | +| [region](variables.tf#L202) | Region used for all resources. | string | ✓ | | +| [binary_authorization](variables.tf#L17) | Binary Authorization configuration. | object({…}) | | null | +| [containers](variables.tf#L27) | Containers in name => attributes format. | map(object({…})) | | {} | +| [context](variables.tf#L107) | Context-specific interpolations. | object({…}) | | {} | +| [deletion_protection](variables.tf#L129) | Deletion protection setting for this Cloud Run service. | string | | null | +| [encryption_key](variables.tf#L135) | The full resource name of the Cloud KMS CryptoKey. | string | | null | +| [iam](variables.tf#L141) | IAM bindings for Cloud Run service in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | +| [job_config](variables.tf#L147) | Cloud Run Job specific configuration. | object({…}) | | {} | +| [labels](variables.tf#L162) | Resource labels. | map(string) | | {} | +| [launch_stage](variables.tf#L168) | The launch stage as defined by Google Cloud Platform Launch Stages. | string | | null | +| [managed_revision](variables.tf#L185) | Whether the Terraform module should control the deployment of revisions. | bool | | true | +| [revision](variables.tf#L207) | Revision template configurations. | object({…}) | | {} | +| [service_account_config](variables-serviceaccount.tf#L17) | Service account configurations. | object({…}) | | {} | +| [service_config](variables.tf#L274) | Cloud Run service specific configuration options. | object({…}) | | {} | +| [tag_bindings](variables.tf#L407) | Tag bindings for this service, in key => tag value id format. | map(string) | | {} | +| [type](variables.tf#L414) | Type of Cloud Run resource to deploy: JOB, SERVICE or WORKERPOOL. | string | | "SERVICE" | +| [volumes](variables.tf#L424) | Named volumes in containers in name => attributes format. | map(object({…})) | | {} | +| [vpc_connector_create](variables-vpcconnector.tf#L17) | VPC connector network configuration. Must be provided if new VPC connector is being created. | object({…}) | | null | +| [workerpool_config](variables.tf#L458) | Cloud Run Worker Pool specific configuration. | object({…}) | | {} | ## Outputs @@ -570,21 +1123,14 @@ module "cloud_run" { |---|---|:---:| | [id](outputs.tf#L17) | Fully qualified job or service id. | | | [invoke_command](outputs.tf#L22) | Command to invoke Cloud Run Service / submit job. | | -| [job](outputs.tf#L36) | Cloud Run Job. | | -| [service](outputs.tf#L41) | Cloud Run Service. | | +| [job](outputs.tf#L27) | Cloud Run Job. | | +| [resource](outputs.tf#L32) | Cloud Run resource (job, service or worker_pool). | | +| [resource_name](outputs.tf#L37) | Cloud Run resource (job, service or workerpool) service name. | | +| [service](outputs.tf#L42) | Cloud Run Service. | | | [service_account](outputs.tf#L46) | Service account resource. | | | [service_account_email](outputs.tf#L51) | Service account email. | | | [service_account_iam_email](outputs.tf#L56) | Service account email. | | | [service_name](outputs.tf#L64) | Cloud Run service name. | | | [service_uri](outputs.tf#L69) | Main URI in which the service is serving traffic. | | | [vpc_connector](outputs.tf#L74) | VPC connector resource if created. | | - -## Fixtures - -- cloudsql-instance.tf -- iam-service-account.tf -- pubsub.tf -- secret-credentials.tf -- shared-vpc.tf -- vpc-connector.tf diff --git a/modules/cloud-run-v2/identity.tf b/modules/cloud-run-v2/identity.tf new file mode 100644 index 000000000..47e17551f --- /dev/null +++ b/modules/cloud-run-v2/identity.tf @@ -0,0 +1,83 @@ +/** + * Copyright 2025 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 { + # effective identity type, defaulting to service account when the service + # does not opt into a Cloud Run managed identity + identity_type = try( + var.service_config.workload_identity_config.identity_type, + "IDENTITY_TYPE_SERVICE_ACCOUNT" + ) + # workload and agent identities are managed by Cloud Run, so no service + # account is created and no roles are bound when either of them is in use + service_account_create = ( + var.service_account_config.create + && local.identity_type == "IDENTITY_TYPE_SERVICE_ACCOUNT" + ) + # the module-managed service account when we create one, the externally + # managed one resolved via context when its email is passed in, null + # otherwise: either a managed identity is in use, or the service falls back + # to the Compute default service account. Variable validation guarantees + # the email is unset for the non service account identity types + service_account_email = ( + local.service_account_create + ? google_service_account.service_account[0].email + : var.service_account_config.email == null + ? null + : lookup( + local.ctx.iam_principals, + var.service_account_config.email, + var.service_account_config.email + ) + ) + service_account_roles = [ + for role in var.service_account_config.roles + : lookup(local.ctx.custom_roles, role, role) + ] + # principal backing a workload identity, resolved via context; agent + # identities leave it unset as Cloud Run assigns the identity itself + workload_identity = ( + try(var.service_config.workload_identity_config.identity, null) == null + ? null + : lookup( + local.ctx.iam_principals, + var.service_config.workload_identity_config.identity, + var.service_config.workload_identity_config.identity + ) + ) +} + +resource "google_service_account" "service_account" { + count = local.service_account_create ? 1 : 0 + project = local.project_id + account_id = coalesce(var.service_account_config.name, var.name) + display_name = coalesce( + var.service_account_config.display_name, + var.service_account_config.name, + var.name + ) +} + +resource "google_project_iam_member" "default" { + for_each = ( + local.service_account_create + ? toset(local.service_account_roles) + : toset([]) + ) + role = each.key + project = local.project_id + member = google_service_account.service_account[0].member +} diff --git a/modules/cloud-run-v2/job-managed.tf b/modules/cloud-run-v2/job-managed.tf new file mode 100644 index 000000000..2e3a0e026 --- /dev/null +++ b/modules/cloud-run-v2/job-managed.tf @@ -0,0 +1,232 @@ +/** + * Copyright 2025 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_cloud_run_v2_job" "job" { + count = var.type == "JOB" && var.managed_revision ? 1 : 0 + provider = google-beta + project = local.project_id + location = local.location + name = var.name + labels = var.labels + launch_stage = var.launch_stage + deletion_protection = var.deletion_protection + + dynamic "binary_authorization" { + for_each = var.binary_authorization == null ? [] : [""] + content { + breakglass_justification = var.binary_authorization.breakglass_justification + policy = var.binary_authorization.policy + use_default = var.binary_authorization.use_default + } + } + template { + labels = var.revision.labels + task_count = var.job_config.task_count + template { + encryption_key = var.encryption_key + gpu_zonal_redundancy_disabled = var.revision.gpu_zonal_redundancy_disabled + dynamic "node_selector" { + for_each = var.revision.node_selector == null ? [] : [""] + content { + accelerator = var.revision.node_selector.accelerator + } + } + dynamic "vpc_access" { + for_each = local.connector == null ? [] : [""] + content { + connector = local.connector + egress = try(var.revision.vpc_access.egress, null) + } + } + dynamic "vpc_access" { + for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] + content { + egress = var.revision.vpc_access.egress + network_interfaces { + subnetwork = var.revision.vpc_access.subnet == null ? null : lookup( + local.ctx.subnets, var.revision.vpc_access.subnet, + var.revision.vpc_access.subnet + ) + network = var.revision.vpc_access.network == null ? null : lookup( + local.ctx.networks, var.revision.vpc_access.network, + var.revision.vpc_access.network + ) + tags = var.revision.vpc_access.tags + } + } + } + max_retries = var.job_config.max_retries + timeout = var.job_config.timeout + service_account = local.service_account_email + dynamic "containers" { + for_each = var.containers + content { + name = containers.key + image = containers.value.image + depends_on = containers.value.depends_on + command = containers.value.command + args = containers.value.args + dynamic "env" { + for_each = coalesce(containers.value.env, tomap({})) + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = coalesce(containers.value.env_from_key, tomap({})) + content { + name = env.key + value_source { + secret_key_ref { + secret = env.value.secret + version = env.value.version + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = containers.value.resources.limits + } + } + dynamic "ports" { + for_each = coalesce(containers.value.ports, tomap({})) + content { + container_port = ports.value.container_port + name = ports.value.name + } + } + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + dynamic "startup_probe" { + for_each = containers.value.startup_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds + timeout_seconds = containers.value.startup_probe.timeout_seconds + period_seconds = containers.value.startup_probe.period_seconds + failure_threshold = containers.value.startup_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.startup_probe.http_get == null ? [] : [""] + content { + path = containers.value.startup_probe.http_get.path + port = containers.value.startup_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.startup_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "tcp_socket" { + for_each = containers.value.startup_probe.tcp_socket == null ? [] : [""] + content { + port = containers.value.startup_probe.tcp_socket.port + } + } + dynamic "grpc" { + for_each = containers.value.startup_probe.grpc == null ? [] : [""] + content { + port = containers.value.startup_probe.grpc.port + service = containers.value.startup_probe.grpc.service + } + } + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + } + + lifecycle { + ignore_changes = [ + template[0].annotations["run.googleapis.com/operation-id"], + ] + } +} diff --git a/modules/cloud-run-v2/job-unmanaged.tf b/modules/cloud-run-v2/job-unmanaged.tf new file mode 100644 index 000000000..7d9ff0b9d --- /dev/null +++ b/modules/cloud-run-v2/job-unmanaged.tf @@ -0,0 +1,236 @@ +/** + * Copyright 2025 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_cloud_run_v2_job" "job_unmanaged" { + count = var.type == "JOB" && !var.managed_revision ? 1 : 0 + provider = google-beta + project = local.project_id + location = local.location + name = var.name + labels = var.labels + launch_stage = var.launch_stage + deletion_protection = var.deletion_protection + + dynamic "binary_authorization" { + for_each = var.binary_authorization == null ? [] : [""] + content { + breakglass_justification = var.binary_authorization.breakglass_justification + policy = var.binary_authorization.policy + use_default = var.binary_authorization.use_default + } + } + template { + labels = var.revision.labels + task_count = var.job_config.task_count + template { + encryption_key = var.encryption_key + gpu_zonal_redundancy_disabled = var.revision.gpu_zonal_redundancy_disabled + dynamic "node_selector" { + for_each = var.revision.node_selector == null ? [] : [""] + content { + accelerator = var.revision.node_selector.accelerator + } + } + dynamic "vpc_access" { + for_each = local.connector == null ? [] : [""] + content { + connector = local.connector + egress = try(var.revision.vpc_access.egress, null) + } + } + dynamic "vpc_access" { + for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] + content { + egress = var.revision.vpc_access.egress + network_interfaces { + subnetwork = var.revision.vpc_access.subnet == null ? null : lookup( + local.ctx.subnets, var.revision.vpc_access.subnet, + var.revision.vpc_access.subnet + ) + network = var.revision.vpc_access.network == null ? null : lookup( + local.ctx.networks, var.revision.vpc_access.network, + var.revision.vpc_access.network + ) + tags = var.revision.vpc_access.tags + } + } + } + max_retries = var.job_config.max_retries + timeout = var.job_config.timeout + service_account = local.service_account_email + dynamic "containers" { + for_each = var.containers + content { + name = containers.key + image = containers.value.image + depends_on = containers.value.depends_on + command = containers.value.command + args = containers.value.args + dynamic "env" { + for_each = coalesce(containers.value.env, tomap({})) + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = coalesce(containers.value.env_from_key, tomap({})) + content { + name = env.key + value_source { + secret_key_ref { + secret = env.value.secret + version = env.value.version + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = containers.value.resources.limits + } + } + dynamic "ports" { + for_each = coalesce(containers.value.ports, tomap({})) + content { + container_port = ports.value.container_port + name = ports.value.name + } + } + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + dynamic "startup_probe" { + for_each = containers.value.startup_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds + timeout_seconds = containers.value.startup_probe.timeout_seconds + period_seconds = containers.value.startup_probe.period_seconds + failure_threshold = containers.value.startup_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.startup_probe.http_get == null ? [] : [""] + content { + path = containers.value.startup_probe.http_get.path + port = containers.value.startup_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.startup_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "tcp_socket" { + for_each = containers.value.startup_probe.tcp_socket == null ? [] : [""] + content { + port = containers.value.startup_probe.tcp_socket.port + } + } + dynamic "grpc" { + for_each = containers.value.startup_probe.grpc == null ? [] : [""] + content { + port = containers.value.startup_probe.grpc.port + service = containers.value.startup_probe.grpc.service + } + } + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + } + + lifecycle { + ignore_changes = [ + client, + client_version, + template[0].annotations["run.googleapis.com/operation-id"], + template[0].template, + template[0].labels + ] + } +} diff --git a/modules/cloud-run-v2/job.tf b/modules/cloud-run-v2/job.tf index bd2584f15..40ce76425 100644 --- a/modules/cloud-run-v2/job.tf +++ b/modules/cloud-run-v2/job.tf @@ -1,5 +1,5 @@ /** - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,168 +14,11 @@ * limitations under the License. */ -resource "google_cloud_run_v2_job" "job" { - count = var.create_job ? 1 : 0 - provider = google-beta - project = var.project_id - location = var.region - name = "${local.prefix}${var.name}" - labels = var.labels - launch_stage = var.launch_stage - deletion_protection = var.deletion_protection - template { - task_count = var.revision.job.task_count - template { - encryption_key = var.encryption_key - dynamic "vpc_access" { - for_each = local.connector == null ? [] : [""] - content { - connector = local.connector - egress = try(var.revision.vpc_access.egress, null) - } - } - dynamic "vpc_access" { - for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] - content { - egress = var.revision.vpc_access.egress - network_interfaces { - subnetwork = var.revision.vpc_access.subnet - network = var.revision.vpc_access.network - tags = var.revision.vpc_access.tags - } - } - } - max_retries = var.revision.job.max_retries - timeout = var.revision.timeout - service_account = local.service_account_email - dynamic "containers" { - for_each = var.containers - content { - name = containers.key - image = containers.value.image - command = containers.value.command - args = containers.value.args - dynamic "env" { - for_each = coalesce(containers.value.env, tomap({})) - content { - name = env.key - value = env.value - } - } - dynamic "env" { - for_each = coalesce(containers.value.env_from_key, tomap({})) - content { - name = env.key - value_source { - secret_key_ref { - secret = env.value.secret - version = env.value.version - } - } - } - } - dynamic "resources" { - for_each = containers.value.resources == null ? [] : [""] - content { - limits = containers.value.resources.limits - } - } - dynamic "ports" { - for_each = coalesce(containers.value.ports, tomap({})) - content { - container_port = ports.value.container_port - name = ports.value.name - } - } - dynamic "volume_mounts" { - for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } - content { - name = volume_mounts.key - mount_path = volume_mounts.value - } - } - # CloudSQL is the last mount in the list returned by API - dynamic "volume_mounts" { - for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } - content { - name = volume_mounts.key - mount_path = volume_mounts.value - } - } - } - } - dynamic "volumes" { - for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } - content { - name = volumes.key - dynamic "secret" { - for_each = volumes.value.secret == null ? [] : [""] - content { - secret = volumes.value.secret.name - default_mode = volumes.value.secret.default_mode - dynamic "items" { - for_each = volumes.value.secret.path == null ? [] : [""] - content { - path = volumes.value.secret.path - version = volumes.value.secret.version - mode = volumes.value.secret.mode - } - } - } - } - - dynamic "empty_dir" { - for_each = volumes.value.empty_dir_size == null ? [] : [""] - content { - medium = "MEMORY" - size_limit = volumes.value.empty_dir_size - } - } - dynamic "gcs" { - for_each = volumes.value.gcs == null ? [] : [""] - content { - bucket = volumes.value.gcs.bucket - read_only = volumes.value.gcs.is_read_only - } - } - dynamic "nfs" { - for_each = volumes.value.nfs == null ? [] : [""] - content { - server = volumes.value.nfs.server - path = volumes.value.nfs.path - read_only = volumes.value.nfs.is_read_only - } - } - } - } - # CloudSQL is the last volume in the list returned by API - dynamic "volumes" { - for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } - content { - name = volumes.key - dynamic "cloud_sql_instance" { - for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] - content { - instances = volumes.value.cloud_sql_instances - } - } - } - } - } - } - - lifecycle { - ignore_changes = [ - template[0].annotations["run.googleapis.com/operation-id"], - ] - } -} - resource "google_cloud_run_v2_job_iam_binding" "binding" { - for_each = var.create_job ? var.iam : {} - project = google_cloud_run_v2_job.job[0].project - location = google_cloud_run_v2_job.job[0].location - name = google_cloud_run_v2_job.job[0].name - role = each.key - members = each.value + for_each = var.type == "JOB" ? var.iam : {} + project = local.resource.project + location = local.resource.location + name = local.resource.name + role = lookup(local.ctx.custom_roles, each.key, each.key) + members = [for member in each.value : lookup(local.ctx.iam_principals, member, member)] } diff --git a/modules/cloud-run-v2/main.tf b/modules/cloud-run-v2/main.tf index 5e506de7c..4333ca5a2 100644 --- a/modules/cloud-run-v2/main.tf +++ b/modules/cloud-run-v2/main.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 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,107 +15,67 @@ */ locals { + _ctx_p = "$" + ctx = { + for k, v in var.context : k => { + for kk, vv in v : "${local._ctx_p}${k}:${kk}" => vv + } if !endswith(k, "_vars") + } connector = ( var.vpc_connector_create != null ? google_vpc_access_connector.connector[0].id : try(var.revision.vpc_access.connector, null) ) - prefix = var.prefix == null ? "" : "${var.prefix}-" + _invoke_command = { + JOB = <<-EOT + gcloud run jobs execute \ + --project ${var.project_id} \ + --region ${var.region} \ + --wait ${local.resource.name} \ + --args= + EOT + WORKERPOOL = "" + SERVICE = <<-EOT + curl -H "Authorization: bearer $(gcloud auth print-identity-token)" \ + ${local.resource.uri} \ + -X POST -d 'data' + EOT + } + invoke_command = local._invoke_command[var.type] + + location = lookup(local.ctx.locations, var.region, var.region) + project_id = lookup(local.ctx.project_ids, var.project_id, var.project_id) + multi_region_regions = ( + try(var.service_config.multi_region_settings.regions, null) == null + ? null + : [ + for r in var.service_config.multi_region_settings.regions : + lookup(local.ctx.locations, r, r) + ] + ) + revision_name = ( var.revision.name == null ? null : "${var.name}-${var.revision.name}" ) - service_account_email = ( - var.service_account_create - ? ( - length(google_service_account.service_account) > 0 - ? google_service_account.service_account[0].email - : null + _resource = { + "JOB" : ( + var.managed_revision ? + try(google_cloud_run_v2_job.job[0], null) : try(google_cloud_run_v2_job.job_unmanaged[0], null) + ) + "WORKERPOOL" : ( + var.managed_revision ? + try(google_cloud_run_v2_worker_pool.default_managed[0], null) : try(google_cloud_run_v2_worker_pool.default_unmanaged[0], null) + ) + "SERVICE" : ( + var.managed_revision ? + try(google_cloud_run_v2_service.service[0], null) : try(google_cloud_run_v2_service.service_unmanaged[0], null) ) - : var.service_account - ) - trigger_sa_create = try( - var.eventarc_triggers.service_account_create, false - ) - trigger_sa_email = try( - google_service_account.trigger_service_account[0].email, - var.eventarc_triggers.service_account_email, - null - ) -} - -resource "google_cloud_run_v2_service_iam_member" "default" { - # if authoritative invoker role is not present and we create trigger sa - # use additive binding to grant it the role - count = ( - lookup(var.iam, "roles/run.invoker", null) == null && - local.trigger_sa_create - ) ? 1 : 0 - project = google_cloud_run_v2_service.service[0].project - location = google_cloud_run_v2_service.service[0].location - name = google_cloud_run_v2_service.service[0].name - role = "roles/run.invoker" - member = "serviceAccount:${local.trigger_sa_email}" -} - -resource "google_service_account" "service_account" { - count = var.service_account_create ? 1 : 0 - project = var.project_id - account_id = "tf-cr-${var.name}" - display_name = "Terraform Cloud Run ${var.name}." -} - -resource "google_eventarc_trigger" "audit_log_triggers" { - for_each = coalesce(var.eventarc_triggers.audit_log, tomap({})) - name = "${local.prefix}audit-log-${each.key}" - location = google_cloud_run_v2_service.service[0].location - project = google_cloud_run_v2_service.service[0].project - matching_criteria { - attribute = "type" - value = "google.cloud.audit.log.v1.written" - } - matching_criteria { - attribute = "serviceName" - value = each.value.service - } - matching_criteria { - attribute = "methodName" - value = each.value.method - } - destination { - cloud_run_service { - service = google_cloud_run_v2_service.service[0].name - region = google_cloud_run_v2_service.service[0].location - } - } - service_account = local.trigger_sa_email -} - -resource "google_eventarc_trigger" "pubsub_triggers" { - for_each = coalesce(var.eventarc_triggers.pubsub, tomap({})) - name = "${local.prefix}pubsub-${each.key}" - location = google_cloud_run_v2_service.service[0].location - project = google_cloud_run_v2_service.service[0].project - matching_criteria { - attribute = "type" - value = "google.cloud.pubsub.topic.v1.messagePublished" - } - transport { - pubsub { - topic = each.value - } } - destination { - cloud_run_service { - service = google_cloud_run_v2_service.service[0].name - region = google_cloud_run_v2_service.service[0].location - } + resource = { + id = local._resource[var.type].id + location = local._resource[var.type].location + name = local._resource[var.type].name + project = local._resource[var.type].project + uri = var.type == "SERVICE" ? local._resource[var.type].uri : "" } - service_account = local.trigger_sa_email -} - -resource "google_service_account" "trigger_service_account" { - count = local.trigger_sa_create ? 1 : 0 - project = var.project_id - account_id = "tf-cr-trigger-${var.name}" - display_name = "Terraform trigger for Cloud Run ${var.name}." } diff --git a/modules/cloud-run-v2/outputs.tf b/modules/cloud-run-v2/outputs.tf index 0abcebd09..6f3d56318 100644 --- a/modules/cloud-run-v2/outputs.tf +++ b/modules/cloud-run-v2/outputs.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,33 +16,33 @@ output "id" { description = "Fully qualified job or service id." - value = var.create_job ? google_cloud_run_v2_job.job[0].id : google_cloud_run_v2_service.service[0].id + value = local.resource.id } output "invoke_command" { description = "Command to invoke Cloud Run Service / submit job." - value = ( - var.create_job ? <<-EOT - gcloud run jobs execute --project ${var.project_id} --region ${var.region} --wait ${google_cloud_run_v2_job.job[0].name} --args= - EOT - : <<-EOT - curl -H "Authorization: bearer $(gcloud auth print-identity-token)" \ - ${google_cloud_run_v2_service.service[0].uri} \ - -X POST -d 'data' - EOT - ) + value = local.invoke_command } output "job" { description = "Cloud Run Job." - value = var.create_job ? google_cloud_run_v2_job.job[0] : null + value = var.type == "JOB" ? local._resource[var.type] : null +} + +output "resource" { + description = "Cloud Run resource (job, service or worker_pool)." + value = local._resource[var.type] +} + +output "resource_name" { + description = "Cloud Run resource (job, service or workerpool) service name." + value = local.resource.name } output "service" { description = "Cloud Run Service." - value = var.create_job ? null : google_cloud_run_v2_service.service[0] + value = var.type == "SERVICE" ? local._resource[var.type] : null } - output "service_account" { description = "Service account resource." value = try(google_service_account.service_account[0], null) @@ -63,12 +63,12 @@ output "service_account_iam_email" { output "service_name" { description = "Cloud Run service name." - value = var.create_job ? null : google_cloud_run_v2_service.service[0].name + value = var.type == "SERVICE" ? local.resource.name : null } output "service_uri" { description = "Main URI in which the service is serving traffic." - value = var.create_job ? null : google_cloud_run_v2_service.service[0].uri + value = local.resource.uri } output "vpc_connector" { diff --git a/modules/cloud-run-v2/recipes/auto-update-image/README.md b/modules/cloud-run-v2/recipes/auto-update-image/README.md new file mode 100644 index 000000000..c0e22e63f --- /dev/null +++ b/modules/cloud-run-v2/recipes/auto-update-image/README.md @@ -0,0 +1,33 @@ +When deploying Cloud Run, and using tags such as `latest`, terraform will not redeploy image after container is built. By using `google_artifact_registry_docker_image` data resource you can force update of the Cloud Run, each time container is rebuild. + + +```hcl +module "docker_artifact_registry" { + source = "./fabric/modules/artifact-registry" + project_id = var.project_id + format = { docker = { standard = {} } } + location = var.region + name = "docker-registry" +} + +data "google_artifact_registry_docker_image" "this" { + project = var.project_id + image_name = "image-name" + location = var.region + repository_id = module.docker_artifact_registry.repository.repository_id +} + +module "hello" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "hello" + region = var.region + containers = { + hello = { + image = data.google_artifact_registry_docker_image.this.self_link # self link returns image URI with hash + } + } +} + +# tftest skip +``` diff --git a/modules/cloud-run-v2/recipes/cloudsql-iam-auth-proxy/README.md b/modules/cloud-run-v2/recipes/cloudsql-iam-auth-proxy/README.md new file mode 100644 index 000000000..737cda47e --- /dev/null +++ b/modules/cloud-run-v2/recipes/cloudsql-iam-auth-proxy/README.md @@ -0,0 +1,106 @@ +# Cloud Run with Cloud SQL IAM Auth Proxy + +Cloud Run provides shorthand to connect to Cloud SQL database, but that requires connecting using password. In this recipe connection is authorized using IAM + +```hcl +# create service account for Cloud Run service +module "run-sa" { + source = "./fabric/modules/iam-service-account" + project_id = var.project_id + name = "db-run" + iam_project_roles = { + (var.project_id) = [ + "roles/storage.objectViewer", + "roles/logging.logWriter", + "roles/cloudsql.client", + "roles/cloudsql.instanceUser" + ] + } +} + +# Create MySQL database +module "db" { + source = "./fabric/modules/cloudsql-instance" + project_id = var.project_id + network_config = { + connectivity = { + psa_config = { + private_network = var.vpc.self_link + } + } + } + name = "db" + region = var.region + database_version = "MYSQL_8_4" + tier = "db-g1-small" + + flags = { + cloudsql_iam_authentication = "on" + disconnect_on_expired_password = "on" + } + + databases = [ + "test" + ] + + users = { + # IAM Service Account + (module.run-sa.email) = { + type = "CLOUD_IAM_SERVICE_ACCOUNT" + } + } + gcp_deletion_protection = false + terraform_deletion_protection = false +} + +module "database_run" { + source = "./fabric/modules/cloud-run-v2" + project_id = var.project_id + name = "db-test" + region = var.region + containers = { + sqlproxy = { + image = "docker.io/phpmyadmin" + ports = { + "" = { + container_port = 8080 + name = "http1" + } + } + env = { + APACHE_PORT = "8080" + PMA_SOCKET = "/cloudsql/${module.db.connection_name}" + PMA_USER = split("@", module.run-sa.email)[0] + } + volume_mounts = { + custom_cloudsql = "/cloudsql" + } + } + authproxy = { + name = "cloudsql" + image = "gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.18.0" + args = [ + "--auto-iam-authn", + "--private-ip", + "--unix-socket=/cloudsql", + module.db.connection_name + ] + ports = {} + volume_mounts = { + custom_cloudsql = "/cloudsql" + } + } + } + service_account_config = { + create = false + email = module.run-sa.email + } + volumes = { + custom_cloudsql = { + empty_dir_size = "128k" + } + } + deletion_protection = false +} +# tftest inventory=recipe-cloudsql-iam-auth-proxy.yaml e2e +``` diff --git a/modules/cloud-run-v2/service-managed.tf b/modules/cloud-run-v2/service-managed.tf new file mode 100644 index 000000000..9bd377365 --- /dev/null +++ b/modules/cloud-run-v2/service-managed.tf @@ -0,0 +1,306 @@ +/** + * Copyright 2025 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_cloud_run_v2_service" "service" { + count = var.type == "SERVICE" && var.managed_revision ? 1 : 0 + provider = google-beta + project = local.project_id + location = local.location + name = var.name + default_uri_disabled = var.service_config.default_uri_disabled + ingress = var.service_config.ingress + invoker_iam_disabled = var.service_config.invoker_iam_disabled + labels = var.labels + launch_stage = var.launch_stage + custom_audiences = var.service_config.custom_audiences + deletion_protection = var.deletion_protection + iap_enabled = var.service_config.iap_config != null + + dynamic "binary_authorization" { + for_each = var.binary_authorization == null ? [] : [""] + content { + breakglass_justification = var.binary_authorization.breakglass_justification + policy = var.binary_authorization.policy + use_default = var.binary_authorization.use_default + } + } + + dynamic "multi_region_settings" { + for_each = local.multi_region_regions == null ? [] : [""] + content { + regions = local.multi_region_regions + } + } + + dynamic "traffic" { + for_each = var.service_config.traffic == null ? [] : var.service_config.traffic + content { + percent = traffic.value.percent + revision = traffic.value.revision + tag = traffic.value.tag + type = traffic.value.type + } + } + + template { + labels = var.revision.labels + encryption_key = var.encryption_key + revision = local.revision_name + execution_environment = ( + var.service_config.gen2_execution_environment + ? "EXECUTION_ENVIRONMENT_GEN2" : "EXECUTION_ENVIRONMENT_GEN1" + ) + gpu_zonal_redundancy_disabled = var.revision.gpu_zonal_redundancy_disabled + max_instance_request_concurrency = var.service_config.max_concurrency + dynamic "node_selector" { + for_each = var.revision.node_selector == null ? [] : [""] + content { + accelerator = var.revision.node_selector.accelerator + } + } + dynamic "scaling" { + for_each = var.service_config.scaling == null ? [] : [""] + content { + max_instance_count = var.service_config.scaling.max_instance_count + min_instance_count = var.service_config.scaling.min_instance_count + } + } + dynamic "vpc_access" { + for_each = local.connector == null ? [] : [""] + content { + connector = local.connector + egress = try(var.revision.vpc_access.egress, null) + } + } + dynamic "vpc_access" { + for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] + content { + egress = var.revision.vpc_access.egress + network_interfaces { + subnetwork = var.revision.vpc_access.subnet == null ? null : lookup( + local.ctx.subnets, var.revision.vpc_access.subnet, + var.revision.vpc_access.subnet + ) + network = var.revision.vpc_access.network == null ? null : lookup( + local.ctx.networks, var.revision.vpc_access.network, + var.revision.vpc_access.network + ) + tags = var.revision.vpc_access.tags + } + } + } + timeout = var.service_config.timeout + service_account = local.service_account_email + dynamic "workload_identity_config" { + for_each = var.service_config.workload_identity_config == null ? [] : [""] + content { + identity = local.workload_identity + identity_certificate_enabled = var.service_config.workload_identity_config.certificate_enabled + identity_type = var.service_config.workload_identity_config.identity_type + } + } + dynamic "containers" { + for_each = var.containers + content { + name = containers.key + image = containers.value.image + depends_on = containers.value.depends_on + command = containers.value.command + args = containers.value.args + dynamic "env" { + for_each = coalesce(containers.value.env, tomap({})) + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = coalesce(containers.value.env_from_key, tomap({})) + content { + name = env.key + value_source { + secret_key_ref { + secret = env.value.secret + version = env.value.version + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = containers.value.resources.limits + cpu_idle = containers.value.resources.cpu_idle + startup_cpu_boost = containers.value.resources.startup_cpu_boost + } + } + dynamic "ports" { + for_each = coalesce(containers.value.ports, tomap({})) + content { + container_port = ports.value.container_port + name = ports.value.name + } + } + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + dynamic "liveness_probe" { + for_each = containers.value.liveness_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.liveness_probe.initial_delay_seconds + timeout_seconds = containers.value.liveness_probe.timeout_seconds + period_seconds = containers.value.liveness_probe.period_seconds + failure_threshold = containers.value.liveness_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.liveness_probe.http_get == null ? [] : [""] + content { + path = containers.value.liveness_probe.http_get.path + port = containers.value.liveness_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.liveness_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "grpc" { + for_each = containers.value.liveness_probe.grpc == null ? [] : [""] + content { + port = containers.value.liveness_probe.grpc.port + service = containers.value.liveness_probe.grpc.service + } + } + } + } + dynamic "startup_probe" { + for_each = containers.value.startup_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds + timeout_seconds = containers.value.startup_probe.timeout_seconds + period_seconds = containers.value.startup_probe.period_seconds + failure_threshold = containers.value.startup_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.startup_probe.http_get == null ? [] : [""] + content { + path = containers.value.startup_probe.http_get.path + port = containers.value.startup_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.startup_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "tcp_socket" { + for_each = containers.value.startup_probe.tcp_socket == null ? [] : [""] + content { + port = containers.value.startup_probe.tcp_socket.port + } + } + dynamic "grpc" { + for_each = containers.value.startup_probe.grpc == null ? [] : [""] + content { + port = containers.value.startup_probe.grpc.port + service = containers.value.startup_probe.grpc.service + } + } + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + + lifecycle { + ignore_changes = [ + client, + client_version, + template[0].annotations["run.googleapis.com/operation-id"], + ] + } +} diff --git a/modules/cloud-run-v2/service-unmanaged.tf b/modules/cloud-run-v2/service-unmanaged.tf new file mode 100644 index 000000000..e1390636e --- /dev/null +++ b/modules/cloud-run-v2/service-unmanaged.tf @@ -0,0 +1,310 @@ +/** + * Copyright 2025 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_cloud_run_v2_service" "service_unmanaged" { + count = var.type == "SERVICE" && !var.managed_revision ? 1 : 0 + provider = google-beta + project = local.project_id + location = local.location + name = var.name + default_uri_disabled = var.service_config.default_uri_disabled + ingress = var.service_config.ingress + invoker_iam_disabled = var.service_config.invoker_iam_disabled + labels = var.labels + launch_stage = var.launch_stage + custom_audiences = var.service_config.custom_audiences + deletion_protection = var.deletion_protection + iap_enabled = var.service_config.iap_config != null + + dynamic "binary_authorization" { + for_each = var.binary_authorization == null ? [] : [""] + content { + breakglass_justification = var.binary_authorization.breakglass_justification + policy = var.binary_authorization.policy + use_default = var.binary_authorization.use_default + } + } + + dynamic "multi_region_settings" { + for_each = local.multi_region_regions == null ? [] : [""] + content { + regions = local.multi_region_regions + } + } + + dynamic "traffic" { + for_each = var.service_config.traffic == null ? [] : var.service_config.traffic + content { + percent = traffic.value.percent + revision = traffic.value.revision + tag = traffic.value.tag + type = traffic.value.type + } + } + + template { + labels = var.revision.labels + encryption_key = var.encryption_key + revision = local.revision_name + execution_environment = ( + var.service_config.gen2_execution_environment + ? "EXECUTION_ENVIRONMENT_GEN2" : "EXECUTION_ENVIRONMENT_GEN1" + ) + gpu_zonal_redundancy_disabled = var.revision.gpu_zonal_redundancy_disabled + max_instance_request_concurrency = var.service_config.max_concurrency + dynamic "node_selector" { + for_each = var.revision.node_selector == null ? [] : [""] + content { + accelerator = var.revision.node_selector.accelerator + } + } + dynamic "scaling" { + for_each = var.service_config.scaling == null ? [] : [""] + content { + max_instance_count = var.service_config.scaling.max_instance_count + min_instance_count = var.service_config.scaling.min_instance_count + } + } + dynamic "vpc_access" { + for_each = local.connector == null ? [] : [""] + content { + connector = local.connector + egress = try(var.revision.vpc_access.egress, null) + } + } + dynamic "vpc_access" { + for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] + content { + egress = var.revision.vpc_access.egress + network_interfaces { + subnetwork = var.revision.vpc_access.subnet == null ? null : lookup( + local.ctx.subnets, var.revision.vpc_access.subnet, + var.revision.vpc_access.subnet + ) + network = var.revision.vpc_access.network == null ? null : lookup( + local.ctx.networks, var.revision.vpc_access.network, + var.revision.vpc_access.network + ) + tags = var.revision.vpc_access.tags + } + } + } + timeout = var.service_config.timeout + service_account = local.service_account_email + dynamic "workload_identity_config" { + for_each = var.service_config.workload_identity_config == null ? [] : [""] + content { + identity = local.workload_identity + identity_certificate_enabled = var.service_config.workload_identity_config.certificate_enabled + identity_type = var.service_config.workload_identity_config.identity_type + } + } + dynamic "containers" { + for_each = var.containers + content { + name = containers.key + image = containers.value.image + depends_on = containers.value.depends_on + command = containers.value.command + args = containers.value.args + dynamic "env" { + for_each = coalesce(containers.value.env, tomap({})) + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = coalesce(containers.value.env_from_key, tomap({})) + content { + name = env.key + value_source { + secret_key_ref { + secret = env.value.secret + version = env.value.version + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = containers.value.resources.limits + cpu_idle = containers.value.resources.cpu_idle + startup_cpu_boost = containers.value.resources.startup_cpu_boost + } + } + dynamic "ports" { + for_each = coalesce(containers.value.ports, tomap({})) + content { + container_port = ports.value.container_port + name = ports.value.name + } + } + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + dynamic "liveness_probe" { + for_each = containers.value.liveness_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.liveness_probe.initial_delay_seconds + timeout_seconds = containers.value.liveness_probe.timeout_seconds + period_seconds = containers.value.liveness_probe.period_seconds + failure_threshold = containers.value.liveness_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.liveness_probe.http_get == null ? [] : [""] + content { + path = containers.value.liveness_probe.http_get.path + port = containers.value.liveness_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.liveness_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "grpc" { + for_each = containers.value.liveness_probe.grpc == null ? [] : [""] + content { + port = containers.value.liveness_probe.grpc.port + service = containers.value.liveness_probe.grpc.service + } + } + } + } + dynamic "startup_probe" { + for_each = containers.value.startup_probe == null ? [] : [""] + content { + initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds + timeout_seconds = containers.value.startup_probe.timeout_seconds + period_seconds = containers.value.startup_probe.period_seconds + failure_threshold = containers.value.startup_probe.failure_threshold + dynamic "http_get" { + for_each = containers.value.startup_probe.http_get == null ? [] : [""] + content { + path = containers.value.startup_probe.http_get.path + port = containers.value.startup_probe.http_get.port + dynamic "http_headers" { + for_each = coalesce(containers.value.startup_probe.http_get.http_headers, tomap({})) + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "tcp_socket" { + for_each = containers.value.startup_probe.tcp_socket == null ? [] : [""] + content { + port = containers.value.startup_probe.tcp_socket.port + } + } + dynamic "grpc" { + for_each = containers.value.startup_probe.grpc == null ? [] : [""] + content { + port = containers.value.startup_probe.grpc.port + service = containers.value.startup_probe.grpc.service + } + } + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + + lifecycle { + ignore_changes = [ + build_config, + client, + client_version, + template[0].revision, + template[0].annotations["run.googleapis.com/operation-id"], + template[0].containers, + template[0].labels + ] + } +} diff --git a/modules/cloud-run-v2/service.tf b/modules/cloud-run-v2/service.tf index 8df793740..bb15a9ee9 100644 --- a/modules/cloud-run-v2/service.tf +++ b/modules/cloud-run-v2/service.tf @@ -1,5 +1,5 @@ /** - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,253 +14,105 @@ * limitations under the License. */ -resource "google_cloud_run_v2_service" "service" { - count = var.create_job ? 0 : 1 - provider = google-beta - project = var.project_id - location = var.region - name = "${local.prefix}${var.name}" - ingress = var.ingress - labels = var.labels - launch_stage = var.launch_stage - custom_audiences = var.custom_audiences +resource "google_cloud_run_v2_service_iam_binding" "binding" { + for_each = var.type == "SERVICE" ? var.iam : {} + project = local.resource.project + location = local.resource.location + name = local.resource.name + role = lookup(local.ctx.custom_roles, each.key, each.key) + members = [for member in each.value : lookup(local.ctx.iam_principals, member, member)] +} - template { - encryption_key = var.encryption_key - revision = local.revision_name - execution_environment = ( - var.revision.gen2_execution_environment == true - ? "EXECUTION_ENVIRONMENT_GEN2" : "EXECUTION_ENVIRONMENT_GEN1" - ) - max_instance_request_concurrency = var.revision.max_concurrency - dynamic "scaling" { - for_each = (var.revision.max_instance_count == null && var.revision.min_instance_count == null) ? [] : [""] - content { - max_instance_count = var.revision.max_instance_count - min_instance_count = var.revision.min_instance_count - } - } - dynamic "vpc_access" { - for_each = local.connector == null ? [] : [""] - content { - connector = local.connector - egress = try(var.revision.vpc_access.egress, null) - } - } - dynamic "vpc_access" { - for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] - content { - egress = var.revision.vpc_access.egress - network_interfaces { - subnetwork = var.revision.vpc_access.subnet - network = var.revision.vpc_access.network - tags = var.revision.vpc_access.tags - } - } - } - timeout = var.revision.timeout - service_account = local.service_account_email - dynamic "containers" { - for_each = var.containers - content { - name = containers.key - image = containers.value.image - command = containers.value.command - args = containers.value.args - dynamic "env" { - for_each = coalesce(containers.value.env, tomap({})) - content { - name = env.key - value = env.value - } - } - dynamic "env" { - for_each = coalesce(containers.value.env_from_key, tomap({})) - content { - name = env.key - value_source { - secret_key_ref { - secret = env.value.secret - version = env.value.version - } - } - } - } - dynamic "resources" { - for_each = containers.value.resources == null ? [] : [""] - content { - limits = containers.value.resources.limits - cpu_idle = containers.value.resources.cpu_idle - startup_cpu_boost = containers.value.resources.startup_cpu_boost - } - } - dynamic "ports" { - for_each = coalesce(containers.value.ports, tomap({})) - content { - container_port = ports.value.container_port - name = ports.value.name - } - } - dynamic "volume_mounts" { - for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } - content { - name = volume_mounts.key - mount_path = volume_mounts.value - } - } - # CloudSQL is the last mount in the list returned by API - dynamic "volume_mounts" { - for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } - content { - name = volume_mounts.key - mount_path = volume_mounts.value - } - } - dynamic "liveness_probe" { - for_each = containers.value.liveness_probe == null ? [] : [""] - content { - initial_delay_seconds = containers.value.liveness_probe.initial_delay_seconds - timeout_seconds = containers.value.liveness_probe.timeout_seconds - period_seconds = containers.value.liveness_probe.period_seconds - failure_threshold = containers.value.liveness_probe.failure_threshold - dynamic "http_get" { - for_each = containers.value.liveness_probe.http_get == null ? [] : [""] - content { - path = containers.value.liveness_probe.http_get.path - dynamic "http_headers" { - for_each = coalesce(containers.value.liveness_probe.http_get.http_headers, tomap({})) - content { - name = http_headers.key - value = http_headers.value - } - } - } - } - dynamic "grpc" { - for_each = containers.value.liveness_probe.grpc == null ? [] : [""] - content { - port = containers.value.liveness_probe.grpc.port - service = containers.value.liveness_probe.grpc.service - } - } - } - } - dynamic "startup_probe" { - for_each = containers.value.startup_probe == null ? [] : [""] - content { - initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds - timeout_seconds = containers.value.startup_probe.timeout_seconds - period_seconds = containers.value.startup_probe.period_seconds - failure_threshold = containers.value.startup_probe.failure_threshold - dynamic "http_get" { - for_each = containers.value.startup_probe.http_get == null ? [] : [""] - content { - path = containers.value.startup_probe.http_get.path - dynamic "http_headers" { - for_each = coalesce(containers.value.startup_probe.http_get.http_headers, tomap({})) - content { - name = http_headers.key - value = http_headers.value - } - } - } - } - dynamic "tcp_socket" { - for_each = containers.value.startup_probe.tcp_socket == null ? [] : [""] - content { - port = containers.value.startup_probe.tcp_socket.port - } - } - dynamic "grpc" { - for_each = containers.value.startup_probe.grpc == null ? [] : [""] - content { - port = containers.value.startup_probe.grpc.port - service = containers.value.startup_probe.grpc.service - } - } - } - } - } - } - dynamic "volumes" { - for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } - content { - name = volumes.key - dynamic "secret" { - for_each = volumes.value.secret == null ? [] : [""] - content { - secret = volumes.value.secret.name - default_mode = volumes.value.secret.default_mode - dynamic "items" { - for_each = volumes.value.secret.path == null ? [] : [""] - content { - path = volumes.value.secret.path - version = volumes.value.secret.version - mode = volumes.value.secret.mode - } - } - } - } +resource "google_iap_web_cloud_run_service_iam_member" "member" { + for_each = var.service_config.iap_config == null ? toset([]) : toset(var.service_config.iap_config.iam_additive) + project = local.resource.project + location = local.resource.location + cloud_run_service_name = local.resource.name + role = "roles/iap.httpsResourceAccessor" + member = lookup(local.ctx.iam_principals, each.key, each.key) +} - dynamic "empty_dir" { - for_each = volumes.value.empty_dir_size == null ? [] : [""] - content { - medium = "MEMORY" - size_limit = volumes.value.empty_dir_size - } - } - dynamic "gcs" { - for_each = volumes.value.gcs == null ? [] : [""] - content { - bucket = volumes.value.gcs.bucket - read_only = volumes.value.gcs.is_read_only - } - } - dynamic "nfs" { - for_each = volumes.value.nfs == null ? [] : [""] - content { - server = volumes.value.nfs.server - path = volumes.value.nfs.path - read_only = volumes.value.nfs.is_read_only - } - } - } - } - # CloudSQL is the last volume in the list returned by API - dynamic "volumes" { - for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } - content { - name = volumes.key - dynamic "cloud_sql_instance" { - for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] - content { - instances = volumes.value.cloud_sql_instances - } - } - } +resource "google_iap_web_cloud_run_service_iam_binding" "binding" { + for_each = ( + var.service_config.iap_config == null ? {} + : length(var.service_config.iap_config.iam) == 0 ? {} : { 1 = 1 } + ) + project = local.resource.project + location = local.resource.location + cloud_run_service_name = local.resource.name + role = "roles/iap.httpsResourceAccessor" + members = [for member in var.service_config.iap_config.iam : lookup(local.ctx.iam_principals, member, member)] +} + +# Event ARC for Cloud Run services +resource "google_eventarc_trigger" "audit_log_triggers" { + for_each = coalesce(var.service_config.eventarc_triggers.audit_log, tomap({})) + name = "audit-log-${each.key}" + location = local.resource.location + project = local.resource.project + matching_criteria { + attribute = "type" + value = "google.cloud.audit.log.v1.written" + } + matching_criteria { + attribute = "serviceName" + value = each.value.service + } + matching_criteria { + attribute = "methodName" + value = each.value.method + } + destination { + cloud_run_service { + service = local.resource.name + region = local.resource.location } } + service_account = var.service_config.eventarc_triggers.service_account_email +} - deletion_protection = var.deletion_protection - lifecycle { - ignore_changes = [ - template[0].annotations["run.googleapis.com/operation-id"], - ] +resource "google_eventarc_trigger" "pubsub_triggers" { + for_each = coalesce(var.service_config.eventarc_triggers.pubsub, tomap({})) + name = "pubsub-${each.key}" + location = local.resource.location + project = local.resource.project + matching_criteria { + attribute = "type" + value = "google.cloud.pubsub.topic.v1.messagePublished" + } + transport { + pubsub { + topic = each.value + } + } + destination { + cloud_run_service { + service = local.resource.name + region = local.resource.location + } } + service_account = var.service_config.eventarc_triggers.service_account_email } -resource "google_cloud_run_v2_service_iam_binding" "binding" { - for_each = var.create_job ? {} : var.iam - project = google_cloud_run_v2_service.service[0].project - location = google_cloud_run_v2_service.service[0].location - name = google_cloud_run_v2_service.service[0].name - role = each.key - members = ( - each.key != "roles/run.invoker" || !local.trigger_sa_create - ? each.value - # if invoker role is present and we create trigger sa, add it as member - : concat( - each.value, ["serviceAccount:${local.trigger_sa_email}"] - ) - ) +resource "google_eventarc_trigger" "storage_triggers" { + for_each = coalesce(var.service_config.eventarc_triggers.storage, tomap({})) + name = "storage-${each.key}" + location = local.resource.location + project = local.resource.project + matching_criteria { + attribute = "type" + value = "google.cloud.storage.object.v1.finalized" + } + matching_criteria { + attribute = "bucket" + value = each.value.bucket + } + destination { + cloud_run_service { + service = local.resource.name + region = local.resource.location + path = try(each.value.path, null) + } + } + service_account = var.service_config.eventarc_triggers.service_account_email } diff --git a/modules/cloud-run-v2/tags.tf b/modules/cloud-run-v2/tags.tf index 001911d03..ae496233d 100644 --- a/modules/cloud-run-v2/tags.tf +++ b/modules/cloud-run-v2/tags.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,22 @@ * limitations under the License. */ +locals { + _tag_bindings = { + for k, v in var.tag_bindings : k => lookup(local.ctx.tag_values, v, v) + } + resource_types = { + JOB = "jobs" + SERVICE = "services" + # WORKERPOOL = "worker-pools" # not yet supported for Worker Pools + } +} + resource "google_tags_location_tag_binding" "binding" { - for_each = var.create_job ? {} : var.tag_bindings + for_each = var.tag_bindings parent = ( - "//run.googleapis.com/projects/${var.project_id}/locations/${var.region}/services/${google_cloud_run_v2_service.service[0].name}" + "//run.googleapis.com/projects/${local.project_id}/locations/${local.location}/${local.resource_types[var.type]}/${local.resource.name}" ) - tag_value = each.value - location = var.region + tag_value = templatestring(local._tag_bindings[each.key], var.context.tag_vars) + location = local.location } diff --git a/modules/cloud-run-v2/variables-serviceaccount.tf b/modules/cloud-run-v2/variables-serviceaccount.tf new file mode 100644 index 000000000..878feed9f --- /dev/null +++ b/modules/cloud-run-v2/variables-serviceaccount.tf @@ -0,0 +1,31 @@ +/** + * 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 "service_account_config" { + description = "Service account configurations." + type = object({ + create = optional(bool, true) + display_name = optional(string) + email = optional(string) + name = optional(string) + roles = optional(list(string), [ + "roles/logging.logWriter", + "roles/monitoring.metricWriter" + ]) + }) + nullable = false + default = {} +} diff --git a/modules/cloud-run-v2/variables-vpcconnector.tf b/modules/cloud-run-v2/variables-vpcconnector.tf index 199f2d5bc..e45ebe20e 100644 --- a/modules/cloud-run-v2/variables-vpcconnector.tf +++ b/modules/cloud-run-v2/variables-vpcconnector.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 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,7 +15,7 @@ */ variable "vpc_connector_create" { - description = "Populate this to create a Serverless VPC Access connector." + description = "VPC connector network configuration. Must be provided if new VPC connector is being created." type = object({ ip_cidr_range = optional(string) machine_type = optional(string) @@ -37,4 +37,12 @@ variable "vpc_connector_create" { }), {}) }) default = null + validation { + condition = ( + var.vpc_connector_create == null || + try(var.vpc_connector_create.instances, null) != null || + try(var.vpc_connector_create.throughput, null) != null + ) + error_message = "VPC connector must specify either instances or throughput." + } } diff --git a/modules/cloud-run-v2/variables.tf b/modules/cloud-run-v2/variables.tf index 951ad84c1..7bc652e13 100644 --- a/modules/cloud-run-v2/variables.tf +++ b/modules/cloud-run-v2/variables.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,24 @@ * limitations under the License. */ +variable "binary_authorization" { + description = "Binary Authorization configuration." + type = object({ + breakglass_justification = optional(string) + policy = optional(string) + use_default = optional(bool) + }) + default = null +} + variable "containers" { description = "Containers in name => attributes format." type = map(object({ - image = string - command = optional(list(string)) - args = optional(list(string)) - env = optional(map(string)) + image = string + depends_on = optional(list(string)) + command = optional(list(string)) + args = optional(list(string)) + env = optional(map(string)) env_from_key = optional(map(object({ secret = string version = string @@ -33,6 +44,7 @@ variable "containers" { http_get = optional(object({ http_headers = optional(map(string)) path = optional(string) + port = optional(number) })) failure_threshold = optional(number) initial_delay_seconds = optional(number) @@ -44,10 +56,7 @@ variable "containers" { name = optional(string) }))) resources = optional(object({ - limits = optional(object({ - cpu = string - memory = string - })) + limits = optional(map(string)) cpu_idle = optional(bool) startup_cpu_boost = optional(bool) })) @@ -59,6 +68,7 @@ variable "containers" { http_get = optional(object({ http_headers = optional(map(string)) path = optional(string) + port = optional(number) })) tcp_socket = optional(object({ port = optional(number) @@ -72,18 +82,48 @@ variable "containers" { })) default = {} nullable = false -} -variable "create_job" { - description = "Create Cloud Run Job instead of Service." - type = bool - default = false + validation { + condition = alltrue([ + for c in var.containers : ( + c.resources == null ? true : 0 == length(setsubtract( + keys(lookup(c.resources, "limits", {})), + ["cpu", "memory", "nvidia.com/gpu"] + )) + ) + ]) + error_message = "Only following resource limits are available: 'cpu', 'memory' and 'nvidia.com/gpu'." + } + validation { + condition = alltrue([ + for c in var.containers : ( + var.type != "WORKERPOOL" || c.depends_on == null + ) + ]) + error_message = "depends_on is not supported when type is WORKERPOOL." + } } -variable "custom_audiences" { - description = "Custom audiences for service." - type = list(string) - default = null +variable "context" { + description = "Context-specific interpolations." + type = object({ + condition_vars = optional(map(map(string)), {}) # not needed here? + cidr_ranges = optional(map(string), {}) + custom_roles = optional(map(string), {}) + iam_principals = optional(map(string), {}) + kms_keys = optional(map(string), {}) + locations = optional(map(string), {}) + networks = optional(map(string), {}) + project_ids = optional(map(string), {}) + subnets = optional(map(string), {}) + tag_values = optional(map(string), {}) + tag_vars = optional(object({ + projects = optional(map(map(string)), {}) + organization = optional(map(string), {}) + }), {}) + }) + nullable = false + default = {} } variable "deletion_protection" { @@ -98,44 +138,24 @@ variable "encryption_key" { default = null } -variable "eventarc_triggers" { - description = "Event arc triggers for different sources." - type = object({ - audit_log = optional(map(object({ - method = string - service = string - }))) - pubsub = optional(map(string)) - service_account_email = optional(string) - service_account_create = optional(bool, false) - }) - default = {} - validation { - condition = var.eventarc_triggers.audit_log == null || (var.eventarc_triggers.audit_log != null && (var.eventarc_triggers.service_account_email != null || var.eventarc_triggers.service_account_create)) - error_message = "When setting var.eventarc_triggers.audit_log provide either service_account_email or set service_account_create to true" - } -} - variable "iam" { description = "IAM bindings for Cloud Run service in {ROLE => [MEMBERS]} format." type = map(list(string)) default = {} } -variable "ingress" { - description = "Ingress settings." - type = string - default = null +variable "job_config" { + description = "Cloud Run Job specific configuration." + type = object({ + max_retries = optional(number) + task_count = optional(number) + timeout = optional(string) + }) + default = {} + nullable = false validation { - condition = ( - var.ingress == null ? true : contains( - ["INGRESS_TRAFFIC_ALL", "INGRESS_TRAFFIC_INTERNAL_ONLY", - "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"], var.ingress) - ) - error_message = < 0 && length(try(var.service_config.iap_config.iam_additive, [])) > 0) + error_message = "Providing both 'iam' and 'iam_additive' in iap_config is not supported." + } + + validation { + condition = var.service_config.iap_config == null || var.launch_stage != "GA" + error_message = "iap is currently not supported in GA. Set launch_stage to 'BETA' or lower." + } + + validation { + condition = ( + var.service_config.ingress == null ? true : contains( + ["INGRESS_TRAFFIC_ALL", "INGRESS_TRAFFIC_INTERNAL_ONLY", + "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"], var.service_config.ingress) + ) + error_message = <= 0 && t.percent <= 100) + ]) + error_message = "Traffic percent must be between 0 and 100." + } + + validation { + condition = var.service_config.traffic == null ? true : alltrue([ + for t in var.service_config.traffic : + t.tag == null ? true : (length(t.tag) >= 3 && length(t.tag) <= 47) + ]) + error_message = "Traffic tag length must be between 3 and 47 characters." + } + + validation { + condition = ( + try(var.service_config.workload_identity_config.identity_type, null) == null + ? true : contains([ + "IDENTITY_TYPE_SERVICE_ACCOUNT", "IDENTITY_TYPE_WORKLOAD_IDENTITY", + "IDENTITY_TYPE_AGENT_IDENTITY"], var.service_config.workload_identity_config.identity_type) + ) + error_message = < v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + + lifecycle { + ignore_changes = [ + client, + client_version, + template[0].annotations["run.googleapis.com/operation-id"], + ] + } +} diff --git a/modules/cloud-run-v2/workerpool-unmanaged.tf b/modules/cloud-run-v2/workerpool-unmanaged.tf new file mode 100644 index 000000000..4e5f10ea2 --- /dev/null +++ b/modules/cloud-run-v2/workerpool-unmanaged.tf @@ -0,0 +1,202 @@ +/** + * Copyright 2025 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_cloud_run_v2_worker_pool" "default_unmanaged" { + count = var.type == "WORKERPOOL" && !var.managed_revision ? 1 : 0 + provider = google-beta + project = local.project_id + location = local.location + name = var.name + labels = var.labels + launch_stage = var.launch_stage + deletion_protection = var.deletion_protection + + dynamic "binary_authorization" { + for_each = var.binary_authorization == null ? [] : [""] + content { + breakglass_justification = var.binary_authorization.breakglass_justification + policy = var.binary_authorization.policy + use_default = var.binary_authorization.use_default + } + } + + dynamic "scaling" { + for_each = var.workerpool_config.scaling == null ? [] : [""] + content { + scaling_mode = var.workerpool_config.scaling.mode + max_instance_count = var.workerpool_config.scaling.max_instance_count + min_instance_count = var.workerpool_config.scaling.min_instance_count + manual_instance_count = var.workerpool_config.scaling.manual_instance_count + } + } + + template { + labels = var.revision.labels + encryption_key = var.encryption_key + revision = local.revision_name + + gpu_zonal_redundancy_disabled = var.revision.gpu_zonal_redundancy_disabled + dynamic "node_selector" { + for_each = var.revision.node_selector == null ? [] : [""] + content { + accelerator = var.revision.node_selector.accelerator + } + } + + # Serverless VPC connector is not supported + # dynamic "vpc_access" { + # for_each = local.connector == null ? [] : [""] + # content { + # connector = local.connector + # egress = try(var.revision.vpc_access.egress, null) + # } + # } + dynamic "vpc_access" { + for_each = var.revision.vpc_access.subnet == null && var.revision.vpc_access.network == null ? [] : [""] + content { + egress = var.revision.vpc_access.egress + network_interfaces { + subnetwork = var.revision.vpc_access.subnet == null ? null : lookup( + local.ctx.subnets, var.revision.vpc_access.subnet, + var.revision.vpc_access.subnet + ) + network = var.revision.vpc_access.network == null ? null : lookup( + local.ctx.networks, var.revision.vpc_access.network, + var.revision.vpc_access.network + ) + tags = var.revision.vpc_access.tags + } + } + } + service_account = local.service_account_email + dynamic "containers" { + for_each = var.containers + content { + name = containers.key + image = containers.value.image + command = containers.value.command + args = containers.value.args + dynamic "env" { + for_each = coalesce(containers.value.env, tomap({})) + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = coalesce(containers.value.env_from_key, tomap({})) + content { + name = env.key + value_source { + secret_key_ref { + secret = env.value.secret + version = env.value.version + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = containers.value.resources.limits + } + } + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k != "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + # CloudSQL is the last mount in the list returned by API + dynamic "volume_mounts" { + for_each = { for k, v in coalesce(containers.value.volume_mounts, tomap({})) : k => v if k == "cloudsql" } + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + } + } + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances == null } + content { + name = volumes.key + dynamic "secret" { + for_each = volumes.value.secret == null ? [] : [""] + content { + secret = volumes.value.secret.name + default_mode = volumes.value.secret.default_mode + dynamic "items" { + for_each = volumes.value.secret.path == null ? [] : [""] + content { + path = volumes.value.secret.path + version = volumes.value.secret.version + mode = volumes.value.secret.mode + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir_size == null ? [] : [""] + content { + medium = "MEMORY" + size_limit = volumes.value.empty_dir_size + } + } + dynamic "gcs" { + for_each = volumes.value.gcs == null ? [] : [""] + content { + bucket = volumes.value.gcs.bucket + read_only = volumes.value.gcs.is_read_only + } + } + dynamic "nfs" { + for_each = volumes.value.nfs == null ? [] : [""] + content { + server = volumes.value.nfs.server + path = volumes.value.nfs.path + read_only = volumes.value.nfs.is_read_only + } + } + } + } + # CloudSQL is the last volume in the list returned by API + dynamic "volumes" { + for_each = { for k, v in var.volumes : k => v if v.cloud_sql_instances != null } + content { + name = volumes.key + dynamic "cloud_sql_instance" { + for_each = length(coalesce(volumes.value.cloud_sql_instances, [])) == 0 ? [] : [""] + content { + instances = volumes.value.cloud_sql_instances + } + } + } + } + } + + lifecycle { + ignore_changes = [ + client, + client_version, + template[0].annotations["run.googleapis.com/operation-id"], + template[0].containers, + template[0].labels + ] + } +} diff --git a/modules/cloud-run/tags.tf b/modules/cloud-run-v2/workerpool.tf similarity index 58% rename from modules/cloud-run/tags.tf rename to modules/cloud-run-v2/workerpool.tf index c988ed650..22f85feee 100644 --- a/modules/cloud-run/tags.tf +++ b/modules/cloud-run-v2/workerpool.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,11 @@ * limitations under the License. */ -resource "google_tags_location_tag_binding" "binding" { - for_each = var.tag_bindings - parent = ( - "//run.googleapis.com/projects/${var.project_id}/locations/europe-west1/services/${google_cloud_run_service.service.name}" - ) - tag_value = each.value - location = var.region +resource "google_cloud_run_v2_worker_pool_iam_binding" "binding" { + for_each = var.type == "WORKERPOOL" ? var.iam : {} + project = local.resource.project + location = local.resource.location + name = local.resource.name + role = lookup(local.ctx.custom_roles, each.key, each.key) + members = [for member in each.value : lookup(local.ctx.iam_principals, member, member)] } diff --git a/modules/cloud-run/README.md b/modules/cloud-run/README.md deleted file mode 100644 index 9dbf641af..000000000 --- a/modules/cloud-run/README.md +++ /dev/null @@ -1,460 +0,0 @@ -# Cloud Run Module - -Cloud Run management, with support for IAM roles, revision annotations and optional Eventarc trigger creation. - - -- [IAM and environment variables](#iam-and-environment-variables) -- [Mounting secrets as volumes](#mounting-secrets-as-volumes) -- [Revision annotations](#revision-annotations) -- [Second generation execution environment](#second-generation-execution-environment) -- [VPC Access Connector creation](#vpc-access-connector-creation) -- [Traffic split](#traffic-split) -- [Eventarc triggers](#eventarc-triggers) - - [PubSub](#pubsub) - - [Audit logs](#audit-logs) - - [Using custom service accounts for triggers](#using-custom-service-accounts-for-triggers) -- [Service account](#service-account) -- [Tag bindings](#tag-bindings) -- [Variables](#variables) -- [Outputs](#outputs) - - -## IAM and environment variables - -IAM bindings support the usual syntax. Container environment values can be declared as key-value strings or as references to Secret Manager secrets. Both can be combined as long as there's no duplication of keys: - -```hcl - -module "secret-manager" { - source = "./fabric/modules/secret-manager" - project_id = var.project_id - secrets = { - credentials = {} - } - iam = { - credentials = { - "roles/secretmanager.secretAccessor" = [module.cloud_run.service_account_iam_email] - } - } -} - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - env = { - VAR1 = "VALUE1" - VAR2 = "VALUE2" - } - env_from = { - SECRET1 = { - name = module.secret-manager.ids["credentials"] - key = "latest" - } - } - } - } - iam = { - "roles/run.invoker" = ["allUsers"] - } - service_account_create = true -} -# tftest modules=2 resources=5 inventory=simple.yaml e2e -``` - -## Mounting secrets as volumes - -```hcl -module "secret-manager" { - source = "./fabric/modules/secret-manager" - project_id = var.project_id - secrets = { - credentials = {} - } - versions = { - credentials = { - v1 = { enabled = true, data = "foo bar baz" } - } - } - iam = { - credentials = { - "roles/secretmanager.secretAccessor" = [module.cloud_run.service_account_iam_email] - } - } -} - - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - name = "hello" - region = var.region - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - volume_mounts = { - "credentials" = "/credentials" - } - } - } - service_account_create = true - volumes = { - credentials = { - name = module.secret-manager.secrets["credentials"].name - secret_name = "credentials" # TODO: module.secret-manager.secrets["credentials"].name - items = { - latest = { path = "v1.txt" } - } - } - } -} -# tftest modules=2 resources=5 inventory=secrets.yaml e2e -``` - -## Revision annotations - -Annotations can be specified via the `revision_annotations` variable: - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - revision_annotations = { - autoscaling = { - max_scale = 10 - min_scale = 1 - } - cloudsql_unstances = ["sql-0", "sql-1"] - vpcaccess_connector = "foo" - vpcaccess_egress = "all-traffic" - } -} -# tftest modules=1 resources=1 inventory=revision-annotations.yaml -``` - -## Second generation execution environment - -Second generation execution environment (gen2) can be enabled by setting the `gen2_execution_environment` variable to true: - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - gen2_execution_environment = true -} -# tftest modules=1 resources=1 inventory=gen2.yaml e2e -``` - -## VPC Access Connector creation - -If creation of a [VPC Access Connector](https://cloud.google.com/vpc/docs/serverless-vpc-access) is required, use the `vpc_connector_create` variable which also support optional attributes for number of instances, machine type, and throughput (not shown here). The annotation to use the connector will be added automatically. - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - vpc_connector_create = { - ip_cidr_range = "10.10.10.0/28" - throughput = { - max = 300 - min = 200 - } - vpc_self_link = var.vpc.self_link - } -} -# tftest modules=1 resources=2 inventory=connector.yaml e2e -``` - -Note that if you are using Shared VPC you need to specify a subnet: - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - vpc_connector_create = { - subnet = { - name = "subnet-vpc-access" - project_id = "host-project" - } - } -} -# tftest modules=1 resources=2 inventory=connector-shared.yaml -``` - -## Traffic split - -This deploys a Cloud Run service with traffic split between two revisions. - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - revision_name = "green" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - traffic = { - blue = { percent = 25 } - green = { percent = 75 } - } -} -# tftest modules=1 resources=1 inventory=traffic.yaml -``` - -## Eventarc triggers - -### PubSub - -This deploys a Cloud Run service that will be triggered when messages are published to Pub/Sub topics. - -```hcl -module "pubsub" { - source = "./fabric/modules/pubsub" - project_id = var.project_id - name = "pubsub_sink" -} - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - eventarc_triggers = { - pubsub = { - topic-1 = module.pubsub.id - } - } -} -# tftest modules=2 resources=3 inventory=eventarc.yaml e2e -``` - -### Audit logs - -This deploys a Cloud Run service that will be triggered when specific log events are written to Google Cloud audit logs. - -```hcl -module "sa" { - source = "./fabric/modules/iam-service-account" - project_id = var.project_id - name = "eventarc-trigger" - iam_project_roles = { - (var.project_id) = ["roles/eventarc.eventReceiver"] - } -} - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - eventarc_triggers = { - audit_log = { - setiampolicy = { - method = "SetIamPolicy" - service = "cloudresourcemanager.googleapis.com" - } - } - service_account_email = module.sa.email - } - iam = { - "roles/run.invoker" = [module.sa.iam_email] - } -} -# tftest modules=2 resources=5 inventory=audit-logs.yaml -``` - -### Using custom service accounts for triggers - -By default `Compute default service account` is used to trigger Cloud Run. If you want to use custom Service Account you can either provide your own in `eventarc_triggers.service_account_email` or set `eventarc_triggers.service_account_create` to true and service account named `tf-cr-trigger-${var.name}` will be created with `roles/run.invoker` granted on this Cloud Run service. - -For example using provided service account refer to [Audit logs](#audit-logs) example. - -Example using automatically created service account: - -```hcl -module "pubsub" { - source = "./fabric/modules/pubsub" - project_id = var.project_id - name = "pubsub_sink" -} - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - eventarc_triggers = { - pubsub = { - topic-1 = module.pubsub.id - } - service_account_create = true - } -} -# tftest modules=2 resources=5 inventory=trigger-service-account.yaml e2e -``` - -## Service account - -To use a custom service account managed by the module, set `service_account_create` to `true` and leave `service_account` set to `null` value (default). - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - service_account_create = true -} -# tftest modules=1 resources=2 inventory=service-account.yaml e2e -``` - -To use an externally managed service account, pass its email in `service_account` and leave `service_account_create` to `false` (the default). - -```hcl -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - service_account = var.service_account.email -} -# tftest modules=1 resources=1 inventory=service-account-external.yaml e2e -``` - -## Tag bindings - -Refer to the [Creating and managing tags](https://cloud.google.com/resource-manager/docs/tags/tags-creating-and-managing) documentation for details on usage. - -```hcl -module "org" { - source = "./fabric/modules/organization" - organization_id = var.organization_id - tags = { - environment = { - description = "Environment specification." - values = { - dev = {} - prod = {} - sandbox = {} - } - } - } -} - -module "cloud_run" { - source = "./fabric/modules/cloud-run" - project_id = var.project_id - region = var.region - name = "hello" - containers = { - hello = { - image = "us-docker.pkg.dev/cloudrun/container/hello" - } - } - tag_bindings = { - env-sandbox = module.org.tag_values["environment/sandbox"].id - } -} -# tftest modules=2 resources=6 -``` - -## Variables - -| name | description | type | required | default | -|---|---|:---:|:---:|:---:| -| [name](variables.tf#L144) | Name used for cloud run service. | string | ✓ | | -| [project_id](variables.tf#L159) | Project id used for all resources. | string | ✓ | | -| [region](variables.tf#L164) | Region used for all resources. | string | ✓ | | -| [container_concurrency](variables.tf#L18) | Maximum allowed in-flight (concurrent) requests per container of the revision. | string | | null | -| [containers](variables.tf#L24) | Containers in arbitrary key => attributes format. | map(object({…})) | | {} | -| [eventarc_triggers](variables.tf#L91) | Event arc triggers for different sources. | object({…}) | | {} | -| [gen2_execution_environment](variables.tf#L113) | Use second generation execution environment. | bool | | false | -| [iam](variables.tf#L119) | IAM bindings for Cloud Run service in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | -| [ingress_settings](variables.tf#L125) | Ingress settings. | string | | null | -| [labels](variables.tf#L138) | Resource labels. | map(string) | | {} | -| [prefix](variables.tf#L149) | Optional prefix used for resource names. | string | | null | -| [revision_annotations](variables.tf#L169) | Configure revision template annotations. | object({…}) | | {} | -| [revision_name](variables.tf#L184) | Revision name. | string | | null | -| [service_account](variables.tf#L190) | Service account email. Unused if service account is auto-created. | string | | null | -| [service_account_create](variables.tf#L196) | Auto-create service account. | bool | | false | -| [startup_cpu_boost](variables.tf#L202) | Enable startup cpu boost. | bool | | false | -| [tag_bindings](variables.tf#L208) | Tag bindings for this service, in key => tag value id format. | map(string) | | {} | -| [timeout_seconds](variables.tf#L215) | Maximum duration the instance is allowed for responding to a request. | number | | null | -| [traffic](variables.tf#L221) | Traffic steering configuration. If revision name is null the latest revision will be used. | map(object({…})) | | {} | -| [volumes](variables.tf#L232) | Named volumes in containers in name => attributes format. | map(object({…})) | | {} | -| [vpc_connector_create](variables.tf#L246) | Populate this to create a VPC connector. You can then refer to it in the template annotations. | object({…}) | | null | - -## Outputs - -| name | description | sensitive | -|---|---|:---:| -| [id](outputs.tf#L18) | Fully qualified service id. | | -| [invoke_command](outputs.tf#L23) | Command to invoke Cloud Run Service / submit job. | | -| [service](outputs.tf#L32) | Cloud Run service. | | -| [service_account](outputs.tf#L37) | Service account resource. | | -| [service_account_email](outputs.tf#L42) | Service account email. | | -| [service_account_iam_email](outputs.tf#L47) | Service account email. | | -| [service_name](outputs.tf#L55) | Cloud Run service name. | | -| [vpc_connector](outputs.tf#L61) | VPC connector resource if created. | | - diff --git a/modules/cloud-run/main.tf b/modules/cloud-run/main.tf deleted file mode 100644 index f9c859a48..000000000 --- a/modules/cloud-run/main.tf +++ /dev/null @@ -1,409 +0,0 @@ -/** - * Copyright 2023 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 { - _vpcaccess_annotation = ( - local.vpc_connector_create - ? merge({ - "run.googleapis.com/vpc-access-connector" = google_vpc_access_connector.connector[0].id - }, - var.revision_annotations.vpcaccess_egress == null ? { - # if creating a vpc connector and no explicit annotation is given, - # add "private-ranges-only" annotation to prevent permanent diff - "run.googleapis.com/vpc-access-egress" = "private-ranges-only" - } : { - "run.googleapis.com/vpc-access-egress" = ( - var.revision_annotations.vpcaccess_egress - ) - }, - ) - : ( - var.revision_annotations.vpcaccess_connector == null - ? {} - : { - "run.googleapis.com/vpc-access-connector" = ( - var.revision_annotations.vpcaccess_connector - ) - } - ) - ) - annotations = merge( - var.ingress_settings == null ? {} : { - "run.googleapis.com/ingress" = var.ingress_settings - }, - ) - prefix = var.prefix == null ? "" : "${var.prefix}-" - revision_annotations = merge( - try(var.revision_annotations.autoscaling, null) == null ? {} : { - "autoscaling.knative.dev/maxScale" = ( - var.revision_annotations.autoscaling.max_scale - ) - }, - try(var.revision_annotations.autoscaling.min_scale, null) == null ? {} : { - "autoscaling.knative.dev/minScale" = ( - var.revision_annotations.autoscaling.min_scale - ) - }, - length(var.revision_annotations.cloudsql_instances) == 0 ? {} : { - "run.googleapis.com/cloudsql-instances" = ( - join(",", var.revision_annotations.cloudsql_instances) - ) - }, - local._vpcaccess_annotation, - var.revision_annotations.vpcaccess_egress == null ? {} : { - "run.googleapis.com/vpc-access-egress" = ( - var.revision_annotations.vpcaccess_egress - ) - }, - var.gen2_execution_environment ? { - "run.googleapis.com/execution-environment" = "gen2" - } : {}, - var.startup_cpu_boost ? { - "run.googleapis.com/startup-cpu-boost" = "true" - } : {}, - ) - revision_name = ( - try(var.revision_name, null) == null - ? null - : "${var.name}-${var.revision_name}" - ) - service_account_email = ( - var.service_account_create - ? ( - length(google_service_account.service_account) > 0 - ? google_service_account.service_account[0].email - : null - ) - : var.service_account - ) - trigger_sa_create = try( - var.eventarc_triggers.service_account_create, false - ) - trigger_sa_email = ( - local.trigger_sa_create ? - google_service_account.trigger_service_account[0].email - : try(var.eventarc_triggers.service_account_email, null) - ) - vpc_connector_create = var.vpc_connector_create != null -} - -resource "google_vpc_access_connector" "connector" { - count = local.vpc_connector_create ? 1 : 0 - project = var.project_id - name = ( - var.vpc_connector_create.name != null - ? var.vpc_connector_create.name - : var.name - ) - region = var.region - ip_cidr_range = var.vpc_connector_create.ip_cidr_range - network = var.vpc_connector_create.vpc_self_link - machine_type = var.vpc_connector_create.machine_type - max_instances = var.vpc_connector_create.instances.max - max_throughput = var.vpc_connector_create.throughput.max - min_instances = var.vpc_connector_create.instances.min - min_throughput = var.vpc_connector_create.throughput.min - dynamic "subnet" { - for_each = alltrue([for k, v in var.vpc_connector_create.subnet : (v == null)]) ? [] : [""] - content { - name = var.vpc_connector_create.subnet.name - project_id = var.vpc_connector_create.subnet.project_id - } - } -} - -resource "google_cloud_run_service" "service" { - provider = google-beta - project = var.project_id - location = var.region - name = "${local.prefix}${var.name}" - - template { - spec { - container_concurrency = var.container_concurrency - service_account_name = local.service_account_email - timeout_seconds = var.timeout_seconds - dynamic "containers" { - for_each = var.containers - content { - image = containers.value.image - args = containers.value.args - command = containers.value.command - dynamic "env" { - for_each = containers.value.env - content { - name = env.key - value = env.value - } - } - dynamic "env" { - for_each = containers.value.env_from_key - content { - name = env.key - value_from { - secret_key_ref { - key = env.value.key - name = env.value.name - } - } - } - } - dynamic "liveness_probe" { - for_each = containers.value.liveness_probe == null ? [] : [""] - content { - failure_threshold = containers.value.liveness_probe.failure_threshold - initial_delay_seconds = containers.value.liveness_probe.initial_delay_seconds - period_seconds = containers.value.liveness_probe.period_seconds - timeout_seconds = containers.value.liveness_probe.timeout_seconds - dynamic "grpc" { - for_each = ( - containers.value.liveness_probe.action.grpc == null ? [] : [""] - ) - content { - port = containers.value.liveness_probe.action.grpc.port - service = containers.value.liveness_probe.action.grpc.service - } - } - dynamic "http_get" { - for_each = ( - containers.value.liveness_probe.action.http_get == null ? [] : [""] - ) - content { - path = containers.value.liveness_probe.action.http_get.path - dynamic "http_headers" { - for_each = ( - containers.value.liveness_probe.action.http_get.http_headers - ) - content { - name = http_headers.key - value = http_headers.value - } - } - } - } - } - } - dynamic "ports" { - for_each = containers.value.ports - content { - container_port = ports.value.container_port - name = ports.value.name - protocol = ports.value.protocol - } - } - dynamic "resources" { - for_each = containers.value.resources == null ? [] : [""] - content { - limits = containers.value.resources.limits - requests = containers.value.resources.requests - } - } - dynamic "startup_probe" { - for_each = containers.value.startup_probe == null ? [] : [""] - content { - failure_threshold = containers.value.startup_probe.failure_threshold - initial_delay_seconds = containers.value.startup_probe.initial_delay_seconds - period_seconds = containers.value.startup_probe.period_seconds - timeout_seconds = containers.value.startup_probe.timeout_seconds - dynamic "grpc" { - for_each = ( - containers.value.startup_probe.action.grpc == null ? [] : [""] - ) - content { - port = containers.value.startup_probe.action.grpc.port - service = containers.value.startup_probe.action.grpc.service - } - } - dynamic "http_get" { - for_each = ( - containers.value.startup_probe.action.http_get == null ? [] : [""] - ) - content { - path = containers.value.startup_probe.action.http_get.path - dynamic "http_headers" { - for_each = ( - containers.value.startup_probe.action.http_get.http_headers - ) - content { - name = http_headers.key - value = http_headers.value - } - } - } - } - dynamic "tcp_socket" { - for_each = ( - containers.value.startup_probe.action.tcp_socket == null ? [] : [""] - ) - content { - port = containers.value.startup_probe.action.tcp_socket.port - } - } - } - } - dynamic "volume_mounts" { - for_each = containers.value.volume_mounts - content { - name = volume_mounts.key - mount_path = volume_mounts.value - } - } - } - } - dynamic "volumes" { - for_each = var.volumes - content { - name = volumes.key - secret { - secret_name = volumes.value.secret_name - default_mode = volumes.value.default_mode - dynamic "items" { - for_each = volumes.value.items - content { - key = items.key - path = items.value.path - mode = items.value.mode - } - } - } - } - } - } - metadata { - name = local.revision_name - annotations = local.revision_annotations - } - } - - metadata { - annotations = local.annotations - labels = var.labels - } - - dynamic "traffic" { - for_each = var.traffic - content { - percent = traffic.value.percent - latest_revision = traffic.value.latest == true - revision_name = ( - traffic.value.latest == true - ? null - : "${var.name}-${traffic.key}" - ) - tag = traffic.value.tag - } - } - - lifecycle { - ignore_changes = [ - metadata[0].annotations["run.googleapis.com/operation-id"], - template[0].metadata[0].labels["run.googleapis.com/startupProbeType"] - ] - } -} - -resource "google_cloud_run_service_iam_binding" "binding" { - for_each = var.iam - project = google_cloud_run_service.service.project - location = google_cloud_run_service.service.location - service = google_cloud_run_service.service.name - role = each.key - members = ( - each.key != "roles/run.invoker" || !local.trigger_sa_create - ? each.value - # if invoker role is present and we create trigger sa, add it as member - : concat( - each.value, ["serviceAccount:${local.trigger_sa_email}"] - ) - ) -} - -resource "google_cloud_run_service_iam_member" "default" { - # if authoritative invoker role is not present and we create trigger sa - # use additive binding to grant it the role - count = ( - lookup(var.iam, "roles/run.invoker", null) == null && - local.trigger_sa_create - ) ? 1 : 0 - project = google_cloud_run_service.service.project - location = google_cloud_run_service.service.location - service = google_cloud_run_service.service.name - role = "roles/run.invoker" - member = "serviceAccount:${local.trigger_sa_email}" -} - -resource "google_service_account" "service_account" { - count = var.service_account_create ? 1 : 0 - project = var.project_id - account_id = "tf-cr-${var.name}" - display_name = "Terraform Cloud Run ${var.name}." -} - -resource "google_eventarc_trigger" "audit_log_triggers" { - for_each = var.eventarc_triggers.audit_log - name = "${local.prefix}audit-log-${each.key}" - location = google_cloud_run_service.service.location - project = google_cloud_run_service.service.project - matching_criteria { - attribute = "type" - value = "google.cloud.audit.log.v1.written" - } - matching_criteria { - attribute = "serviceName" - value = each.value.service - } - matching_criteria { - attribute = "methodName" - value = each.value.method - } - destination { - cloud_run_service { - service = google_cloud_run_service.service.name - region = google_cloud_run_service.service.location - } - } - service_account = local.trigger_sa_email -} - -resource "google_eventarc_trigger" "pubsub_triggers" { - for_each = var.eventarc_triggers.pubsub - name = "${local.prefix}pubsub-${each.key}" - location = google_cloud_run_service.service.location - project = google_cloud_run_service.service.project - matching_criteria { - attribute = "type" - value = "google.cloud.pubsub.topic.v1.messagePublished" - } - transport { - pubsub { - topic = each.value - } - } - destination { - cloud_run_service { - service = google_cloud_run_service.service.name - region = google_cloud_run_service.service.location - } - } - service_account = local.trigger_sa_email -} - -resource "google_service_account" "trigger_service_account" { - count = local.trigger_sa_create ? 1 : 0 - project = var.project_id - account_id = "tf-cr-trigger-${var.name}" - display_name = "Terraform trigger for Cloud Run ${var.name}." -} diff --git a/modules/cloud-run/outputs.tf b/modules/cloud-run/outputs.tf deleted file mode 100644 index dcca33374..000000000 --- a/modules/cloud-run/outputs.tf +++ /dev/null @@ -1,64 +0,0 @@ - -/** - * 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 "id" { - description = "Fully qualified service id." - value = google_cloud_run_service.service.id -} - -output "invoke_command" { - description = "Command to invoke Cloud Run Service / submit job." - value = <<-EOT - curl -H "Authorization: bearer $(gcloud auth print-identity-token)" \ - ${google_cloud_run_service.service.status[0].url} \ - -X POST -d 'data' - EOT -} - -output "service" { - description = "Cloud Run service." - value = google_cloud_run_service.service -} - -output "service_account" { - description = "Service account resource." - value = try(google_service_account.service_account[0], null) -} - -output "service_account_email" { - description = "Service account email." - value = local.service_account_email -} - -output "service_account_iam_email" { - description = "Service account email." - value = join("", [ - "serviceAccount:", - local.service_account_email == null ? "" : local.service_account_email - ]) -} - -output "service_name" { - description = "Cloud Run service name." - value = google_cloud_run_service.service.name -} - - -output "vpc_connector" { - description = "VPC connector resource if created." - value = try(google_vpc_access_connector.connector[0].id, null) -} diff --git a/modules/cloud-run/variables.tf b/modules/cloud-run/variables.tf deleted file mode 100644 index 197864505..000000000 --- a/modules/cloud-run/variables.tf +++ /dev/null @@ -1,267 +0,0 @@ - -/** - * 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 "container_concurrency" { - description = "Maximum allowed in-flight (concurrent) requests per container of the revision." - type = string - default = null -} - -variable "containers" { - description = "Containers in arbitrary key => attributes format." - type = map(object({ - image = string - args = optional(list(string)) - command = optional(list(string)) - env = optional(map(string), {}) - env_from_key = optional(map(object({ - key = string - name = string - })), {}) - liveness_probe = optional(object({ - action = object({ - grpc = optional(object({ - port = optional(number) - service = optional(string) - })) - http_get = optional(object({ - http_headers = optional(map(string), {}) - path = optional(string) - })) - }) - failure_threshold = optional(number) - initial_delay_seconds = optional(number) - period_seconds = optional(number) - timeout_seconds = optional(number) - })) - ports = optional(map(object({ - container_port = optional(number) - name = optional(string) - protocol = optional(string) - })), {}) - resources = optional(object({ - limits = optional(object({ - cpu = string - memory = string - })) - requests = optional(object({ - cpu = string - memory = string - })) - })) - startup_probe = optional(object({ - action = object({ - grpc = optional(object({ - port = optional(number) - service = optional(string) - })) - http_get = optional(object({ - http_headers = optional(map(string), {}) - path = optional(string) - })) - tcp_socket = optional(object({ - port = optional(number) - })) - }) - failure_threshold = optional(number) - initial_delay_seconds = optional(number) - period_seconds = optional(number) - timeout_seconds = optional(number) - })) - volume_mounts = optional(map(string), {}) - })) - default = {} - nullable = false -} - -variable "eventarc_triggers" { - description = "Event arc triggers for different sources." - type = object({ - audit_log = optional(map(object({ - method = string - service = string - })), {}) - pubsub = optional(map(string), {}) - service_account_email = optional(string) - service_account_create = optional(bool, false) - }) - default = {} - validation { - condition = ( - var.eventarc_triggers.service_account_email == null && length(var.eventarc_triggers.audit_log) == 0 - ) || ( - var.eventarc_triggers.service_account_email != null - ) - error_message = "service_account_email is required if providing audit_log" - } -} - -variable "gen2_execution_environment" { - description = "Use second generation execution environment." - type = bool - default = false -} - -variable "iam" { - description = "IAM bindings for Cloud Run service in {ROLE => [MEMBERS]} format." - type = map(list(string)) - default = {} -} - -variable "ingress_settings" { - description = "Ingress settings." - type = string - default = null - validation { - condition = contains( - ["all", "internal", "internal-and-cloud-load-balancing"], - coalesce(var.ingress_settings, "all") - ) - error_message = "Ingress settings can be one of 'all', 'internal', 'internal-and-cloud-load-balancing'." - } -} - -variable "labels" { - description = "Resource labels." - type = map(string) - default = {} -} - -variable "name" { - description = "Name used for cloud run service." - type = string -} - -variable "prefix" { - description = "Optional prefix used for resource names." - type = string - default = null - validation { - condition = var.prefix != "" - error_message = "Prefix cannot be empty, please use null instead." - } -} - -variable "project_id" { - description = "Project id used for all resources." - type = string -} - -variable "region" { - description = "Region used for all resources." - type = string -} - -variable "revision_annotations" { - description = "Configure revision template annotations." - type = object({ - autoscaling = optional(object({ - max_scale = number - min_scale = number - })) - cloudsql_instances = optional(list(string), []) - vpcaccess_connector = optional(string) - vpcaccess_egress = optional(string) - }) - default = {} - nullable = false -} - -variable "revision_name" { - description = "Revision name." - type = string - default = null -} - -variable "service_account" { - description = "Service account email. Unused if service account is auto-created." - type = string - default = null -} - -variable "service_account_create" { - description = "Auto-create service account." - type = bool - default = false -} - -variable "startup_cpu_boost" { - description = "Enable startup cpu boost." - type = bool - default = false -} - -variable "tag_bindings" { - description = "Tag bindings for this service, in key => tag value id format." - type = map(string) - nullable = false - default = {} -} - -variable "timeout_seconds" { - description = "Maximum duration the instance is allowed for responding to a request." - type = number - default = null -} - -variable "traffic" { - description = "Traffic steering configuration. If revision name is null the latest revision will be used." - type = map(object({ - percent = number - latest = optional(bool) - tag = optional(string) - })) - default = {} - nullable = false -} - -variable "volumes" { - description = "Named volumes in containers in name => attributes format." - type = map(object({ - secret_name = string - default_mode = optional(string) - items = optional(map(object({ - path = string - mode = optional(string) - }))) - })) - default = {} - nullable = false -} - -variable "vpc_connector_create" { - description = "Populate this to create a VPC connector. You can then refer to it in the template annotations." - type = object({ - ip_cidr_range = optional(string) - vpc_self_link = optional(string) - machine_type = optional(string) - name = optional(string) - instances = optional(object({ - max = optional(number) - min = optional(number) - }), {}) - throughput = optional(object({ - max = optional(number) - min = optional(number) - }), {}) - subnet = optional(object({ - name = optional(string) - project_id = optional(string) - }), {}) - }) - default = null -} diff --git a/modules/folder/README.md b/modules/folder/README.md index a1466e3d6..a3f20cfa9 100644 --- a/modules/folder/README.md +++ b/modules/folder/README.md @@ -5,13 +5,25 @@ This module allows the creation and management of folders, including support for - [Basic example with IAM bindings](#basic-example-with-iam-bindings) - [IAM](#iam) + - [Conditional IAM by Principals](#conditional-iam-by-principals) - [Assured Workload Folder](#assured-workload-folder) +- [Privileged Access Manager (PAM) Entitlements](#privileged-access-manager-pam-entitlements) + - [Privileged Access Manager (PAM) Entitlements Factory](#privileged-access-manager-pam-entitlements-factory) +- [Service Agents](#service-agents) - [Organization policies](#organization-policies) - [Organization Policy Factory](#organization-policy-factory) - [Hierarchical Firewall Policy Attachments](#hierarchical-firewall-policy-attachments) - [Log Sinks](#log-sinks) - [Data Access Logs](#data-access-logs) +- [KMS Autokey](#kms-autokey) +- [Custom Security Health Analytics Modules](#custom-security-health-analytics-modules) + - [Custom Security Health Analytics Modules Factory](#custom-security-health-analytics-modules-factory) +- [Security Command Center Mute Configs](#security-command-center-mute-configs) + - [Security Command Center Mute Configs Factory](#security-command-center-mute-configs-factory) +- [Cloud Asset Search](#cloud-asset-search) +- [Cloud Asset Inventory Feeds](#cloud-asset-inventory-feeds) - [Tags](#tags) +- [IAM Deny Policies](#iam-deny-policies) - [Files](#files) - [Variables](#variables) - [Outputs](#outputs) @@ -51,10 +63,42 @@ IAM is managed via several variables that implement different features and level - `iam` and `iam_by_principals` configure authoritative bindings that manage individual roles exclusively, and are internally merged - `iam_bindings` configure authoritative bindings with optional support for conditions, and are not internally merged with the previous two variables - `iam_bindings_additive` configure additive bindings via individual role/member pairs with optional support conditions +- `iam_by_principals_conditional` configure authoritative bindings with required conditions, allowing to specify roles and condition for each principal The authoritative and additive approaches can be used together, provided different roles are managed by each. Some care must also be taken with the `iam_by_principals` variable to ensure that variable keys are static values, so that Terraform is able to compute the dependency graph. -Refer to the [project module](../project/README.md#iam) for examples of the IAM interface. +IAM also supports variable interpolation for both roles and principals, via the respective attributes in the `var.context` variable. Refer to the [project module](../project/README.md#iam) for examples of the IAM interface. + +### Conditional IAM by Principals + +The `iam_by_principals_conditional` variable allows defining IAM bindings keyed by principal, where each principal shares a common condition for multiple roles. This is useful for granting access with specific conditions (e.g., time-based or resource-based) to users or groups across different roles. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + iam_by_principals_conditional = { + "user:one@example.com" = { + roles = ["roles/owner", "roles/viewer"] + condition = { + title = "expires_after_2024_12_31" + description = "Expiring at midnight of 2024-12-31" + expression = "request.time < timestamp(\"2025-01-01T00:00:00Z\")" + } + } + "user:two@example.com" = { + roles = ["roles/owner", "roles/viewer"] + condition = { + title = "expires_after_2024_12_31" + description = "Expiring at midnight of 2024-12-31" + expression = "request.time < timestamp(\"2025-01-01T00:00:00Z\")" + } + } + } +} +# tftest modules=1 resources=3 inventory=iam-bpc.yaml +``` ## Assured Workload Folder @@ -87,6 +131,96 @@ module "folder" { # tftest modules=1 resources=3 inventory=assured-workload.yaml ``` +## Privileged Access Manager (PAM) Entitlements + +[Privileged Access Manager](https://docs.cloud.google.com/iam/docs/pam-overview) entitlements can be defined via the `pam_entitlements` variable. + +Note that using PAM entitlements requires specific roles to be granted to the users and groups that will be using them. For more information, see the [official documentation](https://cloud.google.com/iam/docs/pam-permissions-and-setup#before-you-begin). + +Additionally, the Privileged Access Manager Service Agent must be created and granted the `roles/privilegedaccessmanager.folderServiceAgent` role. The service agent can be created automatically by adding `privilegedaccessmanager.googleapis.com` to the `services` list in the `service_agents_config` variable. Refer to the [organization module's documentation](../organization/README.md#privileged-access-manager-pam-entitlements) for an example on how to grant the required role. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Networking" + deletion_protection = false + pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + eligible_users = ["group:gcp-network-admins@example.com"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" }, + ] + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["group:gcp-organization-admins@example.com"] + }] + } + } + } +} +# tftest modules=1 resources=2 +``` + +### Privileged Access Manager (PAM) Entitlements Factory + +PAM entitlements can be loaded from a directory containing YAML files where each file defines one or more entitlements. The structure of the YAML files is exactly the same as the `pam_entitlements` variable. + +Note that entitlements defined via `pam_entitlements` take precedence over those in the factory. In other words, if you specify the same entitlement in a YAML file and in the `pam_entitlements` variable, the latter will take priority. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + factories_config = { + pam_entitlements = "configs/pam-entitlements/" + } +} +# tftest modules=1 resources=2 files=pam +``` + +```yaml +# yaml-language-server: $schema=../schemas/pam-entitlements.schema.json + +net-admins: + max_request_duration: 3600s + eligible_users: + - group:gcp-network-admins@example.com + privileged_access: + - role: roles/compute.networkAdmin + - role: roles/compute.admin + manual_approvals: + require_approver_justification: true + steps: + - approvers: + - group:gcp-organization-admins@example.com +# tftest-file id=pam path=configs/pam-entitlements/entitlements.yaml schema=pam-entitlements.schema.json +``` + +## Service Agents + +The module allows managing service agents at the folder level. Service agent creation is triggered by adding them to the `service_agents_config.services` variable. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + service_agents_config = { + services = [ + "osconfig.googleapis.com", + "privilegedaccessmanager.googleapis.com", + "progressiverollout.googleapis.com" + ] + } +} +# tftest inventory=agents.yaml +``` + ## Organization policies To manage organization policies, the `orgpolicy.googleapis.com` service should be enabled in the quota project. @@ -169,17 +303,17 @@ module "folder" { name = "Folder name" factories_config = { org_policies = "configs/org-policies/" - context = { - org_policies = { - tags = { - my_conditional_tag = "tagKeys/1234" - } - domains = { - secondary = "@secondary.example.com" - } - customer_ids = { - extra = "C0zzzzzzz" - } + } + context = { + condition_vars = { + tags = { + my_conditional_tag = "tagKeys/1234" + } + domains = { + secondary = "@secondary.example.com" + } + customer_ids = { + extra = "C0zzzzzzz" } } } @@ -289,10 +423,9 @@ module "pubsub" { } module "bucket" { - source = "./fabric/modules/logging-bucket" - parent_type = "project" - parent = var.project_id - id = "${var.prefix}-bucket" + source = "./fabric/modules/logging-bucket" + parent = var.project_id + name = "${var.prefix}-bucket" } module "destination-project" { @@ -344,7 +477,7 @@ module "folder-sink" { no-gce-instances = "resource.type=gce_instance" } } -# tftest modules=6 resources=18 inventory=logging.yaml e2e +# tftest inventory=logging.yaml e2e ``` ## Data Access Logs @@ -373,6 +506,217 @@ module "folder" { # tftest modules=1 resources=3 inventory=logging-data-access.yaml e2e ``` +## KMS Autokey + +To enable KMS Autokey at the folder level, set `autokey_config.project` to a valid project id or number, prefixed by `projects/`. The project must already be [configured correctly](https://docs.cloud.google.com/kms/docs/enable-autokey) for Autokey to work. + +If `autokey_config.project` leverages context expansion, the `projects/` prefix is added automatically by the module. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" +} + +# avoid a dependency cycle by configuring autokey in a separate module + +module "folder-iam" { + source = "./fabric/modules/folder" + id = module.folder.id + folder_create = false + autokey_config = { + project = "$project_numbers:test" + } + context = { + project_numbers = { + test = module.project.number + } + } +} + +module "project" { + source = "./fabric/modules/project" + parent = module.folder.id + name = "test-autokey" + billing_account = var.billing_account_id + services = [ + "cloudkms.googleapis.com" + ] + iam = { + "roles/cloudkms.admin" = [ + "group:key-admins@example.com", + module.project.service_agents["cloudkms"].iam_email + ] + "roles/cloudkms.autokeyUser" = [ + "group:key-user@example.com" + ] + } +} + +# tftest modules=3 resources=8 inventory=autokey.yaml +``` + +## Custom Security Health Analytics Modules + +[Security Health Analytics custom modules](https://cloud.google.com/security-command-center/docs/custom-modules-sha-create) can be defined via the `scc_sha_custom_modules` variable: + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + scc_sha_custom_modules = { + cloudkmKeyRotationPeriod = { + description = "The rotation period of the identified cryptokey resource exceeds 30 days." + recommendation = "Set the rotation period to at most 30 days." + severity = "MEDIUM" + predicate = { + expression = "resource.rotationPeriod > duration(\"2592000s\")" + } + resource_selector = { + resource_types = ["cloudkms.googleapis.com/CryptoKey"] + } + } + } +} +# tftest modules=1 resources=2 inventory=custom-modules-sha.yaml +``` + +### Custom Security Health Analytics Modules Factory + +Custom modules can also be specified via a factory. Each file is mapped to a custom module, where the module name defaults to the file name. + +Custom modules defined via the variable are merged with those coming from the factory, and override them in case of duplicate names. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + factories_config = { + scc_sha_custom_modules = "data/scc_sha_custom_modules" + } +} +# tftest modules=1 resources=2 files=custom-module-sha-1 inventory=custom-modules-sha.yaml +``` + +```yaml +# tftest-file id=custom-module-sha-1 path=data/scc_sha_custom_modules/cloudkmKeyRotationPeriod.yaml schema=scc-sha-custom-modules.schema.json +cloudkmKeyRotationPeriod: + description: "The rotation period of the identified cryptokey resource exceeds 30 days." + recommendation: "Set the rotation period to at most 30 days." + severity: "MEDIUM" + predicate: + expression: "resource.rotationPeriod > duration(\"2592000s\")" + resource_selector: + resource_types: + - "cloudkms.googleapis.com/CryptoKey" +``` + +## Security Command Center Mute Configs + +[Security Command Center Mute Configs](https://cloud.google.com/security-command-center/docs/how-to-mute-findings) can be defined via the `scc_mute_configs` variable: + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + scc_mute_configs = { + muteHighSeverity = { + description = "Mute high severity findings" + filter = "severity=\"HIGH\"" + type = "DYNAMIC" + } + } +} +# tftest modules=1 inventory=scc-mute-configs.yaml +``` + +### Security Command Center Mute Configs Factory + +Mute configs can also be specified via a factory. Each file is mapped to a mute config, where the config ID defaults to the file name. + +Mute configs defined via the variable are merged with those coming from the factory, and override them in case of duplicate names. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + factories_config = { + scc_mute_configs = "data/scc_mute_configs" + } +} +# tftest modules=1 files=mute-config-1 inventory=scc-mute-configs.yaml +``` + +```yaml +# tftest-file id=mute-config-1 path=data/scc_mute_configs/muteHighSeverity.yaml schema=scc-mute-config.schema.json +muteHighSeverity: + description: "Mute high severity findings" + filter: "severity=\"HIGH\"" + type: "DYNAMIC" +``` + +## Cloud Asset Search + +The Cloud Asset Search feature allows you to search for resources within the project using the Cloud Asset Inventory API. This is useful for discovering and auditing resources based on asset types and query filters. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + billing_account = var.billing_account_id + id = var.folder_id + folder_create = false + asset_search = { + compute-sas = { + asset_types = ["iam.googleapis.com/ServiceAccount"] + query = "name:compute@developer.gserviceaccount.com" + } + } +} + +output "service_accounts" { + value = module.folder.asset_search_results["compute-sas"] +} +# tftest skip +``` + +## Cloud Asset Inventory Feeds + +Cloud Asset Inventory feeds allow you to monitor asset changes in real-time by publishing notifications to a Pub/Sub topic. Feeds configured at the folder level will monitor all resources within the folder and its subfolders. + +```hcl +module "pubsub" { + source = "./fabric/modules/pubsub" + project_id = var.project_id + name = "folder-asset-feed" +} + +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Monitored Folder" + asset_feeds = { + compute-instances = { + billing_project = var.project_id + feed_output_config = { + pubsub_destination = { + topic = module.pubsub.id + } + } + content_type = "RESOURCE" + asset_types = [ + "compute.googleapis.com/Instance" + ] + } + } +} +# tftest modules=2 resources=3 inventory=feeds.yaml +``` + ## Tags Refer to the [Creating and managing tags](https://cloud.google.com/resource-manager/docs/tags/tags-creating-and-managing) documentation for details on usage. @@ -403,20 +747,76 @@ module "folder" { # tftest modules=2 resources=5 inventory=tags.yaml e2e serial ``` +## IAM Deny Policies + +[IAM Deny policies](https://cloud.google.com/iam/docs/deny-overview) allow you to set centralized guardrails that prevent principals from using specific permissions within the folder and all of its descendants, regardless of the roles they have been granted. + +You can define Deny policies using the `iam_deny_policies` variable. Each policy requires you to specify the principals and permissions to deny. You can optionally define exception principals, exception permissions, and conditions to tailor the restriction. + +Note that IAM Deny policies require a specific prefix for principal definitions (e.g., `principalSet://goog/public:all` or `principalSet://goog/group/group-email@example.com`), and permissions must be prefixed with the service fully qualified domain name (e.g., `iam.googleapis.com/serviceAccountKeys.create`). + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + + iam_deny_policies = { + "prevent-key-creation" = { + display_name = "Prevent SA key creation" + rules = [ + { + description = "Deny service account key creation to all except the folder admin group." + denied_principals = ["principalSet://goog/public:all"] + denied_permissions = ["iam.googleapis.com/serviceAccountKeys.create"] + exception_principals = [ + "principalSet://goog/group/gcp-folder-admins@example.com" + ] + } + ] + } + "conditional-delete-deny" = { + display_name = "Conditional instance deletion deny" + rules = [ + { + description = "Deny deletion of compute instances based on resource tags." + denied_principals = ["principalSet://goog/public:all"] + denied_permissions = ["compute.googleapis.com/instances.delete"] + denial_condition = { + title = "prevent_prod_deletion" + description = "Prevent deletion of instances tagged as production." + expression = "resource.matchTag('123456789012/environment', 'prod')" + } + } + ] + } + } +} +# tftest modules=1 resources=3 inventory=iam-deny-policies.yaml +``` + ## Files | name | description | resources | |---|---|---| +| [assets.tf](./assets.tf) | None | google_cloud_asset_folder_feed | +| [deny-policies.tf](./deny-policies.tf) | IAM Deny policies. | google_iam_deny_policy | | [iam.tf](./iam.tf) | IAM bindings. | google_folder_iam_binding · google_folder_iam_member | | [logging.tf](./logging.tf) | Log sinks and supporting resources. | google_bigquery_dataset_iam_member · google_folder_iam_audit_config · google_logging_folder_exclusion · google_logging_folder_settings · google_logging_folder_sink · google_project_iam_member · google_pubsub_topic_iam_member · google_storage_bucket_iam_member | -| [main.tf](./main.tf) | Module-level locals and resources. | google_assured_workloads_workload · google_compute_firewall_policy_association · google_essential_contacts_contact · google_folder | +| [main.tf](./main.tf) | Module-level locals and resources. | google_assured_workloads_workload · google_compute_firewall_policy_association · google_essential_contacts_contact · google_folder · google_kms_autokey_config | | [organization-policies.tf](./organization-policies.tf) | Folder-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | +| [pam.tf](./pam.tf) | None | google_privileged_access_manager_entitlement | +| [scc-mute-configs.tf](./scc-mute-configs.tf) | Folder-level SCC mute configurations. | google_scc_v2_folder_mute_config | +| [scc-sha-custom-modules.tf](./scc-sha-custom-modules.tf) | Folder-level Custom modules with Security Health Analytics. | google_scc_management_folder_security_health_analytics_custom_module | +| [service-agents.tf](./service-agents.tf) | Service agents supporting resources. | google_folder_service_identity | | [tags.tf](./tags.tf) | None | google_tags_tag_binding | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-logging.tf](./variables-logging.tf) | None | | +| [variables-pam.tf](./variables-pam.tf) | None | | +| [variables-scc.tf](./variables-scc.tf) | None | | | [variables.tf](./variables.tf) | Module variables. | | | [versions.tf](./versions.tf) | Version pins. | | @@ -424,34 +824,48 @@ module "folder" { | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [assured_workload_config](variables.tf#L17) | Create AssuredWorkloads folder instead of regular folder when value is provided. Incompatible with folder_create=false. | object({…}) | | null | -| [contacts](variables.tf#L70) | 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)) | | {} | -| [deletion_protection](variables.tf#L77) | Deletion protection setting for this folder. | bool | | false | -| [factories_config](variables.tf#L83) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | -| [firewall_policy](variables.tf#L95) | Hierarchical firewall policy to associate to this folder. | object({…}) | | null | -| [folder_create](variables.tf#L104) | Create folder. When set to false, uses id to reference an existing folder. | bool | | true | +| [asset_feeds](variables.tf#L18) | Cloud Asset Inventory feeds. | map(object({…})) | | {} | +| [asset_search](variables.tf#L51) | Cloud Asset Inventory search configurations. | map(object({…})) | | {} | +| [assured_workload_config](variables.tf#L61) | Create AssuredWorkloads folder instead of regular folder when value is provided. Incompatible with folder_create=false. | object({…}) | | null | +| [autokey_config](variables.tf#L144) | Enable autokey support for this folder's children. Project accepts either project id or number. | object({…}) | | null | +| [contacts](variables.tf#L153) | 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)) | | {} | +| [context](variables.tf#L172) | Context-specific interpolations. | object({…}) | | {} | +| [deletion_protection](variables.tf#L197) | Deletion protection setting for this folder. | bool | | false | +| [factories_config](variables.tf#L203) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [firewall_policy](variables.tf#L215) | Hierarchical firewall policy to associate to this folder. | object({…}) | | null | +| [folder_create](variables.tf#L226) | Create folder. When set to false, uses id to reference an existing folder. | bool | | true | | [iam](variables-iam.tf#L17) | IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | [iam_bindings](variables-iam.tf#L24) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} | | [iam_bindings_additive](variables-iam.tf#L39) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} | | [iam_by_principals](variables-iam.tf#L61) | Authoritative IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam` variable. | map(list(string)) | | {} | | [iam_by_principals_additive](variables-iam.tf#L54) | Additive IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam_bindings_additive` variable. | map(list(string)) | | {} | -| [id](variables.tf#L110) | Folder ID in case you use folder_create=false. | string | | null | -| [logging_data_access](variables-logging.tf#L17) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | map(object({…})) | | {} | +| [iam_by_principals_conditional](variables-iam.tf#L68) | Authoritative IAM binding in {PRINCIPAL => {roles = [roles], condition = {cond}}} format. Principals need to be statically defined to avoid errors. Condition is required. | map(object({…})) | | {} | +| [iam_deny_policies](variables-iam.tf#L98) | IAM Deny policies to be applied to the folder. | map(object({…})) | | {} | +| [id](variables.tf#L236) | Folder ID in case you use folder_create=false. | string | | null | +| [logging_data_access](variables-logging.tf#L17) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | map(object({…})) | | {} | | [logging_exclusions](variables-logging.tf#L28) | Logging exclusions for this folder in the form {NAME -> FILTER}. | map(string) | | {} | -| [logging_settings](variables-logging.tf#L35) | Default settings for logging resources. | object({…}) | | null | -| [logging_sinks](variables-logging.tf#L45) | Logging sinks to create for the folder. | map(object({…})) | | {} | -| [name](variables.tf#L116) | Folder name. | string | | null | -| [org_policies](variables.tf#L122) | Organization policies applied to this folder keyed by policy name. | map(object({…})) | | {} | -| [parent](variables.tf#L150) | Parent in folders/folder_id or organizations/org_id format. | string | | null | -| [tag_bindings](variables.tf#L160) | Tag bindings for this folder, in key => tag value id format. | map(string) | | null | +| [logging_settings](variables-logging.tf#L35) | Default settings for logging resources. | object({…}) | | null | +| [logging_sinks](variables-logging.tf#L45) | Logging sinks to create for the folder. | map(object({…})) | | {} | +| [name](variables.tf#L242) | Folder name. | string | | null | +| [org_policies](variables.tf#L248) | Organization policies applied to this folder keyed by policy name. | map(object({…})) | | {} | +| [pam_entitlements](variables-pam.tf#L17) | Privileged Access Manager entitlements for this resource, keyed by entitlement ID. | map(object({…})) | | {} | +| [parent](variables.tf#L276) | Parent in folders/folder_id or organizations/org_id format. | string | | null | +| [scc_mute_configs](variables-scc.tf#L17) | SCC mute configurations keyed by name. | map(object({…})) | | {} | +| [scc_sha_custom_modules](variables-scc.tf#L27) | SCC custom modules keyed by module name. | map(object({…})) | | {} | +| [service_agents_config](variables.tf#L290) | Service agents configuration. | object({…}) | | {} | +| [tag_bindings](variables.tf#L300) | Tag bindings for this folder, in key => tag value id format. | map(string) | | null | ## Outputs | name | description | sensitive | |---|---|:---:| -| [assured_workload](outputs.tf#L17) | Assured Workloads workload resource. | | -| [folder](outputs.tf#L22) | Folder resource. | | -| [id](outputs.tf#L27) | Fully qualified folder id. | | -| [name](outputs.tf#L38) | Folder name. | | -| [sink_writer_identities](outputs.tf#L47) | Writer identities created for each sink. | | +| [asset_search_results](outputs.tf#L17) | Cloud Asset Inventory search results. | | +| [assured_workload](outputs.tf#L24) | Assured Workloads workload resource. | | +| [folder](outputs.tf#L29) | Folder resource. | | +| [id](outputs.tf#L34) | Fully qualified folder id. | | +| [name](outputs.tf#L45) | Folder name. | | +| [organization_policies_ids](outputs.tf#L54) | Map of ORGANIZATION_POLICIES => ID in the folder. | | +| [scc_custom_sha_modules_ids](outputs.tf#L59) | Map of SCC CUSTOM SHA MODULES => ID in the folder. | | +| [service_agents](outputs.tf#L64) | Identities of all folder-level service agents. | | +| [sink_writer_identities](outputs.tf#L72) | Writer identities created for each sink. | | diff --git a/modules/folder/assets.tf b/modules/folder/assets.tf new file mode 100644 index 000000000..94a904f8f --- /dev/null +++ b/modules/folder/assets.tf @@ -0,0 +1,57 @@ +/** + * Copyright 2026 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. + */ + +data "google_cloud_asset_search_all_resources" "default" { + for_each = var.asset_search + scope = local.folder_id + asset_types = each.value.asset_types + query = each.value.query +} + +resource "google_cloud_asset_folder_feed" "default" { + for_each = var.asset_feeds + billing_project = lookup( + local.ctx.project_ids, + each.value.billing_project, + each.value.billing_project + ) + folder = local.folder_id + feed_id = each.key + content_type = each.value.content_type + + asset_types = each.value.asset_types + asset_names = each.value.asset_names + + feed_output_config { + pubsub_destination { + topic = lookup( + local.ctx.pubsub_topics, + each.value.feed_output_config.pubsub_destination.topic, + each.value.feed_output_config.pubsub_destination.topic + ) + } + } + + dynamic "condition" { + for_each = each.value.condition == null ? [] : [each.value.condition] + content { + expression = condition.value.expression + title = condition.value.title + description = condition.value.description + location = condition.value.location + } + } +} diff --git a/modules/folder/deny-policies.tf b/modules/folder/deny-policies.tf new file mode 100644 index 000000000..d5de9337e --- /dev/null +++ b/modules/folder/deny-policies.tf @@ -0,0 +1,52 @@ +/** + * Copyright 2026 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 IAM Deny policies. + +resource "google_iam_deny_policy" "default" { + for_each = var.iam_deny_policies + parent = urlencode("cloudresourcemanager.googleapis.com/folders/${local.folder_number}") + name = each.key + display_name = each.value.display_name + dynamic "rules" { + for_each = each.value.rules + iterator = rule + content { + description = rule.value.description + deny_rule { + denied_principals = [ + for p in rule.value.denied_principals : lookup(local.ctx.iam_principals, p, p) + ] + dynamic "denial_condition" { + for_each = rule.value.denial_condition == null ? [] : [""] + content { + title = rule.value.denial_condition.title + expression = templatestring( + rule.value.denial_condition.expression, var.context.condition_vars + ) + description = rule.value.denial_condition.description + location = rule.value.denial_condition.location + } + } + denied_permissions = rule.value.denied_permissions + exception_principals = [ + for p in rule.value.exception_principals : lookup(local.ctx.iam_principals, p, p) + ] + exception_permissions = rule.value.exception_permissions + } + } + } +} diff --git a/modules/folder/iam.tf b/modules/folder/iam.tf index af80f2d5b..c815bdd05 100644 --- a/modules/folder/iam.tf +++ b/modules/folder/iam.tf @@ -1,5 +1,5 @@ /** - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,12 @@ locals { k if try(index(v, r), null) != null ] } + ctx_iam_principals = merge(local.ctx.iam_principals, { + "$iam_principalsets:service_accounts/all" = format( + "principalSet://cloudresourcemanager.googleapis.com/folders/%s/type/ServiceAccount", + coalesce(try(split("/", local.folder_id)[1], null), "-") + ) + }) iam = { for role in distinct(concat(keys(var.iam), keys(local._iam_principals))) : role => concat( @@ -44,24 +50,58 @@ locals { } ]... ) + # convert all the iam_by_principals_conditional into a flat list of bindings + _iam_bindings_conditional = flatten([ + for principal, config in var.iam_by_principals_conditional : [ + for role in config.roles : { + principal = principal + role = role + condition = config.condition + } + ] + ]) + # group by (role, title) + _iam_bindings_conditional_grouped = { + for binding in local._iam_bindings_conditional : + "iam-bpc:${binding.role}-${binding.condition.title}" => binding... + } + # finally we merge iam_bindings with the grouped conditional bindings + iam_bindings = merge( + var.iam_bindings, + { + for k, v in local._iam_bindings_conditional_grouped : + k => { + role = v[0].role + condition = v[0].condition + members = [for b in v : b.principal] + } + } + ) } resource "google_folder_iam_binding" "authoritative" { for_each = local.iam folder = local.folder_id - role = each.key - members = each.value + role = lookup(local.ctx.custom_roles, each.key, each.key) + members = [ + for v in each.value : + lookup(local.ctx_iam_principals, v, v) + ] } resource "google_folder_iam_binding" "bindings" { - for_each = var.iam_bindings + for_each = local.iam_bindings folder = local.folder_id - role = each.value.role - members = each.value.members + role = lookup(local.ctx.custom_roles, each.value.role, each.value.role) + members = [ + for v in each.value.members : lookup(local.ctx_iam_principals, v, v) + ] dynamic "condition" { for_each = each.value.condition == null ? [] : [""] content { - expression = each.value.condition.expression + expression = templatestring( + each.value.condition.expression, var.context.condition_vars + ) title = each.value.condition.title description = each.value.condition.description } @@ -71,12 +111,16 @@ resource "google_folder_iam_binding" "bindings" { resource "google_folder_iam_member" "bindings" { for_each = local.iam_bindings_additive folder = local.folder_id - role = each.value.role - member = each.value.member + role = lookup(local.ctx.custom_roles, each.value.role, each.value.role) + member = lookup( + local.ctx.iam_principals, each.value.member, each.value.member + ) dynamic "condition" { for_each = each.value.condition == null ? [] : [""] content { - expression = each.value.condition.expression + expression = templatestring( + each.value.condition.expression, var.context.condition_vars + ) title = each.value.condition.title description = each.value.condition.description } diff --git a/modules/folder/logging.tf b/modules/folder/logging.tf index 6941a4e7b..00ffaabe5 100644 --- a/modules/folder/logging.tf +++ b/modules/folder/logging.tf @@ -19,27 +19,57 @@ locals { logging_sinks = { for k, v in var.logging_sinks : - # rewrite destination and type when type="project" - k => merge(v, v.type != "project" ? {} : { - destination = "projects/${v.destination}" - type = "logging" - }) + # expand destination contexts + k => merge(v, + v.type != "bigquery" ? {} : { + destination = lookup( + local.ctx.bigquery_datasets, v.destination, v.destination + ) + }, + v.type != "logging" ? {} : { + destination = lookup( + local.ctx.log_buckets, v.destination, v.destination + ) + }, + v.type != "project" ? {} : { + api = "logging" + destination = "projects/${lookup(local.ctx.project_ids, v.destination, v.destination)}" + }, + v.type != "pubsub" ? {} : { + destination = lookup( + local.ctx.pubsub_topics, v.destination, v.destination + ) + }, + v.type != "storage" ? {} : { + destination = lookup( + local.ctx.storage_buckets, v.destination, v.destination + ) + } + ) } sink_bindings = { for type in ["bigquery", "logging", "project", "pubsub", "storage"] : type => { - for name, sink in var.logging_sinks : - name => sink - if sink.iam == true && sink.type == type + for name, sink in local.logging_sinks : + name => sink if sink.iam && sink.type == type } } + sink_bucket_expressions = { + for name, sink in local.sink_bindings["logging"] : + name => "resource.name.endsWith('locations/${split("/", sink.destination)[3]}/buckets/${split("/", sink.destination)[5]}')" + } } resource "google_logging_folder_settings" "default" { count = var.logging_settings != null ? 1 : 0 - folder = local.folder_id + folder = local.folder_number disable_default_sink = var.logging_settings.disable_default_sink - storage_location = var.logging_settings.storage_location + kms_key_name = ( + var.logging_settings.kms_key_name == null + ? null + : lookup(local.ctx.kms_keys, var.logging_settings.kms_key_name, var.logging_settings.kms_key_name) + ) + storage_location = var.logging_settings.storage_location } resource "google_folder_iam_audit_config" "default" { @@ -49,18 +79,25 @@ resource "google_folder_iam_audit_config" "default" { dynamic "audit_log_config" { for_each = { for k, v in each.value : k => v if v != null } content { - log_type = audit_log_config.key - exempted_members = audit_log_config.value.exempted_members + log_type = audit_log_config.key + exempted_members = [ + for m in try(audit_log_config.value.exempted_members, []) : + lookup(local.ctx_iam_principals, m, m) + ] } } } resource "google_logging_folder_sink" "sink" { - for_each = local.logging_sinks - name = each.key - description = coalesce(each.value.description, "${each.key} (Terraform-managed).") + for_each = local.logging_sinks + name = each.key + description = ( + each.value.description == null + ? "${each.key} (Terraform-managed)." + : each.value.description + ) folder = local.folder_id - destination = "${each.value.type}.googleapis.com/${each.value.destination}" + destination = "${lookup(each.value, "api", each.value.type)}.googleapis.com/${each.value.destination}" filter = each.value.filter include_children = each.value.include_children intercept_children = each.value.intercept_children @@ -87,14 +124,24 @@ resource "google_logging_folder_sink" "sink" { ] } -resource "google_storage_bucket_iam_member" "gcs-sinks-binding" { +moved { + from = google_storage_bucket_iam_member.gcs-sinks-binding + to = google_storage_bucket_iam_member.gcs_sinks_binding +} + +resource "google_storage_bucket_iam_member" "gcs_sinks_binding" { for_each = local.sink_bindings["storage"] bucket = each.value.destination role = "roles/storage.objectCreator" member = google_logging_folder_sink.sink[each.key].writer_identity } -resource "google_bigquery_dataset_iam_member" "bq-sinks-binding" { +moved { + from = google_bigquery_dataset_iam_member.bq-sinks-binding + to = google_bigquery_dataset_iam_member.bq_sinks_binding +} + +resource "google_bigquery_dataset_iam_member" "bq_sinks_binding" { for_each = local.sink_bindings["bigquery"] project = split("/", each.value.destination)[1] dataset_id = split("/", each.value.destination)[3] @@ -102,7 +149,12 @@ resource "google_bigquery_dataset_iam_member" "bq-sinks-binding" { member = google_logging_folder_sink.sink[each.key].writer_identity } -resource "google_pubsub_topic_iam_member" "pubsub-sinks-binding" { +moved { + from = google_pubsub_topic_iam_member.pubsub-sinks-binding + to = google_pubsub_topic_iam_member.pubsub_sinks_binding +} + +resource "google_pubsub_topic_iam_member" "pubsub_sinks_binding" { for_each = local.sink_bindings["pubsub"] project = split("/", each.value.destination)[1] topic = split("/", each.value.destination)[3] @@ -110,7 +162,12 @@ resource "google_pubsub_topic_iam_member" "pubsub-sinks-binding" { member = google_logging_folder_sink.sink[each.key].writer_identity } -resource "google_project_iam_member" "bucket-sinks-binding" { +moved { + from = google_project_iam_member.bucket-sinks-binding + to = google_project_iam_member.bucket_sinks_binding +} + +resource "google_project_iam_member" "bucket_sinks_binding" { for_each = local.sink_bindings["logging"] project = split("/", each.value.destination)[1] role = "roles/logging.bucketWriter" @@ -118,18 +175,28 @@ resource "google_project_iam_member" "bucket-sinks-binding" { condition { title = "${each.key} bucket writer" description = "Grants bucketWriter to ${google_logging_folder_sink.sink[each.key].writer_identity} used by log sink ${each.key} on ${local.folder_id}" - expression = "resource.name.endsWith('${each.value.destination}')" + expression = local.sink_bucket_expressions[each.key] } } -resource "google_project_iam_member" "project-sinks-binding" { +moved { + from = google_project_iam_member.project-sinks-binding + to = google_project_iam_member.project_sinks_binding +} + +resource "google_project_iam_member" "project_sinks_binding" { for_each = local.sink_bindings["project"] project = each.value.destination role = "roles/logging.logWriter" member = google_logging_folder_sink.sink[each.key].writer_identity } -resource "google_logging_folder_exclusion" "logging-exclusion" { +moved { + from = google_logging_folder_exclusion.logging-exclusion + to = google_logging_folder_exclusion.logging_exclusion +} + +resource "google_logging_folder_exclusion" "logging_exclusion" { for_each = var.logging_exclusions name = each.key folder = local.folder_id diff --git a/modules/folder/main.tf b/modules/folder/main.tf index 882040a1a..2d93d3876 100644 --- a/modules/folder/main.tf +++ b/modules/folder/main.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2025 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,46 +15,64 @@ */ locals { + ctx = { + for k, v in var.context : k => { + for kk, vv in v : "${local.ctx_p}${k}:${kk}" => vv + } if !endswith(k, "_vars") + } + ctx_p = "$" + _folder_id = ( + var.id == null + ? null + : lookup(local.ctx.folder_ids, var.id, var.id) + ) folder_id = ( var.assured_workload_config == null ? ( var.folder_create - ? try(google_folder.folder[0].id, null) - : var.id - ) - : try( - format("folders/%s", one([ - for r in google_assured_workloads_workload.folder[0].resources : - r.resource_id if r.resource_type == "CONSUMER_FOLDER" - ])), - format("folders/%s", google_assured_workloads_workload.folder[0].resources[0].resource_id), - "" + ? coalesce(local._folder_id, try(google_folder.folder[0].id, "")) + : local._folder_id ) + : format("folders/%s", try(google_assured_workloads_workload.folder[0].resources[0].resource_id, "")) ) + folder_number = split("/", local.folder_id)[1] aw_parent = ( - # Assured Workload only accepls folder as a parent and uses organization as a parent when no value provided. + # Assured Workload only accepts folder as a parent and uses organization as a parent when no value provided. var.parent == null ? null : ( try(startswith(var.parent, "folders/")) ? var.parent - : null + : lookup(local.ctx.folder_ids, var.parent, null) ) ) } resource "google_folder" "folder" { count = var.folder_create && var.assured_workload_config == null ? 1 : 0 - display_name = substr(var.name, 0, 30) - parent = var.parent + display_name = var.name + parent = lookup(local.ctx.folder_ids, var.parent, var.parent) deletion_protection = var.deletion_protection } +resource "google_kms_autokey_config" "default" { + provider = google-beta + count = var.autokey_config != null ? 1 : 0 + folder = local.folder_id + key_project = try( + "projects/${local.ctx.project_ids[var.autokey_config.project]}", + "projects/${local.ctx.project_numbers[var.autokey_config.project]}", + var.autokey_config.project + ) +} + resource "google_essential_contacts_contact" "contact" { - provider = google-beta - for_each = var.contacts - parent = local.folder_id - email = each.key + provider = google-beta + for_each = var.contacts + parent = local.folder_id + email = lookup( + local.ctx.email_addresses, each.key, each.key + ) language_tag = "en" notification_category_subscriptions = each.value depends_on = [ @@ -72,14 +90,15 @@ resource "google_compute_firewall_policy_association" "default" { } resource "google_assured_workloads_workload" "folder" { - count = (var.assured_workload_config != null && var.folder_create) ? 1 : 0 - compliance_regime = var.assured_workload_config.compliance_regime - display_name = var.assured_workload_config.display_name - location = var.assured_workload_config.location - organization = var.assured_workload_config.organization - enable_sovereign_controls = var.assured_workload_config.enable_sovereign_controls - labels = var.assured_workload_config.labels - partner = var.assured_workload_config.partner + count = (var.assured_workload_config != null && var.folder_create) ? 1 : 0 + compliance_regime = var.assured_workload_config.compliance_regime + display_name = var.assured_workload_config.display_name + location = var.assured_workload_config.location + organization = templatestring(var.assured_workload_config.organization, var.context.condition_vars) + enable_sovereign_controls = var.assured_workload_config.enable_sovereign_controls + labels = var.assured_workload_config.labels + partner = var.assured_workload_config.partner + provisioned_resources_parent = local.aw_parent dynamic "partner_permissions" { for_each = try(var.assured_workload_config.partner_permissions, null) == null ? [] : [""] content { @@ -88,9 +107,6 @@ resource "google_assured_workloads_workload" "folder" { service_access_approver = var.assured_workload_config.partner_permissions.service_access_approver } } - - provisioned_resources_parent = local.aw_parent - resource_settings { display_name = var.name resource_type = "CONSUMER_FOLDER" diff --git a/modules/folder/organization-policies.tf b/modules/folder/organization-policies.tf index 45c4c8dbe..40cc11e4c 100644 --- a/modules/folder/organization-policies.tf +++ b/modules/folder/organization-policies.tf @@ -34,7 +34,7 @@ locals { all = try(r.allow.all, null) values = ( can(r.allow.values) - ? [for x in r.allow.values : templatestring(x, var.factories_config.context.org_policies)] + ? [for x in r.allow.values : templatestring(x, var.context.condition_vars)] : null ) } : null @@ -42,7 +42,7 @@ locals { all = try(r.deny.all, null) values = ( can(r.deny.values) - ? [for x in r.deny.values : templatestring(x, var.factories_config.context.org_policies)] + ? [for x in r.deny.values : templatestring(x, var.context.condition_vars)] : null ) } : null @@ -50,28 +50,28 @@ locals { condition = { description = ( can(r.condition.description) - ? templatestring(r.condition.description, var.factories_config.context.org_policies) + ? templatestring(r.condition.description, var.context.condition_vars) : null ) expression = ( can(r.condition.expression) - ? templatestring(r.condition.expression, var.factories_config.context.org_policies) + ? templatestring(r.condition.expression, var.context.condition_vars) : null ) location = ( can(r.condition.location) - ? templatestring(r.condition.location, var.factories_config.context.org_policies) + ? templatestring(r.condition.location, var.context.condition_vars) : null ) title = ( can(r.condition.title) - ? templatestring(r.condition.title, var.factories_config.context.org_policies) + ? templatestring(r.condition.title, var.context.condition_vars) : null ) } parameters = ( can(r.parameters) - ? templatestring(r.parameters, var.factories_config.context.org_policies) + ? templatestring(r.parameters, var.context.condition_vars) : null ) } @@ -138,8 +138,12 @@ resource "google_org_policy_policy" "default" { dynamic "values" { for_each = rule.value.has_values ? [1] : [] content { - allowed_values = try(rule.value.allow.values, null) - denied_values = try(rule.value.deny.values, null) + allowed_values = try(rule.value.allow.values, null) == null ? null : [ + for v in rule.value.allow.values : templatestring(v, var.context.condition_vars) + ] + denied_values = try(rule.value.deny.values, null) == null ? null : [ + for v in rule.value.deny.values : templatestring(v, var.context.condition_vars) + ] } } } @@ -177,8 +181,12 @@ resource "google_org_policy_policy" "default" { dynamic "values" { for_each = rule.value.has_values ? [1] : [] content { - allowed_values = try(rule.value.allow.values, null) - denied_values = try(rule.value.deny.values, null) + allowed_values = try(rule.value.allow.values, null) == null ? null : [ + for v in rule.value.allow.values : templatestring(v, var.context.condition_vars) + ] + denied_values = try(rule.value.deny.values, null) == null ? null : [ + for v in rule.value.deny.values : templatestring(v, var.context.condition_vars) + ] } } } diff --git a/modules/folder/outputs.tf b/modules/folder/outputs.tf index f9acdad40..7a2a8e356 100644 --- a/modules/folder/outputs.tf +++ b/modules/folder/outputs.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,13 @@ * limitations under the License. */ +output "asset_search_results" { + description = "Cloud Asset Inventory search results." + value = { + for k, v in data.google_cloud_asset_search_all_resources.default : k => v.results + } +} + output "assured_workload" { description = "Assured Workloads workload resource." value = try(google_assured_workloads_workload.folder[0], null) @@ -44,6 +51,24 @@ output "name" { ) } +output "organization_policies_ids" { + description = "Map of ORGANIZATION_POLICIES => ID in the folder." + value = { for k, v in google_org_policy_policy.default : k => v.id } +} + +output "scc_custom_sha_modules_ids" { + description = "Map of SCC CUSTOM SHA MODULES => ID in the folder." + value = { for k, v in google_scc_management_folder_security_health_analytics_custom_module.scc_folder_custom_module : k => v.id } +} + +output "service_agents" { + description = "Identities of all folder-level service agents." + value = local.service_agents + depends_on = [ + google_folder_service_identity.default + ] +} + output "sink_writer_identities" { description = "Writer identities created for each sink." value = { diff --git a/modules/folder/pam.tf b/modules/folder/pam.tf new file mode 100644 index 000000000..53f28e827 --- /dev/null +++ b/modules/folder/pam.tf @@ -0,0 +1,163 @@ +/** + * Copyright 2025 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 { + _pam_entitlements_factory_path = pathexpand(coalesce(var.factories_config.pam_entitlements, "-")) + _pam_entitlements_factory_data_raw = merge([ + for f in try(fileset(local._pam_entitlements_factory_path, "*.yaml"), []) : + yamldecode(templatefile("${local._pam_entitlements_factory_path}/${f}", var.context)) + ]...) + # simulate applying defaults to data coming from yaml files + _pam_entitlements_factory_data = { + for k, v in local._pam_entitlements_factory_data_raw : k => { + max_request_duration = v.max_request_duration + eligible_users = v.eligible_users + privileged_access = [ + for pa in v.privileged_access : { + role = pa.role + condition = try(pa.condition, null) + } + ] + requester_justification_config = can(v.requester_justification_config) ? { + not_mandatory = try(v.requester_justification_config.not_mandatory, true) + unstructured = try(v.requester_justification_config.unstructured, false) + } : { + not_mandatory = false + unstructured = true + } + manual_approvals = can(v.manual_approvals) ? { + require_approver_justification = v.manual_approvals.require_approver_justification + steps = [ + for s in v.manual_approvals.steps : { + approvers = s.approvers + approvals_needed = try(s.approvals_needed, 1) + approver_email_recipients = try(s.approver_email_recipients, null) + } + ] + } : null + additional_notification_targets = can(v.additional_notification_targets) ? { + admin_email_recipients = try(v.additional_notification_targets.admin_email_recipients, null) + requester_email_recipients = try(v.additional_notification_targets.requester_email_recipients, null) + } : null + } + } + pam_entitlements = merge( + local._pam_entitlements_factory_data, + var.pam_entitlements + ) +} + +resource "google_privileged_access_manager_entitlement" "default" { + for_each = local.pam_entitlements + + parent = local.folder_id + location = "global" + entitlement_id = each.key + max_request_duration = each.value.max_request_duration + + eligible_users { + principals = [ + for u in each.value.eligible_users : lookup(local.ctx_iam_principals, u, u) + ] + } + + privileged_access { + gcp_iam_access { + resource_type = "cloudresourcemanager.googleapis.com/Folder" + resource = "//cloudresourcemanager.googleapis.com/${local.folder_id}" + dynamic "role_bindings" { + for_each = each.value.privileged_access + iterator = binding + content { + role = lookup(local.ctx.custom_roles, binding.value.role, binding.value.role) + condition_expression = binding.value.condition == null ? null : templatestring( + binding.value.condition, var.context.condition_vars + ) + } + } + } + } + + requester_justification_config { + dynamic "not_mandatory" { + for_each = each.value.requester_justification_config.not_mandatory ? [""] : [] + content {} + } + dynamic "unstructured" { + for_each = each.value.requester_justification_config.unstructured ? [""] : [] + content {} + } + } + + dynamic "approval_workflow" { + for_each = each.value.manual_approvals == null ? [] : [""] + content { + manual_approvals { + require_approver_justification = each.value.manual_approvals.require_approver_justification + dynamic "steps" { + for_each = each.value.manual_approvals.steps + iterator = step + content { + approvers { + principals = [ + for a in step.value.approvers : lookup(local.ctx_iam_principals, a, a) + ] + } + + approvals_needed = step.value.approvals_needed + approver_email_recipients = ( + step.value.approver_email_recipients == null + ? null + : [ + for e in step.value.approver_email_recipients : + lookup(local.ctx.email_addresses, e, e) + ] + ) + } + } + } + } + } + + dynamic "additional_notification_targets" { + for_each = each.value.additional_notification_targets == null ? [] : [""] + content { + admin_email_recipients = ( + each.value.additional_notification_targets.admin_email_recipients == null + ? null + : [ + for e in each.value.additional_notification_targets.admin_email_recipients : + lookup(local.ctx.email_addresses, e, e) + ] + ) + requester_email_recipients = ( + each.value.additional_notification_targets.requester_email_recipients == null + ? null + : [ + for e in each.value.additional_notification_targets.requester_email_recipients : + lookup(local.ctx.email_addresses, e, e) + ] + ) + } + } + + depends_on = [ + google_folder.folder, + google_folder_iam_binding.authoritative, + google_folder_iam_binding.bindings, + google_folder_iam_member.bindings + ] +} diff --git a/modules/folder/scc-mute-configs.tf b/modules/folder/scc-mute-configs.tf new file mode 100644 index 000000000..89e6a6c04 --- /dev/null +++ b/modules/folder/scc-mute-configs.tf @@ -0,0 +1,54 @@ +/** + * Copyright 2025 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 Folder-level SCC mute configurations. + +locals { + _scc_mute_configs_factory_path = pathexpand(coalesce(var.factories_config.scc_mute_configs, "-")) + _scc_mute_configs_factory_data_raw = merge([ + for f in try(fileset(local._scc_mute_configs_factory_path, "*.yaml"), []) : + yamldecode(file("${local._scc_mute_configs_factory_path}/${f}")) + ]...) + _scc_mute_configs_factory_data = { + for k, v in local._scc_mute_configs_factory_data_raw : + k => { + description = try(v.description, null) + filter = v.filter + type = try(v.type, "DYNAMIC") + } + } + _scc_mute_configs = merge( + local._scc_mute_configs_factory_data, + var.scc_mute_configs + ) + scc_mute_configs = { + for k, v in local._scc_mute_configs : + k => merge(v, { + name = k + parent = local.folder_id + }) + } +} + +resource "google_scc_v2_folder_mute_config" "scc_mute_configs" { + for_each = local.scc_mute_configs + folder = replace(local.folder_id, "folders/", "") + location = "global" + mute_config_id = each.key + description = each.value.description + filter = each.value.filter + type = each.value.type +} diff --git a/modules/folder/scc-sha-custom-modules.tf b/modules/folder/scc-sha-custom-modules.tf new file mode 100644 index 000000000..12d7a30eb --- /dev/null +++ b/modules/folder/scc-sha-custom-modules.tf @@ -0,0 +1,69 @@ +/** + * Copyright 2025 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 Folder-level Custom modules with Security Health Analytics. + +locals { + _scc_sha_custom_modules_factory_path = pathexpand(coalesce(var.factories_config.scc_sha_custom_modules, "-")) + _scc_sha_custom_modules_factory_data_raw = merge([ + for f in try(fileset(local._scc_sha_custom_modules_factory_path, "*.yaml"), []) : + yamldecode(file("${local._scc_sha_custom_modules_factory_path}/${f}")) + ]...) + _scc_sha_custom_modules_factory_data = { + for k, v in local._scc_sha_custom_modules_factory_data_raw : + k => { + description = try(v.description, null) + severity = v.severity + recommendation = v.recommendation + predicate = v.predicate + resource_selector = v.resource_selector + enablement_state = try(v.enablement_state, "ENABLED") + } + } + _scc_sha_custom_modules = merge( + local._scc_sha_custom_modules_factory_data, + var.scc_sha_custom_modules + ) + scc_sha_custom_modules = { + for k, v in local._scc_sha_custom_modules : + k => merge(v, { + name = k + parent = local.folder_id + }) + } + +} + +resource "google_scc_management_folder_security_health_analytics_custom_module" "scc_folder_custom_module" { + provider = google + + for_each = local.scc_sha_custom_modules + folder = replace(local.folder_id, "folders/", "") + location = "global" + display_name = each.value.name + custom_config { + predicate { + expression = each.value.predicate.expression + } + resource_selector { + resource_types = each.value.resource_selector.resource_types + } + description = each.value.description + recommendation = each.value.recommendation + severity = each.value.severity + } + enablement_state = each.value.enablement_state +} diff --git a/modules/folder/schemas/org-policies.schema.json b/modules/folder/schemas/org-policies.schema.json deleted file mode 120000 index 3a18ee3b5..000000000 --- a/modules/folder/schemas/org-policies.schema.json +++ /dev/null @@ -1 +0,0 @@ -../../organization/schemas/org-policies.schema.json \ No newline at end of file diff --git a/modules/folder/schemas/org-policies.schema.json b/modules/folder/schemas/org-policies.schema.json new file mode 100644 index 000000000..37501c70a --- /dev/null +++ b/modules/folder/schemas/org-policies.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Organization Policies", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^(dry_run:)?[a-z-]+[a-zA-Z0-9\\.]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "inherit_from_parent": { + "type": "boolean" + }, + "reset": { + "type": "boolean" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "allow": { + "$ref": "#/$defs/allow-deny" + }, + "deny": { + "$ref": "#/$defs/allow-deny" + }, + "enforce": { + "type": "boolean" + }, + "condition": { + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "location": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "parameters": { + "type": "string" + } + } + } + } + } + } + }, + "$defs": { + "allow-deny": { + "type": "object", + "additionalProperties": false, + "properties": { + "all": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/modules/folder/schemas/org-policies.schema.md b/modules/folder/schemas/org-policies.schema.md new file mode 100644 index 000000000..6df06d7a6 --- /dev/null +++ b/modules/folder/schemas/org-policies.schema.md @@ -0,0 +1,33 @@ +# Organization Policies + + + +## Properties + +*additional properties: false* + +- **`^(dry_run:)?[a-z-]+[a-zA-Z0-9\.]+$`**: *object* +
*additional properties: false* + - **inherit_from_parent**: *boolean* + - **reset**: *boolean* + - **rules**: *array* + - items: *object* +
*additional properties: false* + - **allow**: *reference([allow-deny](#refs-allow-deny))* + - **deny**: *reference([allow-deny](#refs-allow-deny))* + - **enforce**: *boolean* + - **condition**: *object* +
*additional properties: false* + - **description**: *string* + - **expression**: *string* + - **location**: *string* + - **title**: *string* + - **parameters**: *string* + +## Definitions + +- **allow-deny**: *object* +
*additional properties: false* + - **all**: *boolean* + - **values**: *array* + - items: *string* diff --git a/modules/folder/schemas/pam-entitlements.schema.json b/modules/folder/schemas/pam-entitlements.schema.json new file mode 100644 index 000000000..1504a3a4c --- /dev/null +++ b/modules/folder/schemas/pam-entitlements.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/modules/folder/schemas/pam-entitlements.schema.md b/modules/folder/schemas/pam-entitlements.schema.md new file mode 100644 index 000000000..dc5b50de2 --- /dev/null +++ b/modules/folder/schemas/pam-entitlements.schema.md @@ -0,0 +1,41 @@ +# None + + + +## Properties + +*additional properties: false* + +- **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* + +## Definitions diff --git a/modules/folder/schemas/scc-mute-config.schema.json b/modules/folder/schemas/scc-mute-config.schema.json new file mode 100644 index 000000000..892df5dac --- /dev/null +++ b/modules/folder/schemas/scc-mute-config.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SCC Mute Configurations", + "type": "object", + "patternProperties": { + "^[a-z]([a-z0-9-]*[a-z0-9])?$": { + "type": "object", + "required": [ + "filter" + ], + "properties": { + "description": { + "type": "string" + }, + "filter": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "DYNAMIC", + "STATIC" + ], + "default": "DYNAMIC" + } + } + } + } +} diff --git a/modules/folder/schemas/scc-mute-config.schema.md b/modules/folder/schemas/scc-mute-config.schema.md new file mode 100644 index 000000000..7a2ed4cef --- /dev/null +++ b/modules/folder/schemas/scc-mute-config.schema.md @@ -0,0 +1,13 @@ +# SCC Mute Configurations + + + +## Properties + +- **`^[a-z]([a-z0-9-]*[a-z0-9])?$`**: *object* + - **description**: *string* + - ⁺**filter**: *string* + - **type**: *string* +
*default: DYNAMIC*, *enum: ['DYNAMIC', 'STATIC']* + +## Definitions diff --git a/modules/folder/schemas/scc-sha-custom-modules.schema.json b/modules/folder/schemas/scc-sha-custom-modules.schema.json new file mode 100644 index 000000000..03d87aa52 --- /dev/null +++ b/modules/folder/schemas/scc-sha-custom-modules.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SCC Security Health Analytics Custom Modules", + "type": "object", + "patternProperties": { + "^[a-z][a-zA-Z0-9_]*$": { + "type": "object", + "required": [ + "predicate", + "resource_selector", + "severity" + ], + "properties": { + "description": { + "type": "string" + }, + "predicate": { + "type": "object", + "required": [ + "expression" + ], + "properties": { + "expression": { + "type": "string" + } + } + }, + "recommendation": { + "type": "string" + }, + "resource_selector": { + "type": "object", + "required": [ + "resource_types" + ], + "properties": { + "resource_types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "severity": { + "type": "string" + } + } + } + } +} diff --git a/modules/folder/schemas/scc-sha-custom-modules.schema.md b/modules/folder/schemas/scc-sha-custom-modules.schema.md new file mode 100644 index 000000000..110439d49 --- /dev/null +++ b/modules/folder/schemas/scc-sha-custom-modules.schema.md @@ -0,0 +1,17 @@ +# SCC Security Health Analytics Custom Modules + + + +## Properties + +- **`^[a-z][a-zA-Z0-9_]*$`**: *object* + - **description**: *string* + - ⁺**predicate**: *object* + - ⁺**expression**: *string* + - **recommendation**: *string* + - ⁺**resource_selector**: *object* + - ⁺**resource_types**: *array* + - items: *string* + - ⁺**severity**: *string* + +## Definitions diff --git a/modules/folder/service-agents.tf b/modules/folder/service-agents.tf new file mode 100644 index 000000000..177989573 --- /dev/null +++ b/modules/folder/service-agents.tf @@ -0,0 +1,38 @@ +/** + * Copyright 2025 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 Service agents supporting resources. + +locals { + _sa_raw = yamldecode(file("${path.module}/service-agents.yaml")) + service_agents = { + for agent in local._sa_raw : + agent.name => { + name = agent.name + api = agent.api + display_name = agent.display_name + email = templatestring(agent.identity, { folder_number = local.folder_number }) + iam_email = "serviceAccount:${templatestring(agent.identity, { folder_number = local.folder_number })}" + } if contains(var.service_agents_config.services, agent.api) + } +} + +resource "google_folder_service_identity" "default" { + provider = google-beta + for_each = var.service_agents_config.create_agents ? local.service_agents : {} + folder = local.folder_number + service = each.value.api +} diff --git a/modules/folder/service-agents.yaml b/modules/folder/service-agents.yaml new file mode 100644 index 000000000..a8a638744 --- /dev/null +++ b/modules/folder/service-agents.yaml @@ -0,0 +1,54 @@ +# Copyright 2025 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: accessapproval + display_name: Access Approval Service Agent + api: accessapproval.googleapis.com + identity: service-f${folder_number}@gcp-sa-accessapproval.iam.gserviceaccount.com +- name: assuredworkloads + display_name: Assured Workloads Service Agent + api: assuredworkloads.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-assuredworkloads.iam.gserviceaccount.com +- name: audit-manager + display_name: Audit Manager Service Agent + api: auditmanager.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-audit-manager.iam.gserviceaccount.com +- name: cloudcontrolspartner + display_name: Cloud Controls Partner Service Agent + api: cloudcontrolspartner.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-cloudcontrolspartner.iam.gserviceaccount.com +- name: logging + display_name: Cloud Logging Service Agent + api: logging.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-logging.iam.gserviceaccount.com +- name: observability + display_name: Cloud Observability Service Account + api: observability.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-observability.iam.gserviceaccount.com +- name: osconfig-rollout + display_name: Google Cloud OS Config Rollout Service Agent + api: osconfig.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-osconfig-rollout.iam.gserviceaccount.com +- name: osconfig + display_name: Google Cloud OS Config Service Agent + api: osconfig.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-osconfig.iam.gserviceaccount.com +- name: pam + display_name: Privileged Access Manager Service Agent + api: privilegedaccessmanager.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-pam.iam.gserviceaccount.com +- name: progrollout + display_name: Progressive Rollout Service Agent + api: progressiverollout.googleapis.com + identity: service-folder-${folder_number}@gcp-sa-progrollout.iam.gserviceaccount.com diff --git a/modules/folder/tags.tf b/modules/folder/tags.tf index 323886ef5..e12b17359 100644 --- a/modules/folder/tags.tf +++ b/modules/folder/tags.tf @@ -14,8 +14,14 @@ * limitations under the License. */ +locals { + _tag_bindings = { + for k, v in coalesce(var.tag_bindings, {}) : k => lookup(local.ctx.tag_values, v, v) + } +} + resource "google_tags_tag_binding" "binding" { for_each = coalesce(var.tag_bindings, {}) parent = "//cloudresourcemanager.googleapis.com/${local.folder_id}" - tag_value = each.value + tag_value = templatestring(local._tag_bindings[each.key], var.context.tag_vars) } diff --git a/modules/folder/variables-iam.tf b/modules/folder/variables-iam.tf index ba2768b29..1c12b45b8 100644 --- a/modules/folder/variables-iam.tf +++ b/modules/folder/variables-iam.tf @@ -1,5 +1,5 @@ /** - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,3 +64,69 @@ variable "iam_by_principals" { default = {} nullable = false } + +variable "iam_by_principals_conditional" { + description = "Authoritative IAM binding in {PRINCIPAL => {roles = [roles], condition = {cond}}} format. Principals need to be statically defined to avoid errors. Condition is required." + type = map(object({ + roles = list(string) + condition = object({ + expression = string + title = string + description = optional(string) + }) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for k, v in var.iam_by_principals_conditional : v.condition != null + ]) + error_message = "The `condition` attribute is required. Use `iam_by_principals` for non-conditional bindings." + } + validation { + condition = alltrue([ + for title, conditions in { + for k, v in var.iam_by_principals_conditional : + v.condition.title => v.condition... + } : + length(distinct(conditions)) == 1 + ]) + error_message = "IAM bindings with the same condition title must have identical expressions and descriptions." + } +} + +variable "iam_deny_policies" { + description = "IAM Deny policies to be applied to the folder." + type = map(object({ + display_name = optional(string) + rules = list(object({ + description = optional(string) + denied_principals = list(string) + denied_permissions = list(string) + denial_condition = optional(object({ + expression = string + title = optional(string) + description = optional(string) + location = optional(string) + })) + exception_principals = optional(list(string), []) + exception_permissions = optional(list(string), []) + })) + })) + default = {} + nullable = false + validation { + # Ensure denied_principals and denied_permissions are explicitly not null + # (to prevent HCL evaluation errors in loops) and contain at least one + # element (required by the GCP API). + condition = alltrue(flatten([ + for k, v in var.iam_deny_policies : [ + for r in v.rules : ( + try(length(r.denied_principals) > 0, false) && + try(length(r.denied_permissions) > 0, false) + ) + ] + ])) + error_message = "Each rule in iam_deny_policies must have at least one denied principal and one denied permission." + } +} diff --git a/modules/folder/variables-logging.tf b/modules/folder/variables-logging.tf index 79a47c9d5..df1db5f95 100644 --- a/modules/folder/variables-logging.tf +++ b/modules/folder/variables-logging.tf @@ -17,9 +17,9 @@ variable "logging_data_access" { description = "Control activation of data access logs. The special 'allServices' key denotes configuration for all services." type = map(object({ - ADMIN_READ = optional(object({ exempted_members = optional(list(string)) })), - DATA_READ = optional(object({ exempted_members = optional(list(string)) })), - DATA_WRITE = optional(object({ exempted_members = optional(list(string)) })) + ADMIN_READ = optional(object({ exempted_members = optional(list(string), []) })), + DATA_READ = optional(object({ exempted_members = optional(list(string), []) })), + DATA_WRITE = optional(object({ exempted_members = optional(list(string), []) })) })) default = {} nullable = false @@ -35,8 +35,8 @@ variable "logging_exclusions" { variable "logging_settings" { description = "Default settings for logging resources." type = object({ - # TODO: add support for CMEK disable_default_sink = optional(bool) + kms_key_name = optional(string) storage_location = optional(string) }) default = null @@ -45,16 +45,16 @@ variable "logging_settings" { variable "logging_sinks" { description = "Logging sinks to create for the folder." type = map(object({ + destination = string + type = string bq_partitioned_table = optional(bool, false) description = optional(string) - destination = string disabled = optional(bool, false) exclusions = optional(map(string), {}) filter = optional(string) iam = optional(bool, true) include_children = optional(bool, true) intercept_children = optional(bool, false) - type = string })) default = {} nullable = false diff --git a/modules/folder/variables-pam.tf b/modules/folder/variables-pam.tf new file mode 100644 index 000000000..75fee5d55 --- /dev/null +++ b/modules/folder/variables-pam.tf @@ -0,0 +1,52 @@ +/** + * Copyright 2025 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 "pam_entitlements" { + description = "Privileged Access Manager entitlements for this resource, keyed by entitlement ID." + type = map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + approver_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for v in values(var.pam_entitlements) : + !v.requester_justification_config.not_mandatory || !v.requester_justification_config.unstructured + ]) + error_message = "Only one of 'not_mandatory' or 'unstructured' can be enabled in 'requester_justification_config'." + } +} diff --git a/modules/folder/variables-scc.tf b/modules/folder/variables-scc.tf new file mode 100644 index 000000000..afa476c8a --- /dev/null +++ b/modules/folder/variables-scc.tf @@ -0,0 +1,43 @@ +/** + * Copyright 2025 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 "scc_mute_configs" { + description = "SCC mute configurations keyed by name." + type = map(object({ + description = optional(string) + filter = string + type = optional(string, "DYNAMIC") + })) + default = {} + nullable = false +} +variable "scc_sha_custom_modules" { + description = "SCC custom modules keyed by module name." + type = map(object({ + description = optional(string) + severity = string + recommendation = string + predicate = object({ + expression = string + }) + resource_selector = object({ + resource_types = list(string) + }) + enablement_state = optional(string, "ENABLED") + })) + default = {} + nullable = false +} diff --git a/modules/folder/variables.tf b/modules/folder/variables.tf index 7ff29ca55..72e34dc4b 100644 --- a/modules/folder/variables.tf +++ b/modules/folder/variables.tf @@ -1,5 +1,5 @@ /** - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,50 @@ * limitations under the License. */ + +variable "asset_feeds" { + description = "Cloud Asset Inventory feeds." + type = map(object({ + billing_project = string + content_type = optional(string) + asset_types = optional(list(string)) + asset_names = optional(list(string)) + feed_output_config = object({ + pubsub_destination = object({ + topic = string + }) + }) + condition = optional(object({ + expression = string + title = optional(string) + description = optional(string) + location = optional(string) + })) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for k, v in var.asset_feeds : + v.content_type == null || contains( + ["RESOURCE", "IAM_POLICY", "ORG_POLICY", "ACCESS_POLICY", "OS_INVENTORY", "RELATIONSHIP"], + v.content_type + ) + ]) + error_message = "Content type must be one of RESOURCE, IAM_POLICY, ORG_POLICY, ACCESS_POLICY, OS_INVENTORY, RELATIONSHIP." + } +} + +variable "asset_search" { + description = "Cloud Asset Inventory search configurations." + type = map(object({ + asset_types = list(string) + query = optional(string) + })) + default = {} + nullable = false +} + variable "assured_workload_config" { description = "Create AssuredWorkloads folder instead of regular folder when value is provided. Incompatible with folder_create=false." type = object({ @@ -37,41 +81,117 @@ variable "assured_workload_config" { condition = try(contains([ "ASSURED_WORKLOADS_FOR_PARTNERS", "AU_REGIONS_AND_US_SUPPORT", - "CA_PROTECTED_B, IL5", + "AUSTRALIA_DATA_BOUNDARY_AND_SUPPORT", + "CA_PROTECTED_B", "CA_REGIONS_AND_SUPPORT", + "CANADA_CONTROLLED_GOODS", + "CANADA_DATA_BOUNDARY_AND_SUPPORT", "CJIS", "COMPLIANCE_REGIME_UNSPECIFIED", + "DATA_BOUNDARY_FOR_CANADA_CONTROLLED_GOODS", + "DATA_BOUNDARY_FOR_CANADA_PROTECTED_B", + "DATA_BOUNDARY_FOR_CJIS", + "DATA_BOUNDARY_FOR_FEDRAMP_HIGH", + "DATA_BOUNDARY_FOR_FEDRAMP_MODERATE", + "DATA_BOUNDARY_FOR_IL2", + "DATA_BOUNDARY_FOR_IL4", + "DATA_BOUNDARY_FOR_IL5", + "DATA_BOUNDARY_FOR_IRS_PUBLICATION_1075", + "DATA_BOUNDARY_FOR_ITAR", + "EU_DATA_BOUNDARY_AND_SUPPORT", "EU_REGIONS_AND_SUPPORT", "FEDRAMP_HIGH", "FEDRAMP_MODERATE", - "HIPAA, HITRUST", + "HEALTHCARE_AND_LIFE_SCIENCES_CONTROLS", + "HEALTHCARE_AND_LIFE_SCIENCES_CONTROLS_US_SUPPORT", + "HIPAA", # DEPRECATED + "HITRUST", # DEPRECATED "IL2", "IL4", - "ISR_REGIONS_AND_SUPPORT", + "IL5", + "IRS_1075", "ISR_REGIONS", + "ISR_REGIONS_AND_SUPPORT", + "ISRAEL_DATA_BOUNDARY_AND_SUPPORT", "ITAR", + "JAPAN_DATA_BOUNDARY", "JP_REGIONS_AND_SUPPORT", + "KSA_DATA_BOUNDARY_WITH_ACCESS_JUSTIFICATIONS", + "KSA_REGIONS_AND_SUPPORT_WITH_SOVEREIGNTY_CONTROLS", + "REGIONAL_CONTROLS", + "REGIONAL_DATA_BOUNDARY", + "US_DATA_BOUNDARY_AND_SUPPORT", + "US_DATA_BOUNDARY_FOR_HEALTHCARE_AND_LIFE_SCIENCES", + "US_DATA_BOUNDARY_FOR_HEALTHCARE_AND_LIFE_SCIENCES_WITH_SUPPORT", "US_REGIONAL_ACCESS" ], var.assured_workload_config.compliance_regime), true) error_message = "Field assured_workload_config.compliance_regime must be one of the values listed in https://cloud.google.com/assured-workloads/docs/reference/rest/Shared.Types/ComplianceRegime" } validation { - condition = try(contains([ + condition = try(var.assured_workload_config.partner == null || contains([ "LOCAL_CONTROLS_BY_S3NS", "PARTNER_UNSPECIFIED", + "SOVEREIGN_CONTROLS_BY_CNTXT_NO_EKM", + "SOVEREIGN_CONTROLS_BY_CNTXT", "SOVEREIGN_CONTROLS_BY_PSN", "SOVEREIGN_CONTROLS_BY_SIA_MINSAIT", - "SOVEREIGN_CONTROLS_BY_T_SYSTEMS" + "SOVEREIGN_CONTROLS_BY_T_SYSTEMS", ], var.assured_workload_config.partner), true) - error_message = "Field assured_workload_config.partner must be one of the values listed in https://cloud.google.com/assured-workloads/docs/reference/rest/Shared.Types/Partner" + error_message = "Field assured_workload_config.partner must be null or one of the values listed in https://cloud.google.com/assured-workloads/docs/reference/rest/Shared.Types/Partner" } } +variable "autokey_config" { + description = "Enable autokey support for this folder's children. Project accepts either project id or number." + type = object({ + project = string + }) + nullable = true + default = null +} + variable "contacts" { description = "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." type = map(list(string)) default = {} nullable = false + validation { + condition = alltrue(flatten([ + for k, v in var.contacts : [ + for vv in v : contains([ + "ALL", "SUSPENSION", "SECURITY", "TECHNICAL", "BILLING", "LEGAL", + "PRODUCT_UPDATES" + ], vv) + ] + ])) + error_message = "Invalid contact notification value." + } +} + + +variable "context" { + description = "Context-specific interpolations." + type = object({ + bigquery_datasets = optional(map(string), {}) + condition_vars = optional(map(map(string)), {}) + custom_roles = optional(map(string), {}) + email_addresses = optional(map(string), {}) + folder_ids = optional(map(string), {}) + iam_principals = optional(map(string), {}) + kms_keys = optional(map(string), {}) + log_buckets = optional(map(string), {}) + project_ids = optional(map(string), {}) + project_numbers = optional(map(string), {}) + pubsub_topics = optional(map(string), {}) + storage_buckets = optional(map(string), {}) + tag_values = optional(map(string), {}) + tag_vars = optional(object({ + projects = optional(map(map(string)), {}) + organization = optional(map(string), {}) + }), {}) + }) + default = {} + nullable = false } variable "deletion_protection" { @@ -83,10 +203,10 @@ variable "deletion_protection" { variable "factories_config" { description = "Paths to data files and folders that enable factory functionality." type = object({ - org_policies = optional(string) - context = optional(object({ - org_policies = optional(map(map(string)), {}) - }), {}) + org_policies = optional(string) + pam_entitlements = optional(string) + scc_mute_configs = optional(string) + scc_sha_custom_modules = optional(string) }) nullable = false default = {} @@ -101,10 +221,16 @@ variable "firewall_policy" { default = null } +# keep the following variable as it allows passing in a dynamic value for id + variable "folder_create" { description = "Create folder. When set to false, uses id to reference an existing folder." type = bool default = true + validation { + condition = var.folder_create || var.id != null + error_message = "Variable `id` cannot be null when `folder_create` is false." + } } variable "id" { @@ -152,11 +278,25 @@ variable "parent" { type = string default = null validation { - condition = var.parent == null || can(regex("(organizations|folders)/[0-9]+", var.parent)) - error_message = "Parent must be of the form folders/folder_id or organizations/organization_id." + condition = ( + var.parent == null || + startswith(coalesce(var.parent, "-"), "$folder_ids:") || + can(regex("(organizations|folders)/[0-9]+", coalesce(var.parent, "-"))) + ) + error_message = "Parent must be of the form folders/folder_id or organizations/organization_id, or map to a context variable via $folder_ids:." } } +variable "service_agents_config" { + description = "Service agents configuration." + type = object({ + services = optional(list(string), []) + create_agents = optional(bool, true) + }) + default = {} + nullable = false +} + variable "tag_bindings" { description = "Tag bindings for this folder, in key => tag value id format." type = map(string) diff --git a/modules/cloud-run/versions.tf b/modules/folder/versions.tofu similarity index 82% rename from modules/cloud-run/versions.tf rename to modules/folder/versions.tofu index c4901ba9c..ea38f2451 100644 --- a/modules/cloud-run/versions.tf +++ b/modules/folder/versions.tofu @@ -12,24 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Fabric release: v38.0.0 +# Fabric release: v59.0.0 terraform { - required_version = ">= 1.10.2" + required_version = ">= 1.11.0" required_providers { google = { source = "hashicorp/google" - version = ">= 6.21.0, < 7.0.0" # tftest + version = ">= 8.4.0, < 9.0.0" # tftest } google-beta = { source = "hashicorp/google-beta" - version = ">= 6.21.0, < 7.0.0" # tftest + version = ">= 8.4.0, < 9.0.0" # tftest } } provider_meta "google" { - module_name = "google-pso-tool/cloud-foundation-fabric/modules/cloud-run:v38.0.0-tf" + module_name = "google-pso-tool/cloud-foundation-fabric/modules/folder:v59.0.0-tofu" } provider_meta "google-beta" { - module_name = "google-pso-tool/cloud-foundation-fabric/modules/cloud-run:v38.0.0-tf" + module_name = "google-pso-tool/cloud-foundation-fabric/modules/folder:v59.0.0-tofu" } } diff --git a/modules/net-lb-int/README.md b/modules/net-lb-int/README.md index 5b6de2fa3..007e7685f 100644 --- a/modules/net-lb-int/README.md +++ b/modules/net-lb-int/README.md @@ -10,7 +10,10 @@ This module allows managing a GCE Internal Load Balancer and integrates the forw - [Multiple forwarding rules](#multiple-forwarding-rules) - [Dual stack (IPv4 and IPv6)](#dual-stack-ipv4-and-ipv6) - [PSC service attachments](#psc-service-attachments) + - [Zonal affinity traffic policy](#zonal-affinity-traffic-policy) + - [Regional health check](#regional-health-check) - [End to end example](#end-to-end-example) + - [Context](#context) - [Deploying changes to load balancer configurations](#deploying-changes-to-load-balancer-configurations) - [Issues](#issues) - [Recipes](#recipes) @@ -31,7 +34,7 @@ module "instance_template" { project_id = var.project_id zone = "europe-west1-b" name = "vm-test" - create_template = true + create_template = {} service_account = { auto_create = true } @@ -79,7 +82,7 @@ module "ilb" { ### Externally managed instances -This examples shows how to create an ILB by combining externally managed instances (in a custom module or even outside of the current root module) in an unmanaged group. When using internally managed groups, remember to run `terraform apply` each time group instances change. +This example shows how to create an ILB by combining externally managed instances (in a custom module or even outside of the current root module) in an unmanaged group. When using internally managed groups, remember to run `terraform apply` each time group instances change. ```hcl module "ilb" { @@ -280,6 +283,70 @@ module "ilb" { # tftest modules=1 resources=7 ``` +### Zonal affinity traffic policy + +The `backend_service_config.network_pass_through_lb_traffic_policy` block allows tuning the backend service behavior for network passthrough load balancers, including zonal affinity spillover settings. + +```hcl +module "ilb" { + source = "./fabric/modules/net-lb-int" + project_id = var.project_id + region = "europe-west1" + name = "ilb-test" + service_label = "ilb-test" + vpc_config = { + network = var.vpc.self_link + subnetwork = var.subnet.self_link + } + backend_service_config = { + network_pass_through_lb_traffic_policy = { + zonal_affinity = { + spillover = "ZONAL_AFFINITY_SPILL_CROSS_ZONE" + spillover_ratio = 0.5 + } + } + } + group_configs = { + my-group = { + zone = "europe-west1-b" + instances = [ + "instance-1-self-link", + "instance-2-self-link" + ] + } + } + backends = [{ + group = module.ilb.groups.my-group.self_link + }] +} +# tftest modules=1 resources=4 +``` + +### Regional health check + +The `is_regional` flag in the `health_check_config` block allows creating a regional health check instead of a global one. + +```hcl +module "ilb" { + source = "./fabric/modules/net-lb-int" + project_id = var.project_id + region = "europe-west1" + name = "ilb-test" + service_label = "ilb-test" + vpc_config = { + network = var.vpc.self_link + subnetwork = var.subnet.self_link + } + health_check_config = { + is_regional = true + http = { + port = 80 + } + } +} +# tftest modules=1 resources=3 +``` + ### End to end example This example spins up a simple HTTP server and combines four modules: @@ -308,10 +375,12 @@ module "instance-group" { addresses = null }] boot_disk = { - initialize_params = { + source = { image = "projects/cos-cloud/global/images/family/cos-stable" - type = "pd-ssd" - size = 10 + } + initialize_params = { + type = "pd-ssd" + size = 10 } } tags = ["http-server", "ssh"] @@ -350,6 +419,62 @@ module "ilb" { # tftest modules=3 resources=7 e2e ``` +### Context + +The module supports the contexts interpolation. For example: + +```hcl +module "ilb" { + source = "./fabric/modules/net-lb-int" + project_id = "$project_ids:my-prj" + region = "$locations:primary-region" + name = "ilb-test" + service_label = "ilb-test" + forwarding_rules_config = { + default = { + address = "$addresses:lb-ip-addr" + } + } + vpc_config = { + network = "$networks:shared-vpc" + subnetwork = "$subnets:my-subnet" + } + group_configs = { + my-group = { + zone = "$locations:primary-zone" + instances = [ + "instance-1-self-link", + "instance-2-self-link" + ] + } + } + health_check_config = { + http = { + port = 80 + } + } + context = { + addresses = { + lb-ip-addr = "192.168.0.1" + } + locations = { + primary-region = "us-central1" + primary-zone = "us-central1-b" + } + networks = { + shared-vpc = "projects/prj-host/global/networks/shared-vpc" + } + project_ids = { + my-prj = "my-project-1" + } + subnets = { + my-subnet = "projects/prj-host/regions/us-central1/subnetworks/sub-1" + } + } +} +# tftest modules=1 resources=4 inventory=context.yaml +``` + ## Deploying changes to load balancer configurations For deploying changes to load balancer configuration please refer to [net-lb-app-ext README.md](../net-lb-app-ext/README.md#deploying-changes-to-load-balancer-configurations) @@ -374,20 +499,21 @@ One other issue is a `Provider produced inconsistent final plan` error which is | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [name](variables.tf#L187) | Name used for all resources. | string | ✓ | | -| [project_id](variables.tf#L192) | Project id where resources will be created. | string | ✓ | | -| [region](variables.tf#L197) | GCP region. | string | ✓ | | -| [vpc_config](variables.tf#L223) | VPC-level configuration. | object({…}) | ✓ | | -| [backend_service_config](variables.tf#L17) | Backend service level configuration. | object({…}) | | {} | -| [backends](variables.tf#L52) | Load balancer backends. | list(object({…})) | | [] | -| [description](variables.tf#L63) | Optional description used for resources. | string | | "Terraform managed." | -| [forwarding_rules_config](variables.tf#L69) | The optional forwarding rules configuration. | map(object({…})) | | {…} | -| [group_configs](variables.tf#L85) | Optional unmanaged groups to create. Can be referenced in backends via outputs. | map(object({…})) | | {} | -| [health_check](variables.tf#L97) | Name of existing health check to use, disables auto-created health check. | string | | null | -| [health_check_config](variables.tf#L103) | Optional auto-created health check configuration, use the output self-link to set it in the auto healing policy. Refer to examples for usage. | object({…}) | | {…} | -| [labels](variables.tf#L181) | Labels set on resources. | map(string) | | {} | -| [service_attachments](variables.tf#L202) | PSC service attachments, keyed by forwarding rule. | map(object({…})) | | null | -| [service_label](variables.tf#L217) | Optional prefix of the fully qualified forwarding rule name. | string | | null | +| [name](variables.tf#L235) | Name used for all resources. | string | ✓ | | +| [project_id](variables.tf#L240) | Project id where resources will be created. | string | ✓ | | +| [region](variables.tf#L245) | GCP region. | string | ✓ | | +| [vpc_config](variables.tf#L271) | VPC-level configuration. | object({…}) | ✓ | | +| [backend_service_config](variables.tf#L17) | Backend service level configuration. | object({…}) | | {} | +| [backends](variables.tf#L85) | Load balancer backends. | list(object({…})) | | [] | +| [context](variables.tf#L96) | Context-specific interpolations. | object({…}) | | {} | +| [description](variables.tf#L109) | Optional description used for resources. | string | | "Terraform managed." | +| [forwarding_rules_config](variables.tf#L115) | The optional forwarding rules configuration. | map(object({…})) | | {…} | +| [group_configs](variables.tf#L131) | Optional unmanaged groups to create. Can be referenced in backends via outputs. | map(object({…})) | | {} | +| [health_check](variables.tf#L144) | Name of existing health check to use, disables auto-created health check. Also set `health_check_config = null` when cross-referencing an health check from another load balancer module to avoid a Terraform error. | string | | null | +| [health_check_config](variables.tf#L150) | Optional auto-created health check configuration, use the output self-link to set it in the auto healing policy. Refer to examples for usage. | object({…}) | | {…} | +| [labels](variables.tf#L229) | Labels set on resources. | map(string) | | {} | +| [service_attachments](variables.tf#L250) | PSC service attachments, keyed by forwarding rule. | map(object({…})) | | null | +| [service_label](variables.tf#L265) | Optional prefix of the fully qualified forwarding rule name. | string | | null | ## Outputs @@ -402,8 +528,8 @@ One other issue is a `Provider produced inconsistent final plan` error which is | [group_self_links](outputs.tf#L57) | Optional unmanaged instance group self links. | | | [groups](outputs.tf#L64) | Optional unmanaged instance group resources. | | | [health_check](outputs.tf#L69) | Auto-created health-check resource. | | -| [health_check_id](outputs.tf#L74) | Auto-created health-check id. | | -| [health_check_self_link](outputs.tf#L79) | Auto-created health-check self link. | | -| [id](outputs.tf#L84) | Fully qualified forwarding rule ids. | | -| [service_attachment_ids](outputs.tf#L92) | Service attachment ids. | | +| [health_check_id](outputs.tf#L78) | Auto-created health-check id. | | +| [health_check_self_link](outputs.tf#L87) | Auto-created health-check self link. | | +| [id](outputs.tf#L96) | Fully qualified forwarding rule ids. | | +| [service_attachment_ids](outputs.tf#L104) | Service attachment ids. | | diff --git a/modules/net-lb-int/groups.tf b/modules/net-lb-int/groups.tf index 736dfc6f5..cdb0ec426 100644 --- a/modules/net-lb-int/groups.tf +++ b/modules/net-lb-int/groups.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,9 @@ resource "google_compute_instance_group" "default" { for_each = var.group_configs - project = var.project_id - zone = each.value.zone - name = "${var.name}-${each.key}" + project = local.project_id + zone = lookup(local.ctx.locations, each.value.zone, each.value.zone) + name = coalesce(each.value.name, "${var.name}-${each.key}") description = each.value.description instances = each.value.instances diff --git a/modules/net-lb-int/health-check.tf b/modules/net-lb-int/health-check.tf index 1e0bd193b..48875b68e 100644 --- a/modules/net-lb-int/health-check.tf +++ b/modules/net-lb-int/health-check.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,8 +30,8 @@ locals { resource "google_compute_health_check" "default" { provider = google-beta - count = local.hc != null ? 1 : 0 - project = var.project_id + count = local.hc != null && !try(local.hc.is_regional, false) ? 1 : 0 + project = local.project_id name = coalesce(local.hc.name, var.name) description = local.hc.description check_interval_sec = local.hc.check_interval_sec @@ -64,6 +64,85 @@ resource "google_compute_health_check" "default" { dynamic "http2_health_check" { for_each = local.hc_http2 ? [""] : [] + content { + host = local.hc.http2.host + port = local.hc.http2.port + port_name = local.hc.http2.port_name + port_specification = local.hc.http2.port_specification + proxy_header = local.hc.http2.proxy_header + request_path = local.hc.http2.request_path + response = local.hc.http2.response + } + } + + dynamic "https_health_check" { + for_each = local.hc_https ? [""] : [] + content { + host = local.hc.https.host + port = local.hc.https.port + port_name = local.hc.https.port_name + port_specification = local.hc.https.port_specification + proxy_header = local.hc.https.proxy_header + request_path = local.hc.https.request_path + response = local.hc.https.response + } + } + + dynamic "ssl_health_check" { + for_each = local.hc_ssl ? [""] : [] + content { + port = local.hc.ssl.port + port_name = local.hc.ssl.port_name + port_specification = local.hc.ssl.port_specification + proxy_header = local.hc.ssl.proxy_header + request = local.hc.ssl.request + response = local.hc.ssl.response + } + } + + dynamic "tcp_health_check" { + for_each = local.hc_tcp ? [""] : [] + content { + port = local.hc.tcp.port + port_name = local.hc.tcp.port_name + port_specification = local.hc.tcp.port_specification + proxy_header = local.hc.tcp.proxy_header + request = local.hc.tcp.request + response = local.hc.tcp.response + } + } + + dynamic "log_config" { + for_each = try(local.hc.enable_logging, null) == true ? [""] : [] + content { + enable = true + } + } +} + +resource "google_compute_region_health_check" "default" { + count = local.hc != null && try(local.hc.is_regional, false) ? 1 : 0 + project = local.project_id + region = local.region + name = coalesce(local.hc.name, var.name) + description = local.hc.description + check_interval_sec = local.hc.check_interval_sec + healthy_threshold = local.hc.healthy_threshold + timeout_sec = local.hc.timeout_sec + unhealthy_threshold = local.hc.unhealthy_threshold + + dynamic "grpc_health_check" { + for_each = local.hc_grpc ? [""] : [] + content { + port = local.hc.grpc.port + port_name = local.hc.grpc.port_name + port_specification = local.hc.grpc.port_specification + grpc_service_name = local.hc.grpc.service_name + } + } + + dynamic "http_health_check" { + for_each = local.hc_http ? [""] : [] content { host = local.hc.http.host port = local.hc.http.port @@ -75,6 +154,19 @@ resource "google_compute_health_check" "default" { } } + dynamic "http2_health_check" { + for_each = local.hc_http2 ? [""] : [] + content { + host = local.hc.http2.host + port = local.hc.http2.port + port_name = local.hc.http2.port_name + port_specification = local.hc.http2.port_specification + proxy_header = local.hc.http2.proxy_header + request_path = local.hc.http2.request_path + response = local.hc.http2.response + } + } + dynamic "https_health_check" { for_each = local.hc_https ? [""] : [] content { @@ -91,12 +183,12 @@ resource "google_compute_health_check" "default" { dynamic "ssl_health_check" { for_each = local.hc_ssl ? [""] : [] content { - port = local.hc.tcp.port - port_name = local.hc.tcp.port_name - port_specification = local.hc.tcp.port_specification - proxy_header = local.hc.tcp.proxy_header - request = local.hc.tcp.request - response = local.hc.tcp.response + port = local.hc.ssl.port + port_name = local.hc.ssl.port_name + port_specification = local.hc.ssl.port_specification + proxy_header = local.hc.ssl.proxy_header + request = local.hc.ssl.request + response = local.hc.ssl.response } } diff --git a/modules/net-lb-int/main.tf b/modules/net-lb-int/main.tf index 1b1996a57..368cccd87 100644 --- a/modules/net-lb-int/main.tf +++ b/modules/net-lb-int/main.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,19 +16,35 @@ locals { + _service_attachments = ( + var.service_attachments == null ? {} : var.service_attachments + ) bs_conntrack = var.backend_service_config.connection_tracking bs_failover = var.backend_service_config.failover_config + bs_nptlb = var.backend_service_config.network_pass_through_lb_traffic_policy forwarding_rule_names = { for k, v in var.forwarding_rules_config : k => k == "" ? var.name : "${var.name}-${k}" } - health_check = ( - var.health_check != null - ? var.health_check - : google_compute_health_check.default[0].self_link + ctx = { + for k, v in var.context : k => { + for kk, vv in v : "${local.ctx_p}${k}:${kk}" => vv + } + } + ctx_p = "$" + health_check = coalesce( + var.health_check, + try(google_compute_health_check.default[0].self_link, null), + try(google_compute_region_health_check.default[0].self_link, null) ) - _service_attachments = ( - var.service_attachments == null ? {} : var.service_attachments + network = lookup( + local.ctx.networks, var.vpc_config.network, var.vpc_config.network + ) + project_id = lookup( + local.ctx.project_ids, var.project_id, var.project_id + ) + region = lookup( + local.ctx.locations, var.region, var.region ) service_attachments = { for k, v in local._service_attachments : @@ -44,36 +60,38 @@ moved { resource "google_compute_forwarding_rule" "default" { for_each = var.forwarding_rules_config provider = google-beta - project = var.project_id + project = local.project_id name = coalesce(each.value.name, local.forwarding_rule_names[each.key]) - region = var.region + region = local.region description = each.value.description - ip_address = each.value.address + ip_address = try(local.ctx.addresses[each.value.address], each.value.address) ip_protocol = each.value.protocol ip_version = each.value.address != null ? null : each.value.ipv6 == true ? "IPV6" : "IPV4" # do not set if address is provided backend_service = ( google_compute_region_backend_service.default.self_link ) load_balancing_scheme = "INTERNAL" - network = var.vpc_config.network + network = local.network ports = each.value.ports # "nnnnn" or "nnnnn,nnnnn,nnnnn" max 5 - subnetwork = var.vpc_config.subnetwork - allow_global_access = each.value.global_access - labels = var.labels - all_ports = each.value.ports == null ? true : null - service_label = var.service_label + subnetwork = lookup( + local.ctx.subnets, var.vpc_config.subnetwork, var.vpc_config.subnetwork + ) + allow_global_access = each.value.global_access + labels = var.labels + all_ports = each.value.ports == null ? true : null + service_label = var.service_label # is_mirroring_collector = false } resource "google_compute_region_backend_service" "default" { provider = google-beta - project = var.project_id - region = var.region + project = local.project_id + region = local.region name = coalesce(var.backend_service_config.name, var.name) - description = var.description + description = var.backend_service_config.description load_balancing_scheme = "INTERNAL" protocol = var.backend_service_config.protocol - network = var.vpc_config.network + network = local.network health_checks = [local.health_check] connection_draining_timeout_sec = var.backend_service_config.connection_draining_timeout_sec session_affinity = var.backend_service_config.session_affinity @@ -117,10 +135,12 @@ resource "google_compute_region_backend_service" "default" { } dynamic "log_config" { - for_each = var.backend_service_config.log_sample_rate == null ? [] : [""] + for_each = var.backend_service_config.log_config == null ? [] : [""] content { - enable = true - sample_rate = var.backend_service_config.log_sample_rate + enable = var.backend_service_config.log_config.enable + sample_rate = var.backend_service_config.log_config.sample_rate + optional_mode = var.backend_service_config.log_config.optional_mode + optional_fields = var.backend_service_config.log_config.optional_fields } } @@ -131,16 +151,30 @@ resource "google_compute_region_backend_service" "default" { } } + dynamic "network_pass_through_lb_traffic_policy" { + for_each = local.bs_nptlb != null ? [""] : [] + content { + dynamic "zonal_affinity" { + for_each = local.bs_nptlb.zonal_affinity != null ? [""] : [] + content { + spillover = local.bs_nptlb.zonal_affinity.spillover + spillover_ratio = local.bs_nptlb.zonal_affinity.spillover_ratio + } + } + } + } } resource "google_compute_service_attachment" "default" { for_each = local.service_attachments - project = var.project_id - region = var.region + project = local.project_id + region = local.region name = local.forwarding_rule_names[each.key] description = var.description target_service = google_compute_forwarding_rule.default[each.key].id - nat_subnets = each.value.nat_subnets + nat_subnets = each.value.nat_subnets == null ? null : [ + for s in each.value.nat_subnets : lookup(local.ctx.subnets, s, s) + ] connection_preference = ( each.value.automatic_connection ? "ACCEPT_AUTOMATIC" : "ACCEPT_MANUAL" ) diff --git a/modules/net-lb-int/outputs.tf b/modules/net-lb-int/outputs.tf index 29c925443..91e629c0a 100644 --- a/modules/net-lb-int/outputs.tf +++ b/modules/net-lb-int/outputs.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,17 +68,29 @@ output "groups" { output "health_check" { description = "Auto-created health-check resource." - value = try(google_compute_health_check.default[0], null) + value = try( + google_compute_health_check.default[0], + google_compute_region_health_check.default[0], + null + ) } output "health_check_id" { description = "Auto-created health-check id." - value = try(google_compute_health_check.default[0].id, null) + value = try( + google_compute_health_check.default[0].id, + google_compute_region_health_check.default[0].id, + null + ) } output "health_check_self_link" { description = "Auto-created health-check self link." - value = try(google_compute_health_check.default[0].self_link, null) + value = try( + google_compute_health_check.default[0].self_link, + google_compute_region_health_check.default[0].self_link, + null + ) } output "id" { diff --git a/modules/net-lb-int/recipe-ilb-next-hop/README.md b/modules/net-lb-int/recipe-ilb-next-hop/README.md index f29e4eb7d..66eaacfe7 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/README.md +++ b/modules/net-lb-int/recipe-ilb-next-hop/README.md @@ -4,7 +4,7 @@ This recipe shows how to bootstraps a minimal infrastructure for testing [ILB as The following diagram shows the resources created by this blueprint -!High-level diagram +![High-level diagram](diagram.png "High-level diagram") Two ILBs are configured on the primary and secondary interfaces of gateway VMs with active health checks, but only a single one is used as next hop by default to simplify testing. The second (right-side) VPC has default routes that point to the gateway VMs, to also use the right-side ILB as next hop set the `ilb_right_enable` variable to `true`. @@ -64,14 +64,14 @@ A sample testing session using `tmux`: | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [prefix](variables.tf#L48) | Prefix used for resource names. | string | ✓ | | -| [project_id](variables.tf#L57) | Existing project id. | string | ✓ | | -| [_testing](variables.tf#L17) | Populate this variable to avoid triggering the data source. | object({…}) | | null | -| [ilb_right_enable](variables.tf#L27) | Route right to left traffic through ILB. | bool | | false | -| [ilb_session_affinity](variables.tf#L33) | Session affinity configuration for ILBs. | string | | "CLIENT_IP" | -| [ip_ranges](variables.tf#L39) | IP CIDR ranges used for VPC subnets. | map(string) | | {…} | -| [region](variables.tf#L62) | Region used for resources. | string | | "europe-west1" | -| [zones](variables.tf#L68) | Zone suffixes used for instances. | list(string) | | ["b", "c"] | +| [prefix](variables.tf#L49) | Prefix used for resource names. | string | ✓ | | +| [project_id](variables.tf#L58) | Existing project id. | string | ✓ | | +| [_testing](variables.tf#L18) | Populate this variable to avoid triggering the data source. | object({…}) | | null | +| [ilb_right_enable](variables.tf#L28) | Route right to left traffic through ILB. | bool | | false | +| [ilb_session_affinity](variables.tf#L34) | Session affinity configuration for ILBs. | string | | "CLIENT_IP" | +| [ip_ranges](variables.tf#L40) | IP CIDR ranges used for VPC subnets. | map(string) | | {…} | +| [region](variables.tf#L63) | Region used for resources. | string | | "europe-west1" | +| [zones](variables.tf#L69) | Zone suffixes used for instances. | list(string) | | ["b", "c"] | ## Outputs @@ -96,5 +96,5 @@ module "test" { number = 1234567890 } } -# tftest modules=18 resources=48 +# tftest modules=18 resources=50 ``` diff --git a/modules/net-lb-int/recipe-ilb-next-hop/assets/gw.yaml b/modules/net-lb-int/recipe-ilb-next-hop/assets/gw.yaml index 73bd18dce..c1cbb3480 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/assets/gw.yaml +++ b/modules/net-lb-int/recipe-ilb-next-hop/assets/gw.yaml @@ -1,6 +1,6 @@ #cloud-config -# Copyright 2023 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/modules/net-lb-int/recipe-ilb-next-hop/backend.tf.sample b/modules/net-lb-int/recipe-ilb-next-hop/backend.tf.sample index e1bb8eaf5..0459df47b 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/backend.tf.sample +++ b/modules/net-lb-int/recipe-ilb-next-hop/backend.tf.sample @@ -1,4 +1,4 @@ -# Copyright 2023 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/modules/net-lb-int/recipe-ilb-next-hop/gateways.tf b/modules/net-lb-int/recipe-ilb-next-hop/gateways.tf index 2e99956e2..16124124f 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/gateways.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/gateways.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 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,17 +15,19 @@ */ module "gw" { - source = "../../../modules/compute-vm" - for_each = local.zones - project_id = module.project.project_id - zone = each.value - name = "${var.prefix}-gw-${each.key}" - instance_type = "f1-micro" + source = "../../../modules/compute-vm" + for_each = local.zones + project_id = module.project.project_id + zone = each.value + name = "${var.prefix}-gw-${each.key}" + machine_type = "f1-micro" boot_disk = { + source = { + image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2004-lts" + } initialize_params = { - image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2004-lts", - type = "pd-ssd", - size = 10 + type = "pd-ssd", + size = 10 } } network_interfaces = [ diff --git a/modules/net-lb-int/recipe-ilb-next-hop/main.tf b/modules/net-lb-int/recipe-ilb-next-hop/main.tf index efdc0f08b..a7abe9029 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/main.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/main.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,8 +26,8 @@ module "project" { source = "../../../modules/project" name = var.project_id project_reuse = { - use_data_source = var._testing == null - project_attributes = var._testing + use_data_source = var._testing == null + attributes = var._testing } services = [ "compute.googleapis.com", diff --git a/modules/net-lb-int/recipe-ilb-next-hop/outputs.tf b/modules/net-lb-int/recipe-ilb-next-hop/outputs.tf index c69501d92..23d4d27f1 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/outputs.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/outputs.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/modules/net-lb-int/recipe-ilb-next-hop/variables.tf b/modules/net-lb-int/recipe-ilb-next-hop/variables.tf index be59b2177..820dd0a3c 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/variables.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/variables.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ * limitations under the License. */ +# tflint-ignore: terraform_naming_convention variable "_testing" { description = "Populate this variable to avoid triggering the data source." type = object({ diff --git a/modules/net-lb-int/recipe-ilb-next-hop/vms.tf b/modules/net-lb-int/recipe-ilb-next-hop/vms.tf index 259eacb09..f3a8d46c7 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/vms.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/vms.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,12 +23,12 @@ END } module "vm-left" { - source = "../../../modules/compute-vm" - for_each = local.zones - project_id = module.project.project_id - zone = each.value - name = "${var.prefix}-vm-left-${each.key}" - instance_type = "f1-micro" + source = "../../../modules/compute-vm" + for_each = local.zones + project_id = module.project.project_id + zone = each.value + name = "${var.prefix}-vm-left-${each.key}" + machine_type = "f1-micro" network_interfaces = [ { network = module.vpc-left.self_link @@ -45,12 +45,12 @@ module "vm-left" { } module "vm-right" { - source = "../../../modules/compute-vm" - for_each = local.zones - project_id = module.project.project_id - zone = each.value - name = "${var.prefix}-vm-right-${each.key}" - instance_type = "f1-micro" + source = "../../../modules/compute-vm" + for_each = local.zones + project_id = module.project.project_id + zone = each.value + name = "${var.prefix}-vm-right-${each.key}" + machine_type = "f1-micro" network_interfaces = [ { network = module.vpc-right.self_link diff --git a/modules/net-lb-int/recipe-ilb-next-hop/vpc-left.tf b/modules/net-lb-int/recipe-ilb-next-hop/vpc-left.tf index 13d4501d1..f09f3c2c9 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/vpc-left.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/vpc-left.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/modules/net-lb-int/recipe-ilb-next-hop/vpc-right.tf b/modules/net-lb-int/recipe-ilb-next-hop/vpc-right.tf index e9cd4cd92..942ff9a44 100644 --- a/modules/net-lb-int/recipe-ilb-next-hop/vpc-right.tf +++ b/modules/net-lb-int/recipe-ilb-next-hop/vpc-right.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/modules/net-lb-int/variables.tf b/modules/net-lb-int/variables.tf index b8af1b14d..2583bf8a6 100644 --- a/modules/net-lb-int/variables.tf +++ b/modules/net-lb-int/variables.tf @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,20 @@ variable "backend_service_config" { drop_traffic_if_unhealthy = optional(bool) ratio = optional(number) })) - log_sample_rate = optional(number) + log_config = optional(object({ + enable = optional(bool) + sample_rate = optional(number) + optional_mode = optional(string) + optional_fields = optional(list(string)) + })) + network_pass_through_lb_traffic_policy = optional(object({ + zonal_affinity = object({ + spillover = optional(string, "ZONAL_AFFINITY_DISABLED") + spillover_ratio = optional(number) + }) + })) name = optional(string) + description = optional(string, "Terraform managed.") protocol = optional(string, "UNSPECIFIED") session_affinity = optional(string) timeout_sec = optional(number) @@ -47,6 +59,27 @@ variable "backend_service_config" { ) error_message = "Invalid session affinity value." } + validation { + condition = ( + try(var.backend_service_config.network_pass_through_lb_traffic_policy, null) == null + || contains( + ["ZONAL_AFFINITY_DISABLED", "ZONAL_AFFINITY_SPILL_CROSS_ZONE", "ZONAL_AFFINITY_STAY_WITHIN_ZONE"], + coalesce(var.backend_service_config.network_pass_through_lb_traffic_policy.zonal_affinity.spillover, "ZONAL_AFFINITY_DISABLED") + ) + ) + error_message = "network_pass_through_lb_traffic_policy.zonal_affinity.spillover must be one of ZONAL_AFFINITY_DISABLED, ZONAL_AFFINITY_SPILL_CROSS_ZONE, or ZONAL_AFFINITY_STAY_WITHIN_ZONE." + } + validation { + condition = ( + try(var.backend_service_config.network_pass_through_lb_traffic_policy, null) == null + || try(var.backend_service_config.network_pass_through_lb_traffic_policy.zonal_affinity.spillover_ratio, null) == null + || ( + var.backend_service_config.network_pass_through_lb_traffic_policy.zonal_affinity.spillover_ratio >= 0 && + var.backend_service_config.network_pass_through_lb_traffic_policy.zonal_affinity.spillover_ratio <= 1 + ) + ) + error_message = "network_pass_through_lb_traffic_policy.zonal_affinity.spillover_ratio must be a number between 0 and 1." + } } variable "backends" { @@ -60,6 +93,19 @@ variable "backends" { nullable = false } +variable "context" { + description = "Context-specific interpolations." + type = object({ + addresses = optional(map(string), {}) + locations = optional(map(string), {}) + networks = optional(map(string), {}) + project_ids = optional(map(string), {}) + subnets = optional(map(string), {}) + }) + default = {} + nullable = false +} + variable "description" { description = "Optional description used for resources." type = string @@ -86,6 +132,7 @@ variable "group_configs" { description = "Optional unmanaged groups to create. Can be referenced in backends via outputs." type = map(object({ zone = string + name = optional(string) description = optional(string, "Terraform managed.") instances = optional(list(string)) named_ports = optional(map(number), {}) @@ -95,7 +142,7 @@ variable "group_configs" { } variable "health_check" { - description = "Name of existing health check to use, disables auto-created health check." + description = "Name of existing health check to use, disables auto-created health check. Also set `health_check_config = null` when cross-referencing an health check from another load balancer module to avoid a Terraform error." type = string default = null } @@ -107,6 +154,7 @@ variable "health_check_config" { description = optional(string, "Terraform managed.") enable_logging = optional(bool, false) healthy_threshold = optional(number) + is_regional = optional(bool, false) name = optional(string) timeout_sec = optional(number) unhealthy_threshold = optional(number) diff --git a/modules/net-lb-int/versions.tofu b/modules/net-lb-int/versions.tofu new file mode 100644 index 000000000..ea2426bc8 --- /dev/null +++ b/modules/net-lb-int/versions.tofu @@ -0,0 +1,35 @@ +# Copyright 2025 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. + +# Fabric release: v59.0.0 + +terraform { + required_version = ">= 1.11.0" + required_providers { + google = { + source = "hashicorp/google" + version = ">= 8.4.0, < 9.0.0" # tftest + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 8.4.0, < 9.0.0" # tftest + } + } + provider_meta "google" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/net-lb-int:v59.0.0-tofu" + } + provider_meta "google-beta" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/net-lb-int:v59.0.0-tofu" + } +} diff --git a/modules/net-lb-proxy-int/README.md b/modules/net-lb-proxy-int/README.md index 276c383fe..818c3fa96 100644 --- a/modules/net-lb-proxy-int/README.md +++ b/modules/net-lb-proxy-int/README.md @@ -1,25 +1,30 @@ # Internal Proxy Network Load Balancer Module -This module allows managing Internal HTTP/HTTPS Load Balancers (L7 ILBs). It's designed to expose the full configuration of the underlying resources, and to facilitate common usage patterns by providing sensible defaults, and optionally managing prerequisite resources like health checks, instance groups, etc. +This module allows managing Internal TCP proxy Load Balancers. It's designed to expose the full configuration of the underlying resources, and to facilitate common usage patterns by providing sensible defaults, and optionally managing prerequisite resources like health checks, instance groups, etc. Due to the complexity of the underlying resources, changes to the configuration that involve recreation of resources are best applied in stages, starting by disabling the configuration in the urlmap that references the resources that need recreation, then doing the same for the backend service, etc. ## Examples + - [Examples](#examples) - [Minimal Example](#minimal-example) - [Health Checks](#health-checks) + - [Specify an existing IP address](#specify-an-existing-ip-address) + - [Specify multiple ports](#specify-multiple-ports) - [Instance Groups](#instance-groups) - [Network Endpoint Groups (NEGs)](#network-endpoint-groups-negs) - [Zonal NEG creation](#zonal-neg-creation) - [Hybrid NEG creation](#hybrid-neg-creation) - [Private Service Connect NEG creation](#private-service-connect-neg-creation) - [Internet NEG creation](#internet-neg-creation) + - [Context](#context) - [Deploying changes to load balancer configurations](#deploying-changes-to-load-balancer-configurations) - [Files](#files) - [Variables](#variables) - [Outputs](#outputs) + ### Minimal Example @@ -27,7 +32,7 @@ Due to the complexity of the underlying resources, changes to the configuration An Regional internal proxy Network Load Balancer with a backend service pointing to an existing GCE instance group: ```hcl -module "tcp-proxy" { +module "int-tcp-proxy" { source = "./fabric/modules/net-lb-proxy-int" name = "ilb-test" project_id = var.project_id @@ -42,7 +47,7 @@ module "tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=4 +# tftest inventory=minimal.yaml ``` ### Health Checks @@ -70,7 +75,7 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=4 +# tftest inventory=health-check-config.yaml ``` To leverage an existing health check without having the module create them, simply pass its self link: @@ -92,7 +97,92 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=3 +# tftest inventory=health-check-link.yaml +``` + +### Specify an existing IP address + +You can pass your forwarding rules existing IP addresses to use. + +```hcl +module "address" { + source = "./fabric/modules/net-address" + project_id = var.project_id + internal_addresses = { + ilb = { + purpose = "INTERNAL" + region = "europe-west1" + subnetwork = var.subnet.self_link + } + } +} + +module "int-tcp-proxy" { + source = "./fabric/modules/net-lb-proxy-int" + name = "int-tcp-proxy" + project_id = var.project_id + region = "europe-west1" + forwarding_rules_config = { + "" = { + ip_address = module.address.internal_addresses["ilb"].address + } + } + backend_service_config = { + backends = [{ + group = "projects/myprj/zones/europe-west1-a/instanceGroups/my-ig" + }] + } + vpc_config = { + network = var.vpc.self_link + subnetwork = var.subnet.self_link + } +} +# tftest inventory=address.yaml +``` + +### Specify multiple ports + +To make your load balancer listen on multiple ports you will need to create multiple forwarding rules listening on the same IP of type `SHARED_LOADBALANCER_VIP` (created outside the module). + +```hcl +module "address" { + source = "./fabric/modules/net-address" + project_id = var.project_id + internal_addresses = { + ilb = { + purpose = "SHARED_LOADBALANCER_VIP" + region = "europe-west1" + subnetwork = var.subnet.self_link + } + } +} + +module "int-tcp-proxy" { + source = "./fabric/modules/net-lb-proxy-int" + name = "int-tcp-proxy" + project_id = var.project_id + region = "europe-west1" + forwarding_rules_config = { + http = { + ip_address = module.address.internal_addresses["ilb"].address + port = 80 + } + https = { + ip_address = module.address.internal_addresses["ilb"].address + port = 443 + } + } + backend_service_config = { + backends = [{ + group = "projects/myprj/zones/europe-west1-a/instanceGroups/my-ig" + }] + } + vpc_config = { + network = var.vpc.self_link + subnetwork = var.subnet.self_link + } +} +# tftest inventory=ports.yaml ``` ### Instance Groups @@ -125,7 +215,7 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=5 +# tftest inventory=group-config.yaml ``` ### Network Endpoint Groups (NEGs) @@ -148,7 +238,7 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=4 +# tftest inventory=neg-link.yaml ``` Similarly to instance groups, NEGs can also be managed by this module which supports GCE, hybrid and Private Service Connect NEGs: @@ -156,15 +246,6 @@ Similarly to instance groups, NEGs can also be managed by this module which supp #### Zonal NEG creation ```hcl -resource "google_compute_address" "test" { - project = var.project_id - name = "neg-test" - subnetwork = var.subnet.self_link - address_type = "INTERNAL" - address = "10.0.0.10" - region = "europe-west1" -} - module "int-tcp-proxy" { source = "./fabric/modules/net-lb-proxy-int" name = "int-tcp-proxy" @@ -186,9 +267,8 @@ module "int-tcp-proxy" { endpoints = { e-0 = { instance = "test-1" - ip_address = google_compute_address.test.address - # ip_address = "10.0.0.10" - port = 80 + ip_address = "10.0.0.10" + port = 80 } } } @@ -199,7 +279,7 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=7 inventory=zonal-neg.yaml +# tftest inventory=zonal-neg.yaml ``` #### Hybrid NEG creation @@ -237,40 +317,133 @@ module "int-tcp-proxy" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=6 +# tftest inventory=hybrid-neg.yaml ``` #### Private Service Connect NEG creation ```hcl +module "address-ilb" { + source = "./fabric/modules/net-address" + project_id = var.project_id + internal_addresses = { + ilb-01 = { + purpose = "SHARED_LOADBALANCER_VIP" + region = var.region + subnetwork = module.vpc.subnets["${var.region}/sub-consumer-0"].id + } + } +} + module "int-tcp-proxy" { source = "./fabric/modules/net-lb-proxy-int" name = "int-tcp-proxy" project_id = var.project_id - region = "europe-west1" + region = var.region + forwarding_rules_config = { + http = { + ip_address = module.address-ilb.internal_addresses["ilb-01"].address + port = 80 + } + https = { + ip_address = module.address-ilb.internal_addresses["ilb-01"].address + port = 443 + } + } backend_service_config = { backends = [{ - group = "my-neg" - balancing_mode = "CONNECTION" - max_connections = { - per_endpoint = 10 - } + group = "my-neg" }] } neg_configs = { my-neg = { psc = { - region = "europe-west1" - target_service = "europe-west1-cloudkms.googleapis.com" + network = module.vpc.id + subnetwork = module.vpc.subnets["${var.region}/sub-consumer-0"].id + region = var.region + producer_port = 80 + target_service = module.ilb-producer.service_attachment_ids["default"] } } } vpc_config = { - network = var.vpc.self_link - subnetwork = var.subnet.self_link + network = module.vpc.id + subnetwork = module.vpc.subnets["${var.region}/sub-consumer-0"].id } } -# tftest modules=1 resources=5 + +module "vpc" { + source = "./fabric/modules/net-vpc" + project_id = var.project_id + name = "net-consumer-0" + subnets = [ + { + ip_cidr_range = "10.0.0.0/24" + name = "sub-consumer-0" + region = var.region + } + ] + subnets_proxy_only = [ + { + name = "sub-proxy-consumer-0" + region = var.region + ip_cidr_range = "10.0.1.0/26" + active = true + } + ] +} + +# PRODUCER - What the PSC NEG points to + +module "ilb-producer" { + source = "./fabric/modules/net-lb-int" + project_id = var.project_id + region = "europe-west1" + name = "ilb-producer" + service_label = "ilb-producer" + vpc_config = { + network = module.vpc-producer.id + subnetwork = module.vpc-producer.subnets["${var.region}/sub-producer-0"].id + } + forwarding_rules_config = { + default = {} + } + service_attachments = { + default = { + nat_subnets = [module.vpc-producer.subnets_psc["${var.region}/sub-psc-producer-0"].id] + automatic_connection = true + } + } +} + +module "vpc-producer" { + source = "./fabric/modules/net-vpc" + project_id = var.project_id + name = "net-producer-0" + subnets = [ + { + ip_cidr_range = "10.0.0.0/24" + name = "sub-producer-0" + region = var.region + } + ] + subnets_proxy_only = [ + { + name = "sub-proxy-producer-0" + region = var.region + ip_cidr_range = "10.0.1.0/26" + active = true + } + ] + subnets_psc = [ + { + name = "sub-psc-producer-0" + region = var.region + ip_cidr_range = "10.0.2.0/26" + } + ] +} +# tftest inventory=psc-neg.yaml ``` #### Internet NEG creation @@ -278,7 +451,7 @@ module "int-tcp-proxy" { This example shows how to create and manage internet NEGs: ```hcl -module "ilb-l7" { +module "ilb-tcp-proxy" { source = "./fabric/modules/net-lb-proxy-int" project_id = var.project_id name = "ilb-test" @@ -290,7 +463,6 @@ module "ilb-l7" { # with a single internet NEG the implied default health check is optional health_checks = [] } - port = 80 neg_configs = { neg-0 = { internet = { @@ -310,10 +482,66 @@ module "ilb-l7" { subnetwork = var.subnet.self_link } } -# tftest modules=1 resources=6 inventory=internet-neg.yaml e2e +# tftest inventory=internet-neg.yaml e2e +``` + +### Context + +The module supports the contexts interpolation. For example: + +```hcl +module "tcp-proxy" { + source = "./fabric/modules/net-lb-proxy-int" + name = "ilb-test" + project_id = "$project_ids:test" + region = "$locations:ew8" + forwarding_rules_config = { + "" = { + ip_address = "$addresses:test" + } + } + backend_service_config = { + backends = [{ + group = "projects/myprj/zones/europe-west1-a/instanceGroups/my-ig" + }] + } + group_configs = { + default = { + zone = "$locations:ew8-b" + instances = [ + "projects/myprj/zones/europe-west1-b/instances/vm-a" + ] + named_ports = { http = 80 } + } + } + vpc_config = { + network = "$networks:test" + subnetwork = "$subnets:test" + } + context = { + addresses = { + test = "10.0.0.10" + } + locations = { + ew8 = "europe-west8" + ew8-b = "europe-west8-b" + } + networks = { + test = "projects/foo-dev-net-spoke-0/global/networks/dev-spoke-0" + } + project_ids = { + test = "foo-test-0" + } + subnets = { + test = "projects/foo-dev-net-spoke-0/regions/europe-west8/subnetworks/gce" + } + } +} +# tftest inventory=context.yaml ``` ## Deploying changes to load balancer configurations + For deploying changes to load balancer configuration please refer to [net-lb-app-ext README.md](../net-lb-app-ext/README.md#deploying-changes-to-load-balancer-configurations) @@ -322,7 +550,7 @@ For deploying changes to load balancer configuration please refer to [net-lb-app | name | description | resources | |---|---|---| -| [backend-service.tf](./backend-service.tf) | Backend service resources. | google_compute_region_backend_service | +| [backend-service.tf](./backend-service.tf) | Backend service resources. | google_compute_region_backend_service · terraform_data | | [groups.tf](./groups.tf) | None | google_compute_instance_group | | [health-check.tf](./health-check.tf) | Health check resource. | google_compute_region_health_check | | [main.tf](./main.tf) | Module-level locals and resources. | google_compute_forwarding_rule · google_compute_network_endpoint · google_compute_network_endpoint_group · google_compute_region_network_endpoint · google_compute_region_network_endpoint_group · google_compute_region_target_tcp_proxy · google_compute_service_attachment | @@ -334,37 +562,36 @@ For deploying changes to load balancer configuration please refer to [net-lb-app | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [name](variables.tf#L198) | Load balancer name. | string | ✓ | | -| [project_id](variables.tf#L267) | Project id. | string | ✓ | | -| [region](variables.tf#L272) | The region where to allocate the ILB resources. | string | ✓ | | -| [vpc_config](variables.tf#L292) | VPC-level configuration. | object({…}) | ✓ | | -| [address](variables.tf#L17) | Optional IP address used for the forwarding rule. | string | | null | -| [backend_service_config](variables.tf#L23) | Backend service level configuration. | object({…}) | | {} | -| [description](variables.tf#L75) | Optional description used for resources. | string | | "Terraform managed." | -| [global_access](variables.tf#L82) | Allow client access from all regions. | bool | | null | -| [group_configs](variables.tf#L88) | Optional unmanaged groups to create. Can be referenced in backends via key or outputs. | map(object({…})) | | {} | -| [health_check](variables.tf#L100) | Name of existing health check to use, disables auto-created health check. | string | | null | -| [health_check_config](variables.tf#L106) | Optional auto-created health check configurations, use the output self-link to set it in the auto healing policy. Refer to examples for usage. | object({…}) | | {…} | -| [labels](variables.tf#L192) | Labels set on resources. | map(string) | | {} | -| [neg_configs](variables.tf#L203) | Optional network endpoint groups to create. Can be referenced in backends via key or outputs. | map(object({…})) | | {} | -| [port](variables.tf#L261) | Port. | number | | 80 | -| [service_attachment](variables.tf#L277) | PSC service attachment. | object({…}) | | null | +| [name](variables.tf#L213) | Load balancer name. | string | ✓ | | +| [project_id](variables.tf#L277) | Project id. | string | ✓ | | +| [region](variables.tf#L282) | The region where to allocate the ILB resources. | string | ✓ | | +| [vpc_config](variables.tf#L303) | VPC-level configuration. | object({…}) | ✓ | | +| [backend_service_config](variables.tf#L17) | Backend service level configuration. | object({…}) | | {} | +| [context](variables.tf#L65) | Context-specific interpolations. | object({…}) | | {} | +| [description](variables.tf#L78) | Optional description used for resources. | string | | "Terraform managed." | +| [forwarding_rules_config](variables.tf#L84) | The optional forwarding rules configuration. | map(object({…})) | | {…} | +| [group_configs](variables.tf#L100) | Optional unmanaged groups to create. Can be referenced in backends via key or outputs. | map(object({…})) | | {} | +| [health_check](variables.tf#L114) | Name of existing health check to use, disables auto-created health check. | string | | null | +| [health_check_config](variables.tf#L120) | Optional auto-created health check configurations, use the output self-link to set it in the auto healing policy. Refer to examples for usage. | object({…}) | | {…} | +| [labels](variables.tf#L207) | Labels set on resources. | map(string) | | {} | +| [neg_configs](variables.tf#L218) | Optional network endpoint groups to create. Can be referenced in backends via key or outputs. | map(object({…})) | | {} | +| [service_attachment](variables.tf#L287) | PSC service attachment. | object({…}) | | null | ## Outputs | name | description | sensitive | |---|---|:---:| -| [address](outputs.tf#L17) | Forwarding rule address. | | -| [backend_service](outputs.tf#L22) | Backend resource. | | -| [backend_service_id](outputs.tf#L27) | Backend id. | | -| [backend_service_self_link](outputs.tf#L32) | Backend self link. | | -| [forwarding_rule](outputs.tf#L37) | Forwarding rule resource. | | -| [group_self_links](outputs.tf#L42) | Optional unmanaged instance group self links. | | -| [groups](outputs.tf#L49) | Optional unmanaged instance group resources. | | -| [health_check](outputs.tf#L54) | Auto-created health-check resource. | | -| [health_check_id](outputs.tf#L59) | Auto-created health-check id. | | -| [health_check_self_link](outputs.tf#L64) | Auto-created health-check self link. | | -| [id](outputs.tf#L69) | Fully qualified forwarding rule id. | | -| [neg_ids](outputs.tf#L74) | Autogenerated network endpoint group ids. | | -| [service_attachment_id](outputs.tf#L81) | Id of the service attachment. | | +| [address](outputs.tf#L17) | Forwarding rules addresses. | | +| [backend_service](outputs.tf#L25) | Backend resource. | | +| [backend_service_id](outputs.tf#L30) | Backend id. | | +| [backend_service_self_link](outputs.tf#L35) | Backend self link. | | +| [forwarding_rules](outputs.tf#L40) | Forwarding rule resources. | | +| [group_self_links](outputs.tf#L45) | Optional unmanaged instance group self links. | | +| [groups](outputs.tf#L52) | Optional unmanaged instance group resources. | | +| [health_check](outputs.tf#L57) | Auto-created health-check resource. | | +| [health_check_id](outputs.tf#L62) | Auto-created health-check id. | | +| [health_check_self_link](outputs.tf#L67) | Auto-created health-check self link. | | +| [ids](outputs.tf#L72) | Fully qualified forwarding rule ids. | | +| [neg_ids](outputs.tf#L79) | Autogenerated network endpoint group ids. | | +| [service_attachment_id](outputs.tf#L86) | Id of the service attachment. | | diff --git a/modules/net-lb-proxy-int/backend-service.tf b/modules/net-lb-proxy-int/backend-service.tf index e6aa07a28..55ed78475 100644 --- a/modules/net-lb-proxy-int/backend-service.tf +++ b/modules/net-lb-proxy-int/backend-service.tf @@ -34,15 +34,23 @@ locals { ) } +resource "terraform_data" "neg_trigger" { + input = { + zonal = { for k, v in google_compute_network_endpoint_group.default : k => v.id } + psc = { for k, v in google_compute_region_network_endpoint_group.psc : k => v.id } + internet = { for k, v in google_compute_region_network_endpoint_group.internet : k => v.id } + } +} + resource "google_compute_region_backend_service" "default" { provider = google-beta - project = var.project_id - region = var.region - name = var.name - description = var.description + project = local.project_id + region = local.region + name = coalesce(var.backend_service_config.name, var.name) + description = var.backend_service_config.description affinity_cookie_ttl_sec = var.backend_service_config.affinity_cookie_ttl_sec connection_draining_timeout_sec = var.backend_service_config.connection_draining_timeout_sec - health_checks = [local.health_check] + health_checks = local.health_check == null ? null : [local.health_check] load_balancing_scheme = "INTERNAL_MANAGED" port_name = var.backend_service_config.port_name # defaults to http, not for NEGs protocol = "TCP" @@ -56,7 +64,6 @@ resource "google_compute_region_backend_service" "default" { balancing_mode = backend.value.balancing_mode capacity_scaler = backend.value.capacity_scaler description = backend.value.description - failover = backend.value.failover max_connections = try( backend.value.max_connections.per_group, null ) @@ -70,34 +77,20 @@ resource "google_compute_region_backend_service" "default" { } } - dynamic "connection_tracking_policy" { - for_each = var.backend_service_config.connection_tracking == null ? [] : [""] + dynamic "log_config" { + for_each = var.backend_service_config.log_config == null ? [] : [""] content { - connection_persistence_on_unhealthy_backends = ( - ar.backend_service_config.connection_tracking.persist_conn_on_unhealthy != null - ? ar.backend_service_config.connection_tracking.persist_conn_on_unhealthy - : null - ) - idle_timeout_sec = var.backend_service_config.connection_tracking.idle_timeout_sec - tracking_mode = try(local.bs_conntrack.track_per_session ? "PER_SESSION" : "PER_CONNECTION", null) + enable = var.backend_service_config.log_config.enable + sample_rate = var.backend_service_config.log_config.sample_rate + optional_mode = var.backend_service_config.log_config.optional_mode + optional_fields = var.backend_service_config.log_config.optional_fields } } - dynamic "failover_policy" { - for_each = var.backend_service_config.failover_config == null ? [] : [""] - content { - disable_connection_drain_on_failover = var.backend_service_config.failover_config.disable_conn_drain - drop_traffic_if_unhealthy = var.backend_service_config.failover_config.drop_traffic_if_unhealthy - failover_ratio = var.backend_service_config.failover_config.ratio - } - } - dynamic "log_config" { - for_each = var.backend_service_config.log_sample_rate == null ? [] : [""] - content { - enable = true - sample_rate = var.backend_service_config.log_sample_rate - } + lifecycle { + replace_triggered_by = [ + terraform_data.neg_trigger + ] } - } diff --git a/modules/net-lb-proxy-int/groups.tf b/modules/net-lb-proxy-int/groups.tf index 53ba6b27f..dede20ef5 100644 --- a/modules/net-lb-proxy-int/groups.tf +++ b/modules/net-lb-proxy-int/groups.tf @@ -18,12 +18,12 @@ resource "google_compute_instance_group" "default" { for_each = var.group_configs project = ( each.value.project_id == null - ? var.project_id + ? local.project_id : each.value.project_id ) - zone = each.value.zone - name = "${var.name}-${each.key}" - description = var.description + zone = try(local.ctx.locations[each.value.zone], each.value.zone) + name = coalesce(each.value.name, "${var.name}-${each.key}") + description = each.value.description instances = each.value.instances dynamic "named_port" { @@ -34,4 +34,3 @@ resource "google_compute_instance_group" "default" { } } } - diff --git a/modules/net-lb-proxy-int/health-check.tf b/modules/net-lb-proxy-int/health-check.tf index 5ec01c255..e1313032d 100644 --- a/modules/net-lb-proxy-int/health-check.tf +++ b/modules/net-lb-proxy-int/health-check.tf @@ -18,7 +18,7 @@ locals { hc = ( - var.health_check != null ? null : var.health_check_config + local.has_psc_backend || var.health_check != null ? null : var.health_check_config ) hc_grpc = try(local.hc.grpc, null) != null hc_http = try(local.hc.http, null) != null @@ -31,14 +31,14 @@ locals { resource "google_compute_region_health_check" "default" { provider = google-beta count = local.hc != null ? 1 : 0 - project = var.project_id - name = var.name - region = var.region - description = local.hc.description - check_interval_sec = local.hc.check_interval_sec - healthy_threshold = local.hc.healthy_threshold - timeout_sec = local.hc.timeout_sec - unhealthy_threshold = local.hc.unhealthy_threshold + project = local.project_id + name = coalesce(try(local.hc.name, null), var.name) + region = local.region + description = try(local.hc.description, null) + check_interval_sec = try(local.hc.check_interval_sec, null) + healthy_threshold = try(local.hc.healthy_threshold, null) + timeout_sec = try(local.hc.timeout_sec, null) + unhealthy_threshold = try(local.hc.unhealthy_threshold, null) dynamic "grpc_health_check" { for_each = local.hc_grpc ? [""] : [] diff --git a/modules/net-lb-proxy-int/main.tf b/modules/net-lb-proxy-int/main.tf index 096ae33ce..33e51b0ca 100644 --- a/modules/net-lb-proxy-int/main.tf +++ b/modules/net-lb-proxy-int/main.tf @@ -14,6 +14,19 @@ * limitations under the License. */ +locals { + ctx = { + for k, v in var.context : k => { + for kk, vv in v : "${local.ctx_p}${k}:${kk}" => vv + } + } + ctx_p = "$" + network = lookup(local.ctx.networks, var.vpc_config.network, var.vpc_config.network) + project_id = lookup(local.ctx.project_ids, var.project_id, var.project_id) + region = lookup(local.ctx.locations, var.region, var.region) + subnetwork = lookup(local.ctx.subnets, var.vpc_config.subnetwork, var.vpc_config.subnetwork) +} + locals { # we need keys in the endpoint type to address issue #1055 _neg_endpoints = flatten([ @@ -23,6 +36,27 @@ locals { }) ] ]) + forwarding_rule_names = { + for k, v in var.forwarding_rules_config : + k => k == "" ? var.name : "${var.name}-${k}" + } + health_check = ( + local.has_psc_backend + ? null + : ( + var.health_check == null + ? ( + var.health_check_config == null + ? null + : google_compute_region_health_check.default[0].self_link + ) + : var.health_check + ) + ) + has_psc_backend = anytrue([ + for b in coalesce(var.backend_service_config.backends, []) : + try(var.neg_configs[b.group].psc != null, false) + ]) neg_endpoints = { for v in local._neg_endpoints : (v.key) => v } @@ -41,36 +75,47 @@ locals { for k, v in var.neg_configs : k => v if v.psc != null } - health_check = ( - var.health_check != null - ? var.health_check - : google_compute_region_health_check.default[0].self_link - ) } resource "google_compute_forwarding_rule" "default" { - provider = google-beta - project = var.project_id - region = var.region - name = var.name - description = var.description - ip_address = var.address - ip_protocol = "TCP" + for_each = var.forwarding_rules_config + provider = google-beta + project = local.project_id + region = local.region + name = coalesce(each.value.name, local.forwarding_rule_names[each.key]) + description = coalesce(each.value.description, var.description) + ip_address = try(local.ctx.addresses[each.value.address], each.value.address) + ip_protocol = "TCP" + ip_version = ( + each.value.address != null + ? null + : ( + each.value.ipv6 == true + ? "IPV6" + : "IPV4" # do not set if address is provided + ) + ) load_balancing_scheme = "INTERNAL_MANAGED" - network = var.vpc_config.network - port_range = var.port - subnetwork = var.vpc_config.subnetwork + network = local.network + port_range = each.value.port + subnetwork = local.subnetwork labels = var.labels target = google_compute_region_target_tcp_proxy.default.id - # during the preview phase you cannot change this attribute on an existing rule - allow_global_access = var.global_access + # During the preview phase you cannot change this attribute on an existing rule + allow_global_access = each.value.global_access + + lifecycle { + replace_triggered_by = [ + google_compute_region_target_tcp_proxy.default + ] + } } resource "google_compute_region_target_tcp_proxy" "default" { - project = var.project_id + project = local.project_id name = var.name description = var.description - region = var.region + region = local.region backend_service = google_compute_region_backend_service.default.self_link } @@ -78,22 +123,27 @@ resource "google_compute_network_endpoint_group" "default" { for_each = local.neg_zonal project = ( each.value.project_id == null - ? var.project_id + ? local.project_id : each.value.project_id ) - zone = each.value.zone + zone = try(local.ctx.locations[each.value.zone], each.value.zone) name = "${var.name}-${each.key}" # re-enable once provider properly supports this # default_port = each.value.default_port description = var.description network_endpoint_type = each.value.type network = ( - each.value.network != null ? each.value.network : var.vpc_config.network + each.value.network != null + ? try(local.ctx.networks[each.value.network], each.value.network) + : local.network ) subnetwork = ( each.value.type == "NON_GCP_PRIVATE_IP_PORT" ? null - : coalesce(each.value.subnetwork, var.vpc_config.subnetwork) + : coalesce( + try(local.ctx.subnets[each.value.subnetwork], each.value.subnetwork), + local.subnetwork + ) ) } @@ -108,22 +158,29 @@ resource "google_compute_network_endpoint" "default" { instance = try(each.value.instance, null) ip_address = each.value.ip_address port = each.value.port - zone = each.value.zone + zone = try(local.ctx.locations[each.value.zone], each.value.zone) } resource "google_compute_region_network_endpoint_group" "psc" { - for_each = local.neg_regional_psc - project = var.project_id - region = each.value.psc.region - name = "${var.name}-${each.key}" - //description = coalesce(each.value.description, var.description) + for_each = local.neg_regional_psc + project = local.project_id + region = each.value.psc.region + name = "${var.name}-${each.key}" network_endpoint_type = "PRIVATE_SERVICE_CONNECT" psc_target_service = each.value.psc.target_service - network = each.value.psc.network - subnetwork = each.value.psc.subnetwork - lifecycle { - # ignore until https://github.com/hashicorp/terraform-provider-google/issues/20576 is fixed - ignore_changes = [psc_data] + network = ( + each.value.psc.network == null + ? null + : try(local.ctx.networks[each.value.psc.network], each.value.psc.network) + ) + subnetwork = ( + each.value.psc.subnetwork == null + ? null + : try(local.ctx.subnets[each.value.psc.subnetwork], each.value.psc.subnetwork) + ) + + psc_data { + producer_port = each.value.psc.producer_port } } @@ -147,7 +204,7 @@ locals { resource "google_compute_region_network_endpoint_group" "internet" { for_each = local.neg_internet - project = var.project_id + project = local.project_id name = "${var.name}-${each.key}" region = each.value.internet.region # re-enable once provider properly supports this @@ -176,12 +233,15 @@ resource "google_compute_region_network_endpoint" "internet" { # PSC Producer Service attachments resource "google_compute_service_attachment" "default" { count = var.service_attachment == null ? 0 : 1 - project = var.project_id + project = local.project_id region = var.region name = var.name description = var.description - target_service = google_compute_forwarding_rule.default.id - nat_subnets = var.service_attachment.nat_subnets + target_service = google_compute_forwarding_rule.default[var.service_attachment.forwarding_rule].id + nat_subnets = [ + for s in var.service_attachment.nat_subnets + : lookup(local.ctx.subnets, s, s) + ] connection_preference = ( var.service_attachment.automatic_connection ? "ACCEPT_AUTOMATIC" @@ -195,9 +255,11 @@ resource "google_compute_service_attachment" "default" { ) enable_proxy_protocol = var.service_attachment.enable_proxy_protocol reconcile_connections = var.service_attachment.reconcile_connections + dynamic "consumer_accept_lists" { for_each = var.service_attachment.consumer_accept_lists iterator = accept + content { project_id_or_num = accept.key connection_limit = accept.value diff --git a/modules/net-lb-proxy-int/outputs.tf b/modules/net-lb-proxy-int/outputs.tf index 7f37aa7e6..a1081e576 100644 --- a/modules/net-lb-proxy-int/outputs.tf +++ b/modules/net-lb-proxy-int/outputs.tf @@ -15,8 +15,11 @@ */ output "address" { - description = "Forwarding rule address." - value = google_compute_forwarding_rule.default.ip_address + description = "Forwarding rules addresses." + value = { + for k, v in google_compute_forwarding_rule.default + : v.name => v.ip_address + } } output "backend_service" { @@ -34,8 +37,8 @@ output "backend_service_self_link" { value = google_compute_region_backend_service.default.self_link } -output "forwarding_rule" { - description = "Forwarding rule resource." +output "forwarding_rules" { + description = "Forwarding rule resources." value = google_compute_forwarding_rule.default } @@ -66,9 +69,11 @@ output "health_check_self_link" { value = try(google_compute_region_health_check.default[0].self_link, null) } -output "id" { - description = "Fully qualified forwarding rule id." - value = google_compute_forwarding_rule.default.id +output "ids" { + description = "Fully qualified forwarding rule ids." + value = { + for k, v in google_compute_forwarding_rule.default : k => v.id + } } output "neg_ids" { diff --git a/modules/net-lb-proxy-int/variables.tf b/modules/net-lb-proxy-int/variables.tf index 119265d24..132254de8 100644 --- a/modules/net-lb-proxy-int/variables.tf +++ b/modules/net-lb-proxy-int/variables.tf @@ -14,29 +14,29 @@ * limitations under the License. */ -variable "address" { - description = "Optional IP address used for the forwarding rule." - type = string - default = null -} - variable "backend_service_config" { description = "Backend service level configuration." type = object({ + name = optional(string) + description = optional(string, "Terraform managed.") affinity_cookie_ttl_sec = optional(number) connection_draining_timeout_sec = optional(number) health_checks = optional(list(string), ["default"]) - log_sample_rate = optional(number) - port_name = optional(string) - project_id = optional(string) - session_affinity = optional(string, "NONE") - timeout_sec = optional(number) + log_config = optional(object({ + enable = optional(bool) + sample_rate = optional(number) + optional_mode = optional(string) + optional_fields = optional(list(string)) + })) + port_name = optional(string) + project_id = optional(string) + session_affinity = optional(string, "NONE") + timeout_sec = optional(number) backends = optional(list(object({ group = string balancing_mode = optional(string, "UTILIZATION") capacity_scaler = optional(number, 1) description = optional(string, "Terraform managed.") - failover = optional(bool, false) max_connections = optional(object({ per_endpoint = optional(number) per_group = optional(number) @@ -44,16 +44,6 @@ variable "backend_service_config" { })) max_utilization = optional(number) }))) - connection_tracking = optional(object({ - idle_timeout_sec = optional(number) - persist_conn_on_unhealthy = optional(string) - track_per_session = optional(bool) - })) - failover_config = optional(object({ - disable_conn_drain = optional(bool) - drop_traffic_if_unhealthy = optional(bool) - ratio = optional(number) - })) }) default = {} nullable = false @@ -64,7 +54,7 @@ variable "backend_service_config" { error_message = "Invalid session affinity value." } validation { - condition = alltrue([ + condition = var.backend_service_config.backends == null ? true : alltrue([ for b in var.backend_service_config.backends : contains( ["CONNECTION", "UTILIZATION"], coalesce(b.balancing_mode, "CONNECTION") )]) @@ -72,22 +62,46 @@ variable "backend_service_config" { } } +variable "context" { + description = "Context-specific interpolations." + type = object({ + addresses = optional(map(string), {}) + locations = optional(map(string), {}) + networks = optional(map(string), {}) + project_ids = optional(map(string), {}) + subnets = optional(map(string), {}) + }) + default = {} + nullable = false +} + variable "description" { description = "Optional description used for resources." type = string default = "Terraform managed." } -# during the preview phase you cannot change this attribute on an existing rule -variable "global_access" { - description = "Allow client access from all regions." - type = bool - default = null +variable "forwarding_rules_config" { + description = "The optional forwarding rules configuration." + type = map(object({ + address = optional(string) + description = optional(string) + global_access = optional(bool, true) + ipv6 = optional(bool, false) + name = optional(string) + port = optional(number, 80) + protocol = optional(string, "TCP") + })) + default = { + "" = {} + } } variable "group_configs" { description = "Optional unmanaged groups to create. Can be referenced in backends via key or outputs." type = map(object({ + name = optional(string) + description = optional(string, "Terraform managed.") zone = string instances = optional(list(string)) named_ports = optional(map(number), {}) @@ -106,6 +120,7 @@ variable "health_check" { variable "health_check_config" { description = "Optional auto-created health check configurations, use the output self-link to set it in the auto healing policy. Refer to examples for usage." type = object({ + name = optional(string) check_interval_sec = optional(number) description = optional(string, "Terraform managed.") enable_logging = optional(bool, false) @@ -180,7 +195,7 @@ variable "health_check_config" { error_message = "Only one health check type can be configured at a time." } validation { - condition = alltrue([ + condition = var.health_check_config == null ? true : alltrue([ for k, v in var.health_check_config : contains([ "-", "USE_FIXED_PORT", "USE_NAMED_PORT", "USE_SERVING_PORT" ], coalesce(try(v.port_specification, null), "-")) @@ -240,6 +255,7 @@ variable "neg_configs" { region = string target_service = string network = optional(string) + producer_port = optional(number) subnetwork = optional(string) })) })) @@ -258,12 +274,6 @@ variable "neg_configs" { } } -variable "port" { - description = "Port." - type = number - default = 80 -} - variable "project_id" { description = "Project id." type = string @@ -284,6 +294,7 @@ variable "service_attachment" { description = optional(string) domain_name = optional(string) enable_proxy_protocol = optional(bool, false) + forwarding_rule = optional(string) reconcile_connections = optional(bool) }) default = null diff --git a/modules/net-lb-proxy-int/versions.tofu b/modules/net-lb-proxy-int/versions.tofu new file mode 100644 index 000000000..7e0f36065 --- /dev/null +++ b/modules/net-lb-proxy-int/versions.tofu @@ -0,0 +1,35 @@ +# Copyright 2025 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. + +# Fabric release: v59.0.0 + +terraform { + required_version = ">= 1.11.0" + required_providers { + google = { + source = "hashicorp/google" + version = ">= 8.4.0, < 9.0.0" # tftest + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 8.4.0, < 9.0.0" # tftest + } + } + provider_meta "google" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/net-lb-proxy-int:v59.0.0-tofu" + } + provider_meta "google-beta" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/net-lb-proxy-int:v59.0.0-tofu" + } +} diff --git a/modules/spanner-instance/README.md b/modules/spanner-instance/README.md index ea0f0a147..c2d850f3a 100644 --- a/modules/spanner-instance/README.md +++ b/modules/spanner-instance/README.md @@ -93,7 +93,7 @@ module "spanner_instance" { source = "./fabric/modules/spanner-instance" project_id = var.project_id instance = { - name = "my-instance" + name = "my-instance" } instance_create = false databases = { @@ -168,13 +168,13 @@ module "spanner_instance" { | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [instance](variables.tf#L89) | Instance attributes. | object({…}) | ✓ | | -| [project_id](variables.tf#L134) | Project id. | string | ✓ | | -| [databases](variables.tf#L17) | Databases. | map(object({…})) | | {} | -| [iam](variables.tf#L63) | IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | -| [iam_bindings](variables.tf#L69) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} | -| [iam_bindings_additive](variables.tf#L79) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} | -| [instance_create](variables.tf#L127) | Set to false to manage databases and IAM bindings in an existing instance. | bool | | true | +| [instance](variables.tf#L90) | Instance attributes. | object({…}) | ✓ | | +| [project_id](variables.tf#L137) | Project id. | string | ✓ | | +| [databases](variables.tf#L17) | Databases. | map(object({…})) | | {} | +| [iam](variables.tf#L64) | IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | +| [iam_bindings](variables.tf#L70) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} | +| [iam_bindings_additive](variables.tf#L80) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} | +| [instance_create](variables.tf#L130) | Set to false to manage databases and IAM bindings in an existing instance. | bool | | true | ## Outputs diff --git a/modules/spanner-instance/main.tf b/modules/spanner-instance/main.tf index ca9189b0b..445bf3de5 100644 --- a/modules/spanner-instance/main.tf +++ b/modules/spanner-instance/main.tf @@ -55,12 +55,14 @@ resource "google_spanner_instance" "spanner_instance" { ? var.instance.config.name : google_spanner_instance_config.spanner_instance_config[0].name ) - name = var.instance.name - display_name = coalesce(var.instance.display_name, var.instance.name) - num_nodes = var.instance.num_nodes - labels = var.instance.labels - force_destroy = var.instance.force_destroy - processing_units = var.instance.processing_units + name = var.instance.name + display_name = coalesce(var.instance.display_name, var.instance.name) + num_nodes = var.instance.num_nodes + labels = var.instance.labels + force_destroy = var.instance.force_destroy + processing_units = var.instance.processing_units + edition = var.instance.edition + default_backup_schedule_type = var.instance.default_backup_schedule_type dynamic "autoscaling_config" { for_each = var.instance.autoscaling == null ? [] : [""] content { @@ -92,8 +94,9 @@ resource "google_spanner_database" "spanner_databases" { instance = local.spanner_instance.name name = each.key ddl = each.value.ddl + default_time_zone = each.value.default_time_zone enable_drop_protection = each.value.enable_drop_protection - deletion_protection = coalesce(each.value.deletion_protection, true) + deletion_protection = false version_retention_period = each.value.version_retention_period dynamic "encryption_config" { for_each = each.value.kms_key_name == null ? [] : [""] diff --git a/modules/spanner-instance/variables.tf b/modules/spanner-instance/variables.tf index 5fa865e38..5fd8044c0 100644 --- a/modules/spanner-instance/variables.tf +++ b/modules/spanner-instance/variables.tf @@ -19,6 +19,7 @@ variable "databases" { type = map(object({ database_dialect = optional(string) ddl = optional(list(string), []) + default_time_zone = optional(string) deletion_protection = optional(bool) enable_drop_protection = optional(bool) iam = optional(map(list(string)), {}) @@ -115,12 +116,14 @@ variable "instance" { )) })) })) - display_name = optional(string) - labels = optional(map(string), {}) - name = string - num_nodes = optional(number) - processing_units = optional(number) - force_destroy = optional(bool) + display_name = optional(string) + edition = optional(string) + default_backup_schedule_type = optional(string) + labels = optional(map(string), {}) + name = string + num_nodes = optional(number) + processing_units = optional(number) + force_destroy = optional(bool) }) } @@ -135,4 +138,3 @@ variable "project_id" { description = "Project id." type = string } - diff --git a/modules/spanner-instance/versions.tofu b/modules/spanner-instance/versions.tofu new file mode 100644 index 000000000..809df0337 --- /dev/null +++ b/modules/spanner-instance/versions.tofu @@ -0,0 +1,35 @@ +# Copyright 2025 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. + +# Fabric release: v59.0.0 + +terraform { + required_version = ">= 1.11.0" + required_providers { + google = { + source = "hashicorp/google" + version = ">= 8.4.0, < 9.0.0" # tftest + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 8.4.0, < 9.0.0" # tftest + } + } + provider_meta "google" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/spanner-instance:v59.0.0-tofu" + } + provider_meta "google-beta" { + module_name = "google-pso-tool/cloud-foundation-fabric/modules/spanner-instance:v59.0.0-tofu" + } +} diff --git a/tests/tools/test_blueprint_and_module_validations.py b/tests/tools/test_blueprint_and_module_validations.py index d7da966ae..efd83e2d8 100644 --- a/tests/tools/test_blueprint_and_module_validations.py +++ b/tests/tools/test_blueprint_and_module_validations.py @@ -55,5 +55,27 @@ def test_modules_enforce_30_char_id_preconditions(self): ) + + def test_synced_upstream_cff_modules_fixes(self): + """Verify upstream CFF fixes in synced folder, net-lb-int, net-lb-proxy-int, spanner-instance, and cloud-run-v2 modules.""" + self.assertFalse((REPO_ROOT / "modules/cloud-run").exists()) + self.assertTrue((REPO_ROOT / "modules/cloud-run-v2").exists()) + + folder_vars = (REPO_ROOT / "modules/folder/variables.tf").read_text(encoding="utf-8") + self.assertNotIn('"CA_PROTECTED_B, IL5, HIPAA, HITRUST"', folder_vars) + self.assertIn('"IL5"', folder_vars) + + nlb_hc = (REPO_ROOT / "modules/net-lb-int/health-check.tf").read_text(encoding="utf-8") + self.assertIn("local.hc.http2.host", nlb_hc) + self.assertIn("local.hc.ssl.port", nlb_hc) + + proxy_bs = (REPO_ROOT / "modules/net-lb-proxy-int/backend-service.tf").read_text(encoding="utf-8") + self.assertNotIn(" ar.backend_service_config", proxy_bs) + self.assertNotIn("local.bs_conntrack", proxy_bs) + + spanner_iam = (REPO_ROOT / "modules/spanner-instance/iam.tf").read_text(encoding="utf-8") + self.assertIn('dynamic "condition"', spanner_iam) + + if __name__ == "__main__": unittest.main()