Módulo 6: Rollback And Safety Nets

7. Manos a la obra: un guardrail mínimo, real y ejecutado

Descripción

Esta es la lección donde construyes, de verdad, el mecanismo que las lecciones 5 y 6 anticiparon: un step de ci.yml que lee el plan en JSON (terraform show -json) y falla el job si ese plan intenta destruir la tabla Shipments. No es conftest, no es una política declarativa — es, a propósito, un grep artesanal, el patrón mínimo real que la industria usaría como primer paso antes de construir algo más serio. Vas a probarlo dos veces: una vez donde pasa (un plan normal, sin ninguna destrucción), y una vez donde falla de verdad, en rojo, con el job completo detenido antes de llegar a apply.

Conexión con el módulo

La lección 6 te dejó con la pregunta correcta y la promesa de que esta lección la respondería con código real. Esta lección cumple esa promesa, y en el camino resuelve un problema honesto que las lecciones 3 y 6 ya adelantaron: probar un guardrail de destrucción necesita un plan que proponga destruir algo que ya existe — y el state de este proyecto, en esta máquina, sin un apply real completado, nunca tuvo nada que destruir. La lección 8 —el proyecto de este módulo— integra este guardrail al pipeline completo y lo prueba dos veces más, sobre el hilo narrativo de Andes Cargo.


Analogía: el detector de metales que solo revisa un bolsillo

Un sistema de seguridad de aeropuerto completo escanea todo el equipaje, contra una lista extensa y actualizada de objetos prohibidos, con máquinas capaces de distinguir formas complejas. El guardrail de esta lección es mucho más modesto que eso — es como un guardia con una sola instrucción muy específica: "revisa si alguien lleva, en este bolsillo exacto, este objeto exacto". No reemplaza al sistema completo —no revisa ningún otro bolsillo, no reconoce ningún otro objeto—, pero dentro de su instrucción específica, funciona de verdad, todas las veces, sin cansarse ni distraerse. Es exactamente el tipo de control que vale la pena tener mientras se construye el sistema completo, y exactamente el tipo de control que la lección 6 ya advirtió que no debe confundirse con el sistema completo.


Paso 1 — El guardrail: un grep sobre el plan en JSON

Ya conoces terraform show -json desde el Módulo 5 (lección 3), donde lo usaste para inspeccionar artefactos. Corrido sobre un archivo de plan guardado (tfplan, el mismo que ci.yml produce con -out=tfplan desde el Módulo 5), produce un único documento JSON —una sola línea, sin ningún salto de línea interno, verificado corriendo el comando de verdad— con, entre otros campos, un arreglo resource_changes, donde cada entrada describe un recurso y la acción que el plan propone sobre él:

{"address":"aws_dynamodb_table.shipments","mode":"managed","type":"aws_dynamodb_table","name":"shipments","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],...

Fíjate en el campo "actions": un arreglo de strings, típicamente ["create"], ["update"], ["delete"], o ["delete","create"] cuando Terraform necesita reemplazar un recurso completo (destruirlo y volver a crearlo, por ejemplo cuando cambia un atributo que no admite actualización en el lugar). Es exactamente el campo que un guardrail de destrucción necesita inspeccionar: si "actions" contiene "delete" para el recurso aws_dynamodb_table.shipments, el plan va a destruir la tabla.

El guardrail completo, agregado a ci.yml justo después del step Terraform plan y antes de Publish the plan to the job summary —a propósito, antes de que el plan se publique o se suba como artefacto, para que un plan bloqueado nunca llegue a apply.yml—:

      - name: Guardrail  block any plan that destroys the Shipments table
        run: |
          tflocal show -json tfplan > plan.json
          MATCH=$(grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]' plan.json || true)
          if echo "$MATCH" | grep -q '"delete"'; then
            echo "::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply."
            echo "$MATCH"
            exit 1
          fi
          echo "Guardrail passed: no destroy action found for aws_dynamodb_table.shipments."

Léelo línea por línea, porque cada una tiene una razón concreta:

  • tflocal show -json tfplan > plan.json — convierte el archivo binario tfplan (el mismo que -out=tfplan produjo, desde el Módulo 5) al JSON legible que este step necesita inspeccionar.
  • grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]' — busca, dentro del JSON de una sola línea, el fragmento que empieza en la dirección exacta del recurso (aws_dynamodb_table.shipments) y sigue hasta 200 caracteres después, capturando el arreglo "actions":[...] que le sigue. El rango de {0,200} no es arbitrario: es, verificado corriendo el comando de verdad contra el plan real de este proyecto, más que suficiente para cubrir los campos mode, type, name y provider_name que Terraform siempre coloca entre address y change.actions en este orden.
  • echo "$MATCH" | grep -q '"delete"' — de todo lo que capturó el primer grep, revisa si el texto "delete" aparece en algún lado. Si el recurso se está creando (["create"]), no aparece; si se está destruyendo (["delete"], o ["delete","create"] en un reemplazo), sí.
  • exit 1 — el código de salida que convierte este step en un fallo de job, deteniendo ci.yml antes de llegar a Publish the plan.../Upload the plan... — el plan bloqueado nunca se convierte en un artefacto que apply.yml pueda descargar.

