Módulo 3: The Iac Pipeline Fmt Validate Plan

2. El patrón HashiCorp/GitHub: `plan` en PR, `apply` en merge

Descripción

Esta lección responde una pregunta muy concreta: ¿de dónde sale exactamente el patrón que organiza este módulo entero? No es una convención inventada para esta guía — es, palabra por palabra, el patrón que documenta el tutorial oficial de HashiCorp para automatizar Terraform con GitHub Actions, verificado hoy contra esa fuente. Vas a ver qué dice ese tutorial, qué partes de su enfoque usa esta guía y cuáles adapta, y —el punto central de la lección— por qué separar plan (en ci.yml, construido en este módulo) de apply (en apply.yml, construido en el Módulo 5) en dos archivos distintos es una decisión de seguridad, verificada con un escenario concreto, no una preferencia de organización de carpetas.

Conexión con el módulo

La lección 1 te dio la idea general ("el plan es el punto de revisión"). Esta lección le pone una fuente exacta y un mecanismo concreto: por qué dos archivos, no uno con una condición. La lección 3 sigue con el cómo — instalar Terraform dentro del runner que va a ejecutar ese plan. Las lecciones 4 a 8 son la construcción real de ci.yml, la mitad del patrón que puedes construir sin tocar credenciales de escritura todavía (esas llegan en el Módulo 4, y el apply.yml que las usa, en el Módulo 5).


Analogía: dos llaves para dos puertas distintas, no una llave con un candado que a veces se abre

Imagina un edificio con dos puertas: una que da a la sala de exhibición (cualquiera con una invitación puede entrar y mirar) y otra que da a la bóveda (solo alguien con la llave correcta, en el momento correcto, puede entrar). Podrías, en teoría, construir una sola puerta con un candado inteligente que decide, según quién toca, si te deja pasar a la sala o a la bóveda — pero eso significa que la seguridad de la bóveda depende de que ese candado nunca se equivoque, nunca se pueda engañar, nunca tenga un bug. Dos puertas físicamente separadas son más simples de razonar: la puerta de la sala de exhibición nunca, bajo ninguna condición, lleva a la bóveda — no porque un candado lo decida bien cada vez, sino porque físicamente no hay otro camino. ci.yml y apply.yml son esas dos puertas: ci.yml escucha pull_request y solo puede leer y calcular (plan); apply.yml escucha push a main y es el único archivo que corre apply. No hay ninguna condición en el medio que decida cuál se ejecuta — son dos archivos distintos, disparados por eventos distintos, y eso es lo que hace la separación confiable.


Qué dice el tutorial oficial de HashiCorp, verificado

El tutorial "Automate Terraform with GitHub Actions" de HashiCorp Developer —la fuente citada en el diseño de esta guía— construye el patrón con dos workflows separados:

  • Un workflow que corre en cada Pull Request, genera un terraform plan, y publica ese plan como un comentario en el propio Pull Request (usando actions/github-script, que vas a ver de cerca en la lección 7).
  • Un workflow separado que corre cuando el cambio llega a la rama principal (push a main, típicamente después de la fusión), y aplica el plan correspondiente.

Una precisión honesta, verificada al leer el tutorial completo hoy: la implementación exacta que muestra HashiCorp usa HCP Terraform (la plataforma gestionada de HashiCorp, con acciones como hashicorp/tfc-workflows-github) para ejecutar el plan/apply remotamente, no el binario de terraform corriendo directamente dentro del runner. Esta guía no usa HCP Terraform —agregaría una cuenta externa y una dependencia que rompería el compromiso de $0 y de reproducibilidad total con Docker que sostiene todo este ecosistema—. Lo que esta guía toma del tutorial no es la implementación línea por línea, es el patrón: dos workflows separados, uno que solo calcula y muestra (disparado por pull_request), otro que aplica (disparado por push a main), con el plan visible para quien lo revisa antes de que exista la posibilidad de aplicar nada. Ese patrón se implementa aquí con el binario terraform/tflocal corriendo directamente dentro del runner de act, contra LocalStack — exactamente como ya ejecutaste terraform en terraform-and-iac-guide, solo que ahora automatizado.


El patrón, adaptado a esta guía

┌─────────────────────────────┐         ┌─────────────────────────────┐
│  ci.yml                     │         │  apply.yml (Módulo 5)       │
│  on: pull_request            │         │  on: push (branches: [main])│
│                              │         │                              │
│  1. checkout                │         │  1. checkout                │
│  2. setup-terraform         │         │  2. setup-terraform         │
│  3. terraform fmt -check    │         │  3. terraform init          │
│  4. terraform init          │         │  4. terraform apply         │
│  5. terraform validate      │         │     (sobre el plan que ya   │
│  6. terraform plan  ───────┼────────▶│      se revisó en el PR)    │
│  7. publicar el plan        │  humano │                              │
│     (STEP_SUMMARY /         │  revisa │                              │
│      comentario en el PR)   │  y      │                              │
│                              │  aprueba│                              │
└─────────────────────────────┘         └─────────────────────────────┘
        NUNCA escribe                          SOLO corre después
        infraestructura real                   de que main cambió

