Skip to content

ci: add protected Terraform plan and apply workflows #4

ci: add protected Terraform plan and apply workflows

ci: add protected Terraform plan and apply workflows #4

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