Skip to content

Commit eec423f

Browse files
authored
github-ci-bootstrap: create a bucket for Terraform binary plan files (#36)
## Summary Khan/actions#274 changes `generate-terraform-plan`/`apply-terraform-plan` to store the Terraform binary plan in GCS instead of committing it to git (a binary plan embeds a full copy of the Terraform state, including sensitive values in cleartext). Every consumer of those actions needs the same bucket with the same security posture, so this PR has the bootstrap module create it, next to the CI service accounts and WIF providers it belongs with, instead of each repo hand-rolling it. ## What the module now creates (opt-out via `create_terraform_plans_bucket = false`) - Bucket `terraform-plans-{org}-{repo}-{service}` (same normalization as the state bucket; overridable via `terraform_plans_bucket`) in `khan-internal-services`, with uniform bucket-level access and public access prevention enforced. - Always per-service, never shared: plan files contain that service's state, so a shared bucket would let each service's CI read the others' state. - `roles/storage.objectAdmin` on the bucket for the read/write service account only (it uploads on plan, downloads and deletes on apply). The read-only account used for PR-branch plans gets no grant. - Lifecycle rule deleting objects after `terraform_plans_expiration_days` (default 30); applied plans are already deleted by the apply action, this catches superseded plan PRs. - New output `terraform_plans_bucket` to feed the actions' `plan_bucket` input. ## Rollout Additive and enabled by default: consumers get the bucket on their next module bump plus a local bootstrap `terraform apply`. For culture-cron, which already created `khan-culture-cron-terraform-plans` by hand in its bootstrap (Khan/culture-cron#28) during testing, adoption means passing `terraform_plans_bucket = "khan-culture-cron-terraform-plans"` and moving the two resources into the module in its bootstrap state: terraform state mv google_storage_bucket.terraform_plans 'module.github_ci_bootstrap.google_storage_bucket.terraform_plans[0]' terraform state mv google_storage_bucket_iam_member.ci_rw_terraform_plans_object_admin 'module.github_ci_bootstrap.google_storage_bucket_iam_member.ci_plans_bucket_access_rw[0]' Related: #35 (make `write_branch_patterns` explicit); if both land together, tag a single `github-ci-bootstrap-v2.0.0`. Author: jwbron Reviewers: csilvers Required Reviewers: Approved By: csilvers Checks: ✅ 1 check was successful Pull Request URL: #36
1 parent ab60540 commit eec423f

4 files changed

Lines changed: 92 additions & 0 deletions

File tree

‎terraform/modules/github-ci-bootstrap/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ module "culture_cron_terraform_ci" {
7474
| `target_projects` | Map of GCP projects where this Terraform configuration will deploy resources. Keys are project IDs. | `map(object)` | `{}` | no |
7575
| `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 |
7676
| `terraform_state_bucket` | GCS bucket name for storing Terraform state for this configuration | `string` | `terraform-{org}-{repo}-{service}` | no |
77+
| `create_terraform_plans_bucket` | Whether to create a bucket for binary plan files produced by the generate-terraform-plan GitHub action | `bool` | `true` | no |
78+
| `terraform_plans_bucket` | GCS bucket name for storing Terraform binary plan files | `string` | `terraform-plans-{org}-{repo}-{service}` | no |
79+
| `terraform_plans_bucket_location` | Location for the Terraform plans bucket | `string` | `"us-central1"` | no |
80+
| `terraform_plans_expiration_days` | Days after which plan objects are deleted (cleans up plans that are never applied) | `number` | `30` | no |
7781
| `secrets_project_id` | Project ID where secrets needed by the Terraform configuration are stored | `string` | `"khan-academy"` | no |
7882
| `secret_ids` | List of secret IDs that the Terraform configuration needs access to | `list(string)` | `[]` | no |
7983

@@ -115,6 +119,17 @@ If `terraform_state_bucket` is not specified, the module automatically generates
115119

116120
This ensures each Terraform setup gets its own isolated state bucket while maintaining consistent, predictable naming that complies with GCS bucket naming requirements.
117121

122+
### Terraform Plans Bucket
123+
124+
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:
125+
126+
- **Naming**: `terraform-plans-{org}-{repo}-{service}` (same normalization as the state bucket), overridable via `terraform_plans_bucket`
127+
- **Isolation**: always per-service, never shared, since plan files contain that service's state
128+
- **Access**: only the read/write service account gets object access (`roles/storage.objectAdmin`); the read-only account gets none
129+
- **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)
130+
131+
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.
132+
118133
### Dual Service Account Configuration
119134

120135
The module always creates two service accounts with different permission levels:
@@ -220,6 +235,7 @@ Each `service_name` gets its own isolated:
220235
| `workload_identity_provider_rw` | Full resource name of the Workload Identity provider (write-enabled branches) |
221236
| `workload_identity_provider_ro` | Full resource name of the Workload Identity provider (read-only, available to any branch) |
222237
| `terraform_state_bucket` | The GCS bucket name used for Terraform state (computed or provided) |
238+
| `terraform_plans_bucket` | The GCS bucket holding binary Terraform plan files awaiting apply (null if not created) |
223239
| `service_name` | The unique identifier for this Terraform configuration and environment |
224240
| `target_projects` | Map of target projects configured |
225241
| `write_branch_patterns` | List of branch patterns that are allowed to use the read/write service account |

‎terraform/modules/github-ci-bootstrap/main.tf‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ locals {
5252
# Use provided bucket name or computed default
5353
terraform_state_bucket = coalesce(var.terraform_state_bucket, local.default_bucket_name)
5454

55+
# Compute default plans bucket name, mirroring the state bucket convention.
56+
# Unlike the state bucket, this is always per-service: plan files contain
57+
# the service's state (including sensitive values), so a bucket shared
58+
# between services would let each service's CI read the others' state.
59+
default_plans_bucket_name = replace("terraform-plans-${lower(local.github_org)}-${lower(local.github_repo)}-${lower(var.service_name)}", "_", "-")
60+
terraform_plans_bucket = coalesce(var.terraform_plans_bucket, local.default_plans_bucket_name)
61+
5562
# Flatten target_projects into individual service permissions for read-write access
5663
project_service_permissions_rw = flatten([
5764
for project_id, config in var.target_projects : [
@@ -194,6 +201,46 @@ resource "google_storage_bucket_iam_member" "ci_state_bucket_legacy_reader_ro" {
194201
member = "serviceAccount:${google_service_account.github_ci_ro.email}"
195202
}
196203

204+
# === TERRAFORM PLANS BUCKET ===
205+
206+
# Bucket for the Terraform binary plan files produced by the
207+
# generate-terraform-plan GitHub action. A binary plan embeds a full copy of
208+
# the Terraform state, including sensitive values in cleartext, so plans are
209+
# stored here (access controlled, like the state bucket) instead of being
210+
# committed to the repository. Objects are keyed by commit SHA and deleted by
211+
# the apply-terraform-plan action after a successful apply; the lifecycle
212+
# rule cleans up plans that are never applied (e.g. superseded plan PRs).
213+
resource "google_storage_bucket" "terraform_plans" {
214+
count = var.create_terraform_plans_bucket ? 1 : 0
215+
216+
name = local.terraform_plans_bucket
217+
project = "khan-internal-services"
218+
location = var.terraform_plans_bucket_location
219+
uniform_bucket_level_access = true
220+
public_access_prevention = "enforced"
221+
222+
lifecycle_rule {
223+
condition {
224+
age = var.terraform_plans_expiration_days
225+
}
226+
action {
227+
type = "Delete"
228+
}
229+
}
230+
}
231+
232+
# The read/write service account uploads plans (plan runs on the deploy
233+
# branch) and later downloads and deletes them (apply runs), so it needs full
234+
# object access. The read-only service account used for PR-branch plans never
235+
# touches this bucket, so it deliberately gets no grant.
236+
resource "google_storage_bucket_iam_member" "ci_plans_bucket_access_rw" {
237+
count = var.create_terraform_plans_bucket ? 1 : 0
238+
239+
bucket = google_storage_bucket.terraform_plans[0].name
240+
role = "roles/storage.objectAdmin"
241+
member = "serviceAccount:${google_service_account.github_ci_rw.email}"
242+
}
243+
197244
# === SECRET MANAGER ===
198245

199246
# Dynamic secret admin access based on provided secret IDs (read-write access)

‎terraform/modules/github-ci-bootstrap/outputs.tf‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ output "terraform_state_bucket" {
5050
value = local.terraform_state_bucket
5151
}
5252

53+
output "terraform_plans_bucket" {
54+
description = "The GCS bucket holding binary Terraform plan files awaiting apply (null when create_terraform_plans_bucket is false)"
55+
value = var.create_terraform_plans_bucket ? google_storage_bucket.terraform_plans[0].name : null
56+
}
57+
5358
output "service_name" {
5459
description = "The unique identifier for this Terraform configuration and environment managed in CI"
5560
value = var.service_name

‎terraform/modules/github-ci-bootstrap/variables.tf‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ variable "terraform_state_bucket" {
5858
default = null
5959
}
6060

61+
variable "create_terraform_plans_bucket" {
62+
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."
63+
type = bool
64+
default = true
65+
}
66+
67+
variable "terraform_plans_bucket" {
68+
description = "GCS bucket name for storing Terraform binary plan files (defaults to terraform-plans-{org}-{repo}-{service})"
69+
type = string
70+
default = null
71+
}
72+
73+
variable "terraform_plans_bucket_location" {
74+
description = "Location for the Terraform plans bucket"
75+
type = string
76+
default = "us-central1"
77+
}
78+
79+
variable "terraform_plans_expiration_days" {
80+
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."
81+
type = number
82+
default = 30
83+
}
84+
6185
variable "secrets_project_id" {
6286
description = "The Google Cloud project ID where secrets are stored (defaults to khan-academy)"
6387
type = string

0 commit comments

Comments
 (0)