| FOTO | AUTO | EDV | AUDIO |

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 über ARM_USE_OIDC und ARM_OIDC_TOKEN bei Entra gegen einen kurzlebigen Azure-Token. Das gilt auch für den Zugriff auf den State im Storage Account.
  • Plan-Artefakt: apply führt exakt die Datei tfplan aus, 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ält plan.txt.
  • resource_group: GitLab führt Apply-Jobs derselben Schicht nie parallel aus, auch nicht aus verschiedenen Pipelines.
  • Reihenfolge: apply:workload wartet auf apply: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.

  • main als 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
  • CODEOWNERS für base/, .gitlab-ci.yml und bootstrap/ – Änderungen daran brauchen die Freigabe der Plattformverantwortlichen
  • Environment lab als 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):

  1. Branch anlegen, workload/terraform.tfvars ändern, pushen, Merge Request öffnen
  2. Pipeline zeigt im MR: 0 anlegen, 1 ändern, 0 löschen
  3. Kollege prüft Code und plan.txt, gibt frei
  4. Merge → Pipeline auf main → Apply-Job manuell starten

Neues Image ausrollen (zwei Merge Requests):

  1. MR 1: Änderung im Ansible-Playbook + packer/VERSION auf 1.1.0. Nach dem Merge baut die Pipeline die Image-Version.
  2. MR 2: image_version = „1.1.0“ in workload/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