Módulo 3: The Iac Pipeline Fmt Validate Plan

8. Proyecto: el `ci.yml` de Andes Cargo

Descripción

Este es el proyecto que cierra el Módulo 3. Las lecciones 4 a 7 construyeron ci.yml pieza por pieza —fmt, validate, la conexión a LocalStack, plan, la publicación como evidencia—, probando cada una por separado. Este proyecto las corre todas juntas, en un único archivo, de punta a punta, sobre un cambio real al HCL de Andes Cargo: exactamente el cambio que el pr-event.json del Módulo 2 anticipó desde el principio —"Add tags to the shipment documents bucket"—, ahora hecho realidad. Vas a agregar un tag nuevo al bucket andes-cargo-shipment-docs, correr act pull_request -e pr-event.json una última vez, y leer el ci.yml completo funcionando como lo haría en un Pull Request real: fmtinitvalidate → conexión a LocalStack → plan → resumen publicado.

Conexión con el módulo

Este proyecto no introduce ningún step nuevo — es la integración de todo lo que ya construiste. El ci.yml que corres aquí es exactamente el mismo archivo que construiste, línea por línea, en las lecciones 4 a 7. Lo único genuinamente nuevo es el cambio de negocio que lo dispara: la primera y única línea de HCL que este módulo agrega, mínima y con un propósito claro, coherente con la rama feature/add-shipment-tags que escribiste a mano en el Módulo 2, lección 6.


El ci.yml completo

.github/workflows/ci.yml, tal como queda al cerrar este módulo:

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

      - name: Install awslocal
        run: pip3 install --quiet --break-system-packages awscli awscli-local

      - name: Confirm the runner can reach LocalStack on the host
        continue-on-error: true
        run: awslocal s3 ls

      - name: Install tflocal
        run: pip3 install --quiet --break-system-packages terraform-local

      - name: Terraform plan
        run: tflocal plan -input=false -no-color | tee plan-output.txt

      - name: Publish the plan to the job summary
        run: |
          {
            echo "## Terraform plan — andes-cargo-infra"
            echo '```'
            cat plan-output.txt
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

Nueve steps, cada uno con un rol específico que ya conoces: verificar estilo, instalar Terraform, verificar sintaxis, confirmar el camino de red, calcular qué cambiaría, y publicar ese cálculo donde alguien lo pueda leer. Ninguno de los nueve, en ningún momento, escribe infraestructura real — el apply que sí lo haría llega recién en el Módulo 5.


El cambio real: agregar el tag Compliance al bucket de manifiestos

Recuerda el pr-event.json que escribiste en el Módulo 2, lección 6: PR #42, rama feature/add-shipment-tagsmain, título "Add tags to the shipment documents bucket". Hasta ahora, ese título describía una intención — este proyecto la hace real. Andes Cargo necesita marcar el bucket andes-cargo-shipment-docs con un tag de cumplimiento, para que herramientas de auditoría externas (fuera del alcance de esta guía) puedan identificar qué buckets están sujetos a una política de retención de manifiestos.

Abre s3.tf y modifica el bloque module "shipment_docs_bucket":

module "shipment_docs_bucket" {
  source = "./modules/s3-bucket"

  bucket_name        = var.bucket_name
  enable_versioning  = true
  bucket_policy_json = data.aws_iam_policy_document.require_https.json
  tags = merge(local.common_tags, {
    Compliance = "manifest-retention-required"
  })
}

El único cambio: tags = local.common_tags pasa a tags = merge(local.common_tags, { Compliance = "manifest-retention-required" }). merge() es una función nativa de Terraform que combina dos o más mapas en uno solo —aquí, toma los tres tags que ya venían de local.common_tags (Project, Environment, ManagedBy) y agrega un cuarto (Compliance) sin tocar los demás—. Es, literalmente, la única línea de HCL de negocio que este módulo entero agrega — coherente con lo que DISEÑO.md prometió desde el principio: automatizar el pipeline, no reescribir el proyecto.


Corriendo el ci.yml completo, de punta a punta

terraform fmt s3.tf
git diff --stat

Qué esperar (literal):

 s3.tf | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

Ahora corre el pipeline completo, exactamente como correría sobre un Pull Request real:

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

Qué esperar (salida literal, ejecutada para escribir esta lección — resumen de los nueve steps, en orden, con los tiempos reales de esta corrida):

[ci/terraform-checks] ⭐ Run Set up job
[ci/terraform-checks]   ✅  Success - Set up job
[ci/terraform-checks] ⭐ Run Main Check out andes-cargo-infra
[ci/terraform-checks]   ✅  Success - Main Check out andes-cargo-infra [25.3615ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks]   ✅  Success - Main Set up Terraform [2.306658833s]
[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] ⭐ Run Main Install awslocal
[ci/terraform-checks]   ✅  Success - Main Install awslocal [11.648304875s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks]   | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[ci/terraform-checks] Failed but continue next step
[ci/terraform-checks]   ❌  Failure - Main Confirm the runner can reach LocalStack on the host [8.406601667s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks]   ✅  Success - Main Install tflocal [2.2965225s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks]   | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks]   ✅  Success - Main Terraform plan [4.3687605s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks]   ✅  Success - Main Publish the plan to the job summary [70.591959ms]
[ci/terraform-checks]   ⚙  Summary - ## Terraform plan — andes-cargo-infra
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job succeeded

