From 5ecad977435fc09611a08bf846652e6a3e9fd279 Mon Sep 17 00:00:00 2001 From: samuelho-dev Date: Sat, 26 Sep 2026 15:58:36 +0000 Subject: [PATCH] feat(terraform): support object-store plan handoff (CTK-835) --- .github/workflows/terraform-apply.yml | 65 +++++++++++++++++++++++++-- .github/workflows/terraform-plan.yml | 50 +++++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index c9dc3ea..e6a96e0 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -1,8 +1,8 @@ name: 'Terraform Apply' -# Applies a saved plan produced by terraform-plan. Downloads the plan artifact, -# re-inits, and applies it. Pair with a GitHub `environment` for required -# reviewers on production applies. +# Applies a saved plan produced by terraform-plan. Retrieves the plan from +# GitHub artifacts or an S3-compatible object store, re-inits, and applies it. +# Pair with a GitHub `environment` for required reviewers on production applies. on: workflow_call: @@ -15,6 +15,21 @@ on: description: 'Plan artifact name produced by terraform-plan' required: true type: string + plan-storage-endpoint: + description: 'S3-compatible endpoint for the plan archive; empty uses GitHub artifacts' + required: false + type: string + default: '' + plan-storage-bucket: + description: 'Bucket containing the plan archive' + required: false + type: string + default: '' + plan-storage-key: + description: 'Attempt-scoped object key containing the plan archive' + required: false + type: string + default: '' terraform-version: description: 'Terraform version (must match the version that produced the plan)' required: false @@ -52,6 +67,12 @@ on: cloudflare-api-token: description: 'Scoped Cloudflare API token (optional; required only when Terraform uses Cloudflare)' required: false + plan-storage-access-key-id: + description: 'Access key for the S3-compatible plan store' + required: false + plan-storage-secret-access-key: + description: 'Secret key for the S3-compatible plan store' + required: false outputs: applied: description: 'true when the apply completed successfully' @@ -93,11 +114,34 @@ jobs: terraform_wrapper: false - name: Download plan artifact + if: inputs.plan-storage-endpoint == '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.plan-artifact-name }} path: ${{ inputs.terraform-path }} + - name: Download plan from object storage + if: inputs.plan-storage-endpoint != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.plan-storage-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.plan-storage-secret-access-key }} + PLAN_ENDPOINT: ${{ inputs.plan-storage-endpoint }} + PLAN_BUCKET: ${{ inputs.plan-storage-bucket }} + PLAN_KEY: ${{ inputs.plan-storage-key }} + run: | + set -euo pipefail + : "${AWS_ACCESS_KEY_ID:?missing plan storage access key}" + : "${AWS_SECRET_ACCESS_KEY:?missing plan storage secret key}" + : "${PLAN_BUCKET:?missing plan storage bucket}" + : "${PLAN_KEY:?missing plan storage key}" + archive="$RUNNER_TEMP/terraform-plan.tar.gz" + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 cp "s3://$PLAN_BUCKET/$PLAN_KEY" "$archive" --no-progress + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 cp "s3://$PLAN_BUCKET/$PLAN_KEY.sha256" "$archive.sha256" --no-progress + (cd "$RUNNER_TEMP" && sha256sum --check terraform-plan.tar.gz.sha256) + tar -xzf "$archive" + - name: Terraform init env: BACKEND_CONFIG: ${{ inputs.backend-config }} @@ -119,6 +163,21 @@ jobs: terraform apply -input=false -no-color tfplan echo "applied=true" >> "$GITHUB_OUTPUT" + - name: Delete plan from object storage + if: always() && inputs.plan-storage-endpoint != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.plan-storage-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.plan-storage-secret-access-key }} + PLAN_ENDPOINT: ${{ inputs.plan-storage-endpoint }} + PLAN_BUCKET: ${{ inputs.plan-storage-bucket }} + PLAN_KEY: ${{ inputs.plan-storage-key }} + run: | + set -euo pipefail + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 rm "s3://$PLAN_BUCKET/$PLAN_KEY" --no-progress + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 rm "s3://$PLAN_BUCKET/$PLAN_KEY.sha256" --no-progress + - name: Summary if: always() run: | diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index a56f438..9e3b5c3 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -1,8 +1,8 @@ name: 'Terraform Plan' -# Runs `terraform plan` against real cloud state via GitHub OIDC, uploads the -# binary plan as an artifact (consumed by terraform-apply), and optionally posts -# the plan to the PR and estimates cost with Infracost. +# Runs `terraform plan` against real cloud state, saves the binary plan for +# terraform-apply, and optionally posts the plan to the PR and estimates cost. +# Plans use GitHub artifacts unless an S3-compatible object store is configured. on: workflow_call: @@ -41,6 +41,21 @@ on: required: false type: string default: 'tfplan' + plan-storage-endpoint: + description: 'S3-compatible endpoint for the plan archive; empty uses GitHub artifacts' + required: false + type: string + default: '' + plan-storage-bucket: + description: 'Bucket for the plan archive when plan-storage-endpoint is set' + required: false + type: string + default: '' + plan-storage-key: + description: 'Attempt-scoped object key for the plan archive when plan-storage-endpoint is set' + required: false + type: string + default: '' post-pr-comment: description: 'Post the plan to the PR as a comment (PR events only)' required: false @@ -71,6 +86,12 @@ on: cloudflare-api-token: description: 'Scoped Cloudflare API token (optional; required only when Terraform uses Cloudflare)' required: false + plan-storage-access-key-id: + description: 'Access key for the S3-compatible plan store' + required: false + plan-storage-secret-access-key: + description: 'Secret key for the S3-compatible plan store' + required: false outputs: has-changes: description: 'true when the plan contains changes' @@ -175,6 +196,7 @@ jobs: PY - name: Upload plan artifact + if: inputs.plan-storage-endpoint == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.plan-artifact-name }} @@ -185,6 +207,28 @@ jobs: retention-days: 5 if-no-files-found: error + - name: Upload plan to object storage + if: inputs.plan-storage-endpoint != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.plan-storage-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.plan-storage-secret-access-key }} + PLAN_ENDPOINT: ${{ inputs.plan-storage-endpoint }} + PLAN_BUCKET: ${{ inputs.plan-storage-bucket }} + PLAN_KEY: ${{ inputs.plan-storage-key }} + run: | + set -euo pipefail + : "${AWS_ACCESS_KEY_ID:?missing plan storage access key}" + : "${AWS_SECRET_ACCESS_KEY:?missing plan storage secret key}" + : "${PLAN_BUCKET:?missing plan storage bucket}" + : "${PLAN_KEY:?missing plan storage key}" + archive="$RUNNER_TEMP/terraform-plan.tar.gz" + tar -czf "$archive" tfplan plan.txt plan.json + (cd "$RUNNER_TEMP" && sha256sum terraform-plan.tar.gz >terraform-plan.tar.gz.sha256) + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 cp "$archive" "s3://$PLAN_BUCKET/$PLAN_KEY" --no-progress + env -u AWS_SESSION_TOKEN aws --endpoint-url "$PLAN_ENDPOINT" --region garage \ + s3 cp "$archive.sha256" "s3://$PLAN_BUCKET/$PLAN_KEY.sha256" --no-progress + - name: Set up Infracost if: inputs.cost-estimation uses: infracost/actions/setup@fb736a8f219195d6efdca58682069b16fe1bc280 # v4.2.0