diff --git a/terraform/modules/github-ci-bootstrap/README.md b/terraform/modules/github-ci-bootstrap/README.md index 196a3ac..c1d3720 100644 --- a/terraform/modules/github-ci-bootstrap/README.md +++ b/terraform/modules/github-ci-bootstrap/README.md @@ -74,6 +74,10 @@ module "culture_cron_terraform_ci" { | `target_projects` | Map of GCP projects where this Terraform configuration will deploy resources. Keys are project IDs. | `map(object)` | `{}` | no | | `write_branch_patterns` | List of branch patterns that are allowed to use the read/write service account (defaults to main and master) | `list(string)` | `["main", "master"]` | no | | `terraform_state_bucket` | GCS bucket name for storing Terraform state for this configuration | `string` | `terraform-{org}-{repo}-{service}` | no | +| `create_terraform_plans_bucket` | Whether to create a bucket for binary plan files produced by the generate-terraform-plan GitHub action | `bool` | `true` | no | +| `terraform_plans_bucket` | GCS bucket name for storing Terraform binary plan files | `string` | `terraform-plans-{org}-{repo}-{service}` | no | +| `terraform_plans_bucket_location` | Location for the Terraform plans bucket | `string` | `"us-central1"` | no | +| `terraform_plans_expiration_days` | Days after which plan objects are deleted (cleans up plans that are never applied) | `number` | `30` | no | | `secrets_project_id` | Project ID where secrets needed by the Terraform configuration are stored | `string` | `"khan-academy"` | no | | `secret_ids` | List of secret IDs that the Terraform configuration needs access to | `list(string)` | `[]` | no | @@ -115,6 +119,17 @@ If `terraform_state_bucket` is not specified, the module automatically generates This ensures each Terraform setup gets its own isolated state bucket while maintaining consistent, predictable naming that complies with GCS bucket naming requirements. +### Terraform Plans Bucket + +By default the module also creates a bucket for the binary plan files produced by the `generate-terraform-plan` GitHub action (Khan/actions). A binary plan embeds a full copy of the Terraform state, including sensitive values in cleartext, so plans live in this access-controlled bucket instead of being committed to the repository: + +- **Naming**: `terraform-plans-{org}-{repo}-{service}` (same normalization as the state bucket), overridable via `terraform_plans_bucket` +- **Isolation**: always per-service, never shared, since plan files contain that service's state +- **Access**: only the read/write service account gets object access (`roles/storage.objectAdmin`); the read-only account gets none +- **Hygiene**: uniform bucket-level access, public access prevention enforced, and a lifecycle rule deleting objects after `terraform_plans_expiration_days` (plans that are applied get deleted by the apply-terraform-plan action immediately) + +Pass the bucket name (the `terraform_plans_bucket` output) as the `plan_bucket` input to the `generate-terraform-plan` and `apply-terraform-plan` actions. Set `create_terraform_plans_bucket = false` if this Terraform setup does not use those actions. + ### Dual Service Account Configuration The module always creates two service accounts with different permission levels: @@ -220,6 +235,7 @@ Each `service_name` gets its own isolated: | `workload_identity_provider_rw` | Full resource name of the Workload Identity provider (write-enabled branches) | | `workload_identity_provider_ro` | Full resource name of the Workload Identity provider (read-only, available to any branch) | | `terraform_state_bucket` | The GCS bucket name used for Terraform state (computed or provided) | +| `terraform_plans_bucket` | The GCS bucket holding binary Terraform plan files awaiting apply (null if not created) | | `service_name` | The unique identifier for this Terraform configuration and environment | | `target_projects` | Map of target projects configured | | `write_branch_patterns` | List of branch patterns that are allowed to use the read/write service account | diff --git a/terraform/modules/github-ci-bootstrap/main.tf b/terraform/modules/github-ci-bootstrap/main.tf index 5324c9e..3b6de5c 100644 --- a/terraform/modules/github-ci-bootstrap/main.tf +++ b/terraform/modules/github-ci-bootstrap/main.tf @@ -52,6 +52,13 @@ locals { # Use provided bucket name or computed default terraform_state_bucket = coalesce(var.terraform_state_bucket, local.default_bucket_name) + # Compute default plans bucket name, mirroring the state bucket convention. + # Unlike the state bucket, this is always per-service: plan files contain + # the service's state (including sensitive values), so a bucket shared + # between services would let each service's CI read the others' state. + default_plans_bucket_name = replace("terraform-plans-${lower(local.github_org)}-${lower(local.github_repo)}-${lower(var.service_name)}", "_", "-") + terraform_plans_bucket = coalesce(var.terraform_plans_bucket, local.default_plans_bucket_name) + # Flatten target_projects into individual service permissions for read-write access project_service_permissions_rw = flatten([ for project_id, config in var.target_projects : [ @@ -194,6 +201,46 @@ resource "google_storage_bucket_iam_member" "ci_state_bucket_legacy_reader_ro" { member = "serviceAccount:${google_service_account.github_ci_ro.email}" } +# === TERRAFORM PLANS BUCKET === + +# Bucket for the Terraform binary plan files produced by the +# generate-terraform-plan GitHub action. A binary plan embeds a full copy of +# the Terraform state, including sensitive values in cleartext, so plans are +# stored here (access controlled, like the state bucket) instead of being +# committed to the repository. Objects are keyed by commit SHA and deleted by +# the apply-terraform-plan action after a successful apply; the lifecycle +# rule cleans up plans that are never applied (e.g. superseded plan PRs). +resource "google_storage_bucket" "terraform_plans" { + count = var.create_terraform_plans_bucket ? 1 : 0 + + name = local.terraform_plans_bucket + project = "khan-internal-services" + location = var.terraform_plans_bucket_location + uniform_bucket_level_access = true + public_access_prevention = "enforced" + + lifecycle_rule { + condition { + age = var.terraform_plans_expiration_days + } + action { + type = "Delete" + } + } +} + +# The read/write service account uploads plans (plan runs on the deploy +# branch) and later downloads and deletes them (apply runs), so it needs full +# object access. The read-only service account used for PR-branch plans never +# touches this bucket, so it deliberately gets no grant. +resource "google_storage_bucket_iam_member" "ci_plans_bucket_access_rw" { + count = var.create_terraform_plans_bucket ? 1 : 0 + + bucket = google_storage_bucket.terraform_plans[0].name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.github_ci_rw.email}" +} + # === SECRET MANAGER === # Dynamic secret admin access based on provided secret IDs (read-write access) diff --git a/terraform/modules/github-ci-bootstrap/outputs.tf b/terraform/modules/github-ci-bootstrap/outputs.tf index 0d91c61..1e937b5 100644 --- a/terraform/modules/github-ci-bootstrap/outputs.tf +++ b/terraform/modules/github-ci-bootstrap/outputs.tf @@ -50,6 +50,11 @@ output "terraform_state_bucket" { value = local.terraform_state_bucket } +output "terraform_plans_bucket" { + description = "The GCS bucket holding binary Terraform plan files awaiting apply (null when create_terraform_plans_bucket is false)" + value = var.create_terraform_plans_bucket ? google_storage_bucket.terraform_plans[0].name : null +} + output "service_name" { description = "The unique identifier for this Terraform configuration and environment managed in CI" value = var.service_name diff --git a/terraform/modules/github-ci-bootstrap/variables.tf b/terraform/modules/github-ci-bootstrap/variables.tf index c536d08..8409b73 100644 --- a/terraform/modules/github-ci-bootstrap/variables.tf +++ b/terraform/modules/github-ci-bootstrap/variables.tf @@ -58,6 +58,30 @@ variable "terraform_state_bucket" { default = null } +variable "create_terraform_plans_bucket" { + description = "Whether to create a GCS bucket for the Terraform binary plan files produced by the generate-terraform-plan GitHub action. A binary plan embeds a full copy of the Terraform state, including sensitive values, so plans are stored in this access-controlled bucket instead of being committed to the repository." + type = bool + default = true +} + +variable "terraform_plans_bucket" { + description = "GCS bucket name for storing Terraform binary plan files (defaults to terraform-plans-{org}-{repo}-{service})" + type = string + default = null +} + +variable "terraform_plans_bucket_location" { + description = "Location for the Terraform plans bucket" + type = string + default = "us-central1" +} + +variable "terraform_plans_expiration_days" { + description = "Days after which objects in the Terraform plans bucket are deleted. This cleans up plans that are never applied (e.g. superseded plan PRs); applied plans are deleted by the apply-terraform-plan action itself." + type = number + default = 30 +} + variable "secrets_project_id" { description = "The Google Cloud project ID where secrets are stored (defaults to khan-academy)" type = string