Plan: 12 to add, 0 to change, 0 to destroy. — el mismo conteo de recursos que la lección 6, porque el nuevo tag no crea ni destruye ningún recurso, solo cambia un atributo dentro de recursos que de todas formas se estaban creando por primera vez. Confírmalo buscando el tag dentro del plan completo:

grep -A1 "Compliance" plan-output.txt

Qué esperar (literal, dos apariciones — una en tags, otra en tags_all, del bloque aws_s3_bucket dentro de module.shipment_docs_bucket):

          + "Compliance"  = "manifest-retention-required"
          + "Environment" = "dev"
--
          + "Compliance"  = "manifest-retention-required"
          + "Environment" = "dev"

Esta es, literalmente, la evidencia que alguien revisando el Pull Request #42 vería en el resumen del job: un plan de 12 recursos, sin nada destruido, con el tag nuevo apareciendo exactamente donde el título del PR prometía que aparecería —en el bucket de manifiestos, no en ningún otro recurso—.


Commiteando el cambio

git add s3.tf
git commit -m "Add Compliance tag to the shipment-docs bucket"
git log --oneline

Qué esperar (representativo en los hashes de commit, literal en la estructura y en los mensajes — seis commits nuevos desde el cierre del Módulo 2, uno por cada pieza que este módulo agregó):

a10c9b7 Add Compliance tag to the shipment-docs bucket
7b6421e ci.yml: publish the plan to the job summary
dfcbfce ci.yml: install tflocal and run terraform plan
70472a2 ci.yml: confirm the runner can reach LocalStack via host.docker.internal
ba47438 Add ci.yml: terraform fmt and validate as CI steps
5094200 Add hello-andes-cargo.yml: first workflow reaching for LocalStack via host.docker.internal
4e4f5c5 Add .secrets (gitignored) and secrets-test workflows for act --secret-file / -s
b42e54d Add pr-event.json and print-event.yml to practice act -e
ce6efa5 Bootstrap CI/CD layer: initialize git repository, .actrc, and .github/workflows/
git status

Qué esperar (literal):

On branch main
nothing to commit, working tree clean

Cierre del Módulo 3

