Terraform Agent

Terraform Agent, paso 1: contratos Pydantic antes que agentes

Paso 1: Por qué empecé un sistema multiagente definiendo contratos Pydantic antes de escribir ningún agente

Antes de un solo LLM, antes de Terraform, antes de Azure: modelos de datos. Aquí está el razonamiento y las decisiones no obvias.


Si llegaste desde el post anterior, ya sabes qué estoy construyendo. Si no, la versión corta: un pipeline multiagente que convierte lenguaje natural en infraestructura Azure desplegada con Terraform. Este post cubre el Paso 1 en detalle.

El Paso 1 no llama a ningún LLM. No genera HCL. No toca Azure. Tampoco es aburrido.


El problema que resuelve este paso

Un sistema con cinco agentes tiene cinco fronteras de datos. Cada frontera es un sitio donde el sistema puede fallar silenciosamente si no tienes contratos explícitos.

Sin contratos formales, un agente que produce un diccionario con clave resource_typ (sin ‘e’) no falla al crearse. Falla cuando el siguiente agente intenta leer resource_type y obtiene None. Si ese fallo ocurre dentro de una llamada LLM que está generando HCL, el error que ves es un problema de Terraform, no un problema de tipado Python. La pila de traza no te lleva al sitio real.

Pydantic v2 resuelve esto en el borde: si un modelo no valida, lanza ValidationError antes de que el dato contaminado cruce la frontera. El fallo es explícito, localizado y tiene contexto.


Qué se construyó

Seis modelos de contrato, un módulo de utilidades, configuración del paquete y tests:

contracts/
├── __init__.py              # exports centralizados
├── requirements.py          # InfraRequirements
├── recommendations.py       # ArchitectRecommendations
├── hcl_output.py            # HCLOutput
├── review_result.py         # ReviewResult
├── pipeline_state.py        # PipelineState
└── conversation.py          # ConversationOutput

core/
├── exceptions.py            # excepciones tipadas
└── logging.py               # logging estructurado

tests/
├── conftest.py              # fixtures reutilizables
└── contracts/
    └── test_contracts.py    # suite completa

Cada modelo define exactamente qué agente lo produce, qué agente lo consume, y qué invariantes debe cumplir.


Las decisiones no obvias

1. pipeline_id se genera una sola vez y solo se propaga

El Orquestador crea un uuid.UUID antes de invocar al primer agente. Desde ahí, ese UUID viaja en todos los contratos sin regenerarse.

Ningún agente crea su propio pipeline_id. Solo lo recibe y lo reenvía.

La consecuencia práctica: puedes hacer grep de un UUID en los logs y ver la traza completa de una ejecución a través de cinco agentes. Sin esto, correlacionar un fallo del Revisor en la iteración 3 con los requisitos originales del Conversacional requiere heurísticas.

El tipo elegido es uuid.UUID, no str. En serialización JSON con model_dump(mode="json") se convierte automáticamente a string. Al deserializar, model_validate reconstruye el UUID. El coste operativo es mínimo; la precisión semántica vale la pena.

2. ReviewResult.passed es una @property, no un campo

El Revisor ejecuta cinco etapas: fmt, tflint, checkov, validate, plan. Cada una produce un ReviewCheck con su estado. El resultado global passed es True si y solo si todas las etapas pasaron.

Si passed fuera un campo editable, podría desincronizarse con la lista de checks. Un agente podría construir un ReviewResult con tres checks fallidos y passed=True, y el Orquestador lo trataría como éxito.

@property
def passed(self) -> bool:
    return all(c.status == ReviewCheckStatus.PASSED for c in self.checks)

El problema secundario: Pydantic no incluye @property en model_dump(). Para persistencia, se usa to_snapshot():

def to_snapshot(self) -> dict:
    data = self.model_dump(mode="json")
    data["passed"] = self.passed  # inclusión explícita
    return data

