diff --git a/modules/cloud-run-v2/main.tf b/modules/cloud-run-v2/main.tf new file mode 100644 index 00000000..5908a0f8 --- /dev/null +++ b/modules/cloud-run-v2/main.tf @@ -0,0 +1,373 @@ +/** + * 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 { + connector = ( + var.vpc_connector_create != null + ? google_vpc_access_connector.connector.0.id + : var.revision_annotations.vpcaccess_connector + ) + egress = { + all-traffic = "ALL_TRAFFIC" + private-ranges-only = "PRIVATE_RANGES_ONLY" + } + ingress = { + all = "INGRESS_TRAFFIC_ALL" + internal = "INGRESS_TRAFFIC_INTERNAL_ONLY" + internal-and-cloud-load-balancing = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" + } + prefix = var.prefix == null ? "" : "${var.prefix}-" + 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 + ) + : 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, null + ) +} + +resource "google_vpc_access_connector" "connector" { + count = var.vpc_connector_create != null ? 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 + subnet { + name = var.vpc_connector_create.subnet.name + project_id = var.vpc_connector_create.subnet.project_id + } +} + +resource "google_cloud_run_v2_service" "service" { + project = var.project_id + location = var.region + name = "${local.prefix}${var.name}" + ingress = try(local.ingress[var.ingress_settings], null) + launch_stage = var.launch_stage + + template { + revision = local.revision_name + scaling { + max_instance_count = try( + var.revision_annotations.autoscaling.max_scale, null + ) + min_instance_count = try( + var.revision_annotations.autoscaling.min_scale, null + ) + } + dynamic "vpc_access" { + for_each = local.connector != null ? [""] : [] + content { + connector = local.connector + egress = ( + try(local.egress[var.revision_annotations.vpcaccess_egress], null) + ) + } + } + dynamic "vpc_access" { + for_each = var.revision_annotations.network_interfaces != null ? [""] : [] + content { + egress = var.revision_annotations.vpcaccess_egress + network_interfaces { + subnetwork = var.revision_annotations.network_interfaces.subnetwork + tags = var.revision_annotations.network_interfaces.tags + } + } + } + timeout = var.timeout_seconds + 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 = containers.value.env + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = containers.value.env_from_key + content { + name = env.key + value_source { + secret_key_ref { + version = env.value.key + secret = env.value.name + } + } + } + } + dynamic "resources" { + for_each = containers.value.resources == null ? [] : [""] + content { + limits = resources.value.limits + cpu_idle = resources.value.cpu_idle + startup_cpu_boost = var.startup_cpu_boost + } + } + dynamic "ports" { + for_each = containers.value.ports + content { + container_port = ports.value.container_port + name = ports.value.name + } + } + dynamic "volume_mounts" { + for_each = containers.value.volume_mounts + content { + name = volume_mounts.key + mount_path = volume_mounts.value + } + } + + dynamic "liveness_probe" { + for_each = containers.value.liveness_probe == null ? [] : [""] + content { + initial_delay_seconds = liveness_probe.value.initial_delay_seconds + timeout_seconds = liveness_probe.value.timeout_seconds + period_seconds = liveness_probe.value.period_seconds + failure_threshold = liveness_probe.value.failure_threshold + dynamic "http_get" { + for_each = liveness_probe.value.action.http_get == null ? [] : [""] + content { + path = http_get.value.path + dynamic "http_headers" { + for_each = http_get.value.http_headers + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "grpc" { + for_each = liveness_probe.value.action.grpc == null ? [] : [""] + content { + port = grpc.value.port + service = grpc.value.service + } + } + } + } + dynamic "startup_probe" { + for_each = containers.value.startup_probe == null ? [] : [""] + content { + initial_delay_seconds = startup_probe.value.initial_delay_seconds + timeout_seconds = startup_probe.value.timeout_seconds + period_seconds = startup_probe.value.period_seconds + failure_threshold = startup_probe.value.failure_threshold + dynamic "http_get" { + for_each = startup_probe.value.action.http_get == null ? [] : [""] + content { + path = http_get.value.path + dynamic "http_headers" { + for_each = http_get.value.http_headers + content { + name = http_headers.key + value = http_headers.value + } + } + } + } + dynamic "tcp_socket" { + for_each = startup_probe.value.action.tcp_socket == null ? [] : [""] + content { + port = tcp_socket.value.port + } + } + dynamic "grpc" { + for_each = startup_probe.value.action.grpc == null ? [] : [""] + content { + port = grpc.value.port + service = grpc.value.service + } + } + + } + } + + } + } + dynamic "volumes" { + for_each = var.volumes + content { + name = volumes.key + secret { + secret = volumes.value.secret_name + default_mode = volumes.value.default_mode + dynamic "items" { + for_each = volumes.value.items + content { + path = items.value.path + version = items.key + mode = items.value.mode + } + } + } + cloud_sql_instance { + instances = revision_annotations.cloudsql_instances + } + } + } + execution_environment = ( + var.gen2_execution_environment == true + ? "EXECUTION_ENVIRONMENT_GEN2" : "EXECUTION_ENVIRONMENT_GEN1" + ) + max_instance_request_concurrency = var.container_concurrency + } + labels = var.labels + dynamic "traffic" { + for_each = var.traffic + content { + percent = traffic.value.percent + type = ( + traffic.value.latest == true + ? "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" + : "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" + ) + revision = ( + traffic.value.latest == true + ? null : "${var.name}-${traffic.key}" + ) + tag = traffic.value.tag + } + } + + lifecycle { + ignore_changes = [ + template.0.annotations["run.googleapis.com/operation-id"], + ] + } +} + +resource "google_cloud_run_service_iam_binding" "binding" { + for_each = var.iam + project = google_cloud_run_v2_service.service.project + location = google_cloud_run_v2_service.service.location + service = google_cloud_run_v2_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_v2_service.service.project + location = google_cloud_run_v2_service.service.location + service = google_cloud_run_v2_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_v2_service.service.location + project = google_cloud_run_v2_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_v2_service.service.name + region = google_cloud_run_v2_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_v2_service.service.location + project = google_cloud_run_v2_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_v2_service.service.name + region = google_cloud_run_v2_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-v2/outputs.tf b/modules/cloud-run-v2/outputs.tf new file mode 100644 index 00000000..d3c61784 --- /dev/null +++ b/modules/cloud-run-v2/outputs.tf @@ -0,0 +1,53 @@ +/** + * 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. + */ + +output "id" { + description = "Fully qualified service id." + value = google_cloud_run_v2_service.service.id +} + +output "service" { + description = "Cloud Run service." + value = google_cloud_run_v2_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_v2_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-v2/variables.tf b/modules/cloud-run-v2/variables.tf new file mode 100644 index 00000000..6b1d4537 --- /dev/null +++ b/modules/cloud-run-v2/variables.tf @@ -0,0 +1,264 @@ +/** + * 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. + */ + +variable "container_concurrency" { + description = "Maximum allowed in-flight (concurrent) requests per container of the revision." + type = number + 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) + })), {}) + resources = optional(object({ + limits = optional(object({ + cpu = string + memory = string + })) + cpu_idle = optional(bool) + })) + 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 = {} +} + +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 should be one of 'all', 'internal', 'internal-and-cloud-load-balancing'." + } +} + +variable "labels" { + description = "Resource labels." + type = map(string) + default = {} +} + +variable "launch_stage" { + description = "The launch stage as defined by Google Cloud Platform Launch Stages." + type = string + default = null + validation { + condition = contains( + ["UNIMPLEMENTED", "PRELAUNCH", "EARLY_ACCESS", "ALPHA", "BETA", + "GA", "DEPRECATED"], coalesce(var.launch_stage, "GA") + ) + error_message = "The launch stage should be one of UNIMPLEMENTED, PRELAUNCH, EARLY_ACCESS, ALPHA, BETA, GA, DEPRECATED." + } +} +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 + default = "europe-west1" +} + +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) + network_interfaces = optional(object({ + subnetwork = optional(string) + tags = optional(list(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 "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/cloud-run-v2/versions.tf b/modules/cloud-run-v2/versions.tf new file mode 100644 index 00000000..3adb51d3 --- /dev/null +++ b/modules/cloud-run-v2/versions.tf @@ -0,0 +1,29 @@ +# 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 +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +terraform { + required_version = ">= 1.4.4" + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.4.0, < 6.0.0" # tftest + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 5.4.0, < 6.0.0" # tftest + } + } +} + +