Saltar a contenido

🔁 CI/CD con Terraform: del plan comentado al apply aprobado

1. Anatomía del pipeline

PULL REQUEST                                           MERGE A MAIN
┌─ calidad ─────────────────────────┐                  ┌─ plan ─────────────────────┐
│ fmt -check · validate · tflint ·  │                  │ init (backend real, OIDC)  │
│ trivy config · terraform test     │                  │ plan -out=tfplan           │
└──────────────┬────────────────────┘                  │ artefacto: tfplan          │
       ┌───────┴────────┐                              └──────────────┬─────────────┘
┌─ plan (dev) ───┐ ┌─ integración (Topaz) ─┐           ┌─ aprobación ─┴─────────────┐
│ plan real,     │ │ apply + destroy en el │           │ environment con revisores  │
│ comentario en  │ │ emulador: sin nube,   │           └──────────────┬─────────────┘
│ la PR          │ │ sin secretos          │           ┌─ apply ──────┴─────────────┐
└────────────────┘ └───────────────────────┘           │ apply tfplan (el mismo)    │
       nada se aplica en Azure real                    │ un solo job a la vez       │
                                                       └────────────────────────────┘
PROGRAMADO: plan -detailed-exitcode cada noche → exit 2 = drift → issue / alerta
Regla Cómo se implementa Qué evita
Se aplica lo revisado plan -out=tfplan → artefacto → apply tfplan. Si el estado cambió entre medias: Saved plan is stale y el job falla Que el apply haga algo que nadie vio (el original volvía a planificar implícitamente si perdía el archivo)
Nunca dos applies a la vez Lock del backend (página 8) + concurrency / resource_group + -lock-timeout=10m Fallos por lock y colas descontroladas
Sin interacción -input=false en todo, TF_IN_AUTOMATION=true, -no-color en lo que se guarda Jobs colgados esperando una variable
Versiones fijadas required_version + versión en el instalador; .terraform.lock.hcl en Git; init sin -upgrade Que el runner use otro Terraform o provider que tu portátil
Mínimo privilegio Identidad de PR: Reader + blob del estado. Identidad de apply: Contributor acotado; solo desde main y el environment Que una PR de un fork despliegue en producción
El plan contiene secretos Artefacto con retención corta (1 día), nunca el tfplan en el comentario: solo terraform show -no-color Filtrar contraseñas por los logs (página 7)

2. Identidad sin secretos: OIDC en las tres plataformas

El pipeline presenta a Entra ID un token firmado por la plataforma; Entra lo cambia por un token de Azure si el issuer y el subject coinciden con una credencial federada de la identidad. No hay secreto que rotar ni que filtrar. El provider lo activa con ARM_USE_OIDC=true y el backend con ARM_USE_AZUREAD=true (páginas 6 y 7).

Plataforma Credencial federada (issuer · subject) Cómo llega el token al provider
GitHub Actions https://token.actions.githubusercontent.com · repo:org/repo:environment:dev (apply) y repo:org/repo:pull_request (plan) permissions: id-token: write; el provider lee ACTIONS_ID_TOKEN_REQUEST_* solo. Solo hace falta ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID (no son secretos: vars)
Azure DevOps Lo crea la service connection de tipo Workload identity federation Tarea AzureCLI@2 con addSpnToEnvironment: true expone $idToken, $servicePrincipalId, $tenantId → ARM_OIDC_TOKEN, ARM_CLIENT_ID, ARM_TENANT_ID
GitLab CI https://gitlab.com (o tu instancia) · project_path:grupo/repo:ref_type:branch:ref:main id_tokens: { GITLAB_OIDC_TOKEN: { aud: api://AzureADTokenExchange } } → ARM_OIDC_TOKEN=$GITLAB_OIDC_TOKEN
# Azure real: dos identidades, dos credenciales federadas (ejemplo GitHub)
az identity create -g rg-plataforma -n id-moodle-dev-plan  -o none
az identity create -g rg-plataforma -n id-moodle-dev-apply -o none
az identity federated-credential create --identity-name id-moodle-dev-plan  -g rg-plataforma -n gh-pr \
  --issuer https://token.actions.githubusercontent.com --subject "repo:org/moodle-infra:pull_request"     --audiences api://AzureADTokenExchange
