Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions terraform/modules/github-ci-bootstrap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
47 changes: 47 additions & 0 deletions terraform/modules/github-ci-bootstrap/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions terraform/modules/github-ci-bootstrap/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions terraform/modules/github-ci-bootstrap/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading