Módulo 5: Apply On Merge The Cd Half
4. Manos a la obra: construyendo `apply.yml`
Descripción
Esta es la lección donde apply.yml existe completo, por primera vez, y corre de punta a punta con act push — el mismo comando que un push real a main dispararía en un repositorio de GitHub. Vas a ver los dos jobs de la lección anterior trabajando juntos: fetch-reviewed-plan descargando el archivo exacto que ci.yml subió, y terraform-apply intentando, de verdad, escribir esos doce recursos contra LocalStack. El intento es real. El resultado, sin un LOCALSTACK_AUTH_TOKEN válido, es un fallo honesto — leído con el mismo cuidado que ya aprendiste a leer estos fallos desde el Módulo 2.
Conexión con el módulo
Esta lección no introduce ningún concepto nuevo — es la integración de la lección 2 (on: push: branches: [main]) y la lección 3 (needs:, upload-artifact/download-artifact) en un único archivo, corrido de verdad. La lección 5 retoma este mismo archivo para agregarle una pieza que todavía le falta: protección contra dos corridas simultáneas.
El apply.yml completo
.github/workflows/apply.yml, dentro de andes-cargo-infra/:
name: apply
on:
push:
branches: [main]
jobs:
fetch-reviewed-plan:
runs-on: ubuntu-latest
steps:
- name: Download the plan reviewed in the pull request
uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Confirm the plan file arrived intact
run: |
test -s tfplan
echo "tfplan is present: $(wc -c < tfplan) bytes"
terraform-apply:
needs: fetch-reviewed-plan
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: Download the plan reviewed in the pull request
uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Terraform init
run: terraform init -input=false
- name: Install tflocal
run: pip3 install --quiet --break-system-packages terraform-local
- name: Terraform apply
run: tflocal apply -input=false -auto-approve tfplan
Dos jobs, dos Stage (confirmado con act -l en la lección anterior), un solo propósito: aplicar exactamente el plan que ya se revisó. Fíjate en el último step —tflocal apply -input=false -auto-approve tfplan—: a diferencia de cada terraform plan/apply que corriste a mano en terraform-and-iac-guide, este comando no le pide confirmación a nadie (-auto-approve) y no recalcula nada (aplica el archivo tfplan tal cual, el mismo que descargaste en el step anterior) — es, literalmente, el equivalente automatizado de que una persona ya haya revisado el plan y haya dicho que sí.
Requisito previo: ci.yml ya tiene que haber subido un plan
apply.yml descarga un artefacto llamado terraform-plan — si nunca corriste el ci.yml extendido de la lección 3 (con -out=tfplan y upload-artifact), no hay nada que descargar. Antes de continuar, confirma que ese artefacto existe, corriendo ci.yml una vez, exactamente como en la lección 3:
export ARTIFACT_ADDR=$(ipconfig getifaddr en0) # Linux: hostname -I | awk '{print $1}'
act pull_request -e .github/act-events/pr-event.json -j terraform-checks \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
Qué esperar (literal, resumen — ya viste la salida completa del plan en el Módulo 3):
[ci/terraform-checks] | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks] | Saved the plan to: tfplan
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks] | Artifact terraform-plan has been successfully uploaded! Final size is 12484 bytes.
[ci/terraform-checks] 🏁 Job succeeded
Con esto, la carpeta ./.artifacts/1/terraform-plan/ ya tiene el archivo que apply.yml va a buscar.
Ejecutándolo: act push, simulando la fusión a main
act push -W .github/workflows/apply.yml \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
-W .github/workflows/apply.yml le dice a act que corra específicamente este archivo — sin él, act push intentaría correr todos los workflows de este repositorio que escuchan push (apply.yml, y también hello-andes-cargo.yml del Módulo 2), algo que no quieres en este momento. El resto del comando ya lo conoces de la lección 3: el mismo --artifact-server-path/--artifact-server-addr que resolvieron el problema de red del servidor de artefactos local.
Qué esperar (salida literal, ejecutada para escribir esta lección — el job fetch-reviewed-plan completo, con éxito):
[apply/fetch-reviewed-plan] ⭐ Run Set up job
[apply/fetch-reviewed-plan] ✅ Success - Set up job
[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 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/fetch-reviewed-plan] | Artifact download completed successfully.
[apply/fetch-reviewed-plan] | Total of 1 artifact(s) downloaded
[apply/fetch-reviewed-plan] ✅ Success - Main Download the plan reviewed in the pull request [972.244958ms]
[apply/fetch-reviewed-plan] ⭐ Run Main Confirm the plan file arrived intact
[apply/fetch-reviewed-plan] | tfplan is present: 15826 bytes
[apply/fetch-reviewed-plan] ✅ Success - Main Confirm the plan file arrived intact [119.824ms]
[apply/fetch-reviewed-plan] 🏁 Job succeeded
(La dirección 192.168.100.35 de la URL de descarga es la IP local de la máquina donde se escribió esta lección — variable, la tuya va a ser distinta, exactamente la que exportaste como ARTIFACT_ADDR.)
El Stage 0 terminó en verde. act avanza automáticamente al Stage 1 — terraform-apply, que también necesita el mismo artefacto (Módulo 5, lección 3: cada job corre en su propio contenedor):
[apply/terraform-apply ] ⭐ Run Main Check out andes-cargo-infra
[apply/terraform-apply ] ✅ Success - Main Check out andes-cargo-infra [39.331208ms]
[apply/terraform-apply ] ⭐ Run Main Set up Terraform
[apply/terraform-apply ] ✅ Success - Main Set up Terraform [3.2910645s]
[apply/terraform-apply ] ⭐ Run Main Download the plan reviewed in the pull request
[apply/terraform-apply ] | Preparing to download the following artifacts:
[apply/terraform-apply ] | - terraform-plan (ID: 2119430229, Size: 96, Expected Digest: undefined)
[apply/terraform-apply ] | SHA256 digest of downloaded artifact is 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/terraform-apply ] | Artifact download completed successfully.
[apply/terraform-apply ] ✅ Success - Main Download the plan reviewed in the pull request [970.303916ms]
[apply/terraform-apply ] ⭐ Run Main Terraform init
[apply/terraform-apply ] | Initializing the backend...
[apply/terraform-apply ] | Initializing modules...
[apply/terraform-apply ] | Initializing provider plugins...
[apply/terraform-apply ] | - Installing hashicorp/aws v6.60.0...
[apply/terraform-apply ] | Terraform has been successfully initialized!
[apply/terraform-apply ] ✅ Success - Main Terraform init [35.464288875s]
[apply/terraform-apply ] ⭐ Run Main Install tflocal
[apply/terraform-apply ] ✅ Success - Main Install tflocal [8.011445042s]
[apply/terraform-apply ] ⭐ Run Main Terraform apply
Hasta aquí, todo funcionó exactamente como se esperaba: el SHA256 del artefacto descargado coincide, en ambos jobs, con el que ci.yml subió —17f7ebe9b774..., idéntico en fetch-reviewed-plan y en terraform-apply—. Es la confirmación directa de la tesis de esta lección: no es un plan nuevo, es exactamente el mismo archivo, verificado bit a bit.
Leyendo el fallo: el intento real de terraform apply
Aquí es donde, sin un LOCALSTACK_AUTH_TOKEN válido, el job se pone en rojo — de la misma forma honesta en que ya fallaron el awslocal del Módulo 2 (lección 8) y el awslocal del Módulo 3 (lección 5), pero con una diferencia importante: esta vez es el propio proveedor de AWS de Terraform el que reintenta, no awslocal/boto3.
Qué esperar (salida literal, ejecutada para escribir esta lección):
[apply/terraform-apply ] | module.app_server_role.aws_iam_role.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.shipment_docs_bucket.aws_s3_bucket.this: Creating...
[apply/terraform-apply ] | module.lambda_manifest_processor_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.app_server_role.aws_iam_role.this: Still creating... [00m10s elapsed]
[apply/terraform-apply ] | aws_dynamodb_table.shipments: Still creating... [00m10s elapsed]
[apply/terraform-apply ] | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m20s elapsed]
[apply/terraform-apply ] | module.app_server_role.aws_iam_role.this: Still creating... [00m20s elapsed]
[apply/terraform-apply ] | module.lambda_manifest_processor_role.aws_iam_role.this: Still creating... [00m20s elapsed]
[apply/terraform-apply ] | aws_dynamodb_table.shipments: Still creating... [00m20s elapsed]
[apply/terraform-apply ] | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m30s elapsed]
[apply/terraform-apply ] | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m40s elapsed]
[apply/terraform-apply ] | ╷
[apply/terraform-apply ] | │ Error: creating AWS DynamoDB Table (Shipments): operation error DynamoDB: CreateTable, exceeded maximum number of attempts, 9, https response error StatusCode: 0, RequestID: , request send failed, Post "http://host.docker.internal:4566/": dial tcp 192.168.65.254:4566: connect: connection refused
[apply/terraform-apply ] | │
[apply/terraform-apply ] | │ with aws_dynamodb_table.shipments,
[apply/terraform-apply ] | │ on dynamodb.tf line 1, in resource "aws_dynamodb_table" "shipments":
[apply/terraform-apply ] | │ 1: resource "aws_dynamodb_table" "shipments" {
[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 [1m3.705523875s]
[apply/terraform-apply ] exitcode '1': failure
[apply/terraform-apply ] 🏁 Job failed
Error: Job 'terraform-apply' failed
Léelo con el mismo cuidado analítico que ya practicaste antes, porque hay mucha información honesta en este fallo:
- Los cuatro recursos que Terraform intentó crear en paralelo —la tabla
Shipments, los dos roles IAM, el bucket— son exactamente los cuatro recursos raíz de Andes Cargo, sin dependencias entre sí (los demás recursos, como la función Lambda o las políticas inline, dependen de que estos cuatro existan primero, así que Terraform ni siquiera llegó a intentarlos). exceeded maximum number of attempts, 9— a diferencia deawslocal(que reintenta según la política debotocore), el proveedor de AWS de Terraform reintenta 9 veces antes de rendirse — la razón por la que este job tardó más de un minuto en fallar (1m3.7s), mucho más que los ~10 segundos que ya viste en fallos anteriores de esta guía.connect: connection refused— un mensaje ligeramente distinto alCould not connect to the endpoint URLque ya conoces deawslocal/boto3, pero exactamente el mismo fenómeno: el nombrehost.docker.internalresolvió correctamente (si no, el error sería de DNS, no de conexión), el intento de red fue real, y no había nada escuchando del otro lado — LocalStack no está corriendo, la misma causa de siempre.- Los cuatro errores aparecen juntos, no uno por uno — porque Terraform crea recursos sin dependencias entre sí en paralelo, no en secuencia; los cuatro intentos fallaron casi al mismo tiempo, y Terraform los reporta todos juntos al final, no apenas ocurre el primero.
Confírmalo de forma independiente:
docker ps -a --filter name=localstack_main
Qué esperar (literal): sin ninguna fila — LocalStack no está corriendo, exactamente la misma causa raíz que ya diagnosticaste en el Módulo 2 y el Módulo 3.
La versión que verías con un token válido (representativa)
Con LOCALSTACK_AUTH_TOKEN correctamente exportado y LocalStack arrancado en tu host (Módulo 1, lección 8, Paso 5), el mismo tflocal apply -input=false -auto-approve tfplan terminaría así:
Qué esperar (representativo — mismo patrón de éxito ya confirmado, en espíritu, por cada terraform apply exitoso de terraform-and-iac-guide; sin una ejecución en vivo contra un token válido en este momento):
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Outputs:
process_shipment_manifest_function_name = "process-shipment-manifest"
shipment_docs_bucket_arn = "arn:aws:s3:::andes-cargo-shipment-docs"
shipments_table_name = "Shipments"
Doce recursos creados, cero cambiados, cero destruidos — el mismo conteo que el plan de la lección 3 prometió, aplicado exactamente tal cual, sin que nadie haya tecleado terraform apply a mano en ningún momento de esta guía.
Errores comunes
Correr act push sin -W, y que corran workflows de más (de flujo). Qué pasa: alguien corre act push a secas dentro de andes-cargo-infra/, esperando que solo corra apply.yml, y en cambio ve también hello-andes-cargo.yml (Módulo 2) intentando ejecutarse, porque ambos escuchan push. Cómo detectarlo: aparecen dos secciones de jobs distintas en la salida, con nombres de workflow que no esperabas. Cómo corregirlo: usa -W .github/workflows/apply.yml para acotar la corrida a un único archivo, como esta lección.
Olvidar --artifact-server-path/--artifact-server-addr al correr apply.yml (de configuración, revisita la lección 3). Qué pasa: alguien corre act push -W .github/workflows/apply.yml sin esos dos flags, y el job fetch-reviewed-plan falla de inmediato con Unable to get the ACTIONS_RUNTIME_TOKEN env variable u otro error relacionado con artefactos. Cómo corregirlo: estos dos flags son obligatorios en cada invocación de act que use upload-artifact/download-artifact en esta guía, tanto para ci.yml como para apply.yml — no son opcionales ni específicos de una sola lección.
Interpretar el fallo de terraform apply como un problema con el plan descargado (de expectativa, el más importante de esta lección). Qué pasa: alguien ve Job failed en rojo y sospecha que el artefacto llegó corrupto, o que el plan tenía algún error. Cómo detectarlo: revisa el mensaje exacto — connection refused después de reintentos reales, sobre los cuatro recursos raíz, es la firma específica de "LocalStack no está corriendo", no de un problema con el archivo tfplan. El SHA256 que coincide entre ambos jobs (visible en los logs) ya confirmó que el archivo llegó intacto. Cómo corregirlo: docker ps -a --filter name=localstack_main es, otra vez, el primer comando de diagnóstico correcto.
Ejercicios
Ejercicio 1 — Reconstruye la secuencia de Stage de memoria. Sin mirar esta lección, describe qué job corre primero en apply.yml, qué pasa si ese primer job falla, y por qué el segundo job vuelve a descargar el mismo artefacto en vez de reutilizar el del primero.
Ver solución
fetch-reviewed-plan corre primero (Stage 0, sin needs:). Si falla —por ejemplo, si el artefacto nunca se subió—, terraform-apply (Stage 1, con needs: fetch-reviewed-plan) no corre en absoluto, evitando cualquier intento de aplicar sin haber confirmado primero que el plan existe. terraform-apply descarga el archivo de nuevo porque cada job corre en su propio contenedor Docker, sin sistema de archivos compartido con otros jobs del mismo workflow — el mecanismo de artefactos es, precisamente, la forma de mover un archivo de un contenedor a otro.
Ejercicio 2 — Explica los "9 intentos" a un colega. Un colega, familiarizado con los fallos de awslocal de módulos anteriores, te pregunta por qué este fallo tardó más de un minuto, cuando los de awslocal tardaban solo unos diez segundos. Respóndele con precisión.
Ver solución
Una respuesta completa suena, más o menos, así: "El proveedor de AWS de Terraform, escrito en Go, tiene su propia política de reintentos, distinta de la de botocore/awslocal en Python — reintenta hasta 9 veces con backoff exponencial antes de rendirse, en vez de los 3-4 intentos típicos de awslocal. Además, Terraform intentó crear los cuatro recursos raíz en paralelo, así que viste los Still creating... de varios recursos al mismo tiempo mientras cada uno agotaba sus propios reintentos por separado, antes de que Terraform reportara los cuatro errores juntos al final."
Ejercicio 3 — Verifica la integridad del plan con tus propios ojos. Sin mirar esta lección, ¿qué línea de la salida de act te permite confirmar que terraform-apply recibió exactamente el mismo archivo que fetch-reviewed-plan descargó primero, sin que nadie lo haya modificado en el medio?
Ver solución
La línea SHA256 digest of downloaded artifact is ... — aparece en ambos jobs, con el mismo valor hexadecimal exacto en los dos (17f7ebe9b774... en el ejemplo de esta lección). Un SHA256 idéntico en ambas descargas es la prueba criptográfica de que el archivo tfplan que terraform-apply usó para aplicar es, bit a bit, el mismo que ci.yml subió originalmente — ninguna reimpresión, ningún recálculo en el medio.
Resumen y siguiente paso
En esta lección construiste apply.yml completo y lo corriste con act push -W .github/workflows/apply.yml: el Stage 0 (fetch-reviewed-plan) descargó el artefacto con éxito, confirmado por SHA256; el Stage 1 (terraform-apply) hizo checkout, instaló Terraform, volvió a descargar el mismo artefacto, corrió terraform init con éxito, y finalmente intentó de verdad tflocal apply -auto-approve tfplan — un intento real, con reintentos reales del proveedor de AWS (9 intentos, más de un minuto), que falló con connection refused porque LocalStack no está corriendo en esta máquina. Viste también, representativo, el resultado que obtendrías con un token válido: Apply complete! Resources: 12 added.
Antes de avanzar deberías poder: correr apply.yml completo con act push, con los flags de artefactos correctos; leer un fallo de terraform apply distinguiendo un problema de red de uno del propio plan; y explicar por qué este job, a diferencia del terraform plan del Módulo 3, no puede completarse sin una conexión real a LocalStack.
La lección 5 agrega la última pieza de robustez que apply.yml todavía no tiene: qué pasa si dos push a main disparan dos corridas de este mismo workflow al mismo tiempo.
Recursos
- Terraform Docs — Command: apply — referencia oficial de
terraform apply, incluido el uso de un archivo de plan guardado. - nektosact.com — User Guide — referencia de
act push -W, usada para acotar la corrida a un único workflow. - AWS SDK for Go — Retry behavior — la política de reintentos del proveedor de AWS de Terraform, la razón de los "9 intentos" de esta lección.
- Módulo 5 de esta guía (
03-job-dependencies-with-needs.md) — el mecanismo completo deneeds:yupload-artifact/download-artifactque este archivo integra.