az identity federated-credential create --identity-name id-moodle-dev-apply -g rg-plataforma -n gh-env-dev \
  --issuer https://token.actions.githubusercontent.com --subject "repo:org/moodle-infra:environment:dev"  --audiences api://AzureADTokenExchange
# Permisos: plan lee la suscripción y necesita escribir el lock del blob; apply gestiona el grupo
PLAN=$(az identity show -g rg-plataforma -n id-moodle-dev-plan  --query principalId -o tsv)
APPLY=$(az identity show -g rg-plataforma -n id-moodle-dev-apply --query principalId -o tsv)
az role assignment create --assignee-object-id $PLAN  --assignee-principal-type ServicePrincipal --role Reader --scope /subscriptions/$SUB -o none
az role assignment create --assignee-object-id $PLAN  --assignee-principal-type ServicePrincipal --role "Storage Blob Data Contributor" --scope $CONTAINER_ID -o none
az role assignment create --assignee-object-id $APPLY --assignee-principal-type ServicePrincipal --role Contributor --scope /subscriptions/$SUB/resourceGroups/rg-moodle-dev-001 -o none
az role assignment create --assignee-object-id $APPLY --assignee-principal-type ServicePrincipal --role "Storage Blob Data Contributor" --scope $CONTAINER_ID -o none

3. GitHub Actions

Dos workflows. El de PR nunca toca Azure real; el de main aplica solo tras aprobación en el environment.

# .github/workflows/pr.yml
name: pr
on:
  pull_request: { branches: [main] }
