🧾 Plan y apply en automatización: el fichero, las políticas y el fallo a medias¶
1. Qué hay dentro de un plan¶
terraform plan hace tres cosas en orden: refresca (lee cada recurso del estado en Azure para conocer su situación real), compara (configuración deseada contra situación real) y ordena (construye el grafo de acciones). Con -out, el resultado se guarda en un fichero que es, en realidad, un ZIP con cinco piezas.
| Pieza | Contenido | Consecuencia |
|---|---|---|
tfplan |
Acciones por recurso, valores planificados, valores de todas las variables no efímeras, versión de Terraform, hash del backend | El fichero es tan secreto como el estado (página 10) |
tfstate |
El estado previo refrescado, con lineage y serial | Si el estado remoto cambia de serial, el plan queda stale |
tfstate-prev |
El estado antes de refrescar | Permite mostrar la deriva detectada durante el plan |
tfconfig/ |
Copia completa de los .tf y módulos |
El apply no relee tu directorio: aplica lo que el plan capturó |
.terraform.lock.hcl |
Versiones y hashes de providers | El apply exige los mismos providers y la misma versión de Terraform |
# El JSON del plan: la interfaz para máquinas (comentarios en PR, políticas, comparaciones)
terraform show -json plan.tfplan | jq '{
terraform_version, applyable, complete, errored,
variables: (.variables | keys), # nombres; los valores están en .variables[].value
cambios: [.resource_changes[] | select(.change.actions != ["no-op"]) | {address, actions: .change.actions, reason: .action_reason}]
}'
# Acciones posibles: ["no-op"] ["create"] ["read"] ["update"] ["delete"] ["delete","create"] ["create","delete"] ["forget"]
# Por qué se reemplaza: .change.replace_paths (el atributo que fuerza el reemplazo) y .action_reason ("replace_because_cannot_update", "replace_by_request" con -replace, "delete_because_no_resource_config"…)
terraform show -json plan.tfplan | jq '.resource_changes[] | select(.change.actions == ["delete","create"]) | {address, por: .change.replace_paths}'
# Lo sensible viene marcado aparte: .change.after_sensitive tiene true donde .change.after tiene un valor que no debe mostrarse
# Lo desconocido hasta el apply: .change.after_unknown (ids, fqdn…)
# Códigos de salida con -detailed-exitcode (página 13): 0 sin cambios · 1 error · 2 cambios
# La versión legible, sin color, con sensibles ocultos: el artefacto adecuado para un revisor humano
terraform show -no-color plan.tfplan > plan.txt
⚠️ Tres condiciones para que un plan siga siendo aplicable. La misma versión de Terraform que lo creó; la misma configuración (el apply usa la copia interna, pero avisa si el directorio difiere); y, sobre todo, el mismo serial del estado remoto. Cualquier apply, import o
state mventre medias produce Saved plan is stale. No es un fallo del pipeline: es Terraform negándose a ejecutar decisiones tomadas sobre una realidad que ya no existe.
2. Qué hace el apply, y qué no¶
terraform apply plan.tfplan no replanifica ni pregunta: ejecuta el grafo del fichero, en paralelo (diez recursos a la vez por defecto), escribiendo el estado tras cada recurso. Por eso -auto-approve con un fichero de plan es redundante, y por eso un apply que falla a medias no deja "nada": deja todo lo que ya se creó, registrado.
terraform apply plan.tfplan # sin prompt; -auto-approve no hace nada aquí. -var no está permitido…
TF_VAR_api_key_sms="$(…)" terraform apply plan.tfplan # …salvo para variables ephemeral, que no viajan en el plan y hay que volver a dar ([página 10](index.md#pagina-10))
terraform apply -lock-timeout=10m plan.tfplan # espera el lease hasta 10 min en lugar de fallar al instante (no lo libera: [página 13](index.md#pagina-13))
terraform apply -parallelism=4 plan.tfplan # menos concurrencia: útil con límites de API de Azure (429) o Topaz en una máquina pequeña
terraform apply -json plan.tfplan | jq -r 'select(.type == "apply_complete" or .type == "apply_errored") | "\(.hook.resource.addr): \(.type)"'
# salida legible por máquinas, línea a línea: qué recurso terminó, cuál falló, cuánto tardó (.hook.elapsed_seconds)
# Sin fichero de plan: apply planifica y pide confirmación; -auto-approve la salta. Solo tiene sentido fuera del pipeline o en dev.
terraform apply -var-file=envs/dev.tfvars -auto-approve
| Qué falla | Qué queda | Recuperación |
|---|---|---|
| Un recurso (Azure devuelve error) | Los anteriores creados y en el estado; los dependientes no se intentan; el apply termina con código 1 | Corrige la causa y vuelve a planificar: el nuevo plan solo contiene lo que falta |
| Un provisioner | El recurso existe pero queda tainted: el siguiente plan lo reemplaza | Si el recurso está bien, terraform untaint; si no, deja que lo reemplace |
| La escritura del estado al backend | Terraform escribe errored.tfstate en el directorio y avisa |
terraform state push errored.tfstate: el único caso en que state push es correcto |
| El runner (cancelado, perdido) | Estado parcial guardado hasta el último recurso completado; el lease queda tomado | force-unlock con el ID (página 13), luego plan: detecta lo creado que no llegó al estado solo si tiene import; revisa en Azure |
3. Separar plan y apply: tres formas, una comparación¶
El original separa plan y apply subiendo tfplan como artefacto y descargándolo en otro job. Eso tiene dos problemas ya vistos (el fichero contiene los secretos; caduca) y uno nuevo: el revisor aprueba mirando la pantalla del plan de la PR, pero lo que se aplica es otro plan, hecho después del merge. ¿Cómo se sabe que son iguales? Comparándolos.
| Patrón | Cómo | Cuándo |
|---|---|---|
| Mismo job (página 13) | plan -out y apply seguidos, detrás de la puerta del environment; el plan de la PR es solo para revisar |
Por defecto. Sin artefactos, sin caducidad, compatible con variables efímeras |
| Mismo job + comparación | Antes de aplicar, se reduce el plan a una huella (direcciones + acciones) y se compara con la huella que la PR publicó; si difieren, el job se detiene | Producción: garantiza que lo aprobado es lo aplicado, aunque haya pasado tiempo o alguien tocara Azure |
| Artefacto (el original, bien hecho) | El fichero se cifra antes de subir (age, clave de Key Vault), retención de horas, misma versión de Terraform fijada, y el apply asume que puede estar stale |
Solo si una norma exige aplicar el fichero exacto que se revisó. Incompatible con variables efímeras |
# Huella de un plan: qué recursos y qué acciones, sin valores (así se puede publicar en la PR sin filtrar nada)
huella() { terraform show -json "$1" | jq -S '[.resource_changes[] | select(.change.actions != ["no-op"]) | {address, actions: .change.actions, replace: .change.replace_paths}]'; }
# En el job de plan de la PR (página 13): la huella va al comentario y se guarda como output del check
huella plan.tfplan | tee huella.json | sha256sum | cut -c1-16 # p. ej. 3f9a1c…; el comentario de la PR la muestra
# En el job de apply: replanifica, calcula la huella y compárala con la aprobada (leída del comentario de la PR o del check)
terraform plan -var-file=envs/pro.tfvars -out=plan.tfplan -detailed-exitcode; code=$?
[ $code -eq 2 ] || exit $code
if [ "$(huella plan.tfplan | sha256sum)" != "$(cat huella_aprobada.sha)" ]; then
echo "::error::El plan difiere del aprobado en la PR"; diff <(huella plan.tfplan) huella_aprobada.json; exit 1
fi
terraform apply plan.tfplan
# Diferencias legítimas (un id que ahora se conoce) no cambian la huella: solo direcciones y acciones. Una deriva sí la cambia: y eso es lo que quieres parar.
4. Políticas sobre el plan: lo que un revisor no debería tener que mirar¶
Un revisor humano detecta un cambio de talla mal pensado; no debería ser quien detecte que un plan borra la base de datos, abre un storage al público o crea un recurso sin etiquetas. Eso lo comprueba el pipeline sobre el JSON del plan, entre el plan y el apply, y lo bloquea. Hay tres herramientas, de menos a más formal, y una cuarta línea de defensa dentro del propio código.
# 1. jq: suficiente para tres o cuatro reglas
terraform show -json plan.tfplan > plan.json
jq -e '[.resource_changes[] | select(.change.actions | index("delete")) | select(.type | IN("azurerm_mysql_flexible_server","azurerm_storage_account","azurerm_key_vault"))] | length == 0' plan.json \
|| { echo "::error::El plan destruye un recurso con datos"; exit 1; }
jq -e '[.resource_changes[] | select(.change.actions | index("create") or index("update")) | select(.change.after.tags? != null) | select((.change.after.tags | has("proyecto") and has("entorno")) | not)] | length == 0' plan.json \
|| { echo "::error::Recursos sin tags obligatorias"; exit 1; }
# 2. OPA / conftest: reglas en Rego, versionadas junto al código, con tests propios. policy/moodle.rego:
# package main
# import rego.v1
# datos := {"azurerm_mysql_flexible_server", "azurerm_storage_account", "azurerm_key_vault"}
# deny contains msg if {
# rc := input.resource_changes[_]; "delete" in rc.change.actions; rc.type in datos
# msg := sprintf("%s: destruir un recurso con datos requiere un cambio separado y aprobado", [rc.address])
# }
# deny contains msg if {
# rc := input.resource_changes[_]; rc.type == "azurerm_storage_account"; rc.change.after.allow_nested_items_to_be_public == true
# msg := sprintf("%s: blobs públicos no permitidos", [rc.address])
# }
# warn contains msg if {
# rc := input.resource_changes[_]; rc.change.actions == ["delete", "create"]
# msg := sprintf("%s se reemplaza (%v): confirma que no hay datos dentro", [rc.address, rc.change.replace_paths])
# }
docker run --rm -v "$PWD:/p" openpolicyagent/conftest test /p/plan.json -p /p/policy --all-namespaces # deny → código 1; warn → aviso
# 3. terraform test (1.6+): aserciones sobre el plan con la sintaxis de Terraform, en tests/politicas.tftest.hcl
# variables { entorno = "pro" location = "eastus" }
# run "pro_sin_borrados" {
# command = plan
# assert {
# condition = azurerm_storage_account.moodledata.shared_access_key_enabled == false
# error_message = "moodledata debe tener las claves compartidas desactivadas"
# }
# assert {
# condition = alltrue([for k in ["proyecto", "entorno", "gestion"] : contains(keys(azurerm_resource_group.moodle.tags), k)])
# error_message = "faltan tags obligatorias en el grupo de recursos"
# }
# }
terraform test # habla con el provider (Topaz): planifica de verdad, sin crear nada con command = plan
# 4. Dentro del código: la última línea, que actúa también fuera del pipeline
resource "azurerm_mysql_flexible_server" "moodle" {
# …
lifecycle {
prevent_destroy = true # el plan que lo borraría falla: "Instance cannot be destroyed"
precondition {
condition = var.entorno != "pro" || var.mysql_sku != "B_Standard_B1ms"
error_message = "En pro no se admite la talla Burstable B1ms."
}
}
}
check "backups_pro" { # check: avisa sin bloquear; para lo que quieres saber pero no impedir
assert {
condition = var.entorno != "pro" || azurerm_mysql_flexible_server.moodle.geo_redundant_backup_enabled
error_message = "Pro sin backup georredundante."
}
}
🔷 Cuál elegir.
jqpara empezar hoy con dos reglas en el propio workflow. Conftest cuando las reglas son más de cinco, las comparten varios repositorios o alguien pregunta "¿dónde está escrito que no se puede?".terraform testpara lo que depende de tus variables y módulos, porque habla el mismo lenguaje que el código.prevent_destroyy las precondiciones, siempre: protegen también al que ejecutadestroydesde su portátil un viernes.
5. Emergencias: -replace, -target y el retroceso¶
El original propone -target para "probar cambios específicos" y ordenar los recursos en el fichero para resolver dependencias. Lo segundo no hace nada: HCL es declarativo y el orden lo dan las referencias entre recursos (y depends_on cuando la dependencia no es visible en un atributo). Lo primero es una herramienta de emergencia: aplica un subconjunto del grafo y deja el resto sin reconciliar, con la advertencia Resource targeting is in effect. Tiene su sitio, pero no en el flujo normal.
# Reemplazar un recurso que está corrupto pero cuya configuración no ha cambiado (una VM que no arranca)
terraform plan -replace=azurerm_linux_virtual_machine_scale_set.web -out=plan.tfplan # action_reason: "replace_by_request"
# Aplicar solo una parte, porque el resto del plan está bloqueado por algo ajeno (un límite de cuota) y hay que sacar un arreglo ya
terraform plan -target=azurerm_key_vault_secret.sms -out=plan.tfplan
# Warning: Resource targeting is in effect … The -target option is not for routine use
# Después, obligatorio: un plan completo sin -target, para que el estado vuelva a coincidir con el código.
# En el pipeline, las emergencias entran por workflow_dispatch con inputs, quedan en el log con quién y por qué, y pasan por el mismo environment:
# on: { workflow_dispatch: { inputs: { replace: { description: "Dirección a reemplazar" }, motivo: { required: true } } } }
# run: terraform plan ${{ inputs.replace && format('-replace={0}', inputs.replace) || '' }} -out=plan.tfplan …
# Retroceder un cambio: revertir el commit y dejar que el pipeline aplique. NUNCA terraform state push de un estado antiguo:
# el estado describe lo que existe, no lo que quieres; empujar uno viejo hace que Terraform crea que recursos reales no existen (y los recree) o que existen (y falle).
git revert <sha> && git push # → PR → plan (deshace exactamente lo que el commit hizo) → aprobación → apply
# Un arreglo manual urgente en el portal: primero reconoce la deriva, después decide
terraform plan -refresh-only # muestra lo que cambió fuera; apply -refresh-only acepta esos valores en el estado sin tocar Azure
# Si el cambio se queda: llévalo al código. Si no: el siguiente apply normal lo revierte.
6. Laboratorio en Topaz¶
Todo el laboratorio ocurre entre ficheros que Terraform produce y el emulador: se abre un plan, se comparan dos, se caduca uno, se evalúan políticas con jq, conftest y terraform test, se provoca un apply parcial con un provisioner que falla y se recupera, y se prueban prevent_destroy y -replace.
mkdir -p ~/tf-plan/{policy,tests} && cd ~/tf-plan && cp ~/tf-st/providers.tf .
# ─── 1. Código con salvaguardas y un fallo provocado ─────────────────────────────
cat > main.tf <<'EOF'
variable "entorno" { type = string, default = "dev" }
variable "location" { type = string, default = "eastus" }
variable "fallar" { type = bool, default = false }
variable "publico" { type = bool, default = false }
locals { tags = { proyecto = "moodle", entorno = var.entorno, gestion = "terraform" } }
resource "azurerm_resource_group" "lab" { name = "rg-plan-lab-${var.entorno}", location = var.location, tags = local.tags }
resource "azurerm_storage_account" "datos" {
name = "stplan${substr(md5(azurerm_resource_group.lab.id), 0, 8)}"
resource_group_name = azurerm_resource_group.lab.name, location = azurerm_resource_group.lab.location
account_tier = "Standard", account_replication_type = "LRS", min_tls_version = "TLS1_2", shared_access_key_enabled = false
allow_nested_items_to_be_public = var.publico
tags = local.tags
lifecycle {
prevent_destroy = true
precondition { condition = var.entorno != "pro" || !var.publico, error_message = "En pro no se admiten blobs públicos." }
}
}
resource "terraform_data" "post" { # simula un paso posterior que puede fallar en el apply (no en el plan)
triggers_replace = [var.fallar]
provisioner "local-exec" { command = var.fallar ? "echo 'fallo simulado' && exit 1" : "echo ok" }
depends_on = [azurerm_storage_account.datos]
}
check "tls" { assert { condition = azurerm_storage_account.datos.min_tls_version == "TLS1_2", error_message = "El TLS mínimo debe ser 1.2." } }
output "storage" { value = azurerm_storage_account.datos.name }
EOF
cat > policy/moodle.rego <<'EOF'
package main
import rego.v1
datos := {"azurerm_mysql_flexible_server", "azurerm_storage_account", "azurerm_key_vault"}
deny contains msg if {
rc := input.resource_changes[_]
"delete" in rc.change.actions
rc.type in datos
msg := sprintf("%s: destruir un recurso con datos requiere un cambio separado y aprobado", [rc.address])
}
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "azurerm_storage_account"
rc.change.after.allow_nested_items_to_be_public == true
msg := sprintf("%s: blobs públicos no permitidos", [rc.address])
}
warn contains msg if {
rc := input.resource_changes[_]
rc.change.actions == ["delete", "create"]
msg := sprintf("%s se reemplaza (%v): confirma que no hay datos dentro", [rc.address, rc.change.replace_paths])
}
EOF
cat > tests/politicas.tftest.hcl <<'EOF'
run "pro_seguro" {
command = plan
variables { entorno = "pro" }
assert {
condition = azurerm_storage_account.datos.shared_access_key_enabled == false
error_message = "Las claves compartidas deben estar desactivadas."
}
assert {
condition = alltrue([for k in ["proyecto", "entorno", "gestion"] : contains(keys(azurerm_resource_group.lab.tags), k)])
error_message = "Faltan tags obligatorias."
}
}
run "pro_publico_rechazado" {
command = plan
variables { entorno = "pro", publico = true }
expect_failures = [azurerm_storage_account.datos] # la precondition debe fallar: el test pasa si el plan falla ahí
}
EOF
terraform init >/dev/null
# ─── 2. Abrir un plan ─────────────────────────────────────────────────────────────
terraform plan -out=plan.tfplan >/dev/null
mkdir -p /tmp/plan && unzip -oq plan.tfplan -d /tmp/plan && ls -A /tmp/plan # tfplan tfstate tfstate-prev tfconfig/ .terraform.lock.hcl
ls /tmp/plan/tfconfig # tu main.tf, copiado: el apply usa esto, no tu directorio
terraform show -json plan.tfplan | jq '{terraform_version, applyable, variables, cambios: [.resource_changes[] | {address, actions: .change.actions}]}'
# variables: {"entorno":{"value":"dev"}, "fallar":{"value":false}, …} ← con valor. Un tfvars con contraseña estaría aquí (página 10)
terraform show -json plan.tfplan | jq '.resource_changes[] | select(.address == "azurerm_storage_account.datos") | .change.after_unknown | keys'
# ["id","primary_blob_endpoint",…]: lo que solo se sabe tras el apply
terraform show -no-color plan.tfplan | head -20 # la versión para humanos, sensibles ocultos
# ─── 3. Dos planes, una huella ────────────────────────────────────────────────────
huella() { terraform show -json "$1" | jq -S '[.resource_changes[] | select(.change.actions != ["no-op"]) | {address, actions: .change.actions, replace: .change.replace_paths}]'; }
huella plan.tfplan | tee huella_aprobada.json | sha256sum | cut -c1-16 # lo que la PR publicaría
terraform plan -out=plan2.tfplan >/dev/null # el plan del job de apply, minutos después
diff <(huella plan.tfplan) <(huella plan2.tfplan) && echo "misma huella: lo aprobado es lo que se aplica"
# ─── 4. Caducidad ────────────────────────────────────────────────────────────────
terraform apply plan2.tfplan # sin prompt: el fichero ya es la decisión
terraform apply plan.tfplan
# Error: Saved plan is stale — el serial del estado cambió con el apply anterior. El "artefacto" del original moriría aquí.
terraform plan -detailed-exitcode >/dev/null; echo "código: $?" # 0: nada pendiente
# ─── 5. Políticas: jq, conftest, terraform test ──────────────────────────────────
terraform plan -var publico=true -out=malo.tfplan >/dev/null && terraform show -json malo.tfplan > malo.json
jq -e '[.resource_changes[] | select(.type == "azurerm_storage_account") | select(.change.after.allow_nested_items_to_be_public == true)] | length == 0' malo.json \
|| echo "jq: bloqueado (blobs públicos)"
docker run --rm -v "$PWD:/p" openpolicyagent/conftest test /p/malo.json -p /p/policy --all-namespaces
# FAIL - … azurerm_storage_account.datos: blobs públicos no permitidos (1 test, 1 failure) → código 1
terraform plan -destroy -var publico=false -out=borra.tfplan 2>/dev/null || true # falla por prevent_destroy (paso 7); la política de conftest lo pararía igual
terraform test
# run "pro_seguro"… pass · run "pro_publico_rechazado"… pass (la precondition falló como se esperaba)
# Ninguno creó nada: command = plan planifica contra Topaz y descarta.
# ─── 6. Apply a medias y recuperación ────────────────────────────────────────────
terraform apply -var fallar=true -auto-approve
# azurerm_storage_account.datos: (sin cambios)
# terraform_data.post: Provisioning with 'local-exec'… fallo simulado
# Error: local-exec provisioner error … Apply no termina con "complete": código 1
terraform state list # el storage sigue ahí; terraform_data.post también, marcado
terraform state show terraform_data.post | head -3 # "# terraform_data.post: (tainted)"
terraform plan -var fallar=true -no-color | grep -E "tainted|must be replaced" # el siguiente plan lo reemplaza
terraform untaint terraform_data.post # si el recurso estuviera bien: se levanta la marca
terraform plan -var fallar=false -out=fix.tfplan >/dev/null && terraform apply fix.tfplan # el arreglo solo toca lo que faltaba
terraform apply -json -var fallar=false -auto-approve | jq -r 'select(.type == "apply_complete" or .type == "apply_errored") | "\(.hook.resource.addr): \(.type) (\(.hook.elapsed_seconds // 0)s)"'
# salida por recurso, legible por máquinas: lo que un dashboard de despliegues consume
# errored.tfstate no se puede provocar en Topaz sin apagar el emulador a mitad de apply; se ve en el bloque de Azure real.
# ─── 7. Salvaguardas y emergencias ───────────────────────────────────────────────
terraform plan -destroy
# Error: Instance cannot be destroyed — azurerm_storage_account.datos has lifecycle.prevent_destroy set …
terraform plan -var entorno=pro -var publico=true
# Error: Resource precondition failed — En pro no se admiten blobs públicos.
terraform plan -replace=terraform_data.post -out=rep.tfplan >/dev/null && terraform show -json rep.tfplan | jq -r '.resource_changes[] | select(.address == "terraform_data.post") | .action_reason'
# replace_by_request
terraform plan -target=terraform_data.post -no-color 2>&1 | grep -A3 "Warning: Resource targeting"
# "…The -target option is not for routine use, and is provided only for exceptional situations…"
terraform plan -refresh-only -detailed-exitcode >/dev/null; echo "deriva: $?" # 0 ahora; 2 tras tocar algo con az ([página 13](index.md#pagina-13))
# ─── 8. Limpiar ──────────────────────────────────────────────────────────────────
sed -i 's/prevent_destroy = true/prevent_destroy = false/' main.tf # la salvaguarda protege también de este comando: hay que quitarla a propósito
terraform destroy -auto-approve
rm -f *.tfplan *.json huella_aprobada.json && rm -rf /tmp/plan
# ─── Solo Azure real ────────────────────────────────────────────────────────────
# A. errored.tfstate: el recurso se created, el estado no se puede escribir. Provócalo quitando el rol sobre el contenedor durante un apply largo:
az role assignment delete --assignee <principal_id de id-moodle-tf> --role "Storage Blob Data Contributor" --scope <id del contenedor tfstate>
# (en otra terminal, mientras corre el apply)
# Error: Failed to save state … Terraform wrote the state to errored.tfstate …
az role assignment create --assignee … --role "Storage Blob Data Contributor" --scope …
terraform state push errored.tfstate # el único state push legítimo; luego terraform plan debe dar 0 cambios
rm errored.tfstate
# B. Límites de la API de Azure: applies con muchos recursos devuelven 429 TooManyRequests; el provider reintenta, pero el tiempo se dispara
terraform apply -parallelism=5 -json plan.tfplan | jq -r 'select(.type == "diagnostic") | .diagnostic.summary' | sort | uniq -c
# Si aparecen reintentos, baja la concurrencia en el pipeline y divide el cambio en PRs más pequeñas.
# C. Políticas en el pipeline (página 13), entre plan y apply, en el mismo job:
# - run: terraform show -json plan.tfplan > plan.json && docker run --rm -v "$PWD:/p" openpolicyagent/conftest test /p/plan.json -p /p/policy --all-namespaces
# - run: terraform test
# Y la huella del plan de la PR guardada como output del check; el apply la compara antes de ejecutar (14.3).
7. Errores comunes¶
⚠️ Solución de problemas
Mensaje o síntoma Causa y solución Saved plan is stale El serial del estado cambió entre plan y apply (otro apply, un import, un state mv). Plan y apply en el mismo job; el plan de la PR es para revisar. Si necesitas garantía de identidad, la huella (14.3)The plan file was created by Terraform v1.11.4 but this is v1.12.x Versiones distintas entre jobs ( terraform_version: latest). Una sola variableTF_VERSIONyrequired_versionen el código (página 13)Can't set variables when applying a saved plan Las variables ya están dentro del fichero; -varno se admite. La excepción son lasephemeral, que no viajan y hay que volver a dar porTF_VAR_(página 10)-auto-approvejunto a un fichero de plan, odeployInputs: {"autoApprove": false}(el original)Aplicar un fichero de plan nunca pregunta: -auto-approvees redundante ydeployInputsno existe enTerraformTaskV4. La aprobación es del pipeline (environment), no del comandotfplansubido conupload-artifacty descargado en otro jobContiene los valores de todas las variables no efímeras y el estado previo: es un secreto descargable y, además, caduca. Mismo job; si una norma exige el fichero exacto, cifrado y con retención de horas (14.3) terraform init && terraform validatefalla en el job de validación sin credencialesinitintenta conectar al backend. Para validar sin tocar Azure:init -backend=falseError: Instance cannot be destroyed … prevent_destroy en un destroy legítimo Está haciendo su trabajo. Quita la marca en un commit propio (que pasa por la PR y queda auditado), aplica, y vuelve a ponerla si el recurso sigue existiendo. Nunca -targetpara esquivarlaResource precondition failed en un entorno donde no debería aplicar La condición no distingue entornos. Formúlala como implicación: var.entorno != "pro" || <regla>El apply falla en el recurso 8 de 12 y "no se ha creado nada" Sí se ha creado: los siete anteriores están en el estado. No borres nada a mano: corrige la causa y planifica; el nuevo plan solo contiene lo que falta … is tainted, so must be replaced Un provisioner falló tras crear el recurso. Si el recurso es correcto, terraform untaint; si no, deja que el plan lo reemplace. Y plantéate si ese provisioner debería ser cloud-init o una extensión (página 15)Failed to save state … wrote the state to errored.tfstate Los recursos se crearon pero el backend no aceptó la escritura (rol quitado, token caducado, red). Restaura el acceso y terraform state push errored.tfstate: el únicostate pushcorrecto. Comprueba con un plan a 0"Retroceder" con state pushde un estado antiguoEl estado describe lo que existe, no lo que quieres: un estado viejo hace que Terraform recree recursos que existen o dé por existentes los que no. Retroceso = git revert→ PR → plan → apply"Ordena los recursos en el fichero para arreglar dependencias" (el original) HCL es declarativo: el orden lo dan las referencias entre atributos. Si la dependencia no aparece en ningún atributo (una asignación de rol que debe existir antes del cloud-init), depends_on-targeten el flujo normal "para probar"Deja el estado sin reconciliar con el código y Terraform lo advierte. Solo emergencias, por workflow_dispatchcon motivo, y siempre seguido de un plan completoterraform refresh(el original)Retirado. plan -refresh-onlymuestra la deriva;apply -refresh-onlyla acepta en el estado sin tocar Azure"Timeout en el pipeline para liberar locks" (el original) -lock-timeoutespera, no libera. El lease lo libera el proceso que lo tomó oforce-unlockcon su ID tras confirmar que ese proceso murió (página 13)conftest no encuentra las reglas o pasa todo Las reglas van en package maino hay que pasar--all-namespaces;denyfalla el test,warnsolo avisa. Prueba la política contra un plan que sabes que debe fallar, como en el laboratorioterraform testcrea recursos realesSin command = plan, elrunhace apply (y destroy al final). Para políticas sobre el plan, siemprecommand = plan429 TooManyRequests en applies grandes Límites de la API de Azure con diez operaciones en paralelo. -parallelism=5y cambios más pequeños; el provider reintenta pero el tiempo creceEn Topaz: no hay 429, errored.tfstateno se puede provocar, el apply es demasiado rápido para cancelarloEsperado: el emulador no impone límites ni tiene identidades a las que quitar roles. Todo lo demás de la página (plan, huella, caducidad, políticas, taint, prevent_destroy, replace) funciona igual que en Azure
8. Autoevaluación¶
- ¿Qué cinco piezas contiene un fichero de plan y cuál lo convierte en secreto?
tfplan,tfstate,tfstate-prev,tfconfig/y.terraform.lock.hcl. Eltfplanincluye los valores de todas las variables no efímeras, y eltfstatees el estado completo. - ¿Qué tres condiciones debe cumplir un plan para seguir siendo aplicable? Misma versión de Terraform, misma configuración y mismo serial del estado remoto. Si el serial cambia: Saved plan is stale.
- ¿Por qué
terraform apply plan.tfplan -auto-approvees redundante? Aplicar un fichero de plan nunca pide confirmación: el fichero ya es la decisión. La aprobación pertenece al pipeline. - ¿Qué es la huella de un plan y qué problema resuelve? La lista de direcciones, acciones y rutas de reemplazo, sin valores. Permite comprobar que el plan que se aplica tras el merge es el mismo que se aprobó en la PR, sin filtrar nada.
- ¿Cuándo es legítimo subir el plan como artefacto? Solo si una norma exige aplicar el fichero exacto revisado; entonces cifrado, con retención corta, misma versión fijada y asumiendo que puede caducar. Nunca con variables efímeras.
- Un apply falla en el recurso 8 de 12. ¿Qué existe y qué haces? Los siete anteriores existen y están en el estado; los dependientes no se intentaron. Corregir la causa y planificar: el nuevo plan solo contiene lo pendiente.
- ¿Qué significa tainted y cuándo usas
untaint? Un provisioner falló tras crear el recurso; el siguiente plan lo reemplazará.untaintsi has verificado que el recurso está bien y solo falló el paso posterior. - ¿Cuál es el único caso en que
terraform state pushes correcto? Tras Failed to save state, empujando elerrored.tfstateque Terraform acaba de escribir, una vez restaurado el acceso al backend. - ¿Cómo se retrocede un cambio y por qué no con un estado antiguo?
git revert→ PR → plan → apply. Un estado antiguo describe una realidad que ya no existe: Terraform recrearía recursos existentes o fallaría sobre los que faltan. - ¿Qué diferencia hay entre
jq, conftest yterraform testcomo políticas?jqpara dos o tres reglas dentro del workflow; conftest cuando las reglas son muchas, compartidas y necesitan sus propios tests;terraform testpara reglas que dependen de tus variables y módulos, en el lenguaje del código. - ¿Qué aportan
prevent_destroyy las precondiciones que las políticas del pipeline no aportan? Actúan también fuera del pipeline: protegen al que ejecutadestroyo un plan desde su portátil. - ¿Cuándo son aceptables
-replacey-target? Como emergencia auditada: entran porworkflow_dispatchcon motivo, pasan por el mismo environment, y a-targetle sigue siempre un plan completo para reconciliar el estado.
9. Referencias¶
terraform plan(-out,-detailed-exitcode,-refresh-only,-replace,-target) yterraform apply(-parallelism,-json,-lock-timeout)- Formato JSON de planes y estado (
resource_changes,action_reason,after_sensitive,after_unknown) y salida legible por máquinas (apply -json) terraform show,taint/untaintystate push(casoerrored.tfstate)- Meta-argumento
lifecycle(prevent_destroy,precondition,postcondition) y bloquescheck terraform test(command = plan,expect_failures)- conftest y OPA con planes de Terraform
- Terraform en automatización (plan y apply separados,
TF_IN_AUTOMATION) - Límites de la API de Azure Resource Manager (429)
- Azure Local Emulator (Topaz)