ci.yml —lo que construyes en este módulo— es la puerta de la sala de exhibición: lee, calcula, muestra. Nunca tiene, ni va a tener, un solo permiso de escritura sobre infraestructura real. apply.yml —Módulo 5— es la puerta de la bóveda: la única que corre terraform apply, y solo se dispara cuando el código ya está en main, es decir, cuando ya pasó por revisión humana y fusión.


Por qué separar en dos archivos es más seguro que uno solo con una condición

Este es el corazón de la lección, y vale la pena razonarlo con un escenario concreto, no en abstracto.

El diseño peligroso (que esta guía no construye): un único workflow, terraform.yml, que escucha tanto pull_request como push, con un job que empieza así:

jobs:
  terraform:
    steps:
      - run: terraform plan
      - name: Apply only on merge to main
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve

A primera vista parece razonable: "el apply solo corre si el evento es push a main". El problema no es que la condición esté mal escrita — es que la condición vive en el mismo archivo que corre en cada Pull Request, incluidos Pull Requests que todavía no se revisaron. Si en algún momento ese archivo se vuelve más complejo (por ejemplo, alguien agrega un workflow_dispatch con un input que controla si aplica o no, o una segunda condición que alguien mal-entiende), el único obstáculo entre "un PR sin revisar" y "un apply real contra producción" es lógica condicional dentro de un archivo que, técnicamente, cualquier Pull Request podría intentar modificar como parte de su propio cambio propuesto. Esto no es un escenario hipotético: es exactamente la familia de vulnerabilidades conocida en la industria como "pwn request" — un Pull Request que manipula el propio workflow que lo está evaluando para obtener permisos que no debería tener.

El diseño de esta guía: ci.yml (este módulo) nunca contiene la palabra apply en ningún run:. apply.yml (Módulo 5) nunca escucha pull_request. No hay ninguna condición que separar correctamente porque no hay ningún camino, en ningún archivo, que conecte un Pull Request sin revisar con un terraform apply real. La seguridad no depende de que una condición esté bien escrita hoy y se mantenga bien escrita para siempre — depende de una separación estructural que no se puede violar por accidente.


El plan guardado, no un plan nuevo (adelanto del Módulo 5)

Hay un segundo detalle del patrón de HashiCorp que vale la pena nombrar ahora, aunque se construye recién en el Módulo 5: cuando apply.yml corre, no debería generar un plan nuevo y aplicarlo a ciegas — debería aplicar el mismo plan exacto que una persona ya revisó en el Pull Request. Entre el momento en que alguien aprueba un plan y el momento en que se fusiona, el estado real de la infraestructura podría haber cambiado (otro cambio se fusionó primero, alguien modificó algo a mano) — si apply.yml recalculara el plan desde cero, podría estar aplicando algo distinto a lo que la persona aprobó. El mecanismo para pasar el plan exacto de un workflow a otro (actions/upload-artifact / download-artifact, con needs: para encadenar los jobs) es contenido del Módulo 5 — se nombra aquí porque es la otra mitad de la razón por la que HashiCorp separa los dos workflows: no solo por seguridad de permisos, también por consistencia entre lo que se revisó y lo que se aplica.


Errores comunes

Asumir que "dos workflows" significa "el doble de trabajo de mantenimiento" (de percepción). Qué pasa: alguien ve ci.yml y apply.yml como archivos separados y asume que hay que mantener la misma lógica dos veces. Por qué pasa: la intuición de "menos archivos es más simple" es razonable en la mayoría de los contextos de código. Cómo corregirlo: los dos archivos comparten muy poco código en común —ci.yml nunca aplica, apply.yml nunca corre sobre una propuesta sin revisar—, así que no hay lógica duplicada que sincronizar. La complejidad de mantener dos archivos pequeños y con un propósito cada uno es, en la práctica, menor que la de mantener un archivo con condiciones que hay que revisar con cuidado cada vez que cambian.

Pensar que HCP Terraform es obligatorio para seguir este patrón (de fuente, aclarado arriba). Qué pasa: alguien lee el tutorial oficial de HashiCorp, ve hashicorp/tfc-workflows-github, y asume que sin una cuenta de HCP Terraform el patrón "plan en PR / apply en merge" no se puede construir. Cómo detectarlo: si dudas de que ci.yml/apply.yml de esta guía sean "el patrón real" porque no usan esas Actions específicas. Cómo corregirlo: el patrón es la separación de workflows por evento y el plan como artefacto de revisión — eso es independiente de si el terraform plan/apply corre contra HCP Terraform o directamente con el binario terraform/tflocal, como hace esta guía. HCP Terraform agrega gestión de estado remoto, políticas Sentinel y ejecución centralizada — capacidades reales, pero fuera del alcance de $0 y $100%$ local de esta guía.

