Módulo 4: Policy As Code With Conftest

5. De HCL a JSON: el `terraform plan` como entrada de `conftest`

Descripción

Todo lo que la lección 4 hizo contra un YAML de once líneas, esta lección lo hace contra el terraform plan completo de andes-cargo-infra/ — el mismo proyecto que terraform-and-iac-guide y el Módulo 2 de esta guía ya construyeron. conftest no sabe leer HCL directamente; necesita el plan convertido a JSON, con terraform show -json. Esta lección corre ese comando de verdad, y explora la estructura resultante hasta que sepas, sin dudar, dónde vive cada pieza que las políticas de las lecciones 6 y 7 van a leer.

Conexión con el módulo

Esta es la lección bisagra de todo el módulo: antes de aquí, cada ejemplo fue deliberadamente trivial (un YAML de prueba); después de aquí, cada política corre contra el input real y completo de Andes Cargo. Nada en las lecciones 6, 7 y 8 tiene sentido sin haber explorado, con tus propios ojos, la forma exacta de este JSON.


Analogía: la radiografía completa, no la foto del paciente

Una foto de un paciente te dice cómo se ve por fuera — útil, pero limitada: no puedes ver una fractura debajo de la piel con una foto. Una radiografía es una representación distinta del mismo paciente, diseñada específicamente para que una máquina —o un ojo entrenado— pueda detectar estructuras que la foto nunca mostraría. El HCL de andes-cargo-infra/ es la foto: legible, expresiva, pensada para que una persona la escriba y la lea. El terraform plan en JSON es la radiografía: una representación distinta del mismo proyecto, diseñada específicamente para que una máquina —conftest, en este caso— pueda examinar cada cambio propuesto con precisión estructural, campo por campo, sin ambigüedad de interpretación.


Paso 1 — terraform plan -out=tfplan: el plan guardado en disco

Ya conoces terraform plan desde terraform-and-iac-guide — lo nuevo aquí es el flag -out, que guarda el resultado del plan en un archivo binario, en vez de solo imprimirlo en la terminal:

cd andes-cargo-infra
terraform init
terraform plan -out=tfplan

Qué esperar (literal, ejecutado para escribir esta lección — el init):

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Finding hashicorp/archive versions matching "~> 2.0"...
- Installing hashicorp/aws v6.60.0...
- Installed hashicorp/aws v6.60.0 (signed by HashiCorp)
- Installing hashicorp/archive v2.8.0...
- Installed hashicorp/archive v2.8.0 (signed by HashiCorp)

Terraform has been successfully initialized!

Qué esperar (literal — el plan, encabezado y cierre; el cuerpo completo son los recursos que ya conoces de módulos anteriores):

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # data.aws_iam_policy_document.lambda_dynamodb_write will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "lambda_dynamodb_write" {
      + id            = (known after apply)
[...]

Plan: 14 to add, 0 to change, 0 to destroy.

Saved the plan to: tfplan

Plan: 14 to add — doce recursos de negocio heredados de terraform-and-iac-guide más los dos que el Módulo 2 de esta guía agregó (aws_iam_openid_connect_provider y el aws_iam_role de modules/oidc-provider/). tfplan, el archivo que acaba de aparecer en tu disco, no es texto legible — es el formato binario interno de Terraform, optimizado para que terraform apply tfplan lo pueda ejecutar exactamente como se planeó, sin recalcular nada. Ni conftest ni ningún otro programa fuera de Terraform puede leer este archivo directamente — para eso existe el Paso 2.


Paso 2 — terraform show -json: la conversión a JSON

terraform show -json tfplan > tfplan.json

Este comando no vuelve a calcular nada — toma el tfplan binario del Paso 1 y lo serializa completo a JSON, sin perder ni un campo. El resultado es un único archivo, potencialmente de cientos de kilobytes en un proyecto grande, con la estructura exacta del plan en un formato que cualquier lenguaje con soporte de JSON —incluido Rego— puede leer sin ambigüedad.

Qué esperar (verificando que el archivo existe y tiene contenido real):

ls -la tfplan.json
-rw-r--r--  1  user  staff  38214  tfplan.json

(El tamaño exacto en bytes varía según el proyecto y la versión del provider — lo que sí es literal y determinista es la estructura que exploras a continuación, no el peso del archivo.)


Paso 3 — Explorando la estructura: las claves de nivel superior

jq 'keys' tfplan.json

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

