diff --git a/README.md b/README.md index 78e22c7695568c52e43d317ff4522069b1e2d8e4..8b8e58ae8b96a91377bbc110b8f9003089495bfd 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,100 @@ # GCP Cloud Run manager terraform module -This module manages a Cloud Run-hosted container. It takes care of making -sure the container is connected to a Cloud SQL instance and sets -environment variables on the application. +This module manages a Cloud Run-hosted container. It takes care of making sure the container is +connected to a Cloud SQL instance and sets environment variables on the application. -Specify the project to deploy into on the command line. So, for -example, to deploy to the project ``my-project``: +Specify the project to deploy into on the command line. So, for example, to deploy to the project +`my-project`: ```console $ terraform init $ terraform apply -var project=my-project ``` -In this example, terraform is configured to use default application credentials. -For Google APIs and these credentials should correspond to a user with owner or -editor access to the target project. You can use the ``gcloud`` command line -tool to set your personal credentials as application default credentials. See -the ``gcloud auth application-default`` command output for more information. +In this example, terraform is configured to use default application credentials. For Google APIs and +these credentials should correspond to a user with owner or editor access to the target project. You +can use the `gcloud` command line tool to set your personal credentials as application default +credentials. See the `gcloud auth application-default` command output for more information. ## Versioning -The `master` branch contains the tip of development and corresponds to the `v4` -branch. The `v1`, `v2` and `v3` branches will maintain source compatibility with -the initial release. +The `master` branch contains the tip of development and corresponds to the `v8` branch. The `v1`, +`v2`, `v3` etc. branches will maintain source compatibility with the initial release. ## Ingress style -There are two supported ingress styles depending on `var.ingress_style` variable. - -`var.ingress_style` can be: -- `domain-mapping` (default): passing DNS domains as `var.dns_names` or `var.dns_name`, -which takes precedence over `var.dns_names`, will create domain mappings to the Cloud -Run service. Before setting this, you *must* have verified ownership of the provided -domains with Google. [Instructions on how to do -this](https://guidebook.devops.uis.cam.ac.uk/en/latest/notes/google-domain-verification/) -can be found in the DevOps division guidebook. +There are two supported ingress styles depending on `var.ingress_style` variable. -- `load-balancer`: a load balancer will be configured instead of a domain mapping. The -DNS domains in `var.dns_names` or `var.dns_name`, which takes precedence over `var.dns_names`, -will get Google-managed or custom TLS certificates depending on `var.use_ssl_certificates` -and `var.ssl_certificates`. An IPv6 address can also be allocated to the load balancer if -`var.enable_ipv6` is `true`. +`var.ingress_style` can be: + +- `domain-mapping` (default): passing DNS domains as `var.dns_names` or `var.dns_name`, which takes + precedence over `var.dns_names`, will create domain mappings to the Cloud Run service. Before + setting this, you _must_ have verified ownership of the provided domains with Google. [Instructions + on how to do + this](https://guidebook.devops.uis.cam.ac.uk/en/latest/notes/google-domain-verification/) can be + found in the DevOps division guidebook. + +- `load-balancer`: a load balancer will be configured instead of a domain mapping. The DNS domains + in `var.dns_names` or `var.dns_name`, which takes precedence over `var.dns_names`, will get + Google-managed or custom TLS certificates depending on `var.use_ssl_certificates` and + `var.ssl_certificates`. An IPv6 address can also be allocated to the load balancer if + `var.enable_ipv6` is `true`. + +## Pre-deploy Cloud Run job + +The `v8` release introduced an `enable_pre_deploy_job` variable. When set to `true` a [Cloud +Run job](https://cloud.google.com/run/docs/create-jobs) is created to execute a configurable command +_before_ the main Cloud Run service is deployed. The initial use case for this is to run database +migrations, however in the future we're sure there'll be more. + +The pre-deploy job uses the image specified in `var.pre_deploy_job_image_name` if set, otherwise it +falls back to the same `var.image_name` that the main service uses. The command and arguments that +the job executes are configurable via the `pre_deploy_job_command` and `pre_deploy_job_args` +variables. + +A `null_resource` is also configured to execute the pre-deploy job whenever it detects that the +value of `var.image_name` has changed (or at every `apply` if `var.force_pre_deploy_job` is set to +`true`). This uses the `gcloud run jobs execute` command and is run in the context of the +`terraform-deploy` service account via an access token. Using `null_resource` is never ideal. +However, in this situation it provides a very handy way to trigger this simple job so it has been +accepted. + +To ensure that the pre-deploy job always runs _before_ a new revision of the Cloud Run webapp +service is deployed, the resources in question are explicitly configured with `depends_on` +relationships, as follows. + +1. The `google_cloud_run_v2_job.pre_deploy` Cloud Run job has no `depends_on` relationships defined + and is therefore deployed first. +2. The `null_resource.pre_deploy_job_trigger` resource depends on + `google_cloud_run_v2_job.pre_deploy` and therefore won't be deployed until the Cloud Run job is + deployed successfully. +3. Finally, the `google_cloud_run_service.webapp` Cloud Run service depends on + `null_resource.pre_deploy_job_trigger`, meaning it is only deployed once the + `null_resource.pre_deploy_job_trigger` has executed successfully. ## Monitoring and Alerting -If the variable [alerting_email_address](variables.tf) is set, the module adds -basic uptime *alerting* via email for failing http polling. +If the variable [alerting_email_address](variables.tf) is set, the module adds basic uptime +_alerting_ via email for failing http polling. -If the variable [disable_monitoring](variables.tf) is true, the module will -disable *monitoring*. This is different from disabling alerting; if no -alerting email addresses are provided, the uptime checks will still be -configured, there just won't be any alerts sent if they fail. Disabling -monitoring will also disable alerting as without any monitoring there is nothing -to alert(!) +If the variable [disable_monitoring](variables.tf) is true, the module will disable _monitoring_. +This is different from disabling alerting; if no alerting email addresses are provided, the uptime +checks will still be configured, there just won't be any alerts sent if they fail. Disabling +monitoring will also disable alerting as without any monitoring there is nothing to alert(!) See [variables.tf](variables.tf) for how to configure alerting and monitoring. -Note that the project containing resources to be monitored must be in a -Stackdriver monitoring workspace and this must be configured manually. At the -time of writing there is no terraform support for this. This module will error -when applying if this is not so. +Note that the project containing resources to be monitored must be in a Stackdriver monitoring +workspace and this must be configured manually. At the time of writing there is no terraform support +for this. This module will error when applying if this is not so. -Stackdriver distinguishes between workspaces and projects within those -workspaces. Each workspace must have a host project and that project *must* be -the default project of the `google.stackdriver` provider used by this module. -The `google.stackdriver` must be configured with credentials allowing monitoring -resources to be created in the *host* project. +Stackdriver distinguishes between workspaces and projects within those workspaces. Each workspace +must have a host project and that project _must_ be the default project of the `google.stackdriver` +provider used by this module. The `google.stackdriver` must be configured with credentials allowing +monitoring resources to be created in the _host_ project. -If the workspace host project differs from the project which contains the -resources to be monitored, you can use a provider alias: +If the workspace host project differs from the project which contains the resources to be monitored, +you can use a provider alias: ```tf provider "google" { @@ -96,12 +122,11 @@ module "cloud_run_service" { ### Monitoring instances which require service account authentication If `allow_unauthenticated_invocations` is not true, a Cloud Function will be created which -authenticates via a service account, allowing the StackDriver monitoring to call the Cloud -Function, with the Cloud Function authentication and proxying the request to the Cloud Run -instance. +authenticates via a service account, allowing the StackDriver monitoring to call the Cloud Function, +with the Cloud Function authentication and proxying the request to the Cloud Run instance. -Because this requires a Cloud Function to be created, the `cloudfunctions.googleapis.com` -service should be enabled on the project that houses the Cloud Run instance. +Because this requires a Cloud Function to be created, the `cloudfunctions.googleapis.com` service +should be enabled on the project that houses the Cloud Run instance. ## Static Egress IP @@ -112,26 +137,25 @@ from the cloud run through a static ip. **Important!** The static ip is configured with `prevent_destroy = true`, meaning that it cannot be destroyed -without removing it from terraform state using `terraform state rm` and then manually destroying -the resource within the GCP console. This is to prevent accidental destruction of an IP which -is likely to be whitelisted within firewall configuration that lives outside of our deployments. +without removing it from terraform state using `terraform state rm` and then manually destroying the +resource within the GCP console. This is to prevent accidental destruction of an IP which is likely +to be whitelisted within firewall configuration that lives outside of our deployments. ## Secrets as Volumes and Env Vars -Secret Manager secrets can be as environment variables or volume mounts (files) in the -running container. +Secret Manager secrets can be as environment variables or volume mounts (files) in the running +container. The service account that Cloud Run runs as needs access to the secrets for this feature to work. Thus, this module gives `secretAccessor` role to that service account for the secrets passed on `secrets_volume` and `secrets_envars`. -Any number of items in the list is supported and not defining these variables -when calling this module is acceptable. The path of the mounted file will be -based on `path/name`. +Any number of items in the list is supported and not defining these variables when calling this +module is acceptable. The path of the mounted file will be based on `path/name`. -For the example configuration below the files will be `/secrets-1/foobarfile1` -and `/secrets-2/foobarfile2`. A common `path` for multiple secrets is not supported, they must -be unique. +For the example configuration below the files will be `/secrets-1/foobarfile1` and +`/secrets-2/foobarfile2`. A common `path` for multiple secrets is not supported, they must be +unique. > Note: `name` should only have alphanumeric characters, hyphens and underscores. diff --git a/locals.tf b/locals.tf index a98d3da264404e2feed05b1f1315f2061580cdb9..bdaba8fad803a6adcc279bd377b7580c3519a381 100644 --- a/locals.tf +++ b/locals.tf @@ -34,6 +34,8 @@ locals { ] )) + pre_deploy_job_image_name = var.pre_deploy_job_image_name == null ? var.image_name : var.pre_deploy_job_image_name + # Certain ingress styles imply that we disallow external access to the base Cloud Run service. webapp_allowed_ingress = lookup({ load-balancer = "internal-and-cloud-load-balancing" diff --git a/main.tf b/main.tf index d68624d4d10a5e45f5c9904628cbb424d80999d5..a81814536465ccea1983430d62a4451d33254e9e 100644 --- a/main.tf +++ b/main.tf @@ -1,5 +1,8 @@ # main.tf configures top-level resources +# This data source pulls the configuration for the default google provider. +data "google_client_config" "current" {} + # A service account which the webapp runs in the context of. resource "google_service_account" "webapp" { project = var.project @@ -199,7 +202,9 @@ resource "google_cloud_run_service" "webapp" { depends_on = [ google_secret_manager_secret_iam_member.secrets_access, + null_resource.pre_deploy_job_trigger ] + # Google Beta provider is required for mounting secrets AToW provider = google-beta } @@ -283,3 +288,119 @@ data "google_cloud_run_service" "webapp" { name = var.name location = var.cloud_run_region } + +# Configure a Cloud Run Job which will be executed before the deployment of the google_cloud_run_service.webapp +# resource. This is primarily useful to run database migrations, however other use cases may exist. +resource "google_cloud_run_v2_job" "pre_deploy" { + count = var.enable_pre_deploy_job ? 1 : 0 + + name = "${var.name}-pre-deploy" + location = var.cloud_run_region + project = var.project + + template { + template { + service_account = google_service_account.webapp.email + + dynamic "volumes" { + for_each = var.sql_instance_connection_name != "" ? [1] : [] + + content { + name = "cloudsql" + cloud_sql_instance { + instances = [var.sql_instance_connection_name] + } + } + } + dynamic "volumes" { + for_each = var.secrets_volume + + content { + name = volumes.value["name"] + secret { + secret = volumes.value["id"] + items { + version = coalesce(volumes.value["version"], "latest") + path = volumes.value["name"] + mode = 0 + } + } + } + } + + containers { + image = local.pre_deploy_job_image_name + command = var.pre_deploy_job_command + args = var.pre_deploy_job_args + + dynamic "env" { + for_each = var.environment_variables + + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = var.secrets_envars + + content { + name = env.value["name"] + value_source { + secret_key_ref { + secret = env.value["id"] + version = coalesce(env.value["version"], "latest") + } + } + } + } + + dynamic "volume_mounts" { + for_each = var.sql_instance_connection_name != "" ? [1] : [] + + content { + name = "cloudsql" + mount_path = "/cloudsql" + } + } + dynamic "volume_mounts" { + for_each = var.secrets_volume + + content { + name = volume_mounts.value["name"] + mount_path = volume_mounts.value["path"] + } + } + } + } + } +} + +# Trigger the pre-deploy job using the gcloud CLI whenever the var.image_name value changes. +resource "null_resource" "pre_deploy_job_trigger" { + count = var.enable_pre_deploy_job && var.trigger_pre_deploy_job ? 1 : 0 + + triggers = merge({ + image_name = var.image_name + }, var.force_pre_deploy_job ? { + timestamp = timestamp() + } : {}) + + provisioner "local-exec" { + command = <<EOI +gcloud --project ${var.project} run jobs execute \ + --region ${var.cloud_run_region} --wait ${google_cloud_run_v2_job.pre_deploy[0].name} +EOI + + environment = { + # This environment variable tells gcloud CLI to authenticate using an access token. We're using the access token + # configured in the default google provider via the google_client_config data source. + CLOUDSDK_AUTH_ACCESS_TOKEN = data.google_client_config.current.access_token + } + } + + depends_on = [ + google_cloud_run_v2_job.pre_deploy + ] +} + diff --git a/variables.tf b/variables.tf index e4cc43ce66353c0e4c9d5d3b1dd7315f3eb1a74b..eb2bc0441b7c11b1801a3340e787c5064698454c 100644 --- a/variables.tf +++ b/variables.tf @@ -19,6 +19,57 @@ variable "sql_instance_connection_name" { default = "" } +variable "enable_pre_deploy_job" { + description = <<EOI +Configure a Cloud Run Job to be executed *before* the main Cloud Run service is deployed. This is useful for running +database migrations among other things. +EOI + type = bool + default = false +} + +variable "trigger_pre_deploy_job" { + description = <<EOI +When true, the pre-deploy Cloud Run job is executed via a null_resource-triggered gcloud command whenever Terraform +detects that var.image_name has changed. +EOI + type = bool + default = true +} + +variable "force_pre_deploy_job" { + description = <<EOI +When true, and only when used in addition to var.trigger_pre_deploy_job, the pre-deploy Cloud Run job is executed at +every terraform apply, regardless of the status of var.image_name. This is sometimes useful for development +environments where the "latest" tag is deployed, as without this the pre-deploy command would never run. For staging +and production environments this should never be required as the var.image_name should change with each +release/deployment of an application. +EOI + type = bool + default = false +} + +variable "pre_deploy_job_image_name" { + description = <<EOI +Specify the URL of a container image to use for the pre-deploy Cloud Run job. By default the var.image_name URL is used +(see locals.tf). +EOI + type = string + default = null +} + +variable "pre_deploy_job_command" { + description = "The command to run in the pre-deploy Cloud Run job." + type = list(string) + default = null +} + +variable "pre_deploy_job_args" { + description = "Arguments supplied to the command in the pre-deploy Cloud Run job." + type = list(string) + default = null +} + variable "grant_sql_client_role_to_webapp_sa" { description = <<EOI When set to true the 'roles/cloudsql.client' role will be granted to the