Módulo 5: Apply On Merge The Cd Half
6. Detección de drift programada
Descripción
Hasta ahora, cada pieza de este pipeline confía en un supuesto: que la única forma de cambiar la infraestructura de Andes Cargo es a través de apply.yml. Esa confianza puede romperse en cualquier momento —alguien con acceso a la consola, o a awslocal directamente, cambia algo a mano, por urgencia o por error— y ni ci.yml ni apply.yml se enterarían, porque ninguno de los dos corre a menos que alguien abra un Pull Request o fusione uno. Esta lección construye drift.yml, el workflow que vigila esa brecha: un terraform plan de solo lectura, corrido de forma periódica, que compara lo que Terraform cree que existe contra lo que existe de verdad.
Conexión con el módulo
Esta lección retoma la sintaxis cron: que ya viste en el Módulo 2 (lección 4) —no la repite desde cero, la aplica a un caso real—. La lección 7 corre el job de drift.yml a mano, con act workflow_dispatch, y muestra cómo se leería un drift real si lo hubiera. La lección 8 —el proyecto de este módulo— integra drift.yml en el pipeline completo de Andes Cargo.
Analogía: el guardia que hace la ronda, no la cámara que graba todo
ci.yml y apply.yml son como una cerradura electrónica: reaccionan a un evento específico (alguien intenta abrir la puerta) y actúan en el momento. drift.yml es distinto — es el guardia que hace una ronda a una hora fija, revisando que todo siga como debería estar, sin que nadie lo haya llamado. No previene que alguien entre por una ventana lateral (eso requeriría vigilancia constante, fuera del alcance de esta guía); lo que sí hace es garantizar que, como máximo unas horas después de que algo cambie sin pasar por la puerta principal, alguien se entere.
La sintaxis cron:, aplicada al caso real
Ya conoces la sintaxis completa desde el Módulo 2 (lección 4): cinco campos, siempre en UTC, con un mínimo de 5 minutos entre corridas. Para Andes Cargo, la decisión es una corrida diaria, a una hora de bajo tráfico:
name: drift-detection
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
"0 6 * * *" — todos los días, a las 6:00 AM UTC. workflow_dispatch acompaña a schedule, por la misma razón que ya viste en el Módulo 2: es la puerta de escape para correr el mismo chequeo a mano, sin esperar al horario programado — exactamente lo que vas a usar en la lección 7.
El job completo: terraform plan de solo lectura
jobs:
check-drift:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_ENDPOINT_URL: http://host.docker.internal:4566
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.8"
terraform_wrapper: false
- name: Terraform init
run: terraform init -input=false
- name: Install tflocal
run: pip3 install --quiet --break-system-packages terraform-local
- name: Terraform plan (read-only drift check)
id: drift_plan
run: |
set +e
tflocal plan -input=false -no-color -detailed-exitcode | tee drift-output.txt
echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Report drift status
run: |
{
echo "## Drift check — andes-cargo-infra"
echo "Triggered by: ${{ github.event_name }}"
case "${{ steps.drift_plan.outputs.exitcode }}" in
0) echo "Result: no drift detected. Infrastructure matches the state." ;;
2) echo "Result: DRIFT DETECTED. Terraform found differences between the state and reality." ;;
*) echo "Result: the plan itself failed (exit code ${{ steps.drift_plan.outputs.exitcode }}) — see the step above." ;;
esac
} >> "$GITHUB_STEP_SUMMARY"
Cuatro piezas nuevas que vale la pena entender antes de correrlo:
-detailed-exitcode, el flag que convierte un plan en una señal
Hasta ahora, cada terraform plan de esta guía terminó con código de salida 0 si corrió sin errores —sin importar si encontró cambios o no—. -detailed-exitcode cambia esa semántica a algo mucho más útil para automatizar una decisión:
| Código de salida | Significado |
|---|---|
0 | El plan corrió sin errores, y no encontró ningún cambio — la infraestructura coincide exactamente con lo que Terraform espera. |
1 | El plan falló —un error real, de sintaxis, de conexión, de lo que sea—. |
2 | El plan corrió sin errores, y sí encontró cambios — exactamente la señal que este job necesita para decidir si hay drift. |
Sin este flag, no habría ninguna forma de distinguir, por código de salida, "todo en orden" de "hay una diferencia" — ambos casos devolverían 0.
terraform_wrapper: false, un hallazgo real
Aquí aparece un detalle verificado hoy, corriendo esta guía, con el mismo espíritu que el Módulo 3 (lección 6) aplicó a skip_requesting_account_id. hashicorp/setup-terraform@v3 envuelve, por defecto (terraform_wrapper: true, el valor implícito si no lo escribes), cada invocación de terraform con un script propio que captura stdout/stderr/exitcode como salidas del step —una comodidad real, que ya usaste sin pensarlo en el Módulo 3—. El problema, confirmado probando ambas configuraciones sobre este mismo job: ese wrapper no propaga correctamente el código 2 de -detailed-exitcode — es un comportamiento documentado del propio proyecto (hashicorp/setup-terraform, issue #9), no una suposición de esta guía.
# Con terraform_wrapper: true (el valor por defecto, SIN el flag de esta lección)
Qué esperar (literal, verificado hoy, con el wrapper en su valor por defecto):
[drift-detection/check-drift] ✅ Success - Main Terraform plan (read-only drift check) [18.905710542s]
[drift-detection/check-drift] ⚙ ::set-output:: exitcode=0
exitcode=0 —a pesar de que el plan sí encontró cambios (los doce recursos por crear, en este momento del proyecto)—. El wrapper reportó éxito plano, sin distinguir "sin cambios" de "con cambios". Con terraform_wrapper: false, el mismo comando, sobre el mismo plan:
Qué esperar (literal, verificado hoy, con terraform_wrapper: false como en el YAML de esta lección):
[drift-detection/check-drift] ✅ Success - Main Terraform plan (read-only drift check) [17.596187875s]
[drift-detection/check-drift] ⚙ ::set-output:: exitcode=2
exitcode=2 — el valor correcto, el que el step siguiente necesita para reportar "drift detectado" de verdad. Sin terraform_wrapper: false, este job compilaría y correría sin ningún error visible, pero reportaría "sin drift" incluso cuando sí lo hay — el tipo de fallo silencioso más peligroso que existe en un sistema de monitoreo: uno que nunca avisa.
${PIPESTATUS[0]}, no $?
tflocal plan ... | tee drift-output.txt es un pipe — dos comandos conectados. $?, inmediatamente después, refleja el código de salida del último comando del pipe (tee, que casi siempre termina en 0, incluso si tflocal falló), no el de tflocal. ${PIPESTATUS[0]} es un arreglo de bash que guarda el código de salida de cada comando del pipe por separado — [0] es, específicamente, el primero: tflocal, el que de verdad importa aquí.
set +e, para que un exitcode=2 no tumbe el job
El shell por defecto de un run: en GitHub Actions —y en act— corre con la opción -e activa: cualquier comando que termine con código distinto de 0 detiene el script inmediatamente. Como -detailed-exitcode usa 2 como una señal válida (no un error), set +e desactiva ese comportamiento dentro de este step específico, para que el script siga hasta la línea que guarda el código en $GITHUB_OUTPUT, sin que el job se marque como fallido solo por encontrar un cambio legítimo.
Ejecutándolo: el temporizador (representativo) frente al job (ejecutado)
La misma distinción exacta que ya estableciste en el Módulo 2 (lección 4), ahora con el archivo real de Andes Cargo:
1. El disparo real del cron: a las 6:00 AM UTC — representativo, por la misma razón de siempre. Ninguna lección escrita, leída en cualquier momento del día, puede "esperar" hasta una hora específica del reloj UTC para mostrarte una corrida genuinamente disparada por el paso del tiempo.
2. El job que ese cron: dispararía — ejecutado, ahora mismo, con act schedule.
act schedule -W .github/workflows/drift.yml
Qué esperar (literal, extracto — el event_name confirma que act sintetiza el mismo contexto que tendría una corrida real de schedule):
[drift-detection/check-drift] ⭐ Run Set up job
[drift-detection/check-drift] ⭐ Run Main Check out andes-cargo-infra
[drift-detection/check-drift] ✅ Success - Main Check out andes-cargo-infra [42.181834ms]
[drift-detection/check-drift] ⭐ Run Main Set up Terraform
[drift-detection/check-drift] ✅ Success - Main Set up Terraform [3.112824208s]
[drift-detection/check-drift] ⭐ Run Main Terraform init
[drift-detection/check-drift] | Terraform has been successfully initialized!
[drift-detection/check-drift] ✅ Success - Main Terraform init [33.162088417s]
[drift-detection/check-drift] ⭐ Run Main Install tflocal
[drift-detection/check-drift] ✅ Success - Main Install tflocal [3.144388042s]
[drift-detection/check-drift] ⭐ Run Main Terraform plan (read-only drift check)
[drift-detection/check-drift] | Plan: 12 to add, 0 to change, 0 to destroy.
[drift-detection/check-drift] ✅ Success - Main Terraform plan (read-only drift check) [17.596187875s]
[drift-detection/check-drift] ⚙ ::set-output:: exitcode=2
[drift-detection/check-drift] ⭐ Run Main Report drift status
[drift-detection/check-drift] ✅ Success - Main Report drift status [102.771083ms]
[drift-detection/check-drift] ⚙ Summary - ## Drift check — andes-cargo-infra
Triggered by: schedule
Result: DRIFT DETECTED. Terraform found differences between the state and reality.
[drift-detection/check-drift] 🏁 Job succeeded
Lee este resultado con precisión, para no confundirlo con drift real. Plan: 12 to add es el mismo plan de siempre —doce recursos por crear, porque en esta máquina nunca se completó un apply real contra LocalStack (Módulo 5, lección 4)—. El job reporta, correctamente, "DRIFT DETECTED" porque sí hay una diferencia entre el state (vacío) y lo que el HCL describe — mecánicamente correcto, aunque en este momento específico del proyecto la causa no es que alguien haya cambiado algo por fuera de Terraform, sino que todavía no existe ningún apply exitoso del cual desviarse. La lección 7 completa el cuadro: qué verías si, en cambio, la infraestructura sí existiera y alguien la hubiera tocado a mano.
Errores comunes
Olvidar terraform_wrapper: false y confiar en un exitcode que siempre da 0 (el hallazgo central de esta lección). Qué pasa: alguien copia el hashicorp/setup-terraform@v3 de ci.yml (que no necesita este flag, porque nunca usa -detailed-exitcode) directamente a drift.yml, sin agregar terraform_wrapper: false. Cómo detectarlo: el job siempre reporta "no drift detected", incluso cuando el plan claramente muestra cambios en su salida de texto. Cómo corregirlo: confirma que el step Set up Terraform de drift.yml, específicamente, incluye terraform_wrapper: false — es la única diferencia real entre el hashicorp/setup-terraform de ci.yml/apply.yml y el de este archivo.
Usar $? en vez de ${PIPESTATUS[0]} después de un pipe con tee (de sintaxis, silencioso). Qué pasa: alguien escribe echo "exitcode=$?" >> "$GITHUB_OUTPUT" inmediatamente después de tflocal plan | tee archivo, esperando capturar el código de tflocal. Cómo detectarlo: el valor capturado casi siempre es 0, porque tee casi nunca falla, sin importar qué haya hecho tflocal. Cómo corregirlo: usa ${PIPESTATUS[0]}, el arreglo de bash que preserva el código de salida de cada comando del pipe por separado.
Interpretar "Plan: 12 to add" en este job como un error del workflow (conceptual, revisita el Módulo 3). Qué pasa: alguien ve drift.yml reportando "DRIFT DETECTED" y asume que algo está mal configurado, porque esperaba ver "no drift" en un proyecto recién creado. Cómo corregirlo: como ya explicó esta lección, en este momento específico del proyecto —sin ningún apply real completado— cualquier plan, incluido uno de creación completa, cuenta como "cambios presentes" para -detailed-exitcode. No es un error; es la consecuencia honesta de correr detección de drift sobre una infraestructura que nunca terminó de aplicarse en esta máquina.
Ejercicios
Ejercicio 1 — Explica -detailed-exitcode sin usar la palabra "flag". En dos frases, explica a un colega qué problema resuelve -detailed-exitcode que un terraform plan normal no resuelve.
Ver solución
Una respuesta completa suena, más o menos, así: "Un terraform plan normal termina con éxito (código 0) tanto si encuentra cambios como si no encuentra ninguno — no hay forma de que un script automatizado distinga los dos casos solo mirando si el comando falló o no. -detailed-exitcode separa esos dos resultados en códigos distintos (0 sin cambios, 2 con cambios), lo que permite que un job como drift.yml tome una decisión automática —reportar drift o no— sin tener que analizar el texto completo de la salida."
Ejercicio 2 — Reproduce el hallazgo de terraform_wrapper de memoria. Sin mirar esta lección, explica qué verías en el exitcode reportado por hashicorp/setup-terraform si dejaras terraform_wrapper en su valor por defecto (true) sobre un plan que sí encuentra cambios.
Ver solución
Verías exitcode=0, incorrectamente — el wrapper por defecto de hashicorp/setup-terraform@v3 no propaga el valor 2 de -detailed-exitcode de forma confiable, un comportamiento documentado (issue #9 del propio repositorio). El job seguiría corriendo "sin errores" según el wrapper, pero cualquier lógica que dependa de distinguir 0 de 2 —como el case de Report drift status— tomaría la rama equivocada, reportando "no drift" incluso si sí lo hay.
Ejercicio 3 — Justifica por qué drift.yml usa workflow_dispatch además de schedule, en tus propias palabras. Ya viste esta justificación en el Módulo 2 (lección 4) para un ejemplo genérico — ahora aplícala específicamente al caso de Andes Cargo.
Ver solución
Una respuesta completa suena, más o menos, así: "Si alguien en el equipo de Andes Cargo sospecha que un cambio reciente en la consola de AWS —o, en este laboratorio, un awslocal corrido a mano— pudo haber tocado algo fuera de Terraform, no tiene sentido esperar hasta las 6:00 AM del día siguiente para confirmarlo. workflow_dispatch le da a cualquier persona con acceso al repositorio la posibilidad de correr exactamente el mismo chequeo de drift en ese momento, bajo demanda, sin tocar ni esperar el cron: programado."
Resumen y siguiente paso
En esta lección construiste drift.yml completo: on: schedule con cron: "0 6 * * *" junto a workflow_dispatch, y un job que corre terraform plan -detailed-exitcode de solo lectura para detectar diferencias entre el state y la infraestructura real. Verificaste, corriendo ambas configuraciones, un hallazgo real y documentado —hashicorp/setup-terraform@v3 necesita terraform_wrapper: false para propagar correctamente el código 2— y confirmaste, con act schedule, que el job corre de punta a punta, aunque el temporizador real del cron: sigue siendo, por naturaleza, representativo.
Antes de avanzar deberías poder: explicar los tres códigos de salida de -detailed-exitcode; escribir de memoria por qué este job necesita terraform_wrapper: false cuando ci.yml/apply.yml no lo necesitan; y distinguir, con precisión, "el job corrió" de "el cron disparó a las 6 AM" como dos afirmaciones completamente distintas.
La lección 7 —manos a la obra— corre este mismo job con act workflow_dispatch, y muestra, con la honestidad exacta que corresponde, cómo se leería un drift real si la infraestructura de Andes Cargo existiera de verdad.
Recursos
- Terraform Docs — Command: plan (
-detailed-exitcode) — referencia oficial del flag central de esta lección. - GitHub — hashicorp/setup-terraform, issue #9 — el reporte documentado sobre el wrapper y
-detailed-exitcode, la fuente del hallazgo de esta lección. - GitHub Docs — Events that trigger workflows:
schedule— documentación oficial decron:, ya citada en el Módulo 2. - Módulo 2 de esta guía (
04-the-schedule-trigger-and-cron-syntax.md) — la sintaxiscron:completa y la distinción entre "el temporizador" y "el job", asumida y no repetida aquí.