IaC-Pipeline: Vom Commit zur Infrastruktur
Im IaC-Lab wird noch vom Laptop aus gebaut. Diese Seite macht daraus eine Pipeline, in der niemand mehr direkt terraform apply ausführt. Jede Änderung läuft als Merge Request mit Plan, Prüfung und Freigabe durch. Es gibt keine gespeicherten Passwörter oder Client Secrets: Die Pipeline meldet sich per OpenID Connect (Workload Identity Federation) bei Azure an.
Zurück zur IaC-Übersicht
Ablauf
Feature-Branch ──► Merge Request
│ validate → security → plan (Lese-Identität)
│ Plan-Zusammenfassung im MR-Widget
▼ Review + Freigabe (Vier-Augen-Prinzip)
main
│ validate → security → image* → plan → apply (Schreib-Identität, manuell)
▼
Azure ◄── nächtlich: Drift-Check (Lese-Identität)
* nur wenn sich packer/ oder ansible/ geändert hat
| Stufe | Was passiert | Bricht ab bei |
|---|---|---|
| validate | terraform fmt, validate, tflint, packer validate | Formatfehler, Syntaxfehler, Lint-Befunden |
| security | Checkov prüft den Terraform-Code gegen Sicherheitsregeln | Befunden ohne dokumentierte Ausnahme |
| image | Packer baut eine neue Image-Version (nur auf main) | Build-Fehler, bereits existierender Version |
| plan | terraform plan je Schicht, Ergebnis als Artefakt | Fehlern in Code oder Berechtigungen |
| apply | Anwenden genau des geprüften Plans, manuell ausgelöst | Plan veraltet (State hat sich geändert) |
| drift | Nächtlicher plan -detailed-exitcode | Jeder Abweichung zwischen Code und Azure |
Sicherheitskonzept: zwei Identitäten
Der wichtigste Baustein steckt nicht in der Pipeline-Datei, sondern in Entra ID. Es gibt zwei App-Registrierungen, und Entra entscheidet anhand des signierten GitLab-Tokens, welche Pipeline welche davon nutzen darf.
| Identität | Rechte in Azure | Wer darf sie nutzen |
|---|---|---|
sp-iaclab-plan | Reader auf der Subscription, Storage Blob Data Reader auf dem State | Pipelines jedes Branches dieses Projekts |
sp-iaclab-apply | Contributor auf der Subscription, Storage Blob Data Contributor auf dem State | Nur Pipelines auf main |
Die Konsequenz: Selbst wenn jemand in einem Feature-Branch die .gitlab-ci.yml so ändert, dass sie die Apply-Identität anfordert, lehnt Entra den Token ab. Der Token trägt im sub-Claim den Branch-Namen, und die Apply-Identität vertraut ausschließlich main. Wer main ändern darf, regelt der Branch-Schutz in GitLab.
Weil terraform plan ohne Schreibrechte keine State-Sperre setzen kann, läuft der Plan mit -lock=false. Das ist unkritisch, weil ein Plan nichts verändert und apply einen veralteten Plan ohnehin ablehnt.
Hinweis zum Plan im Merge Request: Damit die Plan-Identität für beliebige Branch-Namen gilt, braucht sie einen Platzhalter im sub-Claim. Das geht nur mit Flexible Federated Identity Credentials, die Microsoft derzeit als Preview führt. Wer keine Preview-Funktion einsetzen möchte, vergibt der Plan-Identität einen normalen Federated Credential für main. Dann entsteht der Plan erst nach dem Merge, wird aber weiterhin vor dem manuellen Apply geprüft.
Unveränderliche Subjects: Der sub-Claim sollte mit der GitLab-Projekt-ID beginnen (project_id:…) statt mit dem Projektpfad. Ein Pfad kann nach Umbenennen oder Löschen neu vergeben werden, eine Projekt-ID nicht. GitLab bietet dafür eine Projekteinstellung (Dokumentation: „Use the project ID as the subject“).
Repository-Ergänzungen
iac-lab-azure/ ├── .gitlab-ci.yml # Pipeline ├── .tflint.hcl # Lint-Regeln ├── .checkov.yaml # Security-Ausnahmen mit Begründung ├── backend.hcl # aus dem Lab, jetzt eingecheckt (enthält keine Geheimnisse) ├── bootstrap/ │ ├── backend.sh │ └── ci-identities.sh # Identitäten + Federated Credentials anlegen ├── ci/ │ └── Dockerfile # Werkzeug-Image für alle Jobs ├── packer/ │ ├── VERSION # z. B. 1.1.0 – Änderung löst Image-Build aus │ └── web.pkr.hcl ├── base/ … ├── workload/ … └── ansible/ …
Die .terraform.lock.hcl beider Schichten gehört ebenfalls ins Repository, damit Pipeline und Laptop exakt dieselben Provider-Versionen verwenden.
Schritt 1: Identitäten anlegen
Einmalig mit einem Konto ausführen, das App-Registrierungen anlegen und Rollen vergeben darf.
- ci-identities.sh
#!/usr/bin/env bash set -euo pipefail GITLAB_URL="https://gitlab.com" # oder eigene GitLab-Instanz, ohne abschließenden / PROJECT_ID="12345678" # Projekt-ID aus GitLab (Einstellungen → Allgemein) SUB_ID=$(az account show --query id -o tsv) TENANT_ID=$(az account show --query tenantId -o tsv) SA=$(awk -F'"' '/storage_account_name/ {print $2}' "$(dirname "$0")/../backend.hcl") SA_ID=$(az storage account show -n "$SA" -g rg-iac-tfstate --query id -o tsv) create_app() { local app_id app_id=$(az ad app create --display-name "$1" --query appId -o tsv) az ad sp create --id "$app_id" -o none echo "$app_id" } PLAN_APP=$(create_app sp-iaclab-plan) APPLY_APP=$(create_app sp-iaclab-apply) echo "Warte auf Propagierung der Service Principals ..." sleep 30 # --- Rollen --------------------------------------------------------------- az role assignment create --assignee "$PLAN_APP" --role "Reader" \ --scope "/subscriptions/$SUB_ID" -o none az role assignment create --assignee "$PLAN_APP" --role "Storage Blob Data Reader" \ --scope "$SA_ID" -o none az role assignment create --assignee "$APPLY_APP" --role "Contributor" \ --scope "/subscriptions/$SUB_ID" -o none az role assignment create --assignee "$APPLY_APP" --role "Storage Blob Data Contributor" \ --scope "$SA_ID" -o none # --- Apply: normaler Federated Credential, nur Branch main ----------------- az ad app federated-credential create --id "$APPLY_APP" --parameters "{ \"name\": \"gitlab-main\", \"issuer\": \"$GITLAB_URL\", \"subject\": \"project_id:$PROJECT_ID:ref_type:branch:ref:main\", \"audiences\": [\"api://AzureADTokenExchange\"] }" # --- Plan: Flexible Federated Credential (Preview), alle Branches ---------- # Ausdruck vor dem Einsatz mit der aktuellen Microsoft-Doku zu # "Flexible federated identity credentials" abgleichen. PLAN_OBJ=$(az ad app show --id "$PLAN_APP" --query id -o tsv) az rest --method POST \ --uri "https://graph.microsoft.com/beta/applications/$PLAN_OBJ/federatedIdentityCredentials" \ --headers "Content-Type=application/json" \ --body "{ \"name\": \"gitlab-all-branches\", \"issuer\": \"$GITLAB_URL\", \"audiences\": [\"api://AzureADTokenExchange\"], \"claimsMatchingExpression\": { \"value\": \"claims['sub'] matches 'project_id:$PROJECT_ID:ref_type:branch:ref:*' and claims['project_id'] eq '$PROJECT_ID'\", \"languageVersion\": 1 } }" cat <<EOF In GitLab unter Einstellungen → CI/CD → Variablen eintragen: ARM_TENANT_ID = $TENANT_ID ARM_SUBSCRIPTION_ID = $SUB_ID AZURE_CLIENT_ID_PLAN = $PLAN_APP AZURE_CLIENT_ID_APPLY = $APPLY_APP SSH_PUBLIC_KEY = (Typ "Datei", Inhalt des öffentlichen Notfall-Schlüssels) EOF
Keine dieser Variablen ist ein Geheimnis. IDs allein erlauben keine Anmeldung; dafür braucht es den von GitLab signierten Token der passenden Pipeline.
Für das Lab reicht Contributor auf der Subscription, weil Terraform und Packer Ressourcengruppen anlegen. In Produktion gehört die Apply-Identität auf die tatsächlich verwalteten Ressourcengruppen beschränkt, ggf. ergänzt um eine eigene Identität für Packer.
Schritt 2: Werkzeug-Image
Alle Jobs laufen in einem eigenen Container-Image mit Terraform, Packer, Azure CLI, Ansible, Checkov und TFLint. Damit ist die Werkzeugkette selbst versioniert und reproduzierbar. Mehr zu Images unter Docker.
- Dockerfile
FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl gnupg lsb-release git jq unzip \ python3 python3-venv openssh-client \ && curl -fsSL https://apt.releases.hashicorp.com/gpg \ | gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg \ && echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \ > /etc/apt/sources.list.d/hashicorp.list \ && apt-get update \ && apt-get install -y --no-install-recommends terraform packer \ && curl -sL https://aka.ms/InstallAzureCLIDeb | bash \ && rm -rf /var/lib/apt/lists/* RUN python3 -m venv /opt/venv \ && /opt/venv/bin/pip install --no-cache-dir ansible-core checkov ENV PATH="/opt/venv/bin:${PATH}" RUN curl -sL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
docker build -t registry.gitlab.com/<gruppe>/iac-lab-azure/iac-runner:latest ci/ docker push registry.gitlab.com/<gruppe>/iac-lab-azure/iac-runner:latest
Für den Produktivbetrieb die Werkzeugversionen im Dockerfile fest pinnen und das Image mit Datum oder Version taggen statt latest.
Schritt 3: Prüfregeln
- .tflint.hcl
plugin "terraform" { enabled = true preset = "recommended" } # Optional: tflint-ruleset-azurerm ergänzen, prüft z. B. ungültige VM-Größen
- .checkov.yaml
framework: - terraform compact: true quiet: true # Jede Ausnahme ist eine dokumentierte Risikoakzeptanz: # Check-ID, Begründung, Verantwortlicher, Ablaufdatum. skip-check: [] # - CKV_AZURE_XXX # Lab: HTTP statt HTTPS, akzeptiert von A. R.-C. bis 31.12.
Beim ersten Lauf meldet Checkov im Lab-Code erwartungsgemäß Befunde, etwa HTTP statt HTTPS am Load Balancer. Jeder Befund wird entweder im Code behoben oder begründet in .checkov.yaml eingetragen. So entsteht nebenbei ein nachvollziehbares Register akzeptierter Risiken.
Schritt 4: Die Pipeline
- .gitlab-ci.yml
stages: [validate, security, image, plan, apply, drift] default: image: $CI_REGISTRY_IMAGE/iac-runner:latest variables: ARM_USE_OIDC: "true" TF_IN_AUTOMATION: "true" TF_INPUT: "false" TF_VAR_ssh_public_key_path: $SSH_PUBLIC_KEY # Datei-Variable -> Pfad # Pipelines nur für Merge Requests, Pushes auf main und Zeitpläne workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_PIPELINE_SOURCE == "schedule" # ---------------------------------------------------------------- Vorlagen .rules_mr_and_main: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" .id_plan: id_tokens: AZURE_ID_TOKEN: aud: api://AzureADTokenExchange variables: ARM_CLIENT_ID: $AZURE_CLIENT_ID_PLAN before_script: - export ARM_OIDC_TOKEN="$AZURE_ID_TOKEN" .id_apply: id_tokens: AZURE_ID_TOKEN: aud: api://AzureADTokenExchange variables: ARM_CLIENT_ID: $AZURE_CLIENT_ID_APPLY before_script: - export ARM_OIDC_TOKEN="$AZURE_ID_TOKEN" .tf_init: &tf_init - cd "$STACK" - terraform init -backend-config=../backend.hcl -backend-config="key=lab/${STACK}.tfstate" # ---------------------------------------------------------------- validate fmt: stage: validate extends: .rules_mr_and_main script: - terraform fmt -check -recursive -diff validate: stage: validate extends: .rules_mr_and_main parallel: matrix: - STACK: [base, workload] script: - cd "$STACK" - terraform init -backend=false - terraform validate - tflint --init --config=../.tflint.hcl - tflint --config=../.tflint.hcl packer:validate: stage: validate extends: .rules_mr_and_main script: - cd packer - packer init . - packer validate -var image_version=0.0.0 . # ---------------------------------------------------------------- security checkov: stage: security extends: .rules_mr_and_main script: - checkov -d . # ---------------------------------------------------------------- image image:build: stage: image extends: .id_apply script: - az login --service-principal -u "$ARM_CLIENT_ID" -t "$ARM_TENANT_ID" --federated-token "$AZURE_ID_TOKEN" -o none - az account set -s "$ARM_SUBSCRIPTION_ID" - cd packer - packer init . - packer build -color=false -var image_version="$(cat VERSION)" . rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" changes: [packer/**/*, ansible/**/*] # ---------------------------------------------------------------- plan .plan: stage: plan extends: [.id_plan, .rules_mr_and_main] script: - *tf_init - terraform plan -lock=false -out=tfplan - terraform show -no-color tfplan > plan.txt - | terraform show -json tfplan | jq -r '([.resource_changes[]?.change.actions?]|flatten)|{"create":(map(select(.=="create"))|length),"update":(map(select(.=="update"))|length),"delete":(map(select(.=="delete"))|length)}' > plan.json artifacts: paths: ["$STACK/tfplan", "$STACK/plan.txt"] reports: terraform: "$STACK/plan.json" expire_in: 1 week plan:base: extends: .plan variables: STACK: base plan:workload: extends: .plan variables: STACK: workload # ---------------------------------------------------------------- apply .apply: stage: apply extends: .id_apply environment: name: lab script: - *tf_init - terraform apply tfplan rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" when: manual apply:base: extends: .apply resource_group: tf-base variables: STACK: base needs: ["plan:base"] apply:workload: extends: .apply resource_group: tf-workload variables: STACK: workload needs: ["plan:workload", "apply:base"] # ---------------------------------------------------------------- drift drift: stage: drift extends: .id_plan parallel: matrix: - STACK: [base, workload] script: - *tf_init - rc=0; terraform plan -lock=false -detailed-exitcode || rc=$? - | if [ "$rc" -eq 2 ]; then echo "DRIFT in Schicht $STACK: Azure weicht vom Code ab." exit 1 fi exit "$rc" rules: - if: $CI_PIPELINE_SOURCE == "schedule"
Erläuterungen
id_tokens: GitLab stellt pro Job einen signierten OIDC-Token aus. Terraform tauscht ihn überARM_USE_OIDCundARM_OIDC_TOKENbei Entra gegen einen kurzlebigen Azure-Token. Das gilt auch für den Zugriff auf den State im Storage Account.- Plan-Artefakt:
applyführt exakt die Dateitfplanaus, die im selben Pipeline-Lauf erzeugt und geprüft wurde. Hat sich der State zwischenzeitlich geändert, verweigert Terraform die Ausführung. reports: terraform: GitLab zeigt die Anzahl anzulegender, zu ändernder und zu löschender Ressourcen direkt im Merge Request an. Den vollständigen Plan enthältplan.txt.resource_group: GitLab führt Apply-Jobs derselben Schicht nie parallel aus, auch nicht aus verschiedenen Pipelines.- Reihenfolge:
apply:workloadwartet aufapply:base. Ohne Änderungen an der Basis ist dieser Schritt ein Leerlauf, muss aber trotzdem bestätigt werden. - Artefakte sind sensibel: Plan-Dateien können Werte aus dem State enthalten. Zugriff auf Pipeline-Artefakte deshalb auf das Projektteam beschränken.
- Image-Build: Packer braucht vom Runner aus SSH-Zugriff auf die temporäre Build-VM. Mit den GitLab-Shared-Runnern funktioniert das; bei eigenen Runnern hinter Firewall ggf. die Build-VM in ein erreichbares Netz legen. Eine bereits existierende Image-Version lässt Azure nicht überschreiben – Images bleiben dadurch unveränderlich.
Schritt 5: GitLab absichern
Die Pipeline ist nur so sicher wie der Weg nach main.
mainals geschützten Branch einrichten: kein direkter Push, kein Force-Push, Merge nur durch Maintainer- Merge Requests benötigen mindestens eine Freigabe durch eine andere Person
CODEOWNERSfürbase/,.gitlab-ci.ymlundbootstrap/– Änderungen daran brauchen die Freigabe der Plattformverantwortlichen- Environment
labals geschützte Umgebung: nur berechtigte Personen dürfen den Apply-Job starten (Deployment-Freigaben sind je nach GitLab-Edition verfügbar) - Zeitplan unter Build → Pipeline-Zeitpläne anlegen, z. B. täglich 06:00 auf
main, und Benachrichtigung bei fehlgeschlagener Pipeline aktivieren
Arbeitsablauf im Alltag
Infrastruktur ändern (z. B. instance_count auf 5):
- Branch anlegen,
workload/terraform.tfvarsändern, pushen, Merge Request öffnen - Pipeline zeigt im MR: 0 anlegen, 1 ändern, 0 löschen
- Kollege prüft Code und
plan.txt, gibt frei - Merge → Pipeline auf
main→ Apply-Job manuell starten
Neues Image ausrollen (zwei Merge Requests):
- MR 1: Änderung im Ansible-Playbook +
packer/VERSIONauf1.1.0. Nach dem Merge baut die Pipeline die Image-Version. - MR 2:
image_version = „1.1.0“inworkload/terraform.tfvars. Der Plan zeigt ein Update des Scale Sets; nach dem Apply tauscht Azure die Instanzen batchweise aus.
Die Trennung in zwei MRs ist nötig, weil der Plan im ersten MR sonst eine Image-Version referenzieren würde, die noch nicht existiert.
Drift gemeldet: Die nächtliche Pipeline schlägt fehl. Entweder war die manuelle Änderung berechtigt, dann wird sie per MR in den Code übernommen. Oder sie war es nicht, dann setzt der nächste Apply den Soll-Zustand wieder durch. In beiden Fällen ist ein Ticket der richtige Ort für die Klärung.
Bezug zu ITIL
| ITIL-Praxis | Entsprechung in der Pipeline |
|---|---|
| Change Request | Merge Request mit Beschreibung |
| Auswirkungsanalyse | terraform plan und Checkov-Ergebnis |
| Change-Genehmigung | MR-Freigabe + manueller Apply auf geschützter Umgebung |
| Standard Change | Wiederkehrende, vorab bewertete Änderungen (z. B. Skalierung, Image-Rollout) |
| Change-Dokumentation | Git-Historie, Pipeline-Protokoll, Plan-Artefakt |
| Configuration Management | Terraform-State als maschinenlesbarer Ist-Zustand |
| Incident | Drift-Meldung oder fehlgeschlagener Health Check |
Details unter IaC und ITIL v4.
Variante: GitHub Actions
Das Konzept lässt sich eins zu eins übertragen. GitHub hat einen Vorteil beim Plan im Pull Request: Der sub-Claim lautet für alle Pull Requests fest repo:<org>/<repo>:pull_request. Dafür genügt ein normaler Federated Credential ohne Preview-Funktion.
| GitLab CI | GitHub Actions |
|---|---|
id_tokens | permissions: id-token: write |
| Subject Plan: Branch-Platzhalter | repo:<org>/<repo>:pull_request |
Subject Apply: …:ref:main | repo:<org>/<repo>:environment:lab |
| Geschützte Umgebung | Environment mit Required Reviewers |
resource_group | concurrency |
reports: terraform | Plan als PR-Kommentar, z. B. per actions/github-script |
- plan.yml
name: terraform-plan on: pull_request: permissions: id-token: write contents: read jobs: plan: runs-on: ubuntu-latest strategy: matrix: stack: [base, workload] env: ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID_PLAN }} ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} ARM_USE_OIDC: "true" steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - run: terraform -chdir=${{ matrix.stack }} init -backend-config=../backend.hcl -backend-config=key=lab/${{ matrix.stack }}.tfstate - run: terraform -chdir=${{ matrix.stack }} plan -lock=false -input=false
Bei GitHub Actions holt sich der azurerm-Provider den OIDC-Token selbst aus der Runner-Umgebung; ARM_OIDC_TOKEN muss nicht gesetzt werden.
Weiterführend
- IaC-Lab – die Infrastruktur, die diese Pipeline ausrollt
- Lebenszyklus – Rolling Updates, Blue/Green, Decommissioning
- IaC und ITIL v4 – Change Enablement mit Pipelines
