Módulo 8: Capstone The Andes Cargo Pipeline

3. Recorrido end-to-end: un cambio real cruzando el pipeline

Descripción

Esta es la lección que cumple, con evidencia, la promesa que abrió esta guía en el Módulo 1: dejar de correr terraform apply a mano, desde una laptop, y empezar a dejar que un pipeline lo haga por ti, con revisión en el medio. Vas a agregar un cambio real y pequeño al HCL de Andes Cargo —un segundo tag en el bucket andes-cargo-shipment-docs—, abrir una rama, simular un Pull Request, ver ci.yml calcular y aprobar el plan, simular la fusión a main, y ver apply.yml intentar aplicar ese plan exacto contra LocalStack. En ningún momento de esta lección vas a teclear terraform apply — ni una sola vez, en ninguna terminal.

Conexión con el módulo

Esta lección recorre, de izquierda a derecha, exactamente el diagrama que armaste en la lección 2. No introduce ningún workflow nuevo — usa ci.yml y apply.yml tal como quedaron al cerrar el Módulo 6, con el guardrail ya integrado. La lección 4 recorre el mismo diagrama una segunda vez, con un cambio que el guardrail sí detiene.


Analogía: la cinta de montaje, con una pieza real puesta a prueba

Los módulos anteriores probaron cada estación de la cinta de montaje por separado —la estación que verifica formato, la que calcula el plan, la que aplica—, cada una con su propia pieza de prueba. Esta lección pone una pieza real en el extremo de la cinta y la sigue hasta el final, sin sacarla en ningún punto intermedio para revisarla a mano. Si la cinta completa funciona, la pieza sale del otro lado exactamente como se diseñó, sin que nadie haya tenido que intervenir en medio del proceso — la prueba de que las estaciones, juntas, hacen el trabajo que cada una prometió hacer por separado.


Paso 1 — El cambio real: un segundo tag en el bucket de manifiestos

Recuerda el bucket andes-cargo-shipment-docs, tal como quedó al cerrar el Módulo 3: ya tiene el tag Compliance = "manifest-retention-required", agregado con merge() sobre local.common_tags. Ahora, imagina que el equipo de finanzas de Andes Cargo pide que cada recurso de almacenamiento quede etiquetado con su centro de costos, para poder repartir el gasto de S3 entre áreas — un pedido de negocio real, pequeño, y sin relación alguna con la tabla Shipments ni con ningún recurso destructivo.

Abre s3.tf y extiende el mismo merge():

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"
    CostCenter = "logistics-andes"
  })
}

Una línea nueva, CostCenter = "logistics-andes", dentro del mismo mapa que merge() ya combinaba desde el Módulo 3. Formatea y confirma el diff:

terraform fmt s3.tf
git diff --stat

Qué esperar (literal):

 s3.tf | 1 +
 1 file changed, 1 insertion(+)

Paso 2 — La rama y el Pull Request simulado

En un repositorio real, este cambio viviría en una rama de feature, con un Pull Request abierto contra main — exactamente el mismo patrón que sostuvo cada cambio de esta guía desde el Módulo 2. Escribe el evento que simula ese PR, con un número nuevo y un nombre de rama que describan honestamente la intención:

.github/act-events/pr-event-52.json:

{
  "action": "opened",
  "number": 52,
  "pull_request": {
    "number": 52,
    "title": "Add cost center tag to the shipment-docs bucket",
    "head": {
      "ref": "feature/add-cost-center-tag",
      "sha": "d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2"
    },
    "base": {
      "ref": "main",
      "sha": "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
    },
    "html_url": "https://github.com/andes-cargo/andes-cargo-infra/pull/52",
    "user": {
      "login": "andes-cargo-dev"
    }
  },
  "repository": {
    "name": "andes-cargo-infra",
    "full_name": "andes-cargo/andes-cargo-infra",
    "default_branch": "main"
  }
}

PR #52 —números más altos que el #42 (Módulo 2) y el #45 (revert, Módulo 6), coherente con el tiempo que transcurrió desde entonces—, rama feature/add-cost-center-tag. Los sha de head/base siguen siendo, como cada uno de esta guía, valores inventados a mano con el formato correcto de un SHA de Git — ninguna API externa los generó.


