Managing Cloud Build Triggers with Terraform

bradasaurusrex1

Bradley Matola

Posted on April 12, 2022

Managing Cloud Build Triggers with Terraform

On a recent project, I set up several services to run on GCP Cloud Run. To make managing the infrastructure as painless as possible, I set up a Terraform repo that managed it. That way, I could add new services by:

  • Linking the new service repo as a Cloud Source Repository
  • Adding the build trigger in the terraform repo
  • Adding the Cloud Run resources in the terraform repo

Setting up the Build Trigger

This was made manageable by a few modules I set up. First, a serviceAgent module to wrap the IAM permissions needed by the infrastructure build:

resource "google_service_account" "agent" {
    account_id = var.id
    display_name = var.display_name
}

# SA setting this up needs access rights
resource "google_service_account_iam_binding" "impersonator" {
    service_account_id = google_service_account.agent.name
    role = "roles/iam.serviceAccountUser"
    members = concat(
        ["serviceAccount:${var.setup_sa_email}"],
        var.impersonators)
}
Enter fullscreen mode Exit fullscreen mode

Then, a more involved build module to encapsulate a Cloud Build trigger that outputs a docker image to an artifact registry. This can be thought of in three parts. First, some permissions on the service agent running the build:

# do git pull
resource "google_sourcerepo_repository_iam_binding" "builder" {
    project = var.project
    repository = var.build_config.repo_name
    role = "roles/source.reader"
    members = ["serviceAccount:${var.builder_email}"]
}

# read and write docker images to registry
resource "google_artifact_registry_repository_iam_member" "writer" {
  provider = google-beta
  project = var.project
  location = var.location
  repository = var.docker_config.repo_id
  role = "roles/artifactregistry.writer"
  member = "serviceAccount:${var.builder_email}"
}
Enter fullscreen mode Exit fullscreen mode

Second, a storage bucket to hold the build logs:

resource "google_storage_bucket" "build_logs" {
    name = "${var.project}-${var.bucket_name}"
    location = "US"
    force_destroy = true
}

resource "google_storage_bucket_iam_binding" "admin" {
    bucket = google_storage_bucket.build_logs.name
    role = "roles/storage.admin"
    members = ["serviceAccount:${var.builder_email}"]
}
Enter fullscreen mode Exit fullscreen mode

And finally the trigger itself:

data "google_sourcerepo_repository" "infrastructure" {
    name = var.build_config.repo_name
}

resource "google_cloudbuild_trigger" "infra_trigger" {
    name = var.build_config.trigger_name
    description = var.build_config.trigger_description
    service_account = "projects/${var.project}/serviceAccounts/${var.builder_email}"
    filename = "cloudbuild.yaml"

    trigger_template {
      branch_name = "main"
      repo_name = data.google_sourcerepo_repository.infrastructure.name
    }

    substitutions = merge({
        _LOG_BUCKET_URL = google_storage_bucket.build_logs.url
        _DEV_IMAGE_NAME = "${var.location}-docker.pkg.dev/${var.project}/${var.docker_config.repo_name}/${var.docker_config.image_name}"
    }, var.build_variables)
}
Enter fullscreen mode Exit fullscreen mode

Here, I'm setting the log bucket URL and image name as substitutions that the repo itself can reference in its cloudbuild.yaml file. This way the Cloud Run service can reference the same image name.

Putting this all together, we can set up a new build with:

locals {
    location = "us-central1"
    repo_name = "game-scorer-project"
    api_image = "scoring-api"
}

resource "google_artifact_registry_repository" "primary" {
    provider = google-beta

    project = var.project
    location = local.location
    repository_id = local.repo_name
    format = "DOCKER"
}

module "api_builder" {
    source = "../modules/serviceAgent"

    id = "scoring-api-builder"
    display_name = "Scoring API Builder"
    setup_sa_email = var.builder
}

module "api_build" {
    source = "../modules/build"

    project = var.project
    location = local.location
    builder_email = module.api_builder.email
    bucket_name = "api-build-logs"

    docker_config = {
        repo_id = google_artifact_registry_repository.primary.id
        repo_name = local.repo_name
        image_name = local.api_image
    }

    build_config = {
        repo_name = "bitbucket_brmatola_scoring-api"
        trigger_name = "scoring-api-trigger"
        trigger_description = "Scoring API Build"
    }
}
Enter fullscreen mode Exit fullscreen mode