permissions: { contents: read, pull-requests: write, id-token: write }
concurrency: { group: pr-${{ github.event.number }}, cancel-in-progress: true }   # en PR sí se cancela: no aplica nada real
env: { TF_IN_AUTOMATION: "true", TF_INPUT: "0", TF_DIR: entornos/dev }
jobs:
  calidad:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "~1.10", terraform_wrapper: false }
      - run: terraform fmt -check -recursive -diff
      - run: terraform -chdir=$TF_DIR init -backend=false && terraform -chdir=$TF_DIR validate
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init && tflint --recursive
      - uses: aquasecurity/trivy-action@0.28.0
        with: { scan-type: config, scan-ref: ., exit-code: "1", severity: "HIGH,CRITICAL" }
      - run: for m in modules/*/; do (cd "$m" && terraform init -backend=false && terraform test); done

  integracion_topaz:                      # apply REAL contra el emulador: prueba el código de punta a punta sin nube
    needs: calidad
    runs-on: ubuntu-latest
    services:
      topaz:
        image: ${{ vars.TOPAZ_IMAGE }}    # la misma imagen con la que arrancaste Topaz en la [página 1](index.md#pagina-1)
        ports: ["8443:8443"]
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "~1.10", terraform_wrapper: false }
      - run: |                            # overlay: sin backend remoto y con el provider apuntando al emulador (localhost:8443)
          rm -f $TF_DIR/backend.tf && cp ci/topaz/providers.tf $TF_DIR/providers.tf
          terraform -chdir=$TF_DIR init
          terraform -chdir=$TF_DIR apply -auto-approve
          terraform -chdir=$TF_DIR plan -detailed-exitcode        # idempotencia: debe devolver 0
          terraform -chdir=$TF_DIR destroy -auto-approve

  plan_dev:                               # plan contra Azure real con la identidad de solo lectura
    needs: calidad
    runs-on: ubuntu-latest
    env:
      ARM_USE_OIDC: "true"
      ARM_USE_AZUREAD: "true"
      ARM_CLIENT_ID: ${{ vars.ARM_CLIENT_ID_PLAN }}
      ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "~1.10", terraform_wrapper: false }
      - run: terraform -chdir=$TF_DIR init -backend-config=backend.dev.hcl -lockfile=readonly
      - id: plan
        run: |                            # set +e: queremos el código de salida, no que falle el paso
          set +e
          terraform -chdir=$TF_DIR plan -out=tfplan -lock-timeout=5m -detailed-exitcode -no-color
          echo "exit=$?" >> "$GITHUB_OUTPUT"
      - run: terraform -chdir=$TF_DIR show -no-color tfplan > plan.txt   # texto, nunca el binario tfplan
      - uses: actions/github-script@v7       # comenta el plan en la PR (o lo actualiza si ya existe)
        env: { EXIT: "${{ steps.plan.outputs.exit }}" }
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('plan.txt', 'utf8').slice(0, 60000);
            const estado = { '0': '✅ Sin cambios', '2': '📝 Hay cambios', '1': '❌ Error' }[process.env.EXIT];
            const body = `### terraform plan · dev · ${estado}\n<details><summary>Ver plan</summary>\n\n\`\`\`hcl\n${plan}\n\`\`\`\n</details>`;
            const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: context.issue.number });
            const previo = comments.find(c => c.user.type === 'Bot' && c.body.startsWith('### terraform plan · dev'));
            previo ? await github.rest.issues.updateComment({ ...context.repo, comment_id: previo.id, body })
                   : await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
      - if: steps.plan.outputs.exit == '1'
        run: exit 1
# .github/workflows/deploy-dev.yml
name: deploy-dev
on:
  push: { branches: [main], paths: ["entornos/dev/**", "modules/**"] }
  workflow_dispatch:
permissions: { contents: read, id-token: write }
concurrency: { group: tfstate-moodle-dev, cancel-in-progress: false }   # cola, nunca cancelar un apply a medias
env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "0"
  TF_DIR: entornos/dev
  ARM_USE_OIDC: "true"
  ARM_USE_AZUREAD: "true"
  ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }}
jobs:
  plan:
    runs-on: ubuntu-latest
    env: { ARM_CLIENT_ID: "${{ vars.ARM_CLIENT_ID_PLAN }}" }
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "~1.10", terraform_wrapper: false }
      - run: terraform -chdir=$TF_DIR init -backend-config=backend.dev.hcl -lockfile=readonly
      - run: terraform -chdir=$TF_DIR plan -out=tfplan -lock-timeout=10m
      - run: terraform -chdir=$TF_DIR show -no-color tfplan > $GITHUB_STEP_SUMMARY   # el revisor lo lee aquí
      - uses: actions/upload-artifact@v4
        with: { name: tfplan, path: entornos/dev/tfplan, retention-days: 1, if-no-files-found: error }

  apply:
    needs: plan
    runs-on: ubuntu-latest
    environment: dev                       # revisores requeridos + secretos/vars del entorno; el subject OIDC es "environment:dev"
    env: { ARM_CLIENT_ID: "${{ vars.ARM_CLIENT_ID_APPLY }}" }
    steps:
      - uses: actions/checkout@v4          # mismo commit: el plan y el código coinciden
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "~1.10", terraform_wrapper: false }
      - uses: actions/download-artifact@v4
        with: { name: tfplan, path: entornos/dev }
      - run: terraform -chdir=$TF_DIR init -backend-config=backend.dev.hcl -lockfile=readonly
      - run: terraform -chdir=$TF_DIR apply -lock-timeout=10m tfplan     # sin -auto-approve: un plan guardado no pregunta

# .github/workflows/drift.yml — detección programada
on: { schedule: [{ cron: "0 5 * * *" }] }
# … mismos env que "plan"; el paso clave:
#   terraform -chdir=$TF_DIR plan -detailed-exitcode -lock=false -no-color > drift.txt; echo "exit=$?" >> $GITHUB_OUTPUT
#   exit 2 → abre (o actualiza) un issue "Drift en dev" con drift.txt; exit 1 → falla el job; exit 0 → nada

🔷 Por qué terraform_wrapper: false. El wrapper de setup-terraform captura stdout y el código de salida para exponerlos como outputs del paso; rompe -detailed-exitcode, las redirecciones a archivo y el -chdir con rutas relativas. Sin él, Terraform se comporta como en tu terminal.


4. Azure DevOps

Mismo flujo con stages: una service connection de tipo Workload identity federation (sin secreto), un environment con aprobaciones y exclusive lock, y el plan como pipeline artifact. La forma más robusta de pasar la identidad al provider es ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID: el provider pide el token él mismo y no caduca a los diez minutos, como pasa con el idToken de AzureCLI@2.

# azure-pipelines.yml
trigger:
  branches: { include: [main] }
  paths:    { include: [entornos/dev, modules] }
pr:
  branches: { include: [main] }
pool: { vmImage: ubuntu-latest }
lockBehavior: sequential                         # con el check "Exclusive lock" del environment: un run a la vez

variables:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "0"
  TF_DIR: entornos/dev
  ARM_USE_OIDC: "true"
  ARM_USE_AZUREAD: "true"
  ARM_TENANT_ID: $(TENANT_ID)                     # variable group, no secreta
  ARM_SUBSCRIPTION_ID: $(SUBSCRIPTION_ID)
  SYSTEM_ACCESSTOKEN: $(System.AccessToken)       # el provider lo usa junto a SYSTEM_OIDCREQUESTURI (automática)

stages:
- stage: calidad
  jobs:
  - job: calidad
    steps:
    - task: TerraformInstaller@1
      inputs: { terraformVersion: "1.10.5" }
    - script: terraform fmt -check -recursive -diff
    - script: terraform -chdir=$(TF_DIR) init -backend=false && terraform -chdir=$(TF_DIR) validate
    - script: for m in modules/*/; do (cd "$m" && terraform init -backend=false && terraform test); done

