From f236eead7d9ebdd5787f9e4eb238182246b2501d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 08:38:02 +0200 Subject: [PATCH 01/15] ci: add optional Terraform plan for pull requests --- .github/workflows/terraform-plan.yml | 199 +++++++++++++++++++++++++++ README.md | 29 ++++ 2 files changed, 228 insertions(+) create mode 100644 .github/workflows/terraform-plan.yml diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml new file mode 100644 index 0000000..0d9bcd1 --- /dev/null +++ b/.github/workflows/terraform-plan.yml @@ -0,0 +1,199 @@ +name: Terraform Plan + +on: + pull_request: + types: [opened, reopened, synchronize, labeled] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: terraform-plan-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +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' }} + +jobs: + plan: + name: PR Plan + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # Secrets are deliberately never exposed to code from forks. + PLAN_REQUESTED: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-plan') && github.event.pull_request.head.repo.full_name == github.repository }} + STACKIT_SERVICE_ACCOUNT_KEY: ${{ secrets.STACKIT_SERVICE_ACCOUNT_KEY }} + 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 pull request + if: env.PLAN_REQUESTED == 'true' + uses: actions/checkout@v4 + + - name: Setup OpenTofu + if: env.PLAN_REQUESTED == 'true' + uses: opentofu/setup-opentofu@v1 + with: + tofu_version: ${{ env.TOFU_VERSION }} + + - name: Configure STACKIT credentials + if: env.PLAN_REQUESTED == 'true' + shell: bash + run: | + set -euo pipefail + if [[ -z "$STACKIT_SERVICE_ACCOUNT_KEY" ]]; then + echo "::error::Repository secret STACKIT_SERVICE_ACCOUNT_KEY is missing or empty." + 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 + if: env.PLAN_REQUESTED == 'true' + 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 + if: env.PLAN_REQUESTED == 'true' + run: tofu -chdir=src init -input=false -no-color + + - name: Build GitHub variable overrides + id: overrides + if: env.PLAN_REQUESTED == 'true' + 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 plan + id: plan + if: env.PLAN_REQUESTED == 'true' + shell: bash + run: | + set +e + tofu -chdir=src plan \ + -input=false \ + -no-color \ + -detailed-exitcode \ + -var-file="$TF_VAR_FILE" \ + -var-file="${{ steps.overrides.outputs.file }}" \ + -out=tfplan + exit_code=$? + set -e + + case "$exit_code" in + 0) result="No changes" ;; + 2) result="Changes detected" ;; + *) result="Plan failed" ;; + esac + + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + echo "result=$result" >> "$GITHUB_OUTPUT" + # Exit code 2 means a valid plan containing changes. + [[ "$exit_code" -eq 0 || "$exit_code" -eq 2 ]] + + - name: Summarize plan + id: summary + if: steps.plan.outputs.exit_code == '0' || steps.plan.outputs.exit_code == '2' + 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 "add=$add" >> "$GITHUB_OUTPUT" + echo "change=$change" >> "$GITHUB_OUTPUT" + echo "destroy=$destroy" >> "$GITHUB_OUTPUT" + echo "replace=$replace" >> "$GITHUB_OUTPUT" + + - name: Comment plan result + if: always() && env.PLAN_REQUESTED == 'true' + uses: actions/github-script@v7 + env: + PLAN_OUTCOME: ${{ steps.plan.outcome }} + PLAN_RESULT: ${{ steps.plan.outputs.result }} + PLAN_ADD: ${{ steps.summary.outputs.add }} + PLAN_CHANGE: ${{ steps.summary.outputs.change }} + PLAN_DESTROY: ${{ steps.summary.outputs.destroy }} + PLAN_REPLACE: ${{ steps.summary.outputs.replace }} + with: + script: | + const marker = ''; + const succeeded = process.env.PLAN_OUTCOME === 'success'; + const icon = succeeded ? '✅' : '❌'; + const result = process.env.PLAN_RESULT || 'Plan failed before completion'; + const counts = succeeded + ? `| Add | Change | Destroy | Replace |\n|---:|---:|---:|---:|\n| ${process.env.PLAN_ADD} | ${process.env.PLAN_CHANGE} | ${process.env.PLAN_DESTROY} | ${process.env.PLAN_REPLACE} |\n\n` + : ''; + const body = `${marker}\n## ${icon} Terraform Plan: ${result}\n\n${counts}` + + `Configuration: \`src/${process.env.TF_VAR_FILE}\` \n` + + `Commit: \`${context.payload.pull_request.head.sha.slice(0, 7)}\` \n` + + `[Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const previous = comments.find(comment => + comment.user.type === 'Bot' && comment.body?.includes(marker) + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: previous.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Explain why the plan was skipped + if: env.PLAN_REQUESTED != 'true' + run: | + echo "Terraform plan was not requested. Add the 'terraform-plan' label to a same-repository pull request to run it." diff --git a/README.md b/README.md index 81faf0f..f8c4d5e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,35 @@ The STACKIT Landing Zone Accelerator provides a comprehensive Terraform-based fr Contributions are welcome! Please feel free to submit a Pull Request. +### Optional Terraform plan for pull requests + +Add the `terraform-plan` label to a pull request to run an authenticated plan. The +workflow updates a single pull-request comment with the numbers of resources to +add, change, destroy, and replace. A new commit reruns the plan while the label is +present. Pull requests from forks are skipped because GitHub secrets must not be +exposed to untrusted repositories. + +The workflow expects: + +- the repository secret `STACKIT_SERVICE_ACCOUNT_KEY` containing the STACKIT + service-account key JSON; +- optionally, the repository variable `TF_PLAN_VAR_FILE`, set to a path below + `src/` (the default is + `config/hub-and-spoke-prod-nonprod-firewall.tfvars`). + +Values in that file can be overridden with the repository variables +`TF_VAR_OWNER_EMAIL`, `TF_VAR_COMPANY_NAME`, `TF_VAR_COMPANY_CODE`, +`TF_VAR_ORGANIZATION_ID`, `TF_VAR_REGION`, `TF_VAR_CONNECTIVITY`, and +`TF_VAR_LANDING_ZONES`. The last two values must be JSON objects. Empty or missing +variables do not override the selected tfvars file. The workflow writes the +configured values to a second, higher-priority tfvars file so the overrides take +precedence over the explicitly selected base file. + +To make a failed plan prevent merging, add the status check +`Terraform Plan / PR Plan` as a required check in the ruleset or branch protection +for the target branch. When no plan is requested, this check succeeds without +accessing STACKIT. + ## 📄 License This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. From 11335890f805cae555de0add6cf7d1966aa09753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 08:39:24 +0200 Subject: [PATCH 02/15] fix: skip plan summary when plan is not requested --- .github/workflows/terraform-plan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 0d9bcd1..8865e37 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -127,7 +127,7 @@ jobs: - name: Summarize plan id: summary - if: steps.plan.outputs.exit_code == '0' || steps.plan.outputs.exit_code == '2' + if: env.PLAN_REQUESTED == 'true' && steps.plan.outcome == 'success' shell: bash run: | set -euo pipefail From 022b28f8222be3f82f34981da28892400ff7fef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 08:40:23 +0200 Subject: [PATCH 03/15] fix: derive plan result from resource changes --- .github/workflows/terraform-plan.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 8865e37..60b81fa 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -138,17 +138,24 @@ jobs: 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") + if (( add + change + destroy + replace == 0 )); then + result="No changes" + else + result="Changes detected" + fi + echo "add=$add" >> "$GITHUB_OUTPUT" echo "change=$change" >> "$GITHUB_OUTPUT" echo "destroy=$destroy" >> "$GITHUB_OUTPUT" echo "replace=$replace" >> "$GITHUB_OUTPUT" + echo "result=$result" >> "$GITHUB_OUTPUT" - name: Comment plan result if: always() && env.PLAN_REQUESTED == 'true' uses: actions/github-script@v7 env: PLAN_OUTCOME: ${{ steps.plan.outcome }} - PLAN_RESULT: ${{ steps.plan.outputs.result }} + PLAN_RESULT: ${{ steps.summary.outputs.result || steps.plan.outputs.result }} PLAN_ADD: ${{ steps.summary.outputs.add }} PLAN_CHANGE: ${{ steps.summary.outputs.change }} PLAN_DESTROY: ${{ steps.summary.outputs.destroy }} From f84f31b98a2c971277f75d5f8740fa4800fec199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 08:54:43 +0200 Subject: [PATCH 04/15] ci: run Terraform plan for every pull request --- .github/workflows/terraform-plan.yml | 5 ++--- README.md | 19 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 60b81fa..203d759 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -2,7 +2,6 @@ name: Terraform Plan on: pull_request: - types: [opened, reopened, synchronize, labeled] permissions: contents: read @@ -25,7 +24,7 @@ jobs: timeout-minutes: 30 env: # Secrets are deliberately never exposed to code from forks. - PLAN_REQUESTED: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-plan') && github.event.pull_request.head.repo.full_name == github.repository }} + PLAN_REQUESTED: ${{ github.event.pull_request.head.repo.full_name == github.repository }} STACKIT_SERVICE_ACCOUNT_KEY: ${{ secrets.STACKIT_SERVICE_ACCOUNT_KEY }} TF_VAR_owner_email: ${{ vars.TF_VAR_OWNER_EMAIL }} TF_VAR_company_name: ${{ vars.TF_VAR_COMPANY_NAME }} @@ -203,4 +202,4 @@ jobs: - name: Explain why the plan was skipped if: env.PLAN_REQUESTED != 'true' run: | - echo "Terraform plan was not requested. Add the 'terraform-plan' label to a same-repository pull request to run it." + echo "Terraform plan is skipped for fork pull requests because repository secrets are unavailable." diff --git a/README.md b/README.md index f8c4d5e..0f07d45 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ The STACKIT Landing Zone Accelerator provides a comprehensive Terraform-based fr Contributions are welcome! Please feel free to submit a Pull Request. -### Optional Terraform plan for pull requests +### Terraform plan for pull requests -Add the `terraform-plan` label to a pull request to run an authenticated plan. The -workflow updates a single pull-request comment with the numbers of resources to -add, change, destroy, and replace. A new commit reruns the plan while the label is -present. Pull requests from forks are skipped because GitHub secrets must not be -exposed to untrusted repositories. +Every pull request from a branch in this repository runs an authenticated plan. +The workflow updates a single pull-request comment with the numbers of resources +to add, change, destroy, and replace. A new commit reruns the plan. Pull requests +from forks are skipped because GitHub secrets must not be exposed to untrusted +repositories. The workflow expects: @@ -47,10 +47,9 @@ variables do not override the selected tfvars file. The workflow writes the configured values to a second, higher-priority tfvars file so the overrides take precedence over the explicitly selected base file. -To make a failed plan prevent merging, add the status check -`Terraform Plan / PR Plan` as a required check in the ruleset or branch protection -for the target branch. When no plan is requested, this check succeeds without -accessing STACKIT. +The status check `Terraform Plan / PR Plan` must be configured as a required check +in the ruleset or branch protection for the target branch to prevent merging when +the plan fails. ## 📄 License From 0146983cbcd275753939e27e30f542a5bf255e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 09:14:52 +0200 Subject: [PATCH 05/15] ci: protect Terraform credentials with approvals --- .github/workflows/terraform-plan.yml | 7 ++++--- README.md | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 203d759..356f77a 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -22,6 +22,7 @@ jobs: name: PR Plan runs-on: ubuntu-latest timeout-minutes: 30 + environment: terraform-plan env: # Secrets are deliberately never exposed to code from forks. PLAN_REQUESTED: ${{ github.event.pull_request.head.repo.full_name == github.repository }} @@ -36,11 +37,11 @@ jobs: steps: - name: Checkout pull request if: env.PLAN_REQUESTED == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup OpenTofu if: env.PLAN_REQUESTED == 'true' - uses: opentofu/setup-opentofu@v1 + uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1 with: tofu_version: ${{ env.TOFU_VERSION }} @@ -151,7 +152,7 @@ jobs: - name: Comment plan result if: always() && env.PLAN_REQUESTED == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: PLAN_OUTCOME: ${{ steps.plan.outcome }} PLAN_RESULT: ${{ steps.summary.outputs.result || steps.plan.outputs.result }} diff --git a/README.md b/README.md index 0f07d45..eef92c8 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,11 @@ to add, change, destroy, and replace. A new commit reruns the plan. Pull request from forks are skipped because GitHub secrets must not be exposed to untrusted repositories. -The workflow expects: +The workflow uses the protected `terraform-plan` environment. A CODEOWNER must +approve the job before GitHub releases its credentials; administrator bypass is +disabled. The workflow expects: -- the repository secret `STACKIT_SERVICE_ACCOUNT_KEY` containing the STACKIT +- the environment secret `STACKIT_SERVICE_ACCOUNT_KEY` containing the STACKIT service-account key JSON; - optionally, the repository variable `TF_PLAN_VAR_FILE`, set to a path below `src/` (the default is From 3206a45e2d016d2de954251ac9a4c13a1983353d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 09:33:25 +0200 Subject: [PATCH 06/15] ci: add protected Terraform apply workflow --- .github/workflows/terraform-apply.yml | 170 ++++++++++++++++++++++++++ README.md | 12 ++ 2 files changed, 182 insertions(+) create mode 100644 .github/workflows/terraform-apply.yml diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml new file mode 100644 index 0000000..108752d --- /dev/null +++ b/.github/workflows/terraform-apply.yml @@ -0,0 +1,170 @@ +name: Terraform Apply + +on: + 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' }} + +jobs: + authorize: + name: Authorize request + runs-on: ubuntu-latest + env: + REQUEST_ACTOR: ${{ github.actor }} + 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 + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::Terraform Apply may only run from the main branch." + exit 1 + fi + if [[ "${{ inputs.confirm_apply }}" != "true" ]]; then + echo "::error::Apply was not explicitly confirmed." + exit 1 + fi + + 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 }} + 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 main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - 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 + 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 tfplan diff --git a/README.md b/README.md index eef92c8..c04ca46 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ The status check `Terraform Plan / PR Plan` must be configured as a required che in the ruleset or branch protection for the target branch to prevent merging when the plan fails. +### Terraform apply + +The `Terraform Apply` workflow is started manually from the Actions page. Only a +CODEOWNER may request it, and it only accepts the `main` branch. Starting it +requires explicit confirmation, followed by approval from a CODEOWNER through the +protected `terraform-plan` environment. +Administrator bypass is disabled. The workflow creates a fresh plan and applies +that exact saved plan. It rejects plans containing delete or replacement actions; +full destroy remains unsupported while STACKIT projects use soft deletion and +prevent their parent folders from being deleted immediately. Apply also fails +closed unless OpenTofu has initialized a persistent remote state backend. + ## 📄 License This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. From c8d3ae3b882deb605d5bb4af90237f659059bebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 09:42:48 +0200 Subject: [PATCH 07/15] ci: persist Terraform state in STACKIT Object Storage --- .github/workflows/terraform-apply.yml | 7 ++++++ .github/workflows/terraform-plan.yml | 9 +++++++- README.md | 6 ++++++ src/backend.tf | 31 +++++++++++++++------------ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 108752d..b077df2 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -57,6 +57,9 @@ jobs: 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 }} @@ -81,6 +84,10 @@ jobs: 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" diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 356f77a..091f67f 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -27,6 +27,9 @@ jobs: # Secrets are deliberately never exposed to code from forks. PLAN_REQUESTED: ${{ github.event.pull_request.head.repo.full_name == github.repository }} 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 }} @@ -51,7 +54,11 @@ jobs: run: | set -euo pipefail if [[ -z "$STACKIT_SERVICE_ACCOUNT_KEY" ]]; then - echo "::error::Repository secret STACKIT_SERVICE_ACCOUNT_KEY is missing or empty." + 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" diff --git a/README.md b/README.md index c04ca46..532434b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ disabled. The workflow expects: - the environment secret `STACKIT_SERVICE_ACCOUNT_KEY` containing the STACKIT service-account key JSON; +- the environment secrets `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` for the + STACKIT Object Storage state backend; - optionally, the repository variable `TF_PLAN_VAR_FILE`, set to a path below `src/` (the default is `config/hub-and-spoke-prod-nonprod-firewall.tfvars`). @@ -65,6 +67,10 @@ full destroy remains unsupported while STACKIT projects use soft deletion and prevent their parent folders from being deleted immediately. Apply also fails closed unless OpenTofu has initialized a persistent remote state backend. +Terraform state is stored in the versioned STACKIT Object Storage bucket +`lza-terraform-state` under `landing-zone/terraform.tfstate`. The S3 backend uses +an adjacent lock file to serialize plan and apply operations. + ## 📄 License This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/src/backend.tf b/src/backend.tf index c9266ff..226c395 100644 --- a/src/backend.tf +++ b/src/backend.tf @@ -1,14 +1,17 @@ -# terraform { -# backend "s3" { -# bucket = "" -# endpoints = { -# s3 = "https://object.storage.eu01.onstackit.cloud" -# } -# key = "terraform.tfstate" -# region = "eu01" -# skip_credentials_validation = true -# skip_region_validation = true -# skip_requesting_account_id = true -# skip_s3_checksum = true -# } -# } +terraform { + backend "s3" { + bucket = "lza-terraform-state" + endpoints = { + s3 = "https://object.storage.eu01.onstackit.cloud" + } + key = "landing-zone/terraform.tfstate" + region = "eu01" + use_path_style = true + use_lockfile = true + skip_credentials_validation = true + skip_region_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + skip_s3_checksum = true + } +} From 30d2d8bbd481c93c570289e9b0f0506df157d288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 09:54:39 +0200 Subject: [PATCH 08/15] ci: allow protected apply validation on pull requests --- .github/workflows/terraform-apply.yml | 42 +++++++++++++++++++++------ README.md | 9 +++--- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index b077df2..73b8fcf 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -1,6 +1,8 @@ name: Terraform Apply on: + pull_request: + types: [labeled] workflow_dispatch: inputs: confirm_apply: @@ -25,9 +27,13 @@ env: 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 @@ -40,14 +46,32 @@ jobs: exit 1 ;; esac - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then - echo "::error::Terraform Apply may only run from the main branch." - exit 1 - fi - if [[ "${{ inputs.confirm_apply }}" != "true" ]]; then - echo "::error::Apply was not explicitly confirmed." - exit 1 - fi + 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 @@ -68,7 +92,7 @@ jobs: TF_VAR_connectivity: ${{ vars.TF_VAR_CONNECTIVITY }} TF_VAR_landing_zones: ${{ vars.TF_VAR_LANDING_ZONES }} steps: - - name: Checkout main + - name: Checkout selected revision uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup OpenTofu diff --git a/README.md b/README.md index 532434b..3e0643b 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,11 @@ the plan fails. ### Terraform apply -The `Terraform Apply` workflow is started manually from the Actions page. Only a -CODEOWNER may request it, and it only accepts the `main` branch. Starting it -requires explicit confirmation, followed by approval from a CODEOWNER through the -protected `terraform-plan` environment. +The `Terraform Apply` workflow is started manually from the Actions page for +`main`, or for an internal pull request by adding the `terraform-apply` label. +Only a CODEOWNER may request either variant. Starting it requires explicit +confirmation for a manual run, followed in both cases by approval from a CODEOWNER +through the protected `terraform-plan` environment. Administrator bypass is disabled. The workflow creates a fresh plan and applies that exact saved plan. It rejects plans containing delete or replacement actions; full destroy remains unsupported while STACKIT projects use soft deletion and From ed04d66dc12b876f23e6f61c0005400cfec6867e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 10:00:22 +0200 Subject: [PATCH 09/15] ci: use protected secret for configuration tests --- .github/workflows/tflint.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tflint.yml b/.github/workflows/tflint.yml index c1115c2..d958063 100644 --- a/.github/workflows/tflint.yml +++ b/.github/workflows/tflint.yml @@ -66,6 +66,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false timeout-minutes: 30 + environment: terraform-plan env: STACKIT_SERVICE_ACCOUNT_KEY: ${{ secrets.STACKIT_SERVICE_ACCOUNT_KEY }} steps: @@ -81,7 +82,10 @@ jobs: shell: bash run: | set -euo pipefail - test -n "$STACKIT_SERVICE_ACCOUNT_KEY" + if [[ -z "$STACKIT_SERVICE_ACCOUNT_KEY" ]]; then + echo "::error::Environment secret STACKIT_SERVICE_ACCOUNT_KEY is missing or empty." + 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" From dc0da5716218dbb2b50264d38959bc2913422600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 10:53:22 +0200 Subject: [PATCH 10/15] ci: update STACKIT provider and limit apply parallelism --- .github/workflows/terraform-apply.yml | 2 +- src/modules/connectivity/terraform.tf | 4 ++-- src/modules/debug-bastion/terraform.tf | 2 +- src/modules/devops/terraform.tf | 4 ++-- src/modules/governance/terraform.tf | 4 ++-- src/modules/landing-zone/terraform.tf | 4 ++-- src/modules/management/terraform.tf | 4 ++-- src/modules/namespace-service-demo/main.tf | 2 +- src/modules/platform-kubernetes/terraform.tf | 2 +- src/modules/sandboxes/terraform.tf | 4 ++-- src/terraform.tf | 4 ++-- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 73b8fcf..3a4fe60 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -198,4 +198,4 @@ jobs: fi - name: Apply reviewed plan - run: tofu -chdir=src apply -input=false -no-color -auto-approve tfplan + run: tofu -chdir=src apply -input=false -no-color -auto-approve -parallelism=2 tfplan diff --git a/src/modules/connectivity/terraform.tf b/src/modules/connectivity/terraform.tf index a131f20..22ab910 100644 --- a/src/modules/connectivity/terraform.tf +++ b/src/modules/connectivity/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } time = { source = "hashicorp/time" @@ -15,4 +15,4 @@ terraform { version = "3.9.0" } } -} \ No newline at end of file +} diff --git a/src/modules/debug-bastion/terraform.tf b/src/modules/debug-bastion/terraform.tf index 3ebb3ce..8e098d4 100644 --- a/src/modules/debug-bastion/terraform.tf +++ b/src/modules/debug-bastion/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } } } diff --git a/src/modules/devops/terraform.tf b/src/modules/devops/terraform.tf index 233ba67..8a15452 100644 --- a/src/modules/devops/terraform.tf +++ b/src/modules/devops/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } } -} \ No newline at end of file +} diff --git a/src/modules/governance/terraform.tf b/src/modules/governance/terraform.tf index de61aad..78c73d3 100644 --- a/src/modules/governance/terraform.tf +++ b/src/modules/governance/terraform.tf @@ -4,11 +4,11 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } time = { source = "hashicorp/time" version = "~> 0.14.0" } } -} \ No newline at end of file +} diff --git a/src/modules/landing-zone/terraform.tf b/src/modules/landing-zone/terraform.tf index 90cd055..dc11e26 100644 --- a/src/modules/landing-zone/terraform.tf +++ b/src/modules/landing-zone/terraform.tf @@ -4,11 +4,11 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } time = { source = "hashicorp/time" version = "~> 0.14.0" } } -} \ No newline at end of file +} diff --git a/src/modules/management/terraform.tf b/src/modules/management/terraform.tf index e7ee7cd..9a74add 100644 --- a/src/modules/management/terraform.tf +++ b/src/modules/management/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } time = { source = "hashicorp/time" @@ -15,4 +15,4 @@ terraform { version = "5.10.1" } } -} \ No newline at end of file +} diff --git a/src/modules/namespace-service-demo/main.tf b/src/modules/namespace-service-demo/main.tf index f69e0ff..83262bc 100644 --- a/src/modules/namespace-service-demo/main.tf +++ b/src/modules/namespace-service-demo/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } grafana = { source = "grafana/grafana" diff --git a/src/modules/platform-kubernetes/terraform.tf b/src/modules/platform-kubernetes/terraform.tf index 99abeb8..c18d875 100644 --- a/src/modules/platform-kubernetes/terraform.tf +++ b/src/modules/platform-kubernetes/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } time = { source = "hashicorp/time" diff --git a/src/modules/sandboxes/terraform.tf b/src/modules/sandboxes/terraform.tf index 233ba67..8a15452 100644 --- a/src/modules/sandboxes/terraform.tf +++ b/src/modules/sandboxes/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } } -} \ No newline at end of file +} diff --git a/src/terraform.tf b/src/terraform.tf index 1ce1df2..5deb277 100644 --- a/src/terraform.tf +++ b/src/terraform.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "0.106.0" + version = "0.113.0" } kubernetes = { source = "hashicorp/kubernetes" @@ -35,4 +35,4 @@ terraform { version = "0.24.0" } } -} \ No newline at end of file +} From 57046e070ca83fc0b9d62562c1cf1b84e322da55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 11:33:35 +0200 Subject: [PATCH 11/15] ci: fetch and verify OPNsense image for apply --- .github/workflows/terraform-apply.yml | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 3a4fe60..82c2ac9 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -23,6 +23,8 @@ env: 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: @@ -95,6 +97,34 @@ jobs: - 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: From 4daec1e8fe02fff51be880b8dfc99b36d1575137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 11:34:41 +0200 Subject: [PATCH 12/15] fix: use verified firewall image consistently --- .github/workflows/terraform-plan.yml | 32 ++++++++++++++++++++++++++ src/modules/connectivity/5-firewall.tf | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 091f67f..373c995 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -16,6 +16,8 @@ env: 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: plan: @@ -42,6 +44,36 @@ jobs: if: env.PLAN_REQUESTED == 'true' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Restore OPNsense image cache + id: opnsense-image-cache + if: env.PLAN_REQUESTED == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: src/firewall-image.qcow2 + key: opnsense-image-${{ env.OPNSENSE_IMAGE_SHA256 }} + + - name: Download OPNsense image + if: env.PLAN_REQUESTED == 'true' && 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 + if: env.PLAN_REQUESTED == 'true' + shell: bash + run: | + set -euo pipefail + echo "$OPNSENSE_IMAGE_SHA256 src/firewall-image.qcow2" | sha256sum --check --strict + - name: Setup OpenTofu if: env.PLAN_REQUESTED == 'true' uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1 diff --git a/src/modules/connectivity/5-firewall.tf b/src/modules/connectivity/5-firewall.tf index afb8ca3..657686a 100644 --- a/src/modules/connectivity/5-firewall.tf +++ b/src/modules/connectivity/5-firewall.tf @@ -3,7 +3,7 @@ ########### locals { - firewall_image_path = fileexists("${path.root}/firewall-image.qcow2") ? "${path.root}/firewall-image.qcow2" : "/dev/null" + firewall_image_path = "${path.root}/firewall-image.qcow2" firewalls = var.firewalls != null ? var.firewalls : var.firewall != null ? { for key, area in var.network_areas : key => var.firewall } : {} From 476e4bd886d8db7c18c6c5ee27f6dadb0f52f292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 11:39:36 +0200 Subject: [PATCH 13/15] ci: provide firewall image placeholder for validation --- .github/workflows/tflint.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tflint.yml b/.github/workflows/tflint.yml index d958063..7e7149c 100644 --- a/.github/workflows/tflint.yml +++ b/.github/workflows/tflint.yml @@ -57,6 +57,9 @@ jobs: for directory in "${terraform_directories[@]}"; do echo "Validating $directory" + # The STACKIT image schema validates local_file_path even though + # validate never uploads or otherwise reads the image contents. + touch "$directory/firewall-image.qcow2" tofu -chdir="$directory" init -backend=false -input=false -no-color tofu -chdir="$directory" validate -no-color done @@ -90,6 +93,9 @@ jobs: printf '%s' "$STACKIT_SERVICE_ACCOUNT_KEY" > "$HOME/.stackit/credentials.json" chmod 600 "$HOME/.stackit/credentials.json" + - name: Create firewall image placeholder for mocked tests + run: touch src/firewall-image.qcow2 + - name: Initialize test configuration run: tofu -chdir=src init -backend=false -input=false -no-color From fc500f8a91884467fa8cdb720dd6293fb1dfbbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 11:59:57 +0200 Subject: [PATCH 14/15] ci: allow one-time firewall artifact recovery --- .github/workflows/terraform-apply.yml | 54 ++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 82c2ac9..7506681 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -213,17 +213,61 @@ jobs: 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") + approved_recovery=$(jq ' + def replacement: + ((.change.actions | index("create")) != null) and + ((.change.actions | index("delete")) != null); + def approved_firewall_recovery: + replacement and ( + (.address == "module.connectivity[0].stackit_image.firewall[\"nonprod\"]" and + .change.before.id == "a1b26c3e-7268-49b1-a067-fe49f11ab806,eu01,a1f01453-4132-4159-9ec2-3e1b220744a1" and + .change.before.local_file_path == "/dev/null" and + .change.after.local_file_path == "./firewall-image.qcow2") or + (.address == "module.connectivity[0].stackit_image.firewall[\"prod\"]" and + .change.before.id == "cb37a8f3-463e-4908-828a-1e25dd08db35,eu01,fdfe511f-e644-4eec-80df-1f436388b91d" and + .change.before.local_file_path == "/dev/null" and + .change.after.local_file_path == "./firewall-image.qcow2") or + (.address == "module.connectivity[0].stackit_volume.firewall[\"nonprod\"]" and + .change.before.volume_id == "d8047f6d-8f15-4d48-a574-7a62e0e84f24") or + (.address == "module.connectivity[0].stackit_volume.firewall[\"prod\"]" and + .change.before.volume_id == "7a741653-f1ae-4493-980f-0af8a53c6deb") + ); + [.resource_changes[]? | select(approved_firewall_recovery)] | length + ' "$RUNNER_TEMP/tfplan.json") + disallowed_destructive=$(jq ' + def replacement: + ((.change.actions | index("create")) != null) and + ((.change.actions | index("delete")) != null); + def approved_firewall_recovery: + replacement and ( + (.address == "module.connectivity[0].stackit_image.firewall[\"nonprod\"]" and + .change.before.id == "a1b26c3e-7268-49b1-a067-fe49f11ab806,eu01,a1f01453-4132-4159-9ec2-3e1b220744a1" and + .change.before.local_file_path == "/dev/null" and + .change.after.local_file_path == "./firewall-image.qcow2") or + (.address == "module.connectivity[0].stackit_image.firewall[\"prod\"]" and + .change.before.id == "cb37a8f3-463e-4908-828a-1e25dd08db35,eu01,fdfe511f-e644-4eec-80df-1f436388b91d" and + .change.before.local_file_path == "/dev/null" and + .change.after.local_file_path == "./firewall-image.qcow2") or + (.address == "module.connectivity[0].stackit_volume.firewall[\"nonprod\"]" and + .change.before.volume_id == "d8047f6d-8f15-4d48-a574-7a62e0e84f24") or + (.address == "module.connectivity[0].stackit_volume.firewall[\"prod\"]" and + .change.before.volume_id == "7a741653-f1ae-4493-980f-0af8a53c6deb") + ); + [.resource_changes[]? | + select((.change.actions | index("delete")) != null) | + select((approved_firewall_recovery | not))] | length + ' "$RUNNER_TEMP/tfplan.json") { echo "## Terraform Apply plan" echo - echo "| Add | Change | Destroy | Replace |" - echo "|---:|---:|---:|---:|" - echo "| $add | $change | $destroy | $replace |" + echo "| Add | Change | Destroy | Replace | Approved one-time recovery |" + echo "|---:|---:|---:|---:|---:|" + echo "| $add | $change | $destroy | $replace | $approved_recovery |" } >> "$GITHUB_STEP_SUMMARY" - if (( destroy > 0 || replace > 0 )); then - echo "::error::Apply blocked: the plan contains $destroy destroy and $replace replacement operations." + if (( disallowed_destructive > 0 )); then + echo "::error::Apply blocked: $disallowed_destructive destructive operations are not part of the one-time firewall recovery allowlist." exit 1 fi From aa6cc9a7d1bf5ffe53ce2165f36051c1d3b33c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Thu, 3 Sep 2026 12:02:08 +0200 Subject: [PATCH 15/15] ci: allow resource lifecycle changes except folders --- .github/workflows/terraform-apply.yml | 61 ++++++--------------------- README.md | 10 +++-- 2 files changed, 18 insertions(+), 53 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 7506681..9e1077f 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -203,7 +203,7 @@ jobs: -var-file="${{ steps.overrides.outputs.file }}" \ -out=tfplan - - name: Reject destructive changes and summarize + - name: Protect resource-manager folders and summarize shell: bash run: | set -euo pipefail @@ -213,61 +213,24 @@ jobs: 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") - approved_recovery=$(jq ' - def replacement: - ((.change.actions | index("create")) != null) and - ((.change.actions | index("delete")) != null); - def approved_firewall_recovery: - replacement and ( - (.address == "module.connectivity[0].stackit_image.firewall[\"nonprod\"]" and - .change.before.id == "a1b26c3e-7268-49b1-a067-fe49f11ab806,eu01,a1f01453-4132-4159-9ec2-3e1b220744a1" and - .change.before.local_file_path == "/dev/null" and - .change.after.local_file_path == "./firewall-image.qcow2") or - (.address == "module.connectivity[0].stackit_image.firewall[\"prod\"]" and - .change.before.id == "cb37a8f3-463e-4908-828a-1e25dd08db35,eu01,fdfe511f-e644-4eec-80df-1f436388b91d" and - .change.before.local_file_path == "/dev/null" and - .change.after.local_file_path == "./firewall-image.qcow2") or - (.address == "module.connectivity[0].stackit_volume.firewall[\"nonprod\"]" and - .change.before.volume_id == "d8047f6d-8f15-4d48-a574-7a62e0e84f24") or - (.address == "module.connectivity[0].stackit_volume.firewall[\"prod\"]" and - .change.before.volume_id == "7a741653-f1ae-4493-980f-0af8a53c6deb") - ); - [.resource_changes[]? | select(approved_firewall_recovery)] | length - ' "$RUNNER_TEMP/tfplan.json") - disallowed_destructive=$(jq ' - def replacement: - ((.change.actions | index("create")) != null) and - ((.change.actions | index("delete")) != null); - def approved_firewall_recovery: - replacement and ( - (.address == "module.connectivity[0].stackit_image.firewall[\"nonprod\"]" and - .change.before.id == "a1b26c3e-7268-49b1-a067-fe49f11ab806,eu01,a1f01453-4132-4159-9ec2-3e1b220744a1" and - .change.before.local_file_path == "/dev/null" and - .change.after.local_file_path == "./firewall-image.qcow2") or - (.address == "module.connectivity[0].stackit_image.firewall[\"prod\"]" and - .change.before.id == "cb37a8f3-463e-4908-828a-1e25dd08db35,eu01,fdfe511f-e644-4eec-80df-1f436388b91d" and - .change.before.local_file_path == "/dev/null" and - .change.after.local_file_path == "./firewall-image.qcow2") or - (.address == "module.connectivity[0].stackit_volume.firewall[\"nonprod\"]" and - .change.before.volume_id == "d8047f6d-8f15-4d48-a574-7a62e0e84f24") or - (.address == "module.connectivity[0].stackit_volume.firewall[\"prod\"]" and - .change.before.volume_id == "7a741653-f1ae-4493-980f-0af8a53c6deb") - ); - [.resource_changes[]? | - select((.change.actions | index("delete")) != null) | - select((approved_firewall_recovery | not))] | 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 | Approved one-time recovery |" + echo "| Add | Change | Destroy | Replace | Protected folder deletions |" echo "|---:|---:|---:|---:|---:|" - echo "| $add | $change | $destroy | $replace | $approved_recovery |" + echo "| $add | $change | $destroy | $replace | $folder_delete |" } >> "$GITHUB_STEP_SUMMARY" - if (( disallowed_destructive > 0 )); then - echo "::error::Apply blocked: $disallowed_destructive destructive operations are not part of the one-time firewall recovery allowlist." + 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 diff --git a/README.md b/README.md index 3e0643b..9b7520b 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,12 @@ Only a CODEOWNER may request either variant. Starting it requires explicit confirmation for a manual run, followed in both cases by approval from a CODEOWNER through the protected `terraform-plan` environment. Administrator bypass is disabled. The workflow creates a fresh plan and applies -that exact saved plan. It rejects plans containing delete or replacement actions; -full destroy remains unsupported while STACKIT projects use soft deletion and -prevent their parent folders from being deleted immediately. Apply also fails -closed unless OpenTofu has initialized a persistent remote state backend. +that exact saved plan. Normal delete and replacement actions are allowed so the +landing zone can evolve, but deleting or replacing Resource Manager folders is +blocked. A full destroy workflow is deliberately not provided while STACKIT +projects use soft deletion and prevent their parent folders from being deleted +immediately. Apply also fails closed unless OpenTofu has initialized a persistent +remote state backend. Terraform state is stored in the versioned STACKIT Object Storage bucket `lza-terraform-state` under `landing-zone/terraform.tfstate`. The S3 backend uses