3. SubnetSpec como modelo, no como dict

La primera versión usaba list[dict[str, str]] para las subredes. Funcionaba. Pero Pydantic no puede validar el contenido de un diccionario genérico: si address_prefix tiene un valor malformado, no te enteras hasta que el Builder genera HCL inválido.

class SubnetSpec(BaseModel):
    name: str
    address_prefix: str  # próximo paso: @field_validator con ip_network()
# En NetworkSpec:
subnets: list[SubnetSpec] = []

La interfaz es idéntica desde fuera. La capacidad de añadir validación de CIDR en el futuro sin cambiar la interfaz es lo que lo justifica.

4. config: dict[str, object], no dict a secas

ResourceSpec tiene un campo config para atributos específicos de cada recurso Terraform. No puedes modelar estos atributos con un esquema fijo sin duplicar el schema del provider.

dict en Pydantic v2 equivale a dict[Any, Any]. dict[str, object] fuerza que las claves sean strings (que es lo que Terraform espera) y documenta la intención. Los valores siguen siendo libres, pero eso es correcto: el Builder los validará contra el schema del provider en el Paso 2.

5. AgentStepStatus como Enum, no como str

Los estados posibles de un paso del agente son un conjunto cerrado: PENDING, RUNNING, SUCCESS, FAILED, SKIPPED. Un str libre no falla al construirse con el valor "succes". Un Enum sí.

class AgentStepStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    SUCCESS = "success"
    FAILED = "failed"
    SKIPPED = "skipped"

Hereda de str para que model_dump(mode="json") serialice el valor como string, no como objeto Enum.

6. Logging estructurado desde el principio

El logging estándar de Python no acepta kwargs arbitrarios. Este código lanza TypeError en runtime:

# ❌ no funciona con logging estándar
logger.info("Builder iteration", pipeline_id="abc", iteration=2)

La forma correcta, compatible con cualquier handler JSON (Loki, Datadog, etc.):

# ✅
logger.info("Builder iteration", extra={"pipeline_id": str(pipeline_id), "iteration": 2})

Es un detalle pequeño que, si no lo corriges desde el principio, se reproduce en cada agente y se convierte en una deuda técnica difusa.


Diagrama 1 — modelos de datos:

graph TB
    CO[ConversationOutput] -->|contiene| IR[InfraRequirements]
    IR -->|contiene| RS[ResourceSpec]
    IR -->|contiene| TS[TagsSpec]
    RS -.->|opcional| NS[NetworkSpec]
    NS -->|lista| SS[SubnetSpec]
    AR[ArchitectRecommendations] -.->|referencia| IR
    HCL[HCLOutput] -.->|referencia| IR
    RR[ReviewResult] -->|lista| RC[ReviewCheck]
    PS[PipelineState] -->|lista| AS[AgentStep]
    PS -.->|snapshot| IR
    PS -.->|snapshot| HCL
    PS -.->|snapshot| RR

    style CO fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style IR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style AR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style HCL fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style RR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style PS fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style AS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style RC fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style RS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style NS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style SS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style TS fill:#1a2a3a,stroke:#3b82f6,color:#fff

Diagrama 2 — flujo de agentes:

graph LR
    ORC([Orquestador]) -->|genera pipeline_id| CONV[Conversacional]
    CONV -->|produce ConversationOutput| ARQ[Arquitecto]
    CONV -->|produce ConversationOutput| BUILDER[Builder]
    ARQ -->|produce ArchitectRecommendations| BUILDER
    BUILDER -->|produce HCLOutput| REVISOR[Revisor]
    REVISOR -->|produce ReviewResult| BUILDER
    REVISOR -->|passed=true| ORC
    ORC -->|mantiene PipelineState| ORC

    style ORC fill:#1F4E79,stroke:#2E75B6,color:#fff
    style CONV fill:#1e3a5f,stroke:#6366f1,color:#fff
    style ARQ fill:#1e3a5f,stroke:#6366f1,color:#fff
    style BUILDER fill:#1a3a2a,stroke:#10b981,color:#fff
    style REVISOR fill:#3a2a1a,stroke:#f59e0b,color:#fff