Completaste el módulo que construye la mitad de CI del pipeline de Andes Cargo. Repasa lo que te llevas:

  • El patrón, con fuente exacta: plan en Pull Request, apply en merge, en dos workflows separados —no una condición dentro de un archivo compartido—, citado del tutorial oficial de HashiCorp, con la razón de seguridad concreta (evitar la familia de vulnerabilidades "pwn request") explicada a fondo (lección 2).
  • Terraform, instalado en cada corrida: hashicorp/setup-terraform@v3, pineado a 1.15.8, confirmado con un comando que busca el binario antes y después de instalarlo — la prueba directa de que un runner no recuerda nada entre corridas, a diferencia de tu laptop (lección 3).
  • fmt y validate, vistos fallar y corregirse dos veces: un error de formato (código de salida 3, sin más detalle que el nombre del archivo) y un error de referencia (mensaje completo, con sugerencia de corrección) — cada uno atrapado por la herramienta correcta, en el orden correcto (lección 4).
  • La conexión a LocalStack, probada antes de confiar en ella: host.docker.internal:4566, con un fallo honesto (LocalStack apagado, 11 segundos de reintento real) que no bloqueó el resto del job gracias a continue-on-error: true (lección 5).
  • El primer terraform plan real de esta guía: 12 recursos, calculados sin necesitar LocalStack corriendo —un hallazgo verificado sobre skip_requesting_account_id, con la advertencia honesta de que es una propiedad de este momento específico del proyecto, no una garantía general (lección 6).
  • El plan como evidencia, dos formas: $GITHUB_STEP_SUMMARY, confirmado funcionando bajo act; y el comentario directo en el Pull Request vía actions/github-script, mostrado en YAML completo, etiquetado honestamente como no-ejecutable sin un Pull Request real (lección 7).
  • Todo junto, sobre un cambio real: el tag Compliance en el bucket de manifiestos, exactamente lo que el título del Pull Request #42 prometía desde el Módulo 2, corrido de punta a punta con act pull_request -e pr-event.json (este proyecto).

Qué viene después

El Módulo 4 construye la mitad de seguridad que hace posible que un apply real exista sin comprometer una credencial de larga vida: GitHub Secrets, por qué una credencial nunca vive en el repositorio, qué es la federación OIDC —mostrada en YAML completo, con la razón técnica exacta de por qué no se ejecuta bajo act—, y ambientes de GitHub (dev/prod) como control de aprobación. El Módulo 5 toma ese manejo de secretos y el ci.yml que acabas de terminar, y construye apply.yml: el workflow que, disparado únicamente por push a main —nunca por un Pull Request sin revisar—, aplica exactamente el plan que una persona ya aprobó aquí.


Errores comunes

Olvidar terraform fmt s3.tf después de editar el bucket a mano (de flujo, el más fácil de cometer en este proyecto específico). Qué pasa: alguien escribe el bloque merge(...) con una indentación ligeramente distinta a la de esta lección, y el step Terraform format check falla al correr el ci.yml completo, interrumpiendo el resto del job antes de llegar al plan. Cómo detectarlo: el mismo patrón de la lección 4 —iam.tf (o, en este caso, s3.tf) impreso, seguido de Terraform exited with code 3—. Cómo corregirlo: corre terraform fmt s3.tf (sin -check) antes de commitear cualquier cambio de HCL, un hábito que vale la pena mantener para el resto de esta guía.

Esperar que el número de recursos cambie porque se agregó un tag (conceptual). Qué pasa: alguien ve Plan: 12 to add —el mismo número que en la lección 6, sin el tag nuevo— y se pregunta si el cambio realmente se aplicó al HCL. Cómo detectarlo: comparar el conteo con lecciones anteriores y no ver ninguna diferencia. Cómo corregirlo: un tag nuevo modifica un atributo dentro de un recurso que ya se estaba creando —no agrega ni quita ningún recurso completo—. El conteo de Plan: cuenta recursos, no atributos; para confirmar que el cambio está ahí, revisa el contenido del plan (como el grep de esta lección), no el número de la última línea.

Pensar que este proyecto ya aplicó el tag a LocalStack (de expectativa, el error más importante de cerrar este módulo). Qué pasa: alguien, después de ver Plan: 12 to add en verde, corre awslocal s3api get-bucket-tagging --bucket andes-cargo-shipment-docs esperando ver el tag Compliance ya presente. Cómo detectarlo: ese comando fallaría (LocalStack ni siquiera está corriendo en este módulo) o, con LocalStack corriendo pero sin ningún apply ejecutado, no mostraría el tag. Cómo corregirlo: recuerda la tesis completa de este módulo — construiste la mitad de CI, la que calcula y muestra. Ningún comando de este módulo, ni de este proyecto, aplicó nada real. El tag existe en el plan, listo para ser revisado; aplicarlo de verdad es, explícitamente, el trabajo del Módulo 5.


Ejercicios

Ejercicio 1 — Explica la coherencia narrativa del cambio. Sin mirar esta lección, explica a un colega por qué el cambio de HCL de este proyecto (Compliance tag en el bucket) no es arbitrario, sino que estaba anticipado desde el Módulo 2.

Ver solución