- stage: plan
  dependsOn: calidad
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - job: plan
    variables:
      ARM_CLIENT_ID: $(CLIENT_ID_PLAN)
      ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID: $(SC_PLAN_ID)   # id (GUID) de la service connection de plan
    steps:
    - task: TerraformInstaller@1
      inputs: { terraformVersion: "1.10.5" }
    - script: terraform -chdir=$(TF_DIR) init -backend-config=backend.dev.hcl -lockfile=readonly
    - script: terraform -chdir=$(TF_DIR) plan -out=tfplan -lock-timeout=10m
    - script: terraform -chdir=$(TF_DIR) show -no-color tfplan > $(Build.ArtifactStagingDirectory)/plan.txt
    - task: PublishPipelineArtifact@1
      inputs: { targetPath: $(TF_DIR)/tfplan, artifact: tfplan }

- stage: apply
  dependsOn: plan
  jobs:
  - deployment: apply
    environment: dev                                # aprobaciones + exclusive lock configurados en el environment
    variables:
      ARM_CLIENT_ID: $(CLIENT_ID_APPLY)
      ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID: $(SC_APPLY_ID)
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
          - task: TerraformInstaller@1
            inputs: { terraformVersion: "1.10.5" }
          - task: DownloadPipelineArtifact@2
            inputs: { artifact: tfplan, path: $(TF_DIR) }
          - script: terraform -chdir=$(TF_DIR) init -backend-config=backend.dev.hcl -lockfile=readonly
          - script: terraform -chdir=$(TF_DIR) apply -lock-timeout=10m tfplan

# Alternativa clásica si el provider es antiguo: AzureCLI@2 con addSpnToEnvironment: true expone $idToken →
#   env: { ARM_OIDC_TOKEN: $(idToken), ARM_CLIENT_ID: $(servicePrincipalId), ARM_TENANT_ID: $(tenantId) }
#   pero el token caduca (~10 min): un apply largo falla a mitad.

5. GitLab CI

El only: del original está deprecado en favor de rules:, y su job no tenía ninguna credencial: init habría fallado al abrir el backend. GitLab emite tokens OIDC con id_tokens; el resource_group serializa los jobs que tocan el mismo estado; el apply es manual y solo en main, sobre un protected environment.

# .gitlab-ci.yml
stages: [calidad, plan, apply]

default:
  image:
    name: hashicorp/terraform:1.10
    entrypoint: [""]                              # la imagen oficial tiene "terraform" como entrypoint: sin esto, "script" no arranca
  before_script:
    - export TF_IN_AUTOMATION=true TF_INPUT=0