Paso 3 — ci.yml: el plan, calculado y revisado

Corre el pipeline completo de CI, exactamente como correría sobre un Pull Request real:

export ARTIFACT_ADDR=$(ipconfig getifaddr en0)   # Linux: hostname -I | awk '{print $1}'
rm -rf .artifacts && mkdir -p .artifacts

act pull_request -e .github/act-events/pr-event-52.json -j terraform-checks --secret-file .secrets \
  --artifact-server-path ./.artifacts --artifact-server-addr "$ARTIFACT_ADDR"

Qué esperar (salida literal, ejecutada para escribir esta lección — doce steps, en orden):

[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 [36.820792ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks]   ✅  Success - Main Set up Terraform [2.737736916s]
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks]   ✅  Success - Main Terraform format check [139.219791ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks]   ✅  Success - Main Terraform init [15.859424583s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.936238084s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks]   ✅  Success - Main Install awslocal [10.97458975s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks]   |
[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 [6.085954667s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks]   ✅  Success - Main Install tflocal [2.358674125s]
[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.452404s]
[ci/terraform-checks] ⭐ Run Main Guardrail — block any plan that destroys the Shipments table
[ci/terraform-checks]   | Guardrail passed: no destroy action found for aws_dynamodb_table.shipments.
[ci/terraform-checks]   ✅  Success - Main Guardrail — block any plan that destroys the Shipments table [2.094138167s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks]   ✅  Success - Main Publish the plan to the job summary [56.640833ms]
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks]   | Uploaded bytes 12316
[ci/terraform-checks]   | SHA256 digest of uploaded artifact zip is ccb8f6766aa01964aaaf15436a93c6f05800b6d4461fc0f045fe00f3158ed9de
[ci/terraform-checks]   | Artifact terraform-plan.zip successfully finalized. Artifact ID 2119430229
[ci/terraform-checks]   | Artifact terraform-plan has been successfully uploaded! Final size is 12316 bytes. Artifact ID is 2119430229
[ci/terraform-checks]   | Artifact download URL: https://github.com/nektos/act/actions/runs/1/artifacts/2119430229
[ci/terraform-checks]   ✅  Success - Main Upload the plan for apply.yml to use later [769.993ms]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job succeeded

Tres cosas para leer con el mismo cuidado que ya practicaste en cada módulo anterior:

  • Plan: 12 to add, 0 to change, 0 to destroy. — el mismo conteo de siempre, porque un tag nuevo modifica un atributo dentro de un recurso que de todas formas se está creando por primera vez en este state local, no agrega ni quita ningún recurso completo (la misma lección del Módulo 3, lección 8).
  • Guardrail passed — el grep del Módulo 6 corrió de verdad, contra el JSON real de este plan, y no encontró ninguna acción delete sobre Shipments. El cambio de esta lección no toca esa tabla en absoluto; el guardrail lo confirma con evidencia, no lo asume.
  • /runs/1/artifacts/2119430229 — fíjate en el 1 dentro de la URL: es github.run_id, fijo bajo act (confirmado desde el Módulo 2), no un número que subió porque ya corriste este pipeline muchas veces en esta guía.

Confirma el contenido exacto del tag nuevo, la misma disciplina de "no confíes solo en el resumen" que ya aprendiste en el Módulo 3:

grep -A2 "CostCenter" plan-output.txt

