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 delplan, idéntico al que usarías conterraform state show. La lección 6 lo usa para señalar, en el mensaje dedeny, 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 filtraresource_changespor este campo antes de mirar nada más — la lección 6 solo le importan losaws_dynamodb_table, la lección 7 solo losaws_iam_role_policyy losaws_s3_bucket/aws_s3_bucket_public_access_block.change.before— el estado del recurso antes de esteplan. Para un recurso que se está creando por primera vez (como este),beforeesnull— no existía. Para un recurso que se está destruyendo,beforetiene el contenido completo del recurso tal como existía;afteresnull.change.after— el estado propuesto, después de aplicar elplan. Este es el campo que la mayoría de las políticas de este módulo inspeccionan: quéhash_keytiene la tabla, quéActiontiene cadaStatementde una política IAM, qué valor tiene cada flag de unaws_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_change —address, 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
- Terraform CLI —
terraform show— la referencia oficial del comando que producetfplan.json, incluida la bandera-json. - Terraform — JSON Output Format — la especificación completa del formato JSON de un
plan, incluidosresource_changes,change.actions, yafter_unknown. jq— Manual oficial — la herramienta usada en esta lección para explorar el JSON antes de escribir Rego contra la misma estructura.- 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 plancalcula un diff local, sin tocar ningún recurso remoto.