🔁 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 desetup-terraformcaptura stdout y el código de salida para exponerlos como outputs del paso; rompe-detailed-exitcode, las redirecciones a archivo y el-chdircon 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_topazarranca la imagen del emulador junto al runner y el overlayci/topaz/providers.tfapunta el provider alocalhost:8443. Da algo que ningúnplanofrece: la certeza de que el código aplica y es idempotente, sin gastar ni exponer nada. Si el emulador tarda en arrancar, añadeoptions: --health-cmd "curl -kf https://localhost:8443/…" --health-interval 5sal servicio para que el job espere. Publica la imagen en un registry al que el runner llegue (GHCR, ACR) y guárdala en la variableTOPAZ_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 demain, trasenvironment. Y la identidad de PR no debe tener permiso de escritura: aunque el YAML esté mal, Azure lo rechazaError building ARM Config / building account: could not acquire access token en initLas credenciales solo estaban en el paso apply(original). El backend las necesita eninity el provider enplan:enva nivel de job o workflowAADSTS70021: No matching federated identity record found for presented assertion El subjectdel token no coincide con la credencial federada: el job de apply usaenvironment: dev(subject…:environment:dev), no…:ref:refs/heads/main. Compara conaz identity federated-credential listUnable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable Falta permissions: id-token: writeen el workflow o el job. En PRs desde forks no se emite: elplan_devdebe saltarse (if: github.event.pull_request.head.repo.full_name == github.repository)Saved plan is stale El estado cambió entre planyapply: otro pipeline, un apply manual, drift. Es la protección funcionando. Relanza el pipeline completo; revisa por qué hubo otro apply (¿faltaconcurrency?)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: falseen apply y-lock-timeout=10m-detailed-exitcodedevuelve siempre 0, o el> plan.txtsale vacíoEl wrapper de setup-terraform.terraform_wrapper: falseError: Inconsistent dependency lock file … doesn't match any of the checksums El .terraform.lock.hclse generó en macOS/Windows y el runner es Linux.terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 …y commit. Nunca-upgradeen CIEl job se queda colgado sin salida Terraform pide una variable o confirmación por consola. TF_INPUT=0/-input=false; variables porTF_VAR_*o-var-fileAzure DevOps: applylargo falla con token expired / AADSTS700024El idTokendeAzureCLI@2caduca a los ~10 min. UsaARM_ADO_PIPELINE_SERVICE_CONNECTION_ID+SYSTEM_ACCESSTOKEN: el provider renueva soloGitLab: el job muere al instante con terraform: command not found o muestra la ayuda de Terraform La imagen hashicorp/terraformtieneterraformcomo entrypoint: losscriptse le pasan como argumentos.entrypoint: [""]en la definición de la imagenGitLab: only/except is deprecated o el job corre en todas las ramas Sustituye only:porrules:con$CI_PIPELINE_SOURCEy$CI_COMMIT_BRANCH. Sin regla, un job se ejecuta siempreEl comentario del plan en la PR expone una contraseña Un valor sin sensitive = true, o unshow -json(que sí destapa lo sensible). Marca el valor, usashow -no-color(texto) para el comentario yephemeraldonde sea posible; el artefactotfplannunca sale del pipelineEl job de PR de un fork falla en plan_devLos forks no reciben vars,secretsni token OIDC. Es correcto: solocalidadeintegracion_topazdeben correr para forks. Condicionaplan_devconif:sobre el repo de origenEl job integracion_topazfalla con connection refused en el primerinitEl emulador aún arrancaba. Añade options: --health-cmd … --health-interval 5sal servicio, o un paso de espera concurl -k --retry 20 --retry-connrefusedEl applydemainaplica un plan de un commit anteriorDos pushes seguidos sin concurrency: el segundoplanpisó el artefacto del primero, o al revés. La cola por grupo garantiza que cada run planifica y aplica su propio commitEl 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=falseen el plan de solo lectura y revisa los logs; el drift real da 2terraform_version: 1.5.7 del original Sin bloques import/removed, sinephemeral, sin-or-create. Fija la versión conrequired_versiony alinea el instalador; súbela de forma deliberada, no por accidente
8. Autoevaluación¶
- ¿Qué está mal en el workflow original de GitHub Actions y qué consecuencia tiene?
Se dispara con
on: pull_requesty ejecutaapply: cada PR despliega en Azure antes de revisarse. Además, las credenciales solo estaban en el pasoapply, así queinityplanfallarían antes. - ¿Por qué se aplica el artefacto
tfplany 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. - ¿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. - ¿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
maincon el subject delenvironment. - ¿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. - ¿Qué aporta el lock del backend y qué aporta
concurrency? ¿Sobra alguno? El lock impide la corrupción del estado;concurrency(oresource_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. - ¿Por qué
cancel-in-progress: trueen el workflow de PR yfalseen el de apply? En PR nada se aplica en Azure real, así que cancelar un run viejo ahorra tiempo. Cancelar unapplya medias deja recursos parciales y el lock tomado. - ¿Qué papel juega Topaz dentro del pipeline?
Como service container en el job de PR permite un
apply+plan -detailed-exitcode+destroyreales: prueba que el código aplica y es idempotente, sin suscripción ni secretos. - ¿Por qué
terraform_wrapper: false? El wrapper desetup-terraformintercepta stdout y el código de salida: rompe-detailed-exitcode, las redirecciones a archivo y-chdir. - ¿Qué debe ir en el comentario de la PR y qué no?
El texto de
terraform show -no-color tfplan, recortado. Nunca el binariotfplanni elshow -json, que contienen los valores sensibles en claro. - ¿Por qué
.terraform.lock.hclva 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 dedarwin_arm64, en Linux falla:terraform providers lock -platform=…para varias plataformas.
9. Referencias¶
- Running Terraform in automation (
-input=false,TF_IN_AUTOMATION, plan guardado) y-detailed-exitcode - Provider azurerm: autenticación OIDC (GitHub, Azure DevOps con
ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID, token genérico) y backend azurerm conuse_oidc/use_azuread_auth - Workload Identity Federation en Entra ID y conectar GitHub Actions a Azure con OIDC
- hashicorp/setup-terraform (
terraform_wrapper), environments con revisores, concurrency, service containers y OIDC en GitHub Actions - Azure DevOps: service connections con Workload identity federation, aprobaciones y exclusive lock y TerraformInstaller@1
- GitLab CI: OIDC con Azure,
id_tokens,resource_group, protected environments yreports: terraform - TFLint, Trivy config, Terraform Test y act (ejecutar workflows de GitHub en local)
terraform providers lock(lockfile multiplataforma) y variablesephemeral- Azure Local Emulator (Topaz)