variables:
  TF_DIR: entornos/dev
  ARM_USE_OIDC: "true"
  ARM_USE_AZUREAD: "true"
  ARM_TENANT_ID: $TENANT_ID
  ARM_SUBSCRIPTION_ID: $SUBSCRIPTION_ID

.oidc: &oidc
  id_tokens:
    GITLAB_OIDC_TOKEN: { aud: api://AzureADTokenExchange }
  before_script:
    - export TF_IN_AUTOMATION=true TF_INPUT=0 ARM_OIDC_TOKEN="$GITLAB_OIDC_TOKEN"

calidad:
  stage: calidad
  script:
    - terraform fmt -check -recursive -diff
    - terraform -chdir=$TF_DIR init -backend=false && terraform -chdir=$TF_DIR validate
    - for m in modules/*/; do (cd "$m" && terraform init -backend=false && terraform test); done
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

plan:
  stage: plan
  <<: *oidc
  variables: { ARM_CLIENT_ID: $CLIENT_ID_PLAN }      # credencial federada: project_path:…:ref_type:branch:ref:main
  resource_group: tfstate-moodle-dev
  script:
    - terraform -chdir=$TF_DIR init -backend-config=backend.dev.hcl -lockfile=readonly
    - terraform -chdir=$TF_DIR plan -out=tfplan -lock-timeout=10m
    - terraform -chdir=$TF_DIR show -no-color tfplan | tee plan.txt
    - terraform -chdir=$TF_DIR show -json tfplan > plan.json
  artifacts:
    paths: [$TF_DIR/tfplan, plan.txt]
    reports: { terraform: plan.json }                # GitLab muestra el resumen del plan en la MR
    expire_in: 1 day
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"   # en MR: el plan se ve, no se aplica
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  stage: apply
  <<: *oidc
  variables: { ARM_CLIENT_ID: $CLIENT_ID_APPLY }
  resource_group: tfstate-moodle-dev
  environment: { name: dev }                          # protected environment: solo aprobadores pueden lanzarlo
  needs: [plan]
  script:
    - terraform -chdir=$TF_DIR init -backend-config=backend.dev.hcl -lockfile=readonly
    - terraform -chdir=$TF_DIR apply -lock-timeout=10m tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual                                    # la aprobación es el "play"
      allow_failure: false
Concepto GitHub Actions Azure DevOps GitLab CI
Identidad sin secreto id-token: write + credencial federada por subject Service connection WIF + ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID id_tokens + ARM_OIDC_TOKEN
Aprobación environment con required reviewers Environment con Approvals Protected environment + when: manual
Un apply a la vez concurrency.group, cancel-in-progress: false Exclusive lock + lockBehavior: sequential resource_group
Plan como artefacto upload/download-artifact@v4, retention-days: 1 PublishPipelineArtifact@1 / DownloadPipelineArtifact@2 artifacts.paths, expire_in: 1 day
Plan visible al revisor Comentario en la PR / $GITHUB_STEP_SUMMARY plan.txt como artefacto; extensiones de marketplace para el resumen reports: terraform en la MR
Instalar Terraform hashicorp/setup-terraform@v3 (wrapper: false) TerraformInstaller@1 Imagen hashicorp/terraform con entrypoint: [""]

6. Laboratorio en Topaz

Sin cuenta en ninguna plataforma: reproducimos las fases del pipeline con un script, comprobamos los códigos de salida y provocamos los dos fallos que el diseño evita (plan obsoleto y drift). Al final, el mismo ci/local.sh es lo que ejecutaría el job integracion_topaz.

mkdir -p ~/moodle-infra/{entornos/dev,modules,ci/topaz,.github/workflows} && cd ~/moodle-infra && git init -q
cp -r ~/tf-mod/modules/red modules/                       # módulo de la [página 9](index.md#pagina-9)/11
cp ~/tf-st/providers.tf ci/topaz/providers.tf             # overlay del emulador: el pipeline lo copia encima en el job de integración
cat > entornos/dev/main.tf <<'EOF'
terraform {
  required_version = "~> 1.10"
  required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" } }
}
locals { tags = { proyecto = "moodle", entorno = "dev", gestion = "terraform" } }
resource "azurerm_resource_group" "moodle" {
  name     = "rg-moodle-dev-001"
  location = "eastus"
  tags     = local.tags
  lifecycle { ignore_changes = [tags] }
}
module "red" {
  source              = "../../modules/red"
  nombre              = "vnet-moodle-dev"
  resource_group_name = azurerm_resource_group.moodle.name
  location            = azurerm_resource_group.moodle.location
  address_space       = "10.100.0.0/16"
  subredes            = { web = "10.100.1.0/24", datos = "10.100.2.0/24" }
}
output "subnet_ids" { value = module.red.subnet_ids }
EOF
# backend.tf y backend.dev.hcl: los de la página 7 (en Topaz no se usan: el overlay los aparta)

# ─── 1. El script que ejecuta el pipeline (y tú) ────────────────────────────────
cat > ci/local.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export TF_IN_AUTOMATION=true TF_INPUT=0
DIR=entornos/dev
echo "── calidad"
terraform fmt -check -recursive -diff
terraform -chdir=$DIR init -backend=false -input=false >/dev/null && terraform -chdir=$DIR validate
command -v tflint >/dev/null && tflint --recursive || echo "(tflint no instalado: se omite)"
for m in modules/*/; do [ -d "$m/tests" ] && (cd "$m" && terraform init -backend=false >/dev/null && terraform test); done
echo "── integración en Topaz"
rm -f $DIR/backend.tf && cp ci/topaz/providers.tf $DIR/providers.tf       # overlay
trap 'terraform -chdir=$DIR destroy -auto-approve >/dev/null; rm -f $DIR/providers.tf; git checkout -q -- $DIR/backend.tf 2>/dev/null || true' EXIT
terraform -chdir=$DIR init -input=false >/dev/null
terraform -chdir=$DIR apply -auto-approve
terraform -chdir=$DIR plan -detailed-exitcode >/dev/null && echo "✅ idempotente (exit 0)"
EOF
chmod +x ci/local.sh && git add . && git commit -qm "infra inicial" && ./ci/local.sh

# ─── 2. Códigos de salida de -detailed-exitcode ─────────────────────────────────
cd entornos/dev && cp ../../ci/topaz/providers.tf . && terraform init >/dev/null && terraform apply -auto-approve >/dev/null
terraform plan -detailed-exitcode >/dev/null; echo "exit=$?"     # 0: sin cambios
az network vnet update -g rg-moodle-dev-001 -n vnet-moodle-dev --set tags.drift=si -o none    # alguien toca el portal
terraform plan -detailed-exitcode >/dev/null; echo "exit=$?"     # 2: hay cambios → el job de drift abriría un issue
echo 'resource "azurerm_subnet" "rota" {}' > rota.tf
terraform plan -detailed-exitcode >/dev/null 2>&1; echo "exit=$?"   # 1: error → el job falla
rm rota.tf

# ─── 3. El plan obsoleto: por qué se aplica el artefacto y no un plan nuevo ─────
terraform plan -out=tfplan -no-color | tail -3                    # "1 to change" (deshace la tag drift): esto es lo que "aprueba" el revisor
terraform apply -auto-approve -var-file=/dev/null >/dev/null      # entre medias, otro apply cambia el estado (simula otro pipeline)
terraform apply tfplan                                            # Error: Saved plan is stale — el estado ya no es el que vio el plan
terraform plan -detailed-exitcode >/dev/null; echo "exit=$?"     # 0: el "otro" apply ya aplicó el cambio; nada que hacer
rm -f tfplan

# ─── 4. El lock también en local ────────────────────────────────────────────────
terraform apply -auto-approve -lock-timeout=1s & sleep 0.3
terraform plan -lock-timeout=1s 2>&1 | grep -E "Error acquiring|Lock Info" ; wait   # el segundo espera 1 s y falla: lo mismo que en CI sin concurrency

# ─── 5. Pre-commit: lo que el pipeline rechazaría, que no llegue a la PR ────────
cd ~/moodle-infra
cat > .git/hooks/pre-commit <<'EOF'
#!/usr/bin/env bash
terraform fmt -check -recursive -diff || { echo "✗ ejecuta: terraform fmt -recursive"; exit 1; }
for d in entornos/*/; do terraform -chdir=$d init -backend=false -input=false >/dev/null && terraform -chdir=$d validate -no-color || exit 1; done
EOF
chmod +x .git/hooks/pre-commit
printf 'resource "azurerm_resource_group"   "mal" {\n  name="x"\n  location="eastus" }\n' >> entornos/dev/main.tf
git add -A && git commit -qm "prueba"                             # ✗ el hook lo rechaza por formato
git checkout -- entornos/dev/main.tf

# ─── 6. (Opcional) ejecutar el workflow de PR en local con act ──────────────────
# act pull_request -j calidad                                    # sin credenciales: fmt, validate, tflint, trivy, test
# act pull_request -j integracion_topaz --var TOPAZ_IMAGE=<imagen>   # levanta Topaz como service container en Docker

# ─── 7. Limpiar ────────────────────────────────────────────────────────────────
cd entornos/dev && terraform destroy -auto-approve && rm -f providers.tf terraform.tfstate*

🔷 Topaz como service container. El job integracion_topaz arranca la imagen del emulador junto al runner y el overlay ci/topaz/providers.tf apunta el provider a localhost:8443. Da algo que ningún plan ofrece: la certeza de que el código aplica y es idempotente, sin gastar ni exponer nada. Si el emulador tarda en arrancar, añade options: --health-cmd "curl -kf https://localhost:8443/…" --health-interval 5s al servicio para que el job espere. Publica la imagen en un registry al que el runner llegue (GHCR, ACR) y guárdala en la variable TOPAZ_IMAGE.


7. Errores comunes

⚠️ Solución de problemas

Mensaje o síntoma Causa y solución
Una PR desplegó en producción El workflow del original: on: pull_request + apply. El apply solo vive en el workflow de main, tras environment. Y la identidad de PR no debe tener permiso de escritura: aunque el YAML esté mal, Azure lo rechaza
Error building ARM Config / building account: could not acquire access token en init Las credenciales solo estaban en el paso apply (original). El backend las necesita en init y el provider en plan: env a nivel de job o workflow
AADSTS70021: No matching federated identity record found for presented assertion El subject del token no coincide con la credencial federada: el job de apply usa environment: dev (subject …:environment:dev), no …:ref:refs/heads/main. Compara con az identity federated-credential list
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable Falta permissions: id-token: write en el workflow o el job. En PRs desde forks no se emite: el plan_dev debe saltarse (if: github.event.pull_request.head.repo.full_name == github.repository)
Saved plan is stale El estado cambió entre plan y apply: otro pipeline, un apply manual, drift. Es la protección funcionando. Relanza el pipeline completo; revisa por qué hubo otro apply (¿falta concurrency?)
Error acquiring the state lock en CI Dos runs a la vez o un run cancelado que dejó el lease (página 8). concurrency/resource_group/exclusive lock con cola, cancel-in-progress: false en apply y -lock-timeout=10m
-detailed-exitcode devuelve siempre 0, o el > plan.txt sale vacío El wrapper de setup-terraform. terraform_wrapper: false
Error: Inconsistent dependency lock file … doesn't match any of the checksums El .terraform.lock.hcl se generó en macOS/Windows y el runner es Linux. terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 … y commit. Nunca -upgrade en CI
El job se queda colgado sin salida Terraform pide una variable o confirmación por consola. TF_INPUT=0 / -input=false; variables por TF_VAR_* o -var-file
Azure DevOps: apply largo falla con token expired / AADSTS700024 El idToken de AzureCLI@2 caduca a los ~10 min. Usa ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID + SYSTEM_ACCESSTOKEN: el provider renueva solo
GitLab: el job muere al instante con terraform: command not found o muestra la ayuda de Terraform La imagen hashicorp/terraform tiene terraform como entrypoint: los script se le pasan como argumentos. entrypoint: [""] en la definición de la imagen
GitLab: only/except is deprecated o el job corre en todas las ramas Sustituye only: por rules: con $CI_PIPELINE_SOURCE y $CI_COMMIT_BRANCH. Sin regla, un job se ejecuta siempre
El comentario del plan en la PR expone una contraseña Un valor sin sensitive = true, o un show -json (que sí destapa lo sensible). Marca el valor, usa show -no-color (texto) para el comentario y ephemeral donde sea posible; el artefacto tfplan nunca sale del pipeline
El job de PR de un fork falla en plan_dev Los forks no reciben vars, secrets ni token OIDC. Es correcto: solo calidad e integracion_topaz deben correr para forks. Condiciona plan_dev con if: sobre el repo de origen
El job integracion_topaz falla con connection refused en el primer init El emulador aún arrancaba. Añade options: --health-cmd … --health-interval 5s al servicio, o un paso de espera con curl -k --retry 20 --retry-connrefused
El apply de main aplica un plan de un commit anterior Dos pushes seguidos sin concurrency: el segundo plan pisó el artefacto del primero, o al revés. La cola por grupo garantiza que cada run planifica y aplica su propio commit
El job de drift falla cada noche con exit 1 en vez de 2 Exit 1 es error, no drift: normalmente el lock (otro run) o un provider caducado. -lock=false en el plan de solo lectura y revisa los logs; el drift real da 2
terraform_version: 1.5.7 del original Sin bloques import/removed, sin ephemeral, sin -or-create. Fija la versión con required_version y alinea el instalador; súbela de forma deliberada, no por accidente

8. Autoevaluación

  1. ¿Qué está mal en el workflow original de GitHub Actions y qué consecuencia tiene? Se dispara con on: pull_request y ejecuta apply: cada PR despliega en Azure antes de revisarse. Además, las credenciales solo estaban en el paso apply, así que init y plan fallarían antes.
  2. ¿Por qué se aplica el artefacto tfplan y no un plan nuevo en el job de apply? Para garantizar que lo aplicado es lo revisado. Si el estado cambió entre medias, Terraform falla con Saved plan is stale en vez de hacer algo que nadie vio.
  3. ¿Qué elimina OIDC respecto a ARM_CLIENT_SECRET? El secreto en sí: no hay nada que copiar, rotar ni filtrar. La plataforma firma un token y Entra ID lo acepta si el issuer y el subject coinciden con la credencial federada.
  4. ¿Por qué dos identidades (plan y apply) en lugar de una? La de PR solo lee (Reader + blob del estado): aunque el YAML tenga un error, Azure rechaza cualquier escritura. La de apply solo se obtiene desde main con el subject del environment.
  5. ¿Qué significan los códigos 0, 1 y 2 de plan -detailed-exitcode? 0: sin cambios. 1: error. 2: hay cambios. El job de drift trata el 2 como alerta y el 1 como fallo del propio job.
  6. ¿Qué aporta el lock del backend y qué aporta concurrency? ¿Sobra alguno? El lock impide la corrupción del estado; concurrency (o resource_group, exclusive lock) evita que dos runs lleguen a competir por él y forma una cola ordenada. Son complementarios: el lock es la última defensa, no la primera.
  7. ¿Por qué cancel-in-progress: true en el workflow de PR y false en el de apply? En PR nada se aplica en Azure real, así que cancelar un run viejo ahorra tiempo. Cancelar un apply a medias deja recursos parciales y el lock tomado.
  8. ¿Qué papel juega Topaz dentro del pipeline? Como service container en el job de PR permite un apply + plan -detailed-exitcode + destroy reales: prueba que el código aplica y es idempotente, sin suscripción ni secretos.
  9. ¿Por qué terraform_wrapper: false? El wrapper de setup-terraform intercepta stdout y el código de salida: rompe -detailed-exitcode, las redirecciones a archivo y -chdir.
  10. ¿Qué debe ir en el comentario de la PR y qué no? El texto de terraform show -no-color tfplan, recortado. Nunca el binario tfplan ni el show -json, que contienen los valores sensibles en claro.
  11. ¿Por qué .terraform.lock.hcl va en Git y qué pasa si se generó en un Mac? Fija los checksums de los providers para que el runner use exactamente los mismos. Si solo tiene hashes de darwin_arm64, en Linux falla: terraform providers lock -platform=… para varias plataformas.

9. Referencias