Qué esperar (literal, dos apariciones — tags y tags_all, ambas dentro de module.shipment_docs_bucket.aws_s3_bucket.this):

      + tags                        = {
          + "Compliance"  = "manifest-retention-required"
          + "CostCenter"  = "logistics-andes"
          + "Environment" = "dev"
...
      + tags_all                    = {
          + "Compliance"  = "manifest-retention-required"
          + "CostCenter"  = "logistics-andes"
          + "Environment" = "dev"

Los cuatro tags aparecen juntos, en orden alfabético (así los ordena Terraform al imprimir un mapa) — Compliance sigue ahí, sin que este cambio lo haya tocado, y CostCenter aparece exactamente donde el título del PR #52 prometía. Este es el momento donde, en un repositorio real, alguien del equipo entraría a revisar este resumen y aprobaría la fusión.


Paso 4 — La fusión (simulada) y apply.yml

Simula la fusión del PR #52 corriendo apply.yml directamente, el mismo comando del Módulo 5:

act push -W .github/workflows/apply.yml \
  --artifact-server-path ./.artifacts --artifact-server-addr "$ARTIFACT_ADDR"

Qué esperar (salida literal, ejecutada para escribir esta lección):

[apply/fetch-reviewed-plan] ⭐ Run Main Download the plan reviewed in the pull request
[apply/fetch-reviewed-plan]   | Preparing to download the following artifacts:
[apply/fetch-reviewed-plan]   | - terraform-plan (ID: 2119430229, Size: 96, Expected Digest: undefined)
[apply/fetch-reviewed-plan]   | Redirecting to blob download url: http://192.168.100.35:34567/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact
[apply/fetch-reviewed-plan]   | SHA256 digest of downloaded artifact is ccb8f6766aa01964aaaf15436a93c6f05800b6d4461fc0f045fe00f3158ed9de
[apply/fetch-reviewed-plan]   | Total of 1 artifact(s) downloaded
[apply/fetch-reviewed-plan]   ✅  Success - Main Download the plan reviewed in the pull request [397.6365ms]
[apply/fetch-reviewed-plan] ⭐ Run Main Confirm the plan file arrived intact
[apply/fetch-reviewed-plan]   | tfplan is present: 15650 bytes
[apply/fetch-reviewed-plan]   ✅  Success - Main Confirm the plan file arrived intact [63.144542ms]
[apply/fetch-reviewed-plan] 🏁  Job succeeded
[apply/terraform-apply    ] ⭐ Run Main Check out andes-cargo-infra
[apply/terraform-apply    ]   ✅  Success - Main Check out andes-cargo-infra [33.099708ms]
[apply/terraform-apply    ] ⭐ Run Main Set up Terraform
[apply/terraform-apply    ]   ✅  Success - Main Set up Terraform [2.583884542s]
[apply/terraform-apply    ] ⭐ Run Main Download the plan reviewed in the pull request
[apply/terraform-apply    ]   | SHA256 digest of downloaded artifact is ccb8f6766aa01964aaaf15436a93c6f05800b6d4461fc0f045fe00f3158ed9de
[apply/terraform-apply    ]   ✅  Success - Main Download the plan reviewed in the pull request [427.469417ms]
[apply/terraform-apply    ] ⭐ Run Main Terraform init
[apply/terraform-apply    ]   ✅  Success - Main Terraform init [15.485153542s]
[apply/terraform-apply    ] ⭐ Run Main Install tflocal
[apply/terraform-apply    ]   ✅  Success - Main Install tflocal [5.882883292s]
[apply/terraform-apply    ] ⭐ Run Main Terraform apply
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Creating...
[apply/terraform-apply    ]   | module.lambda_manifest_processor_role.aws_iam_role.this: Creating...
[apply/terraform-apply    ]   | aws_dynamodb_table.shipments: Creating...
[apply/terraform-apply    ]   | module.app_server_role.aws_iam_role.this: Creating...
[apply/terraform-apply    ]   | aws_dynamodb_table.shipments: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.app_server_role.aws_iam_role.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.lambda_manifest_processor_role.aws_iam_role.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating AWS DynamoDB Table (Shipments): operation error DynamoDB: CreateTable, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating IAM Role (AppServerRole): operation error IAM: CreateRole, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating IAM Role (LambdaManifestProcessorRole): operation error IAM: CreateRole, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating S3 Bucket (andes-cargo-shipment-docs): operation error S3: CreateBucket, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   ❗  ::error::Terraform exited with code 1.
[apply/terraform-apply    ]   ❌  Failure - Main Terraform apply [54.804567333s]
[apply/terraform-apply    ] 🏁  Job failed
Error: Job 'terraform-apply' failed

El SHA256 vuelve a coincidir, en las dos apariciones que importan: ccb8f6766aa0... en el Upload de ci.yml y en las dos descargas de apply.yml. El archivo que casi se aplicó es, bit a bit, el mismo que se calculó y revisó en el Paso 3 — ninguna reimpresión, ningún recálculo en el medio. Los mismos cuatro recursos raíz de Andes Cargo, sin dependencias entre sí, intentan crearse en paralelo, y los cuatro fallan de la misma forma honesta que ya conoces desde el Módulo 5: connection refused, porque LocalStack no está corriendo en esta máquina en este momento.

Confírmalo de forma independiente:

docker ps -a --filter name=localstack_main

Qué esperar (literal): sin ninguna fila — la misma causa raíz de siempre, no un problema nuevo introducido por este cambio.


Paso 5 — Verificado con awslocal (representativo)

Con un LOCALSTACK_AUTH_TOKEN válido y LocalStack corriendo, este apply habría terminado en éxito, y este comando confirmaría el tag directamente contra el bucket real:

awslocal s3api get-bucket-tagging --bucket andes-cargo-shipment-docs

Qué esperar (representativo — mismo formato ya confirmado en las guías anteriores de este ecosistema, sin una ejecución en vivo contra un token válido en este momento):

{
    "TagSet": [
        {"Key": "Project", "Value": "andes-cargo"},
        {"Key": "Environment", "Value": "dev"},
        {"Key": "ManagedBy", "Value": "terraform"},
        {"Key": "Compliance", "Value": "manifest-retention-required"},
        {"Key": "CostCenter", "Value": "logistics-andes"}
    ]
}

Cinco tags, los cuatro heredados más el CostCenter de esta lección — la confirmación final, contra el recurso real, de que el pipeline completo hizo exactamente lo que el plan del Paso 3 prometió. La diferencia entre este bloque y lo que corriste de verdad en los Pasos 3 y 4 no está en ningún mecanismo del pipeline —los tres workflows son idénticos en ambos casos— sino, exclusivamente, en si hay un LocalStack real del otro lado de host.docker.internal:4566.


Commiteando el cierre

git add .github/act-events/pr-event-52.json s3.tf
git commit -m "Add CostCenter tag to the shipment-docs bucket"
git log --oneline -3

Qué esperar (representativo en los hashes, literal en la estructura):

<hash> Add CostCenter tag to the shipment-docs bucket
<hash> Add doc/adr/0001-choosing-cicd-tooling.md: ADR for GitHub Actions over Jenkins/GitLab CI
<hash> ci.yml: add a guardrail step that blocks any plan destroying the Shipments table; add guardrail-demo.yml to test it against a seeded fixture

Errores comunes

Buscar terraform apply en algún punto de esta lección (el error central, de expectativa). Qué pasa: alguien, siguiendo la costumbre de terraform-and-iac-guide, busca el comando terraform apply escrito en alguna parte de esta lección, sin encontrarlo. Cómo corregirlo: no está, a propósito — el Paso 4 corre act push -W apply.yml, que dentro de su propio contenedor ejecuta tflocal apply -auto-approve tfplan, pero el alumno nunca teclea ese comando directamente en su terminal. Esa es, literalmente, la tesis completa de esta guía, cumplida en esta lección.

Esperar que el Plan: 12 to add cambie a 13 porque se agregó un tag (conceptual, revisita el Módulo 3). Qué pasa: alguien ve el mismo número que en cada corrida anterior y se pregunta si el cambio se aplicó al archivo. Cómo corregirlo: un tag es un atributo dentro de un recurso, no un recurso nuevo — el conteo de Plan: nunca cambia por un tag, sin importar cuántos se agreguen. La evidencia correcta es el contenido del plan (el grep del Paso 3), no el número de la última línea.

Confundir el fallo del Paso 4 con un problema de este cambio específico (de diagnóstico, revisita el Módulo 5). Qué pasa: alguien ve Job failed y sospecha que el tag CostCenter rompió algo. Cómo detectarlo: el mensaje —connection refused tras reintentos reales, sobre los cuatro recursos raíz, idéntico al de cada apply.yml anterior de esta guía— no tiene ninguna relación con el contenido del cambio. Cómo corregirlo: docker ps -a --filter name=localstack_main confirma, de nuevo, que la causa es la ausencia de LocalStack, no el HCL de esta lección.


Ejercicios

Ejercicio 1 — Reconstruye el hilo completo de PRs de esta guía, de memoria. Sin mirar atrás, lista los tres Pull Requests simulados que atravesaron andes-cargo-infra/ a lo largo de esta guía, con su número y su propósito.

Ver solución

PR #42 (Módulo 2, lección 6; hecho realidad en el Módulo 3, lección 8): feature/add-shipment-tagsmain, agrega el tag Compliance al bucket de manifiestos. PR #45 (Módulo 6, lección 3): revert-shipment-tagsmain, revierte ese mismo tag ante un cambio de política de cumplimiento. PR #52 (esta lección): feature/add-cost-center-tagmain, agrega el tag CostCenter al mismo bucket, a pedido del equipo de finanzas. Los tres comparten el mismo mecanismo exacto —pr-event.json escrito a mano, act pull_request -e—, solo cambia el contenido del cambio de HCL que cada uno describe.

Ejercicio 2 — Explica la cadena de verificación de SHA256 en esta lección, sin mirar el texto. ¿En cuántos puntos se verificó el mismo SHA256 en esta lección, y qué confirma cada uno?

Ver solución

Dos puntos: (1) cuando ci.yml sube el artefacto en el Paso 3 (ccb8f6766aa0..., calculado por primera vez); (2) cuando apply.yml, en el Paso 4, lo descarga dos veces —una en fetch-reviewed-plan, otra en terraform-apply, cada job en su propio contenedor—, con el mismo valor exacto en ambas descargas. Juntas, las tres apariciones confirman que el archivo que terraform-apply casi aplicó es, bit a bit, el mismo que se calculó y se revisó en ci.yml — nunca un plan nuevo, recalculado en el camino.

Ejercicio 3 — Predice qué pasaría si alguien agregara el tag directamente en dynamodb.tf en vez de s3.tf. Sin correr nada, ¿el guardrail del Módulo 6 se activaría si alguien agregara, por error, un tag nuevo a la tabla Shipments en vez de al bucket?

Ver solución

No. El guardrail busca específicamente una acción "delete" en el arreglo "actions" del recurso aws_dynamodb_table.shipments — agregar un tag a esa tabla produciría, como mucho, una acción "update" (o, en este proyecto, sin ningún apply real completado, seguiría apareciendo como "create", igual que el resto del state vacío), nunca "delete". El guardrail está diseñado para detectar específicamente destrucción, no cualquier modificación sobre la tabla — un tag nuevo en Shipments, aunque fuera un cambio fuera de lugar en términos de dónde debería vivir esa lógica de negocio, no dispararía ninguna alarma de seguridad.


Resumen y siguiente paso

En esta lección seguiste un cambio real de HCL —un segundo tag en el bucket de manifiestos— desde una rama de feature, a través de un Pull Request simulado, ci.yml calculando y aprobando el plan (con el guardrail del Módulo 6 confirmando explícitamente que no había ninguna destrucción), una fusión simulada, y apply.yml intentando de verdad aplicar ese plan exacto contra LocalStack —verificado por SHA256 en dos puntos, con el fallo honesto de siempre por falta de un token—. En ningún momento tecleaste terraform apply.

Antes de avanzar deberías poder: reconstruir el recorrido completo de memoria, desde el cambio de HCL hasta el intento de apply; explicar por qué el guardrail no interfirió con este cambio específico; y describir, con precisión, qué verías con un LOCALSTACK_AUTH_TOKEN válido en cada uno de los cuatro pasos de esta lección.

La lección 4 recorre exactamente el mismo diagrama, con un cambio que no debería pasar — la otra mitad de la prueba final de esta guía.

Recursos

  1. nektosact.com — User Guide — referencia completa de act pull_request y act push -W, usadas de punta a punta en esta lección.
  2. HashiCorp Developer — Automate Terraform with GitHub Actions — el patrón completo que este recorrido demuestra funcionando de punta a punta.
  3. Terraform Docs — the merge function — la función reutilizada para el segundo tag de esta lección.
  4. Módulo 3 de esta guía (08-project-andes-cargos-ci-workflow.md) — el origen del primer cambio real (Compliance) que este recorrido extiende.