Por qué esto es, a propósito, artesanal — y dónde se rompe

Vale la pena decirlo con la misma honestidad que la lección 6 ya adelantó: este grep funciona porque el JSON que produce esta versión de Terraform coloca "actions" a una distancia predecible de "address", dentro del mismo objeto. Si una versión futura de Terraform reordenara los campos de resource_changes, o si el mismo patrón de texto apareciera por coincidencia en otro lugar del documento, este grep podría fallar en silencio —dando un falso negativo (dejar pasar un delete real) o un falso positivo (bloquear un plan inocente)—. Es exactamente la fragilidad que la lección 6 ya nombró como la razón de fondo por la que conftest/Rego existe: una política real recorre el JSON como una estructura de datos, con acceso directo al campo resource_changes[].change.actions, sin depender de dónde cae cada carácter dentro de una línea de texto. Este grep no pretende ser esa solución — es el primer escalón, honesto sobre su propio límite, exactamente lo que cloud-security-and-guardrails-guide (Módulo 4 de esa guía) reemplaza más adelante por una política Rego real.


Paso 2 — Probando el caso que pasa: un plan normal

Corre ci.yml, con el guardrail ya agregado, exactamente como cualquier otro Pull Request de esta guía:

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.json -j terraform-checks \
  --artifact-server-path ./.artifacts \
  --artifact-server-addr "$ARTIFACT_ADDR"

Qué esperar (salida literal, ejecutada para escribir esta lección — extracto centrado en el guardrail; el resto del job es idéntico al que ya conoces desde el Módulo 3):

[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.452198s]
[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.086478584s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks]   ✅  Success - Main Publish the plan to the job summary [74.862333ms]
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks]   ✅  Success - Main Upload the plan for apply.yml to use later [743.220208ms]
[ci/terraform-checks] 🏁  Job succeeded

Guardrail passed — el grep corrió de verdad, contra el JSON real de un plan de 12 creaciones, y no encontró ninguna acción delete sobre Shipments, exactamente lo esperado: este plan solo crea recursos, sobre un state vacío. El job siguió normalmente hasta el final, subiendo el artefacto como cualquier otra corrida de esta guía.


El problema honesto: probar el caso que falla necesita algo que no existe todavía

Aquí es donde vale la pena detenerse, con la misma disciplina de honestidad del resto de esta guía. Para que el guardrail tenga algo real que bloquear, necesitas un plan cuyo "actions" para Shipments contenga "delete" — y "delete" solo aparece cuando el state de Terraform cree que el recurso ya existe y el HCL ya no lo describe. Como confirmaron el Módulo 3 (lección 6) y el Módulo 5 (lecciones 6 y 7), el state de este proyecto, en esta máquina, sin un LOCALSTACK_AUTH_TOKEN válido, nunca tuvo un apply real completado — está, y siempre estuvo, completamente vacío. Quitar la tabla del HCL sobre un state vacío no produce una destrucción: produce, simplemente, "una cosa menos por crear" — cero recursos que destruir, porque nunca hubo nada aplicado.

Esto no es una limitación de este guardrail específico — es la misma limitación estructural que ya viste con drift.yml en el Módulo 5 (lección 7): sin infraestructura real aplicada, no existe forma de generar, de manera honesta, un plan con una acción delete genuina contra esta LocalStack.

La solución, verificada, es una técnica legítima y bien acotada: sembrar el state local con un archivo de prueba (fixture) que declara, sin necesitar ningún apply real, que la tabla Shipments ya existe. Es el mismo principio detrás de probar cualquier control de seguridad contra un caso de prueba en vez de depender de infraestructura viva —la misma razón, en espíritu, por la que un sistema de políticas real (conftest, lección 6) suele probarse contra plan de ejemplo (fixtures) antes de confiar en que detecta lo que debería—.