Cómo replicarlo

Prerequisitos: Python 3.11+, pip.

# 1. Crear entorno virtual
python -m venv .venv

# Linux/macOS
source .venv/bin/activate

# Windows PowerShell
.venv\Scripts\Activate.ps1

# 2. Instalar en modo editable con dependencias de dev
pip install -e ".[dev]"

# 3. Ejecutar los tests
pytest tests/ -v --cov=contracts --cov=core --cov-report=term-missing

Resultado esperado: 30 tests en verde, cobertura >90% en la capa de contratos.

Verificación manual rápida para confirmar los invariantes críticos:

from uuid import uuid4
from contracts import InfraRequirements, ResourceSpec, TagsSpec
from contracts import ReviewResult, ReviewCheck, ReviewStage, ReviewCheckStatus
import json

pid = uuid4()

# UUID serializa a str en JSON
req = InfraRequirements(
    pipeline_id=pid, client="acme", environment="dev",
    location="westeurope",
    tags=TagsSpec(environment="dev", owner="alice@example.com"),
    resources=[ResourceSpec(resource_type="azurerm_virtual_network", logical_name="vnet")]
)
data = req.model_dump(mode="json")
assert isinstance(data["pipeline_id"], str)
json.dumps(data)  # no lanza

# passed es propiedad calculada, no campo editable
result = ReviewResult(pipeline_id=pid, iteration=1, checks=[
    ReviewCheck(stage=ReviewStage.FMT, status=ReviewCheckStatus.PASSED),
    ReviewCheck(stage=ReviewStage.TFLINT, status=ReviewCheckStatus.FAILED, errors=["E001"]),
])
assert result.passed is False
assert "passed" not in ReviewResult.model_fields  # no es campo
snap = result.to_snapshot()
assert snap["passed"] is False  # sí aparece en snapshot

Lo que este paso no hace (y no debe hacer)

No valida que address_prefix sea un CIDR real. No comprueba que location sea una región Azure válida. No verifica que los atributos en config existan en el schema del provider.

Esas validaciones tienen su sitio en pasos posteriores: el Arquitecto valida convenciones, el Builder valida contra el schema del provider, el Revisor valida con herramientas reales. Los contratos del Paso 1 validan estructura y tipos, no semántica de dominio.

Esta separación es deliberada. Si los contratos hicieran validación de dominio, cambiar la versión del provider o añadir una nueva región Azure requeriría modificar los contratos base. Con la separación actual, los contratos son estables; la lógica de dominio vive donde le corresponde.


Siguiente paso

El Paso 2 construye el SchemaManager: el módulo que carga el JSON generado por terraform providers schema -json (~15MB para azurerm 4.x) y expone una interfaz limpia para que el Builder consulte exactamente qué atributos existen en cada recurso, cuáles son requeridos y cuáles son opcionales.

Sin el SchemaManager, el Builder no tiene más referencia que su entrenamiento para saber si enable_https_traffic_only existe en azurerm_storage_account versión 4.x o si fue renombrado. Con él, las alucinaciones de atributos se convierten en un error detectable antes de ejecutar un solo comando de Terraform.


El código de este paso, la documentación técnica completa y los tests estarán disponibles en el repositorio del proyecto.


Tags: terraform azure pydantic python multiagent llm iac platform-engineering software-architecture

Revisado el 24 de marzo de 2026.

Ilustración de Luis Adán Muñoz
Luis Adán MuñozArquitecto cloud especializado en Azure, Terraform e IA con Microsoft Foundry.
Compartir en LinkedIn

Te aviso cuando publique algo nuevo

Un correo por artículo, sin nada más. Te das de baja con un clic.