Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .github/workflows/terraform-apply.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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: Protect resource-manager folders 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")
folder_delete=$(jq '[.resource_changes[]? |
select(.type == "stackit_resourcemanager_folder") |
select((.change.actions | index("delete")) != null)] | length' "$RUNNER_TEMP/tfplan.json")

{
echo "## Terraform Apply plan"
echo
echo "| Add | Change | Destroy | Replace | Protected folder deletions |"
echo "|---:|---:|---:|---:|---:|"
echo "| $add | $change | $destroy | $replace | $folder_delete |"
} >> "$GITHUB_STEP_SUMMARY"

if (( folder_delete > 0 )); then
echo "::error::Apply blocked: the plan deletes or replaces $folder_delete resource-manager folder(s)."
jq -r '.resource_changes[]? |
select(.type == "stackit_resourcemanager_folder") |
select((.change.actions | index("delete")) != null) |
"::error::Protected folder: \(.address)"' "$RUNNER_TEMP/tfplan.json"
exit 1
fi

- name: Apply reviewed plan
run: tofu -chdir=src apply -input=false -no-color -auto-approve -parallelism=2 tfplan
Loading
Loading