[
  "applyable",
  "checks",
  "complete",
  "configuration",
  "errored",
  "format_version",
  "output_changes",
  "planned_values",
  "prior_state",
  "relevant_attributes",
  "resource_changes",
  "terraform_version",
  "timestamp",
  "variables"
]

Catorce claves en total, pero solo tres importan para todo lo que este módulo hace: format_version y terraform_version (metadatos de contexto, útiles para confirmar contra qué versión de Terraform se generó un plan), y resource_changes — la lista completa de cada recurso que este plan va a crear, modificar, destruir, o simplemente leer. Cada política que vas a escribir en las lecciones 6 y 7 itera sobre input.resource_changes; el resto de las catorce claves queda fuera del alcance de este módulo.

jq '{format_version, terraform_version}' tfplan.json

Qué esperar (literal):

{
  "format_version": "1.2",
  "terraform_version": "1.15.8"
}

Paso 4 — resource_changes: la lista que las políticas recorren

jq '.resource_changes | length' tfplan.json

Qué esperar (literal):

16

Dieciséis, no catorce — y la diferencia importa entenderla antes de seguir. Plan: 14 to add cuenta solo los recursos gestionados (aws_dynamodb_table, aws_iam_role, y el resto) que Terraform va a crear. resource_changes incluye, además, los data sources —dos, en este proyecto— cuya acción no es create sino read: no son infraestructura que se vaya a crear, son consultas que Terraform resuelve para poder calcular el resto del plan. Confírmalo tú mismo:

jq -r '.resource_changes[] | "\(.address) | \(.type) | \(.change.actions)"' tfplan.json

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

data.aws_iam_policy_document.lambda_dynamodb_write | aws_iam_policy_document | ["read"]
aws_dynamodb_table.shipments | aws_dynamodb_table | ["create"]
aws_iam_role_policy.lambda_write_shipments | aws_iam_role_policy | ["create"]
aws_lambda_function.process_shipment_manifest | aws_lambda_function | ["create"]
aws_lambda_permission.allow_s3_invoke | aws_lambda_permission | ["create"]
aws_s3_bucket_notification.shipment_docs_trigger | aws_s3_bucket_notification | ["create"]
module.app_server_role.aws_iam_role.this | aws_iam_role | ["create"]
module.app_server_role.aws_iam_role_policy.this | aws_iam_role_policy | ["create"]
module.github_oidc.data.aws_iam_policy_document.trust | aws_iam_policy_document | ["read"]
module.github_oidc.aws_iam_openid_connect_provider.github_actions | aws_iam_openid_connect_provider | ["create"]
module.github_oidc.aws_iam_role.deploy | aws_iam_role | ["create"]
module.lambda_manifest_processor_role.aws_iam_role.this | aws_iam_role | ["create"]
module.lambda_manifest_processor_role.aws_iam_role_policy.this | aws_iam_role_policy | ["create"]
module.shipment_docs_bucket.aws_s3_bucket.this | aws_s3_bucket | ["create"]
module.shipment_docs_bucket.aws_s3_bucket_policy.this[0] | aws_s3_bucket_policy | ["create"]
module.shipment_docs_bucket.aws_s3_bucket_versioning.this[0] | aws_s3_bucket_versioning | ["create"]

Dos filas con ["read"] (las dos data "aws_iam_policy_document" cuyo valor depende de un ARN todavía no calculado — vas a ver esto de nuevo en la lección 7, cuando una de estas dos afecte qué puede o no puede verificar una política), catorce filas con ["create"]. .change.actions es siempre una lista, nunca un valor único — porque una acción real de Terraform puede ser compuesta: ["delete", "create"] describe un reemplazo completo (destruir y volver a crear, el patrón que ya viste en terraform-and-iac-guide con count condicional), no dos acciones independientes. Toda política de este módulo que busca destrucciones —la lección 6, específicamente— tiene que probar si "delete" está contenido en esta lista, no si la lista es exactamente ["delete"].


Paso 5 — Un resource_change completo, campo por campo

jq '.resource_changes[] | select(.address == "aws_dynamodb_table.shipments")' tfplan.json

Qué esperar (literal, recortado a los campos que este módulo usa; el objeto completo real incluye más metadatos de estado interno de Terraform, sin relevancia para las políticas de este módulo):

{
  "address": "aws_dynamodb_table.shipments",
  "type": "aws_dynamodb_table",
  "change": {
    "actions": [
      "create"
    ],
    "before": null,
    "after": {
      "name": "Shipments",
      "hash_key": "shipmentId",
      "billing_mode": "PAY_PER_REQUEST",
      "tags": {
        "Environment": "dev",
        "ManagedBy": "terraform",
        "Project": "andes-cargo"
      }
    }
  }
}