Paso 3 — Construyendo el fixture del state

Terraform acepta escribir su state directamente desde un archivo, con terraform state push — un comando que no llama a ningún proveedor de nube, solo reemplaza el contenido del state local. Construye un archivo que declara la tabla Shipments como ya aplicada, con los atributos exactos que el esquema del proveedor hashicorp/aws (versión 6.60.0, la misma que ya instalaste desde el Módulo 3) espera:

.github/guardrail-fixtures/shipments-already-applied.state.json:

{
  "version": 4,
  "terraform_version": "1.15.8",
  "serial": 1,
  "lineage": "b6e3f9a2-4c1d-4e8a-9f2b-7d5c8a1e3f60",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "aws_dynamodb_table",
      "name": "shipments",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "arn": "arn:aws:dynamodb:us-east-1:000000000000:table/Shipments",
            "attribute": [{"name": "shipmentId", "type": "S"}],
            "billing_mode": "PAY_PER_REQUEST",
            "deletion_protection_enabled": false,
            "global_secondary_index": [],
            "hash_key": "shipmentId",
            "id": "Shipments",
            "local_secondary_index": [],
            "name": "Shipments",
            "point_in_time_recovery": [],
            "range_key": null,
            "read_capacity": 0,
            "region": "us-east-1",
            "replica": [],
            "restore_backup_arn": null,
            "restore_date_time": null,
            "restore_source_name": null,
            "restore_source_table_arn": null,
            "restore_to_latest_time": null,
            "server_side_encryption": [],
            "stream_arn": "",
            "stream_enabled": false,
            "stream_label": "",
            "stream_view_type": "",
            "table_class": "STANDARD",
            "tags": {"Environment": "dev", "ManagedBy": "terraform", "Project": "andes-cargo"},
            "tags_all": {"Environment": "dev", "ManagedBy": "terraform", "Project": "andes-cargo"},
            "timeouts": null,
            "ttl": [],
            "write_capacity": 0
          },
          "sensitive_attributes": [],
          "private": ""
        }
      ]
    }
  ]
}

Nota el nombre del archivo: no termina en .tfstate ni en .tfstate.* — es shipments-already-applied.state.json, a propósito. El .gitignore de este proyecto, heredado de terraform-and-iac-guide, tiene la línea *.tfstate, que excluye por diseño cualquier state real del historial de Git. Este archivo no es el state real del proyecto — es un caso de prueba (fixture), committeado como parte del repositorio, exactamente como se committea cualquier dato de prueba para un test automatizado. Si le pusieras un nombre que coincidiera con *.tfstate, Git lo ignoraría, y —como vas a confirmar en el Paso 5— act, corriendo en modo local, tampoco lo copiaría al contenedor del job, porque su checkout local respeta .gitignore.


Un hallazgo real: el checkout local de act refleja tu working tree, no solo tus commits

Antes de seguir, vale la pena nombrar algo verificado hoy, corriendo esta guía, con el mismo espíritu que cada hallazgo anterior de esta familia de guías. Cuando act corre localmente, sin un repositorio remoto real de GitHub configurado, su step actions/checkout@v4 no hace un clon limpio desde un remoto — copia el contenido de tu directorio de trabajo actual tal cual está, incluidos los cambios sin commitear en archivos ya trackeados por Git. Confirmado con una prueba directa: un archivo modificado en el disco, nunca commiteado, apareció exactamente con su contenido modificado dentro del contenedor del job.

Pero hay un límite exacto a esto, también verificado: cualquier archivo que coincida con un patrón de .gitignore —como terraform.tfstate, que coincide con *.tfstateno se copia al contenedor, esté o no presente en tu directorio de trabajo local. Confirmado con la misma prueba, en sentido contrario: un archivo terraform.tfstate de prueba, presente en el disco pero ignorado por Git, simplemente no existía dentro del contenedor.

   QUÉ COPIA EL CHECKOUT LOCAL DE act                QUÉ NO COPIA

   ✅ Archivos trackeados, con o sin                 ❌ Archivos que coinciden con
      cambios sin commitear                             cualquier patrón de .gitignore
   ✅ Archivos nuevos, sin trackear,                    (ej: terraform.tfstate,
      que NO coincidan con .gitignore                    cualquier *.tfstate)

Esta es exactamente la razón por la que el fixture de esta lección necesita un nombre que no coincida con *.tfstate: si se llamara terraform.tfstate o cualquier variante ignorada, ni siquiera llegaría al contenedor donde el guardrail se prueba.