Confundir "separar en dos archivos" con "separar en dos repositorios" (de alcance). Qué pasa: alguien asume que la separación de seguridad de esta lección requiere que ci.yml y apply.yml vivan en repositorios distintos. Cómo corregirlo: ambos archivos viven en el mismo repositorio, dentro de .github/workflows/ — la separación que importa es por evento disparador (pull_request vs. push a main), no por ubicación física. Un mismo repositorio puede tener decenas de workflows distintos, cada uno con su propio on:, sin que eso comprometa la separación de responsabilidades entre ellos.


Ejercicios

Ejercicio 1 — Identifica el riesgo del diseño peligroso. Mirando el YAML de "el diseño peligroso" de arriba (terraform.yml con la condición if:), identifica específicamente qué tendría que salir mal para que un Pull Request sin revisar terminara aplicando infraestructura real.

Ver solución

Tendría que fallar la condición if: github.event_name == 'push' && github.ref == 'refs/heads/main' — por ejemplo, si alguien la reescribe mal en un cambio posterior (un || en vez de &&, una comparación de rama incorrecta), o si el propio workflow se modifica como parte de un Pull Request que también intenta explotar esa modificación en la misma corrida (el escenario de "pwn request" nombrado en la lección). El punto central: la seguridad de todo el pipeline depende de que una línea de código condicional nunca se equivoque, nunca se manipule — un único punto de falla, en vez de una separación estructural que no depende de que nada esté "bien escrito".

Ejercicio 2 — Explica la analogía de las dos puertas a un colega escéptico. Un colega te dice: "si la condición if: está bien escrita, funciona igual de bien que dos archivos separados — es solo preferencia de estilo". Respóndele en dos o tres frases.

Ver solución

Una respuesta completa suena, más o menos, así: "Es cierto que, si la condición nunca falla, el resultado es el mismo — pero 'nunca falla' es exactamente la garantía que no puedes dar de una condición dentro de un archivo que un Pull Request sin revisar puede, en teoría, intentar modificar. Con dos archivos separados por evento disparador, no hay ninguna condición que revisar: apply.yml simplemente no existe como posibilidad hasta que el evento es push a main, algo que solo ocurre después de la fusión. No es preferencia de estilo, es eliminar una categoría entera de error posible."

Ejercicio 3 — Decide qué workflow corresponde a cada acción. Para cada una de estas acciones, decide si pertenece a ci.yml (este módulo) o a apply.yml (Módulo 5): (a) terraform fmt -check; (b) terraform apply sobre el plan guardado; (c) terraform plan; (d) publicar el plan en el resumen del job para que alguien lo revise.

Ver solución

(a) ci.yml — fmt no aplica nada, es una verificación de estilo, corre en cada PR. (b) apply.yml — únicamente este archivo tiene permiso conceptual de aplicar, y solo corre tras la fusión a main. (c) ci.yml — el plan es exactamente el artefacto de revisión que este módulo construye. (d) ci.yml — publicar el plan para revisión es la razón de ser de este workflow; sin este paso, nadie tendría nada que leer antes de aprobar la fusión.


Resumen y siguiente paso

En esta lección confirmaste, contra la fuente oficial, el patrón que organiza este módulo: HashiCorp documenta dos workflows separados por evento disparador —uno que calcula y muestra (pull_request), otro que aplica (push a main)—, con el plan publicado como evidencia de revisión antes de la fusión. Esta guía adapta ese patrón con el binario terraform/tflocal corriendo directamente contra LocalStack, en vez de HCP Terraform, manteniendo el mismo principio de seguridad: separación estructural por archivo y por evento, no una condición dentro de un archivo compartido.

Antes de avanzar deberías poder: explicar de memoria por qué ci.yml y apply.yml son archivos distintos, no un archivo con una condición; nombrar la vulnerabilidad concreta ("pwn request") que la separación estructural evita; y distinguir qué parte del tutorial de HashiCorp esta guía sigue al pie de la letra (la separación de workflows, el plan como evidencia) de qué parte adapta (el binario local en vez de HCP Terraform).

La lección 3 sigue con el cómo: instalar Terraform dentro del runner efímero que va a ejecutar el primer fmt/validate/plan real de esta guía.

Recursos

  1. HashiCorp Developer — Automate Terraform with GitHub Actions — la fuente oficial del patrón de esta lección, verificada hoy.
  2. GitHub Docs — Security hardening for GitHub Actions — la documentación oficial sobre riesgos de Pull Requests y separación de permisos, base del argumento de seguridad de esta lección.
  3. actions/github-script — la Action que el tutorial de HashiCorp usa para comentar el plan en el PR, profundizada en la lección 7 de este módulo.