Cuatro campos que vas a usar en cada política del resto de este módulo:

  • address — el identificador único del recurso dentro del plan, idéntico al que usarías con terraform state show. La lección 6 lo usa para señalar, en el mensaje de deny, exactamente qué recurso violó la regla.
  • type — el tipo de recurso de Terraform (aws_dynamodb_table, aws_iam_role_policy, aws_s3_bucket...). Cada política de este módulo filtra resource_changes por este campo antes de mirar nada más — la lección 6 solo le importan los aws_dynamodb_table, la lección 7 solo los aws_iam_role_policy y los aws_s3_bucket/aws_s3_bucket_public_access_block.
  • change.before — el estado del recurso antes de este plan. Para un recurso que se está creando por primera vez (como este), before es null — no existía. Para un recurso que se está destruyendo, before tiene el contenido completo del recurso tal como existía; after es null.
  • change.after — el estado propuesto, después de aplicar el plan. Este es el campo que la mayoría de las políticas de este módulo inspeccionan: qué hash_key tiene la tabla, qué Action tiene cada Statement de una política IAM, qué valor tiene cada flag de un aws_s3_bucket_public_access_block.

Profundización: before/after, y por qué algunos valores faltan

Un detalle real que vas a encontrar de nuevo en la lección 7, así que vale la pena verlo aquí primero, en un caso más simple: no todos los campos de after están siempre presentes. Cuando el valor de un atributo depende de algo que Terraform todavía no puede calcular en el momento del plan —típicamente, una referencia a un ARN o un id de otro recurso que todavía no existe—, ese campo directamente no aparece en after; en cambio, aparece marcado como conocido-después-de-aplicar en una sección separada del JSON (after_unknown), fuera del alcance de este módulo. Una política Rego que asuma que un campo siempre está presente puede fallar en silencio —no con un error, sino con una condición que simplemente nunca se cumple, porque intentó leer una clave que no existe— si no considera este caso. Vas a ver el ejemplo concreto y real de esto en la lección 7: la política de mínimo privilegio necesita leer el campo policy de cada aws_iam_role_policy, y uno de los tres roles reales de Andes Cargo tiene ese campo ausente en este plan específico, por esta razón exacta.


Errores comunes

Correr terraform show -json antes de terraform plan -out=, sobre un archivo que no existe todavía (de orden). Qué pasa: alguien, apurado, corre terraform show -json tfplan > tfplan.json sin haber generado tfplan primero. Cómo detectarlo: el comando falla con un error explícito, algo como Error: Failed to read the given file as a state or plan file — no produce un tfplan.json vacío ni corrupto, se detiene de inmediato. Cómo corregirlo: el orden es siempre plan -out= primero, show -json después — el segundo comando lee el archivo binario que el primero produjo, no puede generar nada por sí solo.

Confundir Plan: N to add con el número de elementos en resource_changes (de conteo, el error de esta lección específicamente). Qué pasa: alguien ve Plan: 14 to add en la salida de texto, después cuenta resource_changes con jq y obtiene 16, y concluye que algo está mal o que el JSON tiene un bug. Cómo detectarlo: si tu primera reacción a los dos números distintos es "esto no cuadra". Cómo corregirlo: no hay ningún error — Plan: N to add cuenta únicamente recursos gestionados con acción create/update/delete; resource_changes incluye, además, los data sources con acción read, que nunca aparecen en el resumen de texto porque no son infraestructura que Terraform vaya a crear o cambiar. Esta lección lo confirmó con el mismo proyecto: 14 recursos gestionados, 2 lecturas de datos, 16 en total.

Escribir una política que asume que change.actions siempre tiene un solo elemento (de estructura de datos). Qué pasa: alguien escribe rc.change.actions == "delete" (comparación directa contra un string) en vez de "delete" in rc.change.actions (verificación de pertenencia a una lista). Cómo detectarlo: la política nunca dispara, ni siquiera contra un plan que sí destruye el recurso — porque change.actions es ["delete"], una lista de un elemento, y una lista nunca es igual a un string aunque contenga ese único string. Cómo corregirlo: siempre usa el operador in de Rego contra change.actions, exactamente como vas a ver en la política de la lección 6 — nunca una comparación de igualdad directa, porque una acción de reemplazo (["delete", "create"]) tiene más de un elemento.