Paso 4 — El cambio que dispara la destrucción

Con el fixture listo, el escenario necesita un cambio de HCL real que, combinado con ese state sembrado, produzca una acción delete sobre Shipments. Imagina que alguien en Andes Cargo, pensando erróneamente que la tabla se está reemplazando por un servicio nuevo (fuera del alcance de esta guía), abre un Pull Request que elimina el recurso por completo.

Vacía dynamodb.tf (el archivo entero, incluida la política IAM que dependía de la tabla, para que el HCL siga siendo válido):

# La tabla Shipments se dio de baja aquí por error — se deja el archivo
# vacío a propósito, para que el diff sea exactamente lo que describe
# esta lección: se elimina el recurso, no el archivo completo.

Y quita el output que dependía de ese recurso, en outputs.tf:

output "shipment_docs_bucket_arn" {
  description = "ARN of the shipment-docs bucket."
  value       = module.shipment_docs_bucket.bucket_arn
}

output "process_shipment_manifest_function_name" {
  description = "Name of the manifest-processing Lambda function."
  value       = aws_lambda_function.process_shipment_manifest.function_name
}

Confírmalo con terraform validate (no necesita ningún state ni conexión):

terraform validate

Qué esperar (literal):

Success! The configuration is valid.

Nota importante para tu propio proyecto: este cambio es exactamente el tipo de diff que viviría en una rama de feature, nunca en main — no lo commitees. Esta lección lo aplica directamente sobre el directorio de trabajo, sin crear una rama nueva de Git, porque el propósito es exclusivamente probar el guardrail; en la lección 8 vas a ver el mismo patrón, aplicado y luego revertido, sin dejar rastro permanente en el historial.


Paso 5 — Un workflow dedicado para probar el guardrail

El paso de "sembrar el state" nunca debería vivir dentro de ci.yml — ningún Pull Request real debería, jamás, empujar un state falso al proyecto. Por eso esta prueba vive en su propio workflow, separado, disparado solo a mano —el mismo patrón que ya usaste con hello-andes-cargo.yml y los workflows de prueba de secretos en el Módulo 1 y el Módulo 2—:

.github/workflows/guardrail-demo.yml:

name: guardrail-demo

on: workflow_dispatch

jobs:
  destroy-shipments-check:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_DEFAULT_REGION: us-east-1
    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 init
        run: terraform init -input=false

      - name: Seed the state with the fixture (Shipments already exists)
        run: terraform state push .github/guardrail-fixtures/shipments-already-applied.state.json

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

      - name: Terraform plan (offline, against the seeded fixture)
        run: tflocal plan -refresh=false -input=false -no-color -out=tfplan

      - name: Guardrail  block any plan that destroys the Shipments table
        run: |
          tflocal show -json tfplan > plan.json
          MATCH=$(grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]' plan.json || true)
          if echo "$MATCH" | grep -q '"delete"'; then
            echo "::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply."
            echo "$MATCH"
            exit 1
          fi
          echo "Guardrail passed: no destroy action found for aws_dynamodb_table.shipments."

Dos detalles que vale la pena notar: -refresh=false en el step de plan es lo que mantiene esta corrida completamente offline —sin él, Terraform intentaría confirmar el estado real de la tabla contra LocalStack antes de calcular el plan, y fallaría con el mismo tipo de connection refused que ya conoces—; y el guardrail en sí es exactamente el mismo bloque de código, sin ninguna modificación, que ya agregaste a ci.yml en el Paso 1 — la prueba de que estás validando la lógica real, no una versión distinta escrita solo para este demo.


Paso 6 — Corriéndolo: el job falla, de verdad, en rojo

rm -f terraform.tfstate
act workflow_dispatch -j destroy-shipments-check -W .github/workflows/guardrail-demo.yml

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

