Resources
Git holds the resources an app should have. A Cloud Storage object holds the resources Terraform last created. terraform plan prints what an apply would add, change, or destroy. Apply is the only step that changes the project.
Where each step runs
All of this runs on a GitHub-hosted runner, in the app repository, by calling workflows in this repository. The runner is not a machine in the GCP project.
| Step | When | What it does to GCP |
|---|---|---|
| Lint and tests | Every pull request | Nothing |
terraform plan |
Every pull request | Reads state and the live project. Writes a short summary comment. Changes nothing. |
docker build and push |
Merge to staging or production |
Pushes an image tagged with that commit's SHA to Artifact Registry in the target project |
terraform plan again, then terraform apply |
Same merge, after the push | Creates, updates, or deletes resources so the project matches infra/ |
A pull request into main plans opptra-staging and does not apply. A push to staging applies that plan in the staging project. A pull request into production plans prod_project, and the merge applies it.
On a laptop, make ci runs that same docker build before the first push to staging, once the deploy workflows exist. It also runs terraform validate with -backend=false when Terraform is installed. That check does not read state and does not apply. terraform plan still runs on the pull request.
The build context is the component directory, and the Dockerfile there is Dockerfile. A component whose image must read other files in the repo sets docker_context and dockerfile in service.yaml. Both paths stay inside the repo, and the Dockerfile stays inside the context. The laptop build and the shared workflow use that pair. The workflow passes the directory to the shell as BUILD_CONTEXT. It does not export DOCKER_CONTEXT: the Docker CLI reserves that name, and a value of . makes docker build fail with no builder "." found.
The plan on the pull request is the one a person reads. The comment lists each resource to add, change, replace, or destroy, and the attributes that differ. A destroy or a replace is at the top. The comment links to the Actions run, where the job summary has the full plan without the refresh log. odlc sync-infra copies scripts/summarize_plan.py into the app so the workflow can write that summary. Until that file is in the app, the comment is the text of terraform show for the saved plan. The merge creates a new commit, so the apply job plans again for that commit and applies that plan. The image tag in the applied plan is the commit that landed on the branch.
What Terraform owns
An app's infra/main.tf calls the shared modules, and the generator copies those module folders into infra/modules/ so a deploy does not clone this repository. Local Terraform modules is that layout. Each recipe is described in modules/.
modules/cloud-run-service declares, for one app in one project:
- Runtime service account
<name>-rt(the account id is cut at 28 characters) - The Cloud Run service, including CPU, memory, env, and the image
- Who can invoke it
Removing a resource from that module shows a destroy in the plan. Apply then deletes it. deletion_protection on the Cloud Run service is false so that destroy is allowed.
The state object is gs://<project>-tfstate/<app name>. Staging state is in the opptra-staging bucket. Production state is in the prod project's bucket. Two applies cannot change the same app at once. The state bucket has object versioning, so a bad apply can be recovered from the previous object.
What stays outside Terraform
The state bucket and the Workload Identity pool are created once, before Terraform can run. gcp-bootstrap.md is that procedure. Terraform cannot store state in a bucket that does not exist yet.
scripts/bind-staging.sh creates the CI service account GitHub authenticates as in opptra-staging. scripts/bind-production.sh creates the matching account in prod_project. Each grants that account permission to apply and to push images, and lets only that GitHub environment impersonate it. Neither creates the Cloud Run service. The first apply does that. The order is in bind.md.
A Cloud SQL instance is added in the app's infra/ when the app needs a database, with lifecycle { prevent_destroy = true }. Apply then refuses to drop it until someone removes that block in a reviewed change.
An existing service
Import it once, from a machine that can read the project, before the first apply. Otherwise Terraform plans a second service.
cd infra
terraform init -backend-config="bucket=<project>-tfstate" -backend-config="prefix=<name>"
terraform import module.service.google_cloud_run_v2_service.app projects/<project>/locations/<region>/services/<name>
terraform import module.service.google_service_account.runtime projects/<project>/serviceAccounts/<name>-rt@<project>.iam.gserviceaccount.com
Then open a pull request. The plan should be an update, not a create.