Where var.project and var.builder reference the GCP project and service account running the infrastructure build, respectively.

This configures a cloud build trigger on our API repo (bitbucket_brmatola_scoring-api). The repo itself then must have a cloudbuild.yaml file that publishes a docker image:

steps:
  - id: 'Build Docker Image With SHA Hash'
    name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args: [ '-c', 'docker build -f GameScoring/Dockerfile -t ${_DEV_IMAGE_NAME}:$COMMIT_SHA .' ]

  - id: 'Tag Docker Image with dev tag'
    name: 'gcr.io/cloud-builders/docker'
    args: [ 'tag', '${_DEV_IMAGE_NAME}:$COMMIT_SHA', '${_DEV_IMAGE_NAME}:dev' ]

  - id: 'Update dev tag with image to run'
    name: 'gcr.io/cloud-builders/docker'
    args: [ 'push', '${_DEV_IMAGE_NAME}:dev']

images: ['${_DEV_IMAGE_NAME}:$COMMIT_SHA']

logsBucket: '$_LOG_BUCKET_URL'
options:
  logging: GCS_ONLY
Enter fullscreen mode Exit fullscreen mode

Running the Service on Cloud Run

Once we have our build continuously deploying docker images, we want to actually run the image in Cloud Run. This process is documented here, but boils down to running a cloud command after publishing the image. To do so, however, we'll need a Cloud Run instance to deploy to.

We'll set up a service account to run the Cloud Run instance as well as some IAM permissions to let the build service account run the service:

module "api_runner" {
    source = "../modules/serviceAgent"

    id = "scoring-api-runner"
    display_name = "Scoring API Runner"
    setup_sa_email = var.builder
    impersonators = ["serviceAccount:${module.api_builder.email}"]
}

resource "google_cloud_run_service_iam_binding" "runner" {
    location = local.location
    service = google_cloud_run_service.api.name
    role = "roles/run.developer"
    members = ["serviceAccount:${module.api_builder.email}"]
}
Enter fullscreen mode Exit fullscreen mode

The key here is that both the service build and infrastructure build service accounts need the iam.serviceAccountUser role on the Cloud Run service. Additionally, the service build account needs the run.developer role in order to deploy the service.

Then, if we want our service to be available to users, we'll need to give the run.invoker role to everyone:

resource "google_cloud_run_service_iam_binding" "builder" {
    location = local.location
    service = google_cloud_run_service.api.name
    role = "roles/run.invoker"
    members = ["allUsers"]
}
Enter fullscreen mode Exit fullscreen mode

Then, we need to build the actual service:

resource "google_cloud_run_service" "api" {
    name = "scoring-api-service"
    location = local.location

    template {
        spec {
            service_account_name = module.api_runner.email
            containers {
                image = "${local.location}-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.primary.repository_id}/${local.api_image}:dev"
            }
        }
    }

    traffic {
        percent = 100
        latest_revision = true
    }

    lifecycle {
        ignore_changes = [
            metadata.0.annotations
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, in our service build we modify our Cloudbuild.yaml file to actually deploy the service:

steps:
  - id: 'Build Docker Image With SHA Hash'
    name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args: [ '-c', 'docker build -f GameScoring/Dockerfile -t ${_DEV_IMAGE_NAME}:$COMMIT_SHA .' ]

  - id: 'Tag Docker Image with dev tag'
    name: 'gcr.io/cloud-builders/docker'
    args: [ 'tag', '${_DEV_IMAGE_NAME}:$COMMIT_SHA', '${_DEV_IMAGE_NAME}:dev' ]

  - id: 'Update dev tag with image to run'
    name: 'gcr.io/cloud-builders/docker'
    args: [ 'push', '${_DEV_IMAGE_NAME}:dev']

  - id: 'Deploy dev tag to Cloud Run'
    name: 'gcr.io/cloud-builders/gcloud'
    args: ['run', 'deploy', '${_CLOUD_RUN_NAME}', '--image', '${_DEV_IMAGE_NAME}:dev', '--region', 'us-central1']

images: ['${_DEV_IMAGE_NAME}:$COMMIT_SHA']

logsBucket: '$_LOG_BUCKET_URL'
options:
  logging: GCS_ONLY
Enter fullscreen mode Exit fullscreen mode

Where we've configured the cloud run name as a substitution in the infrastructure so the build can just reference it.

💖 💪 🙅 🚩
bradasaurusrex1
Bradley Matola

Posted on April 12, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related