Repository navigation
terraform: store binary plans in GCS instead of committing them #274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
e0a0c48
586fefb
9f8ebe5
70dd2af
17a88ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| "generate-terraform-plan": major | ||
| "apply-terraform-plan": major | ||
| --- | ||
|
|
||
| Stop committing the Terraform binary plan file to git. A binary plan embeds a full copy of the Terraform state, including sensitive values in cleartext, and the tip-delete cleanup never removed it from git history. | ||
|
|
||
| generate-terraform-plan now uploads the binary plan to a GCS object keyed by a slug of the terraform path plus the plan's commit SHA, and the plan PR commits only the redacted plan text plus a small pointer file (default `tfplan.commit`) recording that SHA. apply-terraform-plan reads the pointer, rebuilds the object path from its own inputs, downloads the exact plan that was reviewed, applies it, and best-effort deletes the object during cleanup. | ||
|
|
||
| Breaking changes: both actions gain a `plan_bucket` input (required for the plan-PR/apply flow; not needed for PR-comment-only or auto_approve usage) and must be upgraded together. Consumers need a GCS bucket that the plan workflow's service account can write objects to and the apply workflow's service account can read (and ideally delete) objects from, plus a lifecycle rule to expire plans that are never applied (e.g. superseded plan PRs). | ||
|
|
||
| Consumer workflows must also update their trigger paths: apply workflows typically trigger on `push` with a `paths` filter on `tfplan.binary`, which is never committed anymore. Point that filter (and any `!tfplan.binary` excludes in plan workflows) at the pointer file (default `tfplan.commit`) instead, or apply will never run. | ||
|
|
||
| Security model for the bucket: a binary plan contains the same sensitive data as the Terraform state itself, so treat the plan bucket exactly like the state bucket. Create it in the same project, enable uniform bucket-level access and public access prevention, and grant object access only to the CI service accounts (no human-facing grants). Note that project-level IAM (owners/editors, storage admins) inherits access to any bucket in the project; that audience can already read the state bucket, so the plan bucket exposes nothing new to it. | ||
|
|
||
| Note: this only stops new leaks. Any `tfplan.binary` still committed at a repo's tip should be deleted, and histories that ever contained committed binary plans still need scrubbing and rotation of any secrets present in the embedded state. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| # This workflow applies checked-in Terraform plans created by the `terraform-plan` workflow | ||
| # This workflow applies Terraform plans created by the `terraform-plan` workflow | ||
| # and can be called from any repository. It handles: | ||
| # - Checking for plan files (unless auto_approve is enabled) | ||
| # - Checking for the committed plan pointer/text files (unless auto_approve is enabled) | ||
| # - Authenticating with GCP | ||
| # - Downloading the binary plan from GCS (binary plans embed a full copy of the | ||
| # Terraform state, including sensitive values, so they are never committed) | ||
| # - Applying the Terraform plan (or running a blind apply with -auto-approve) | ||
| # - Cleaning up plan files | ||
| # - Commenting on PRs for success/failure | ||
|
|
@@ -20,6 +22,14 @@ inputs: | |
| description: "Path to the plan text file (relative to terraform_path)" | ||
| required: false | ||
| default: "tfplan.txt" | ||
| plan_pointer_path: | ||
| description: "Path to the committed pointer file recording the commit SHA whose uploaded binary plan should be applied (relative to terraform_path)" | ||
| required: false | ||
| default: "tfplan.commit" | ||
| plan_bucket: | ||
| description: "GCS bucket, optionally with a path prefix, that the generate-terraform-plan action uploaded the binary plan to. Must match that action's plan_bucket input. Required unless auto_approve is 'true'. The service account needs permission to read (and, for cleanup, delete) objects in this bucket. A binary plan contains the same sensitive data as the Terraform state, so restrict this bucket exactly like the state bucket: CI service accounts only, no human-facing grants." | ||
| required: false | ||
| default: "" | ||
| gcloud_project: | ||
| description: "Google Cloud project ID" | ||
| required: true | ||
|
|
@@ -68,7 +78,7 @@ runs: | |
| if [ "${{ inputs.auto_approve }}" == "true" ]; then | ||
| echo "Auto-approve mode enabled, skipping plan file check" | ||
| echo "skip_apply=false" >> "$GITHUB_OUTPUT" | ||
| elif [ ! -f "${{ inputs.terraform_path }}/${{ inputs.plan_file_path }}" ] || [ ! -f "${{ inputs.terraform_path }}/${{ inputs.plan_text_path }}" ]; then | ||
| elif [ ! -f "${{ inputs.terraform_path }}/${{ inputs.plan_pointer_path }}" ] || [ ! -f "${{ inputs.terraform_path }}/${{ inputs.plan_text_path }}" ]; then | ||
| echo "Plan files not found, skipping apply steps" | ||
| echo "skip_apply=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
|
|
@@ -87,6 +97,34 @@ runs: | |
| workload_identity_provider: ${{ inputs.workload_identity_provider }} | ||
| service_account: ${{ inputs.service_account }} | ||
|
|
||
| # generate-terraform-plan uploads the binary plan to GCS (keyed by a slug | ||
| # of the terraform path plus the commit SHA the plan was generated from) | ||
| # and commits only a pointer file, because the binary embeds a full copy | ||
| # of the Terraform state, including sensitive values in cleartext. | ||
| # Rebuilding the object path here from our own plan_bucket input, rather | ||
| # than trusting a URI committed to the repo, means a tampered pointer can | ||
| # at worst select another plan previously uploaded for this same stack. | ||
| - name: Download binary plan from GCS | ||
| if: steps.check_files.outputs.skip_apply != 'true' && inputs.auto_approve != 'true' | ||
| id: download | ||
| shell: bash | ||
| working-directory: ${{ inputs.terraform_path }} | ||
| run: | | ||
| if [ -z "${{ inputs.plan_bucket }}" ]; then | ||
| echo "The plan_bucket input is required to download the binary plan uploaded by generate-terraform-plan." >&2 | ||
| exit 1 | ||
| fi | ||
| plan_sha=$(tr -d '[:space:]' < "${{ inputs.plan_pointer_path }}") | ||
| if ! printf '%s' "$plan_sha" | grep -Eq '^[0-9a-f]{40}$'; then | ||
| echo "Pointer file ${{ inputs.plan_pointer_path }} does not contain a valid commit SHA: '$plan_sha'" >&2 | ||
| exit 1 | ||
| fi | ||
| # Slug the terraform path the same way generate-terraform-plan does. | ||
| slug=$(printf '%s' "${{ inputs.terraform_path }}" | tr -c 'A-Za-z0-9._-' '-' | sed -E 's/-+/-/g; s/^-//; s/-$//') | ||
| object="gs://${{ inputs.plan_bucket }}/${slug}/${plan_sha}/${{ inputs.plan_file_path }}" | ||
|
khan-actions-bot marked this conversation as resolved.
Outdated
|
||
| gcloud storage cp "$object" "${{ inputs.plan_file_path }}" | ||
|
khan-actions-bot marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note (non-blocking): Re-running this workflow on the same commit after a completed apply is not idempotent. Once apply succeeds, the cleanup PR merges, and the object is deleted, a GitHub "Re-run all jobs" re-checks out the same commit — whose committed pointer the cleanup PR never touched — so |
||
| echo "object=$object" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Terraform Init | ||
| if: steps.check_files.outputs.skip_apply != 'true' | ||
| shell: bash | ||
|
|
@@ -106,9 +144,10 @@ runs: | |
| fi | ||
| continue-on-error: true | ||
|
|
||
| # Once the plan is applied, we remove the checked-in plan files to clean up. | ||
| # This prevents stale plans from being applied and ensures that each | ||
| # apply job has a fresh plan generated for it. | ||
| # Once the plan is applied, we remove the committed pointer and plan text | ||
| # files to clean up, and delete the uploaded binary plan from GCS. This | ||
| # prevents stale plans from being applied and ensures that each apply job | ||
| # has a fresh plan generated for it. | ||
| # | ||
| # The base branch (typically `master`/`main`) is protected and rejects | ||
| # direct pushes, so rather than committing and pushing the cleanup directly | ||
|
|
@@ -129,7 +168,17 @@ runs: | |
| run: | | ||
| # create-pull-request stages these deletions itself via add-paths; a | ||
| # plain rm is more robust than `git rm -f` when a file is already gone. | ||
| rm -f "${{ inputs.terraform_path }}/${{ inputs.plan_file_path }}" "${{ inputs.terraform_path }}/${{ inputs.plan_text_path }}" | ||
| # The downloaded binary is untracked but removed too, so nothing later | ||
| # in the job can pick up an already-applied plan. | ||
| rm -f "${{ inputs.terraform_path }}/${{ inputs.plan_pointer_path }}" "${{ inputs.terraform_path }}/${{ inputs.plan_text_path }}" "${{ inputs.terraform_path }}/${{ inputs.plan_file_path }}" | ||
|
|
||
| # Best-effort delete of the uploaded binary plan; a failure (e.g. a | ||
| # service account without delete permission) shouldn't fail the run | ||
| # since the apply already succeeded. A bucket lifecycle rule should | ||
| # expire anything this misses, including plans that are never applied. | ||
| if ! gcloud storage rm "${{ steps.download.outputs.object }}"; then | ||
|
khan-actions-bot marked this conversation as resolved.
Outdated
|
||
| echo "Warning: failed to delete ${{ steps.download.outputs.object }}; relying on the bucket lifecycle rule." >&2 | ||
| fi | ||
|
|
||
| # Scope the cleanup branch to this terraform stack. If two apply jobs | ||
| # for different stacks run concurrently in the same repo, a shared | ||
|
|
@@ -154,12 +203,12 @@ runs: | |
| body: | | ||
| This PR was automatically generated by the `${{ github.workflow }}` workflow after the Terraform plan was applied. | ||
|
|
||
| It removes the checked-in plan files so that a stale plan can't be re-applied and the next run generates a fresh plan. It is merged automatically to complete the apply cycle. | ||
| It removes the committed plan pointer and plan text files so that a stale plan can't be re-applied and the next run generates a fresh plan. It is merged automatically to complete the apply cycle. | ||
|
|
||
| Plan file: `${{ inputs.terraform_path }}/${{ inputs.plan_file_path }}` | ||
| Plan pointer file: `${{ inputs.terraform_path }}/${{ inputs.plan_pointer_path }}` | ||
| Plaintext plan file: `${{ inputs.terraform_path }}/${{ inputs.plan_text_path }}` | ||
| add-paths: | | ||
| ${{ inputs.terraform_path }}/${{ inputs.plan_file_path }} | ||
| ${{ inputs.terraform_path }}/${{ inputs.plan_pointer_path }} | ||
| ${{ inputs.terraform_path }}/${{ inputs.plan_text_path }} | ||
|
|
||
| - name: Merge plan-file cleanup PR | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.