Módulo 3: The Iac Pipeline Fmt Validate Plan
6. Manos a la obra: `terraform plan` corriendo dentro de CI
Descripción
Esta es la lección donde ci.yml corre, por primera vez, el comando que le da sentido a todo este módulo: terraform plan sobre la infraestructura real de Andes Cargo, dentro de un contenedor efímero de act. Vas a instalar tflocal en el runner (el mismo wrapper de LocalStack que usaste en terraform-and-iac-guide), correr el plan, y leer su salida completa y literal — incluido un hallazgo real, verificado hoy, que conecta directamente con la lección anterior: por qué este plan específico no necesita que LocalStack esté corriendo para calcular correctamente qué crearía.
Conexión con el módulo
La lección 5 te dejó con la conexión de red confirmada, pero con un fallo honesto (LocalStack apagado). Esta lección usa la misma variable (AWS_ENDPOINT_URL: http://host.docker.internal:4566) para un comando mucho más importante — y, contra lo que podrías esperar después de la lección 5, este comando sí tiene éxito, sin que LocalStack esté corriendo. La lección 7 toma exactamente esta salida y la convierte en el artefacto de revisión que cierra la mitad de CI del pipeline.
Analogía: un presupuesto de obra, no la obra misma
Cuando un contratista prepara un presupuesto para remodelar una cocina, no necesita tener la cocina enfrente todavía para calcular cuánto material hace falta, cuántas horas de trabajo, qué se instala y qué se retira — necesita los planos y el catálogo de materiales, no la obra en curso. terraform plan, cuando parte de un estado completamente vacío (nada creado todavía, como es el caso de Andes Cargo en esta guía), funciona de forma parecida: puede calcular con precisión qué va a crear, a partir únicamente de tu HCL, sin necesitar consultar nada que ya exista del otro lado. Eso cambia en el momento en que ya hay algo construido —ahí sí, el presupuesto de una remodelación necesita medir la cocina real primero—; pero para una obra que empieza de cero, el catálogo alcanza.
Paso 1 — Instalar tflocal y correr el plan
Agrega dos steps más a ci.yml, después del step de awslocal de la lección 5:
- 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
Dos detalles de este step, antes de correrlo:
tflocal, noterraform— la misma decisión que tomaste enterraform-and-iac-guidea partir de su Módulo 1:tflocales un wrapper que genera automáticamente, en un archivo temporal, el bloqueprovider "aws" { endpoints {...} } }completo apuntando a LocalStack —sin queproviders.tf, que se mantiene mínimo, tenga que declararlo a mano—. La variableAWS_ENDPOINT_URLque ya está en elenvdel job (lección 5) es exactamente lo quetflocallee para saber que el destino eshost.docker.internal:4566, nolocalhost:4566.| tee plan-output.txt—teeimprime la salida en la terminal (la ves en los logs deact, igual que cualquier otro step) y, al mismo tiempo, la guarda en un archivo. La lección 7 usa exactamente ese archivo para publicar el plan como evidencia de revisión.
Un hallazgo real: providers.tf necesita una línea más de lo que asumirías
Antes de correr esto, hay un detalle verificado hoy, corriendo el comando de verdad, que vale la pena nombrar con honestidad completa —en el mismo espíritu que el Módulo 2 (lección 3) descubrió que act no evalúa branches:—. terraform-and-iac-guide te enseñó que tflocal genera automáticamente los flags skip_credentials_validation y skip_metadata_api_check dentro de su bloque de override — cierto, confirmado. Lo que no genera automáticamente, en la versión de tflocal que usa esta guía, es un tercer flag: skip_requesting_account_id. Sin él, el proveedor de AWS intenta —incluso con las credenciales de prueba y los otros dos flags activos— averiguar el ID de cuenta contactando a IAM/STS antes de calcular el plan, algo que solo puede lograr si LocalStack está corriendo y alcanzable.
Por eso, providers.tf de andes-cargo-infra/ lleva esta única línea, la única desviación del "providers.tf mínimo" que terraform-and-iac-guide estableció:
provider "aws" {
region = "us-east-1"
skip_requesting_account_id = true
}
Es un ajuste a nivel de proveedor, no un truco específico de LocalStack —tendría exactamente el mismo efecto, y sería igual de válido, si este proyecto apuntara a una cuenta real de AWS—: le dice al proveedor "no necesitas saber el ID de cuenta antes de calcular un plan", algo cierto para un create-completo como el de Andes Cargo, donde ningún recurso necesita ese dato para decidir qué crear. Con esta línea agregada, terraform plan puede completarse sin tocar la red en absoluto — la razón exacta por la que este comando, a diferencia del awslocal de la lección 5, no depende de que LocalStack esté corriendo.
Paso 2 — Corriendo el plan, con LocalStack todavía apagado
docker ps -a --filter name=localstack_main
Qué esperar (literal): sin ninguna fila — LocalStack sigue apagado, exactamente como en la lección 5.
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
Qué esperar (salida literal, ejecutada para escribir esta lección — extracto; la salida completa del plan supera las 400 líneas, así que se muestra un recorte representativo del inicio, un recurso completo, y el cierre):
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks] | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[ci/terraform-checks] ✅ Success - Main Install tflocal [2.319248s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/8] user= workdir=
[ci/terraform-checks] | data.archive_file.lambda_zip: Reading...
[ci/terraform-checks] | data.archive_file.lambda_zip: Read complete after 0s [id=772c6895d44fc6c470f613203a7df0faeee06e87]
[ci/terraform-checks] | data.aws_iam_policy_document.require_https: Reading...
[ci/terraform-checks] | data.aws_iam_policy_document.app_server_permissions: Reading...
[ci/terraform-checks] | data.aws_iam_policy_document.lambda_permissions: Reading...
[ci/terraform-checks] | data.aws_iam_policy_document.lambda_trust: Reading...
[ci/terraform-checks] | data.aws_iam_policy_document.ec2_trust: Reading...
[ci/terraform-checks] | data.aws_iam_policy_document.lambda_permissions: Read complete after 0s [id=4087165242]
[ci/terraform-checks] | data.aws_iam_policy_document.ec2_trust: Read complete after 0s [id=2851119427]
[ci/terraform-checks] | data.aws_iam_policy_document.lambda_trust: Read complete after 0s [id=2690255455]
[ci/terraform-checks] | data.aws_iam_policy_document.require_https: Read complete after 0s [id=4186015114]
[ci/terraform-checks] | data.aws_iam_policy_document.app_server_permissions: Read complete after 0s [id=803512137]
[ci/terraform-checks] |
[ci/terraform-checks] | Terraform used the selected providers to generate the following execution
[ci/terraform-checks] | plan. Resource actions are indicated with the following symbols:
[ci/terraform-checks] | + create
[ci/terraform-checks] |
[ci/terraform-checks] | Terraform will perform the following actions:
[ci/terraform-checks] |
[ ... 11 recursos más, uno por uno, cada uno con su bloque + resource "..." { ... } ... ]
[ci/terraform-checks] | # aws_dynamodb_table.shipments will be created
[ci/terraform-checks] | + resource "aws_dynamodb_table" "shipments" {
[ci/terraform-checks] | + billing_mode = "PAY_PER_REQUEST"
[ci/terraform-checks] | + hash_key = "shipmentId"
[ci/terraform-checks] | + name = "Shipments"
[ci/terraform-checks] | + region = "us-east-1"
[ci/terraform-checks] | + tags = {
[ci/terraform-checks] | + "Environment" = "dev"
[ci/terraform-checks] | + "ManagedBy" = "terraform"
[ci/terraform-checks] | + "Project" = "andes-cargo"
[ci/terraform-checks] | }
[ci/terraform-checks] |
[ci/terraform-checks] | + attribute {
[ci/terraform-checks] | + name = "shipmentId"
[ci/terraform-checks] | + type = "S"
[ci/terraform-checks] | }
[ci/terraform-checks] | }
[ ... resto de los recursos ... ]
[ci/terraform-checks] | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks] |
[ci/terraform-checks] | Changes to Outputs:
[ci/terraform-checks] | + process_shipment_manifest_function_name = "process-shipment-manifest"
[ci/terraform-checks] | + shipment_docs_bucket_arn = (known after apply)
[ci/terraform-checks] | + shipments_table_name = "Shipments"
[ci/terraform-checks] |
[ci/terraform-checks] | Note: You didn't use the -out option to save this plan, so Terraform can't
[ci/terraform-checks] | guarantee to take exactly these actions if you run "terraform apply" now.
[ci/terraform-checks] ✅ Success - Main Terraform plan [4.348992625s]
[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. — doce recursos, el conteo completo de lo que terraform-and-iac-guide declaró en su capstone: el bucket, su política, su versionado (tres recursos del módulo s3-bucket), los dos roles con sus políticas inline (cuatro recursos del módulo iam-role, duplicado para LambdaManifestProcessorRole y AppServerRole), la función Lambda, el permiso que le da a S3 invocarla, la notificación del bucket hacia la función, la tabla Shipments, y la política inline que le da a la función permiso de escribir en esa tabla. Cero recursos por cambiar, cero por destruir — exactamente lo que esperarías de un plan sobre un estado que todavía no tiene nada creado.
Confirmando el hallazgo: LocalStack sigue apagado
docker ps -a --filter name=localstack_main
Qué esperar (literal): sigue sin ninguna fila. El plan de arriba terminó en 4.3 segundos, sin ningún reintento de red visible en la salida —contrástalo con los 11.3 segundos de la lección 5, donde awslocal sí reintentó contra un endpoint inalcanzable—. Esa diferencia de tiempo es la confirmación externa de que este plan, con skip_requesting_account_id = true en providers.tf, no hizo ningún intento de red en absoluto.
Una advertencia honesta, para no sacar la conclusión equivocada: esto es una propiedad de este momento específico del proyecto —un create-completo, sin ningún recurso ya existente, sin ningún data source que necesite leer algo real de LocalStack—. En cuanto exista un terraform.tfstate con recursos reales (algo que empieza a pasar recién en el Módulo 5, cuando apply.yml corra por primera vez), un plan posterior sí va a necesitar leer el estado real de esos recursos contra el proveedor, y ahí sí, la conexión que verificaste en la lección 5 va a dejar de ser opcional.
Errores comunes
Asumir que terraform plan nunca necesita LocalStack corriendo (conceptual, la generalización incorrecta más tentadora de esta lección). Qué pasa: alguien, después de ver esta lección, concluye que plan "siempre" funciona sin LocalStack activo, y se sorprende cuando un plan posterior —en el Módulo 5, sobre un estado ya poblado— sí falla sin conexión. Cómo detectarlo: si tu razonamiento es "el plan nunca necesita red" en vez de "este plan específico, sobre este estado específico, no la necesitó". Cómo corregirlo: recuerda la advertencia de arriba — la propiedad depende del estado (vacío ahora, con recursos reales después de la primera aplicación) y de si el HCL tiene algún data source que lea algo en vivo. No es una propiedad general de terraform plan.
Olvidar skip_requesting_account_id y no entender por qué el plan de repente falla (de configuración, ver el hallazgo de esta lección). Qué pasa: alguien reconstruye providers.tf desde cero, copiando solo lo que terraform-and-iac-guide documentó como "mínimo" (region únicamente), sin la línea que esta lección agrega, y el plan falla con un error sobre no poder recuperar el ID de cuenta. Cómo detectarlo: un error mencionando iam:GetUser, sts:GetCallerIdentity o iam:ListRoles, con un fallo de conexión hacia host.docker.internal. Cómo corregirlo: confirma que providers.tf tiene la línea skip_requesting_account_id = true — la única diferencia real, verificada en esta lección, entre lo que terraform-and-iac-guide documentó como suficiente y lo que esta versión específica de tflocal genera automáticamente.
Interpretar | tee plan-output.txt como algo que cambia lo que el step imprime (de sintaxis). Qué pasa: alguien piensa que agregar tee a un comando oculta su salida de los logs de act, o la cambia de alguna forma. Cómo corregirlo: tee no oculta ni transforma nada — imprime exactamente lo mismo que verías sin él, y además lo guarda en el archivo indicado. La salida que ves en los logs de act es idéntica con o sin tee; lo único que cambia es que ahora existe también plan-output.txt, dentro del contenedor del job, disponible para el step siguiente.
Ejercicios
Ejercicio 1 — Predice el resultado sin skip_requesting_account_id. Si quitaras la línea skip_requesting_account_id = true de providers.tf y corrieras el mismo act pull_request -e pr-event.json -j terraform-checks, con LocalStack todavía apagado, ¿qué esperas que pase con el step Terraform plan, y en cuánto tiempo aproximado?
Ver solución
El step fallaría, no con éxito — el proveedor de AWS intentaría contactar a IAM/STS a través de host.docker.internal:4566 para obtener el ID de cuenta antes de calcular el plan, y como LocalStack no está corriendo, ese intento fallaría de la misma forma que el awslocal de la lección 5: después de varios segundos de reintento (no instantáneo), con un mensaje mencionando errores de iam:GetUser, sts:GetCallerIdentity e iam:ListRoles al intentar "retrieving account information"/"retrieving caller identity".
Ejercicio 2 — Explica la analogía del presupuesto de obra a un colega escéptico. Un colega te dice: "si el plan no necesita LocalStack corriendo, entonces la lección 5 fue innecesaria". Respóndele en tres frases, sin exagerar en ningún sentido.
Ver solución
Una respuesta completa suena, más o menos, así: "No es innecesaria — es la diferencia entre 'este plan específico no lo necesitó' y 'nunca lo va a necesitar'. En cuanto Andes Cargo tenga infraestructura real aplicada (Módulo 5) o un data source que lea algo en vivo, el plan sí va a depender de esa misma conexión que probamos en la lección 5. Verificar el cable antes de que la heladera esté cargada de mercadería sigue siendo lo correcto, incluso si el primer electrodoméstico que enchufaste resultó no necesitarlo."
Ejercicio 3 — Cuenta los recursos por módulo. Sin volver a mirar la salida completa, de memoria: ¿cuántos de los 12 recursos del plan vienen de module.shipment_docs_bucket, cuántos de los dos roles IAM combinados, y cuántos son recursos "sueltos" (fuera de cualquier módulo)?
Ver solución
3 recursos de module.shipment_docs_bucket (aws_s3_bucket, aws_s3_bucket_policy, aws_s3_bucket_versioning). 4 recursos de los dos roles IAM combinados —dos por cada instancia del módulo iam-role (aws_iam_role + aws_iam_role_policy), una vez para LambdaManifestProcessorRole y otra para AppServerRole—. 5 recursos sueltos, declarados directamente en la raíz del proyecto: aws_lambda_function.process_shipment_manifest, aws_lambda_permission.allow_s3_invoke, aws_s3_bucket_notification.manifest_processor_trigger, aws_dynamodb_table.shipments, y aws_iam_role_policy.lambda_write_shipments (la política que conecta la función Lambda con la tabla). 3 + 4 + 5 = 12, el número exacto del Plan: de esta lección.
Resumen y siguiente paso
En esta lección corriste el primer terraform plan real de esta guía, dentro de un job de act, y confirmaste, con salida literal, que calcula correctamente los 12 recursos de Andes Cargo — sin que LocalStack estuviera corriendo. Descubriste, verificando el comportamiento real y no solo asumiéndolo, que tflocal necesita una línea más de lo documentado (skip_requesting_account_id = true) para que esto funcione sobre un estado vacío, y entendiste por qué esa propiedad es específica de este momento del proyecto, no una garantía general.
Antes de avanzar deberías poder: explicar por qué un plan sobre un create-completo puede no necesitar conexión de red, mientras un plan sobre infraestructura ya existente sí la necesitaría; ubicar skip_requesting_account_id en providers.tf y explicar qué problema resuelve; y leer un Plan: N to add, N to change, N to destroy. con precisión sobre qué representa cada número.
La lección 7 toma exactamente el plan-output.txt que este step generó y lo convierte en el artefacto de revisión que cierra la mitad de CI del pipeline: publicado en el resumen del job, y —mostrado, no ejecutado— comentado en el propio Pull Request, el patrón que la lección 2 citó del tutorial oficial de HashiCorp.
Recursos
- Terraform Docs — Command: plan — referencia oficial de
terraform plan. - Terraform Registry — hashicorp/aws provider:
skip_requesting_account_id— el flag central del hallazgo de esta lección. - terraform-local — PyPI — el paquete de
tflocal, instalado en esta lección dentro del runner. terraform-and-iac-guide, Módulo 1, lección 6 (NIEVA) — la introducción original atflocal, extendida aquí con el hallazgo deskip_requesting_account_id.