Repository navigation
ci: add protected Terraform plan and apply workflows #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Terraform Apply | |
| on: | |
| pull_request: | |
| types: [labeled] | |
| workflow_dispatch: | |
| inputs: | |
| confirm_apply: | |
| description: "Confirm that the reviewed configuration should be applied" | |
| required: true | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: terraform-apply | |
| cancel-in-progress: false | |
| env: | |
| TOFU_VERSION: "1.12.5" | |
| TF_IN_AUTOMATION: "true" | |
| TF_INPUT: "false" | |
| TF_VAR_FILE: ${{ vars.TF_PLAN_VAR_FILE || 'config/hub-and-spoke-prod-nonprod-firewall.tfvars' }} | |
| OPNSENSE_IMAGE_URL: "https://opnsense.object.storage.eu01.onstackit.cloud/opnsense-26.1-amd64-21-05-2026.qcow2" | |
| OPNSENSE_IMAGE_SHA256: "5d0fb1cb4375eb258859cafc688f1e5e6f830f54f8d86b9e8bb815611bae1068" | |
| jobs: | |
| authorize: | |
| name: Authorize request | |
| if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'terraform-apply' | |
| runs-on: ubuntu-latest | |
| env: | |
| REQUEST_ACTOR: ${{ github.actor }} | |
| REQUEST_EVENT: ${{ github.event_name }} | |
| REQUEST_LABEL: ${{ github.event.label.name }} | |
| PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} | |
| steps: | |
| - name: Enforce authorized requester, main branch, and confirmation | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| case "|lweberru|mahauber|simpe00|dweezl|" in | |
| *"|$REQUEST_ACTOR|"*) ;; | |
| *) | |
| echo "::error::$REQUEST_ACTOR is not authorized to request Terraform Apply." | |
| exit 1 | |
| ;; | |
| esac | |
| case "$REQUEST_EVENT" in | |
| workflow_dispatch) | |
| if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then | |
| echo "::error::A manually dispatched Terraform Apply may only run from main." | |
| exit 1 | |
| fi | |
| if [[ "${{ inputs.confirm_apply }}" != "true" ]]; then | |
| echo "::error::Apply was not explicitly confirmed." | |
| exit 1 | |
| fi | |
| ;; | |
| pull_request) | |
| if [[ "$REQUEST_LABEL" != "terraform-apply" ]]; then | |
| echo "::error::Pull request Apply requires the terraform-apply label." | |
| exit 1 | |
| fi | |
| if [[ "$PR_HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]]; then | |
| echo "::error::Terraform Apply is not available to fork pull requests." | |
| exit 1 | |
| fi | |
| ;; | |
| *) | |
| echo "::error::Unsupported event: $REQUEST_EVENT" | |
| exit 1 | |
| ;; | |
| esac | |
| apply: | |
| name: Apply | |
| needs: authorize | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| environment: terraform-plan | |
| env: | |
| STACKIT_SERVICE_ACCOUNT_KEY: ${{ secrets.STACKIT_SERVICE_ACCOUNT_KEY }} | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| AWS_EC2_METADATA_DISABLED: "true" | |
| TF_VAR_owner_email: ${{ vars.TF_VAR_OWNER_EMAIL }} | |
| TF_VAR_company_name: ${{ vars.TF_VAR_COMPANY_NAME }} | |
| TF_VAR_company_code: ${{ vars.TF_VAR_COMPANY_CODE }} | |
| TF_VAR_organization_id: ${{ vars.TF_VAR_ORGANIZATION_ID }} | |
| TF_VAR_region: ${{ vars.TF_VAR_REGION }} | |
| TF_VAR_connectivity: ${{ vars.TF_VAR_CONNECTIVITY }} | |
| TF_VAR_landing_zones: ${{ vars.TF_VAR_LANDING_ZONES }} | |
| steps: | |
| - name: Checkout selected revision | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - name: Restore OPNsense image cache | |
| id: opnsense-image-cache | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 | |
| with: | |
| path: src/firewall-image.qcow2 | |
| key: opnsense-image-${{ env.OPNSENSE_IMAGE_SHA256 }} | |
| - name: Download OPNsense image | |
| if: steps.opnsense-image-cache.outputs.cache-hit != 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| curl \ | |
| --fail \ | |
| --location \ | |
| --retry 3 \ | |
| --retry-all-errors \ | |
| --connect-timeout 20 \ | |
| --output src/firewall-image.qcow2.part \ | |
| "$OPNSENSE_IMAGE_URL" | |
| mv src/firewall-image.qcow2.part src/firewall-image.qcow2 | |
| - name: Verify OPNsense image | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "$OPNSENSE_IMAGE_SHA256 src/firewall-image.qcow2" | sha256sum --check --strict | |
| - name: Setup OpenTofu | |
| uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1 | |
| with: | |
| tofu_version: ${{ env.TOFU_VERSION }} | |
| - name: Configure STACKIT credentials | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "$STACKIT_SERVICE_ACCOUNT_KEY" ]]; then | |
| echo "::error::Environment secret STACKIT_SERVICE_ACCOUNT_KEY is missing or empty." | |
| exit 1 | |
| fi | |
| if [[ -z "$AWS_ACCESS_KEY_ID" || -z "$AWS_SECRET_ACCESS_KEY" ]]; then | |
| echo "::error::S3 backend credentials are missing from the terraform-plan environment." | |
| exit 1 | |
| fi | |
| install -d -m 700 "$HOME/.stackit" | |
| printf '%s' "$STACKIT_SERVICE_ACCOUNT_KEY" > "$HOME/.stackit/credentials.json" | |
| chmod 600 "$HOME/.stackit/credentials.json" | |
| - name: Check variable file | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "$TF_VAR_FILE" = /* || "$TF_VAR_FILE" == *".."* || ! -f "src/$TF_VAR_FILE" ]]; then | |
| echo "::error::TF_PLAN_VAR_FILE must reference an existing file below src (current value: $TF_VAR_FILE)." | |
| exit 1 | |
| fi | |
| - name: Initialize | |
| run: tofu -chdir=src init -input=false -no-color | |
| - name: Require a remote state backend | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| backend_type=$(jq -r '.backend.type // "local"' src/.terraform/terraform.tfstate) | |
| if [[ "$backend_type" == "local" ]]; then | |
| echo "::error::Apply blocked: a persistent remote state backend is not configured." | |
| exit 1 | |
| fi | |
| echo "Using remote state backend: $backend_type" | |
| - name: Build GitHub variable overrides | |
| id: overrides | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| override_file="$RUNNER_TEMP/github-overrides.tfvars.json" | |
| jq -n \ | |
| --arg owner_email "$TF_VAR_owner_email" \ | |
| --arg company_name "$TF_VAR_company_name" \ | |
| --arg company_code "$TF_VAR_company_code" \ | |
| --arg organization_id "$TF_VAR_organization_id" \ | |
| --arg region "$TF_VAR_region" \ | |
| --arg connectivity "$TF_VAR_connectivity" \ | |
| --arg landing_zones "$TF_VAR_landing_zones" \ | |
| 'def optional_string($name; $value): if $value == "" then {} else {($name): $value} end; | |
| def optional_json($name; $value): if $value == "" then {} else {($name): ($value | fromjson)} end; | |
| optional_string("owner_email"; $owner_email) + | |
| optional_string("company_name"; $company_name) + | |
| optional_string("company_code"; $company_code) + | |
| optional_string("organization_id"; $organization_id) + | |
| optional_string("region"; $region) + | |
| optional_json("connectivity"; $connectivity) + | |
| optional_json("landing_zones"; $landing_zones)' > "$override_file" | |
| echo "file=$override_file" >> "$GITHUB_OUTPUT" | |
| - name: Create apply plan | |
| run: | | |
| tofu -chdir=src plan \ | |
| -input=false \ | |
| -no-color \ | |
| -var-file="$TF_VAR_FILE" \ | |
| -var-file="${{ steps.overrides.outputs.file }}" \ | |
| -out=tfplan | |
| - name: Reject destructive changes and summarize | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| tofu -chdir=src show -json tfplan > "$RUNNER_TEMP/tfplan.json" | |
| add=$(jq '[.resource_changes[]? | select(.change.actions == ["create"])] | length' "$RUNNER_TEMP/tfplan.json") | |
| change=$(jq '[.resource_changes[]? | select(.change.actions == ["update"])] | length' "$RUNNER_TEMP/tfplan.json") | |
| destroy=$(jq '[.resource_changes[]? | select(.change.actions == ["delete"])] | length' "$RUNNER_TEMP/tfplan.json") | |
| replace=$(jq '[.resource_changes[]? | select((.change.actions | index("create")) and (.change.actions | index("delete")))] | length' "$RUNNER_TEMP/tfplan.json") | |
| { | |
| echo "## Terraform Apply plan" | |
| echo | |
| echo "| Add | Change | Destroy | Replace |" | |
| echo "|---:|---:|---:|---:|" | |
| echo "| $add | $change | $destroy | $replace |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| if (( destroy > 0 || replace > 0 )); then | |
| echo "::error::Apply blocked: the plan contains $destroy destroy and $replace replacement operations." | |
| exit 1 | |
| fi | |
| - name: Apply reviewed plan | |
| run: tofu -chdir=src apply -input=false -no-color -auto-approve -parallelism=2 tfplan |