Ejercicios

Ejercicio 1 — Cuenta cuántos recursos de este plan son de tipo aws_iam_role_policy, usando jq. Sin mirar el listado completo de esta lección, escribe el comando jq que filtre resource_changes por type == "aws_iam_role_policy" y cuente el resultado. ¿Cuántos esperarías encontrar, basándote en lo que sabes del Módulo 2 de esta guía?

Ver solución
jq '[.resource_changes[] | select(.type == "aws_iam_role_policy")] | length' tfplan.json

El resultado esperado es 3: la política inline de LambdaManifestProcessorRole (declarada dentro de modules/iam-role/), la de AppServerRole (mismo módulo), y aws_iam_role_policy.lambda_write_shipments (declarada aparte, en dynamodb.tf, para el permiso de escritura de la Lambda sobre la tabla Shipments). Los tres son recursos gestionados con type == "aws_iam_role_policy", aunque dos vivan dentro de un módulo y uno esté en el archivo raíz — jq, igual que Rego, no distingue "dentro de un módulo" de "en la raíz" al filtrar por type, solo mira el campo.

Ejercicio 2 — Explica, a un compañero, por qué resource_changes tiene entradas con actions: ["read"]. Tu compañero pregunta: "¿Por qué hay data sources en un archivo que se supone que describe qué se va a crear?". Respóndele en dos o tres frases.

Ver solución

Una respuesta completa: "terraform show -json no describe solo lo que se va a crear — describe todo lo que Terraform tuvo que evaluar para producir el plan, y eso incluye los data sources, que Terraform necesita leer (no crear) para poder calcular el valor de otros recursos que dependen de ellos. Por ejemplo, un data \"aws_iam_policy_document\" no crea ningún recurso de AWS — solo calcula el JSON de una política que después otro recurso (aws_iam_role_policy) sí va a crear con ese contenido. Su acción es read porque eso es, literalmente, lo único que hace: leer/calcular un valor, no modificar infraestructura."

Ejercicio 3 — Predice qué pasaría con change.before y change.after si este mismo plan describiera, en cambio, la destrucción de la tabla Shipments. Sin haber leído todavía la lección 6, predice: para un recurso que se está destruyendo (no creando), ¿qué esperarías encontrar en change.before y en change.after? Justifica con lo que ya sabes del Paso 5 de esta lección.

Ver solución

Sería exactamente lo inverso de lo que viste en el Paso 5: change.before tendría el contenido completo del recurso tal como existe hoy (name: "Shipments", hash_key: "shipmentId", y el resto), y change.after sería null — porque, después de aplicar este plan, el recurso ya no existiría. change.actions sería ["delete"]. Esta predicción es exactamente lo que la lección 6 confirma con un plan real que sí destruye la tabla, generado a propósito para probar que la política nueva lo detecta.


Resumen y siguiente paso

En esta lección convertiste el terraform plan de andes-cargo-infra/ a JSON con terraform show -json, y exploraste su estructura real con jq: catorce claves de nivel superior, de las cuales solo resource_changes importa para este módulo; dieciséis entradas en esa lista —catorce recursos gestionados con create, dos data sources con read—; y la anatomía completa de un resource_changeaddress, type, change.before, change.after— que cada política de las próximas dos lecciones va a leer.

Antes de avanzar deberías poder: generar tfplan.json desde cero, con los dos comandos exactos de esta lección; explicar la diferencia entre Plan: N to add y el conteo total de resource_changes; y predecir qué campos (before/after) tendría un recurso según si se está creando, modificando, o destruyendo.

La lección 6 usa, por primera vez, input.resource_changes dentro de una política real: no-destroy-shipments.rego, la que reemplaza el grep artesanal de cicd-and-gitops-on-aws-guide — y la que vas a ver fallar de verdad, contra un plan que sí destruye la tabla.

Recursos

  1. Terraform CLI — terraform show — la referencia oficial del comando que produce tfplan.json, incluida la bandera -json.
  2. Terraform — JSON Output Format — la especificación completa del formato JSON de un plan, incluidos resource_changes, change.actions, y after_unknown.
  3. jq — Manual oficial — la herramienta usada en esta lección para explorar el JSON antes de escribir Rego contra la misma estructura.
  4. Este módulo, lección 1 — la confirmación de que ni esta lección ni ninguna otra de este módulo necesita LocalStack: terraform plan calcula un diff local, sin tocar ningún recurso remoto.