[guardrail-demo/destroy-shipments-check] ⭐ Run Set up job
[guardrail-demo/destroy-shipments-check]   ✅  Success - Set up job
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Check out andes-cargo-infra
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Check out andes-cargo-infra [58.503167ms]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Set up Terraform
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Set up Terraform [2.669285875s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Terraform init
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Terraform init [14.325724792s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Seed the state with the fixture (Shipments already exists)
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Seed the state with the fixture (Shipments already exists) [729.188333ms]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Install tflocal
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Install tflocal [5.643428375s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Terraform plan (offline, against the seeded fixture)
[guardrail-demo/destroy-shipments-check]   | Plan: 10 to add, 0 to change, 1 to destroy.
[guardrail-demo/destroy-shipments-check]   ✅  Success - Main Terraform plan (offline, against the seeded fixture) [4.476598084s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Guardrail — block any plan that destroys the Shipments table
[guardrail-demo/destroy-shipments-check]   ❗  ::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply.
[guardrail-demo/destroy-shipments-check]   | "address":"aws_dynamodb_table.shipments","mode":"managed","type":"aws_dynamodb_table","name":"shipments","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["delete"]
[guardrail-demo/destroy-shipments-check]   ❌  Failure - Main Guardrail — block any plan that destroys the Shipments table [2.03995275s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Complete job
[guardrail-demo/destroy-shipments-check]   ✅  Success - Complete job
[guardrail-demo/destroy-shipments-check] 🏁  Job failed
Error: Job 'destroy-shipments-check' failed

Plan: 10 to add, 0 to change, 1 to destroy. — a diferencia de cada otro plan de esta guía hasta ahora, este número incluye una destrucción real: la tabla Shipments, sembrada como ya existente por el fixture, y ausente del HCL. Los otros 10 recursos (de los 12 originales, sin contar la tabla ni la política IAM que dependía de ella, ambas eliminadas del HCL) siguen apareciendo como creaciones, porque el fixture solo declaró la tabla como aplicada — nada más.

El guardrail encontró exactamente lo que tenía que encontrar: la línea capturada por el grep"actions":["delete"]— confirma, con el mismo texto que produjo el propio Terraform, que este plan destruye la tabla. El job terminó en rojo, con el mensaje de error exacto que escribiste en el Paso 1, antes de que existiera cualquier oportunidad de que este plan llegara a un apply real.


Volviendo el proyecto a su estado saludable

Antes de cerrar esta lección, deshaz el cambio del Paso 4 —el HCL destructivo nunca debe quedar como el estado permanente del proyecto—:

git checkout -- dynamodb.tf outputs.tf
rm -f terraform.tfstate tfplan plan.json
git status

Qué esperar (literal):

On branch main
nothing to commit, working tree clean

git checkout -- <archivo> restaura cada archivo a su versión commiteada, descartando los cambios sin commitear del Paso 4 — exactamente lo que corresponde para un cambio que nunca se aprobó, nunca se fusionó, y nunca debió tocar main. Lo que sí queda, de forma permanente, es el guardrail agregado a ci.yml en el Paso 1, y el workflow guardrail-demo.yml junto a su fixture — las dos piezas reales de esta lección.

git add .github/workflows/ci.yml .github/workflows/guardrail-demo.yml .github/guardrail-fixtures/
git commit -m "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

Escribir el fixture con un nombre que termina en .tfstate (de configuración, el hallazgo central de esta lección). Qué pasa: alguien nombra el archivo shipments.tfstate en vez de shipments-already-applied.state.json, y terraform state push funciona perfectamente en el disco local, pero el paso de checkout dentro de act nunca lo copia al contenedor —el step Seed the state with the fixture falla con "no such file or directory". Cómo detectarlo: revisa el patrón de .gitignore (*.tfstate) contra el nombre exacto de tu archivo. Cómo corregirlo: cualquier nombre que no contenga la palabra "tfstate" en un patrón coincidente funciona — esta lección usa .state.json, a propósito, para que quede claro que es un state de Terraform sin activar el .gitignore.

Olvidar -refresh=false en el plan del guardrail-demo.yml (de configuración, revisita el Módulo 3). Qué pasa: alguien quita ese flag, y el step de plan intenta confirmar el estado real de la tabla contra LocalStack antes de calcular el diff, fallando con el mismo connection refused que ya conoces de cada intento de apply sin LocalStack corriendo. Cómo corregirlo: -refresh=false es exactamente lo que mantiene esta prueba offline — sin él, el fixture sembrado no es suficiente, porque Terraform de todas formas intenta verificar contra el proveedor real antes de confiar en el state local.

Dejar el cambio destructivo del Paso 4 commiteado en main (de flujo, potencialmente grave si se repite en un proyecto real). Qué pasa: alguien, después de correr el guardrail demo, olvida el paso de git checkout -- dynamodb.tf outputs.tf, y el proyecto queda con la tabla Shipments eliminada del HCL de forma permanente. Cómo detectarlo: git status mostraría cambios sin commitear en dynamodb.tf/outputs.tf, o —peor— un commit real con ese contenido. Cómo corregirlo: siempre restaura los archivos afectados antes de seguir a la lección 8, exactamente como muestra esta lección — el cambio destructivo existe únicamente para probar el guardrail, nunca para quedarse.


Ejercicios

Ejercicio 1 — Reconstruye el guardrail de memoria. Sin mirar esta lección, escribe (en papel o en un editor) las cuatro líneas del bloque run: del guardrail, en el orden correcto, y explica qué hace cada una.

Ver solución

1. tflocal show -json tfplan > plan.json — convierte el plan binario a JSON legible. 2. MATCH=$(grep -oE '...' plan.json || true) — busca el fragmento de texto correspondiente a aws_dynamodb_table.shipments y su campo actions. 3. if echo "$MATCH" | grep -q '"delete"'; then ... exit 1; fi — si ese fragmento contiene "delete", imprime un error y falla el step. 4. echo "Guardrail passed: ..." — si no se disparó el exit 1, confirma que el guardrail pasó. Si reconstruiste esto sin mirar, entendiste el mecanismo completo, no solo lo copiaste.

Ejercicio 2 — Explica por qué el fixture solo declara la tabla Shipments, no los otros once recursos. Un colega pregunta por qué no sembraste un state completo, con los 12 recursos aplicados. Respóndele con la razón exacta de esta lección.

Ver solución

Una respuesta completa suena, más o menos, así: "El único propósito de este fixture es darle al guardrail algo real que evaluar: un plan con una acción delete sobre Shipments. Para eso alcanza con que el state sepa que esa tabla específica ya existe — no hace falta simular los otros once recursos, que de todas formas seguirían apareciendo como 'to add' en el plan, sin afectar en nada la lógica del guardrail, que solo mira el fragmento correspondiente a la tabla. Sembrar solo lo estrictamente necesario mantiene el fixture más simple y más fácil de mantener, sin perder nada de la validez de la prueba."

Ejercicio 3 — Predice el resultado si el guardrail buscara "actions":["create"] en vez de "delete". Sin correr nada, ¿qué pasaría si alguien cambiara por error la condición del grep para buscar "create" en vez de "delete"? Corre el Paso 2 mentalmente sobre ese guardrail modificado.

Ver solución

El guardrail fallaría cada corrida normal de ci.yml, incluso sin ningún intento de destrucción — porque el plan de 12 recursos, en cualquier corrida sin apply real completado, siempre contiene "actions":["create"] para Shipments. Sería el equivalente a un detector de metales que se dispara con cualquier objeto, no solo con el prohibido: técnicamente "funciona" (siempre bloquea), pero deja de distinguir un cambio peligroso de un cambio normal, volviendo el pipeline completo inutilizable. Es un buen recordatorio de por qué la condición exacta del grep —qué acción, sobre qué recurso— importa tanto como el mecanismo en sí.


Resumen y siguiente paso

En esta lección construiste el primer guardrail automatizado real de esta guía: un grep sobre terraform show -json, agregado a ci.yml, probado en dos escenarios genuinos. En el primero —un plan normal, de 12 creaciones— el guardrail pasó, confirmado con salida literal de act. En el segundo —un plan calculado contra un fixture de state sembrado a propósito, junto a un cambio de HCL que elimina la tabla— el guardrail falló de verdad, deteniendo el job antes de cualquier oportunidad de aplicar. En el camino confirmaste un hallazgo real sobre cómo act refleja tu directorio de trabajo local, con el límite exacto que impone .gitignore.

Antes de avanzar deberías poder: explicar cada línea del guardrail sin mirar el archivo; describir por qué probar una acción delete necesita un state sembrado en este proyecto específico; y ubicar con precisión por qué este grep, aunque real y funcional, no reemplaza a un sistema de políticas como conftest.

La lección 8 —el proyecto de este módulo— integra este guardrail al pipeline completo de Andes Cargo y lo prueba una vez más, sobre el hilo narrativo completo: un cambio inocuo que pasa, y un intento de destruir Shipments que el pipeline entero detiene antes de aplicar.

Recursos

  1. Terraform Docs — Command: show — referencia oficial de terraform show -json, el comando central de esta lección.
  2. Terraform Internals — JSON Output Format — el esquema público y estable del JSON que produce terraform show -json, incluido el campo resource_changes[].change.actions que el guardrail evalúa.
  3. Terraform Docs — Command: state push — referencia oficial del comando que siembra el fixture de esta lección.
  4. Módulo 6 de esta guía (06-guardrails-of-apply-conftest-named.md) — la lección anterior, con la diferencia exacta entre este grep y un sistema de políticas real.