El pr-event.json que se escribió a mano en el Módulo 2, lección 6, ya incluía el título "Add tags to the shipment documents bucket" para el PR #42, en la rama feature/add-shipment-tags — un evento simulado que describía una intención de cambio antes de que ese cambio existiera de verdad en el HCL. Este proyecto cierra ese círculo: hace real, en s3.tf, exactamente lo que el título del Pull Request simulado prometía, para que cada pieza de esta guía —desde el nombre de la rama hasta el contenido del plan— cuente la misma historia coherente, sin números ni nombres inventados sobre la marcha.

Ejercicio 2 — Reconstruye el ci.yml completo de memoria. Sin mirar esta lección, escribe (en papel o en un editor) los nueve nombres de step de ci.yml, en el orden correcto. Después compara contra el archivo real.

Ver solución

En orden: 1. Check out andes-cargo-infra. 2. Set up Terraform. 3. Terraform format check. 4. Terraform init. 5. Terraform validate. 6. Install awslocal. 7. Confirm the runner can reach LocalStack on the host. 8. Install tflocal. 9. Terraform plan. Más un décimo, la publicación del resumen: 10. Publish the plan to the job summary. Si reconstruiste este orden sin mirar, tienes internalizada la progresión completa de las lecciones 4 a 7: barato antes de caro, verificación antes de red, red antes de plan, plan antes de publicación.

Ejercicio 3 — Decide qué pasaría si el guardrail del Módulo 6 ya existiera. Adelantándote al Módulo 6 (que vas a construir más adelante en esta guía): ese módulo agrega un guardrail que falla el job si el plan intenta destruir la tabla Shipments. Si ese guardrail ya estuviera activo en este ci.yml, ¿esperarías que el cambio de esta lección (agregar un tag al bucket) lo disparara? Justifica.

Ver solución

No, no lo dispararía. El guardrail del Módulo 6 revisa específicamente si el plan en JSON contiene una acción de destrucción sobre la tabla Shipments — este cambio no toca la tabla Shipments en absoluto (toca el bucket andes-cargo-shipment-docs, un recurso completamente distinto), y de cualquier forma, ninguna acción de este proyecto es una destrucción: son 12 creaciones, cero cambios, cero destrucciones, sobre un estado que todavía no tiene nada aplicado. El guardrail está diseñado para detectar un patrón muy específico y peligroso, no para bloquear cualquier cambio de HCL en general.


Resumen y siguiente paso

En este proyecto corriste ci.yml completo, de punta a punta, sobre un cambio real: un tag de cumplimiento agregado al bucket de manifiestos de Andes Cargo, exactamente la intención que el Pull Request #42 simulado anunció desde el Módulo 2. Los nueve steps corrieron en orden —fmtinitvalidate → conexión a LocalStack (con un fallo honesto, no bloqueante) → plan (12 recursos, sin necesitar LocalStack corriendo) → resumen publicado—, confirmando que la mitad de CI del pipeline funciona de punta a punta, no solo pieza por pieza.

Antes de avanzar deberías poder: reconstruir ci.yml completo de memoria, con los nueve steps en el orden correcto; explicar por qué este módulo, en ningún momento, tocó infraestructura real; y decir con precisión qué evidencia de revisión existiría, hoy, para que alguien apruebe la fusión del PR #42.

Con esto, el Módulo 3 queda cerrado. Tienes la mitad de CI del pipeline completa, probada de punta a punta, con un plan real listo para ser revisado.

Siguiente módulo: el manejo de secretos que hace posible construir la otra mitad —GitHub Secrets, por qué una credencial de larga vida en un repositorio es el antipatrón que la propia auditoría de mercado de esta guía señala como el hueco más citado de la competencia, y OIDC federado, mostrado en YAML real, como la alternativa moderna.

Recursos

  1. nektosact.com — User Guide — referencia completa de act pull_request -e, usada de punta a punta en este proyecto.
  2. HashiCorp Developer — Automate Terraform with GitHub Actions — el patrón completo que este módulo implementa, citado en la lección 2.
  3. Terraform Docs — the merge function — la función usada en el cambio de HCL de este proyecto.
  4. terraform-and-iac-guide (NIEVA) — el proyecto andes-cargo-infra/ completo, automatizado sin reescribirse, salvo esta única línea de tags.