Módulo 3: The Iac Pipeline Fmt Validate Plan
4. Manos a la obra: `fmt` y `validate` como pasos que pueden fallar el job
Descripción
Esta es la primera lección de este módulo donde escribes ci.yml de verdad, dentro de andes-cargo-infra/, y lo corres con act. Vas a construir los dos primeros steps —terraform fmt -check y terraform validate— confirmar que pasan sobre el HCL heredado de terraform-and-iac-guide, y después vas a romper ese HCL a propósito, dos veces: primero un error de formato, después un error de sintaxis/semántica — para ver, con tus propios ojos, el job de act fallar en rojo, leer el mensaje exacto que produce cada uno, corregirlo, y confirmar que vuelve a pasar en verde. Cada bloque "Qué esperar" de esta lección es salida literal, ejecutada hoy, para escribir esta lección.
Conexión con el módulo
La lección 3 te dejó con Terraform instalable en cualquier corrida (hashicorp/setup-terraform@v3, pineado a 1.15.8). Esta lección usa exactamente esa pieza para dar el primer paso real de ci.yml: dos verificaciones baratas, rápidas, que no tocan ningún servicio de AWS/LocalStack —por eso van primero, según lo que ya razonaste en el Ejercicio 1 de la lección 1—. La lección 5 sigue construyendo sobre este mismo archivo, agregando la conexión hacia LocalStack.
Punto de partida: andes-cargo-infra/ ya tiene el HCL real
Antes de escribir una sola línea de YAML, confirma algo importante: tu copia de andes-cargo-infra/ —la que preparaste en el Módulo 1 y extendiste en el Módulo 2— ya trae, heredado sin cambios de terraform-and-iac-guide, el HCL completo del capstone de esa guía: versions.tf (required_version = ">= 1.15.0", provider AWS ~> 6.0), providers.tf (mínimo, listo para tflocal), variables.tf/locals.tf/outputs.tf, y los cuatro archivos de recursos —s3.tf (bucket andes-cargo-shipment-docs), iam.tf (roles LambdaManifestProcessorRole y AppServerRole), lambda.tf (función process-shipment-manifest), dynamodb.tf (tabla Shipments)— más los módulos reutilizables en modules/s3-bucket/ y modules/iam-role/. Esta lección no reescribe ni una línea de ese HCL de negocio; el trabajo entero es el pipeline que lo corre.
cd andes-cargo-infra
git log --oneline -- s3.tf
Qué esperar (confirma que el HCL de negocio ya está en tu historial, heredado, no algo que esta lección introduce):
ce6efa5 Bootstrap CI/CD layer: initialize git repository, .actrc, and .github/workflows/
Paso 1 — El esqueleto de ci.yml
.github/workflows/ci.yml:
name: ci
on:
pull_request:
branches: [main]
jobs:
terraform-checks:
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"
- name: Terraform format check
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -input=false
- name: Terraform validate
run: terraform validate
Tres decisiones que vale la pena nombrar antes de correrlo:
on: pull_request: branches: [main]— el disparador exacto que la lección 2 justificó: este workflow calcula y muestra, nunca aplica, y solo tiene sentido sobre una propuesta de cambio contramain. Recuerda el hallazgo del Módulo 2 (lección 3):actno evalúabranches:— vas a poder disparar este job con cualquier eventopull_request, sin que el filtro de rama bloquee la simulación; el filtro sigue siendo correcto para GitHub real.enva nivel de job, conAWS_ENDPOINT_URL: http://host.docker.internal:4566— adelantado de la lección 5. Esta variable no le hace nada todavía afmt/validate(ninguno de los dos habla con AWS/LocalStack), pero la dejas declarada desde ahora porque el resto del job —awslocal, y en la lección 6,tflocal— sí la necesita, y así el bloqueenvcompleto queda a la vista desde el principio.terraform fmt -check -recursive— el flag-recursivees necesario porque este proyecto tiene HCL dentro demodules/, no solo en la raíz; sin él,fmt -checksolo miraría los archivos del directorio actual.
Paso 2 — Confirmando el baseline: todo en verde
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección):
[ci/terraform-checks] ⭐ Run Set up job
[ci/terraform-checks] 🚀 Start image=catthehacker/ubuntu:act-la***
[ci/terraform-checks] ✅ Success - Set up job
[ci/terraform-checks] ☁ git clone 'https://github.com/hashicorp/setup-terraform' # ref=v3
[ci/terraform-checks] ⭐ Run Main Check out andes-cargo-infra
[ci/terraform-checks] 🐳 docker cp src=/ruta/a/andes-cargo-infra/. dst=/ruta/a/andes-cargo-infra
[ci/terraform-checks] ✅ Success - Main Check out andes-cargo-infra [26.727958ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks] 🐳 docker cp src=/Users/.../hashicorp-setup-terraform@v3/ dst=/var/run/act/actions/hashicorp-setup-terraform@v3/
[ci/terraform-checks] | [command]/usr/bin/unzip -o -q /tmp/fda1f22d-b719-47ed-9f10-b233363c2e89
[ci/terraform-checks] ✅ Success - Main Set up Terraform [2.362979916s]
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[ci/terraform-checks] ✅ Success - Main Terraform format check [141.175208ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/3] user= workdir=
[ci/terraform-checks] | Initializing the backend...
[ci/terraform-checks] |
[ci/terraform-checks] | Initializing modules...
[ci/terraform-checks] | - lambda_manifest_processor_role in modules/iam-role
[ci/terraform-checks] | - shipment_docs_bucket in modules/s3-bucket
[ci/terraform-checks] | - app_server_role in modules/iam-role
[ci/terraform-checks] |
[ci/terraform-checks] | Initializing provider plugins...
[ci/terraform-checks] | - Finding hashicorp/aws versions matching "~> 6.0"...
[ci/terraform-checks] | - Finding hashicorp/archive versions matching "~> 2.0"...
[ci/terraform-checks] | - Installing hashicorp/aws v6.60.0...
[ci/terraform-checks] | - Installed hashicorp/aws v6.60.0 (signed by HashiCorp)
[ci/terraform-checks] | - Installing hashicorp/archive v2.8.0...
[ci/terraform-checks] | - Installed hashicorp/archive v2.8.0 (signed by HashiCorp)
[ci/terraform-checks] |
[ci/terraform-checks] | Terraform has been successfully initialized!
[ci/terraform-checks] ✅ Success - Main Terraform init [12.343412291s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [1.924798541s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks] ✅ Success - Complete job
[ci/terraform-checks] 🏁 Job succeeded
Fíjate en dos cosas que confirman ideas de lecciones anteriores, con tus propios ojos: - Installing hashicorp/aws v6.60.0... es el runner descargando el provider de nuevo, dentro de este contenedor efímero, aunque tu laptop ya tiene ese mismo provider instalado desde terraform-and-iac-guide — exactamente la lección 3 de este módulo, confirmada en la práctica. Y git clone 'https://github.com/hashicorp/setup-terraform' es act resolviendo el tag @v3 la primera vez que lo usa en esta máquina —lo vas a ver una sola vez, con act cacheando la Action localmente para corridas siguientes—.
Paso 3 — Rompiendo el formato a propósito
Abre iam.tf y desalinea, a mano, los signos = del primer bloque module:
module "lambda_manifest_processor_role" {
source = "./modules/iam-role"
role_name = "LambdaManifestProcessorRole"
trust_policy_json = data.aws_iam_policy_document.lambda_trust.json
permissions_policy_json = data.aws_iam_policy_document.lambda_permissions.json
tags = local.common_tags
}
(La versión correcta, que tenías antes, alinea los cuatro signos = a la misma columna, calculada por la clave más larga del bloque — permissions_policy_json. Fíjate que el HCL sigue siendo sintácticamente válido con esta desalineación: Terraform lo entendería igual. Es puramente una cuestión de estilo, y es exactamente lo que fmt -check existe para atrapar.)
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección — el job falla en rojo):
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[ci/terraform-checks] | iam.tf
[ci/terraform-checks] ❗ ::error::Terraform exited with code 3.
[ci/terraform-checks] ❌ Failure - Main Terraform format check [137.46625ms]
[ci/terraform-checks] exitcode '1': failure
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks] ✅ Success - Complete job
[ci/terraform-checks] 🏁 Job failed
Error: Job 'terraform-checks' failed
Lee esto con atención: terraform fmt -check no imprime un diff completo por defecto —solo el nombre del archivo que no cumple el formato esperado (iam.tf)— y sale con código 3 (el código específico que Terraform usa para "hay diferencias de formato", distinto del 1 genérico de un error). act traduce ese código de salida distinto de cero en un job fallido, y —esto es la parte más importante— ningún step posterior corrió: ni terraform init, ni terraform validate. El job se detiene en el primer step que falla, exactamente el comportamiento que hace valioso poner fmt -check primero: barato, rápido, y si falla, no gastas tiempo en pasos más caros.
Paso 4 — Corrigiendo el formato
terraform fmt iam.tf
Esto reescribe el archivo, realineando los = automáticamente —el mismo comando que corriste, sin -check, en terraform-and-iac-guide cada vez que Terraform te avisaba de un archivo mal formateado—. Vuelve a correr el job:
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección — fmt pasa, y ahora sí llega hasta validate):
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks] ✅ Success - Main Terraform format check [138.0015ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks] ✅ Success - Main Terraform init [10.937006333s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [1.800934209s]
[ci/terraform-checks] 🏁 Job succeeded
Paso 5 — Rompiendo la validación a propósito
fmt solo mira estilo — nunca detectaría, por ejemplo, una referencia a algo que no existe. Para eso está validate. Rompe algo distinto, un typo real en un nombre de variable local, en el segundo bloque module de iam.tf:
module "app_server_role" {
source = "./modules/iam-role"
role_name = "AppServerRole"
trust_policy_json = data.aws_iam_policy_document.ec2_trust.json
permissions_policy_json = data.aws_iam_policy_document.app_server_permissions.json
tags = local.common_tagz
}
(Fíjate: local.common_tagz, con "z" en vez de "s" — un typo de una sola letra, del tipo que se comete escribiendo rápido y que fmt jamás detectaría, porque el HCL sigue perfectamente bien formateado.)
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección — fmt pasa, init pasa, validate falla con un mensaje que te dice exactamente qué está mal):
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks] | ╷
[ci/terraform-checks] | │ Error: Reference to undeclared local value
[ci/terraform-checks] | │
[ci/terraform-checks] | │ on iam.tf line 74, in module "app_server_role":
[ci/terraform-checks] | │ 74: tags = local.common_tagz
[ci/terraform-checks] | │
[ci/terraform-checks] | │ A local value with the name "common_tagz" has not been declared. Did you
[ci/terraform-checks] | │ mean "common_tags"?
[ci/terraform-checks] | ╵
[ci/terraform-checks] ❗ ::error::Terraform exited with code 1.
[ci/terraform-checks] ❌ Failure - Main Terraform validate [1.822465458s]
[ci/terraform-checks] exitcode '1': failure
[ci/terraform-checks] 🏁 Job failed
Error: Job 'terraform-checks' failed
Este es un mensaje de error de una calidad que vale la pena señalar: Terraform no solo dice "algo está mal" — te da el archivo y la línea exactos (iam.tf línea 74), reproduce la línea completa, y hasta sugiere la corrección ("Did you mean "common_tags"?"), porque el nombre que escribiste se parece mucho a uno que sí existe. Fíjate también que fmt -check y terraform init corrieron ambos en verde antes de que validate fallara — la falta de formato de antes ya no existe (la corregiste en el Paso 4), y el problema nuevo es de un tipo completamente distinto: no de estilo, de referencia a algo inexistente.
Paso 6 — Corrigiendo y revalidando
sed -i '' 's/local.common_tagz/local.common_tags/' iam.tf
(O corrígelo a mano en tu editor — la "z" de vuelta a "s".)
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección — de vuelta a verde, de punta a punta):
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [1.7687455s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks] ✅ Success - Complete job
[ci/terraform-checks] 🏁 Job succeeded
Commitea ci.yml —el primer artefacto nuevo de este módulo—:
git add .github/workflows/ci.yml
git commit -m "Add ci.yml: terraform fmt and validate as CI steps"
Qué esperar (representativo en el hash, literal en el mensaje):
[main ba47438] Add ci.yml: terraform fmt and validate as CI steps
Profundización: por qué dos verificaciones distintas, no una sola
fmt -check y validate prueban cosas que no se solapan, y esta lección lo demostró en la práctica: el error del Paso 3 (desalineación de =) es válido para validate —Terraform lo entiende perfectamente— pero inválido para fmt -check. El error del Paso 5 (common_tagz) es exactamente lo opuesto: perfectamente formateado, pero semánticamente roto. Ninguno de los dos comandos, por sí solo, atrapa ambos tipos de problema — necesitas los dos, en ese orden (barato primero), para cubrir las dos categorías de "esto está mal" que un cambio de HCL puede introducir antes de que un terraform plan siquiera se calcule.
Errores comunes
Error: Could not find any stages to run al pedir el evento equivocado (recap del Módulo 2, relevante otra vez aquí). Qué pasa: alguien corre act push en vez de act pull_request sobre ci.yml, que solo escucha pull_request. Cómo detectarlo: el mensaje exacto Could not find any stages to run, ya visto en el Módulo 1/lección 7 y el Módulo 2/lección 3. Cómo corregirlo: revisa act -l — la columna Events de ci.yml dice pull_request, no push.
docker: Error response from daemon al arrancar act sin Docker corriendo. Qué pasa: Docker Desktop (o el daemon de Docker) no está activo cuando corres cualquier comando de act. Cómo detectarlo: el mensaje menciona no poder conectar al daemon de Docker, no un error de Terraform ni de YAML. Cómo corregirlo: confirma con docker ps que el daemon responde antes de correr act — el mismo chequeo del Módulo 1, lección 6.
Confundir el código de salida 3 de fmt -check con un error genérico (de diagnóstico). Qué pasa: alguien ve Terraform exited with code 3 y busca ese código como si fuera un error desconocido o un bug de la herramienta. Cómo detectarlo: el código específico 3, junto con el nombre de un archivo impreso justo antes (sin ningún mensaje de "Error:"). Cómo corregirlo: código 3 es la señal específica de terraform fmt -check para "hay archivos sin formatear" — no es un fallo de la herramienta, es exactamente el resultado que -check está diseñado para producir cuando encuentra algo. Corre terraform fmt -diff <archivo> localmente para ver exactamente qué cambiaría, antes de aplicar terraform fmt sin -check.
Ejercicios
Ejercicio 1 — Predice cuál step falla primero. Si iam.tf tuviera, al mismo tiempo, el error de formato del Paso 3 y el error de validación del Paso 5, ¿qué step de ci.yml fallaría, y cuáles no llegarían a correr?
Ver solución
Fallaría Terraform format check primero, porque es el primer step en el orden del archivo que toca ese error. Terraform init y Terraform validate no llegarían a correr en absoluto —ni para confirmar que están bien, ni para revelar el segundo error—, porque act (como GitHub Actions real) detiene el job en el primer step que falla, salvo que se declare explícitamente continue-on-error: true en ese step (algo que este ci.yml no hace para fmt/validate, a propósito: ambos son bloqueantes). Solo después de corregir el error de formato y volver a correr, validate tendría la oportunidad de revelar el segundo error.
Ejercicio 2 — Explica por qué el mensaje de validate es más útil que el de fmt -check. Compara los dos mensajes de error que viste en esta lección. ¿Por qué validate te da más información que fmt -check sobre qué exactamente está mal?
Ver solución
fmt -check solo necesita decirte qué archivo no cumple el formato esperado —el archivo completo se puede corregir automáticamente con terraform fmt, sin que hiciera falta señalar una línea específica—. validate, en cambio, está evaluando la semántica del HCL —si una referencia existe, si un tipo es correcto—, un tipo de error que no se puede "corregir automáticamente" sin saber la intención de quien escribió el código; por eso necesita señalar el archivo, la línea exacta, reproducir el código, y hasta sugerir una corrección probable, porque solo un humano (o quien escribió el cambio) puede confirmar cuál era la intención real.
Ejercicio 3 — Decide si un tercer tipo de error existiría. Terraform también tiene un comando terraform plan que puede fallar por razones que ni fmt ni validate detectan (por ejemplo, un recurso que ya existe con el mismo nombre en el proveedor real). ¿Por qué fmt/validate no atrapan ese tipo de problema, según lo que aprendiste sobre qué evalúa cada uno?
Ver solución
Ni fmt ni validate hablan con ningún proveedor real (AWS/LocalStack) — ambos trabajan exclusivamente sobre el texto del HCL, sin ninguna llamada de red. Un conflicto contra el estado real de la infraestructura (un recurso que ya existe, un límite de cuenta alcanzado, un permiso insuficiente) solo se puede descubrir en el momento en que Terraform sí habla con el proveedor — exactamente lo que hace terraform plan, el tema de la lección 6 de este módulo. Es, literalmente, la tercera categoría de verificación que completa el trío: estilo (fmt), coherencia interna (validate), y coherencia contra el mundo real (plan).
Resumen y siguiente paso
En esta lección construiste los dos primeros steps reales de ci.yml —terraform fmt -check y terraform validate— y los viste fallar en rojo, dos veces, por razones distintas: un error de formato (código 3, sin más detalle que el nombre del archivo) y un error de referencia (mensaje completo, con archivo, línea, y sugerencia de corrección). Confirmaste que un job de act se detiene en el primer step que falla, y que corregir cada error —terraform fmt para el primero, editar el HCL a mano para el segundo— vuelve el job a verde, de punta a punta.
Antes de avanzar deberías poder: explicar qué tipo de error atrapa fmt -check frente a validate, con un ejemplo de cada uno; predecir qué steps de un job corren y cuáles no cuando uno falla en el medio; y leer el código de salida 3 de terraform fmt -check sin confundirlo con un error genérico.
La lección 5 sigue construyendo sobre este mismo ci.yml, agregando la pieza de red que conecta el contenedor del job con el LocalStack que corre en tu host — el prerequisito directo del terraform plan real que llega en la lección 6.
Recursos
- Terraform Docs — Command: fmt — referencia oficial de
terraform fmt, incluido el flag-checky sus códigos de salida. - Terraform Docs — Command: validate — referencia oficial de
terraform validate. - nektosact.com — User Guide — documentación de
act pull_request -e, usada en cada corrida de esta lección. terraform-and-iac-guide(NIEVA) — el HCL de Andes Cargo que esta lección corre por primera vez dentro de un pipeline, sin reescribir una sola línea de negocio.