The Infrastructure Hub -- Parte 2
Módulos Terraform con Golden Path
El Problema
Le pides a tres ingenieros que creen un módulo de Terraform. Recibes tres cosas diferentes.
El ingeniero A crea un solo main.tf con todo en un archivo. Sin archivo de variables, sin outputs, sin README. Funciona, pero nadie más puede usarlo sin leer el código línea por línea.
El ingeniero B sigue la estructura de HashiCorp — main.tf, variables.tf, outputs.tf, versions.tf. Tiene un README. Pero sin tests, sin ejemplos, sin pipeline de CI. El módulo funciona hoy. En seis meses, alguien actualiza el provider de Azure y se rompe en silencio.
El ingeniero C crea un módulo con tests, ejemplos, documentación y un pipeline de CI. Le lleva una semana. ¿El siguiente módulo que crea? Otra semana. Porque empieza desde cero cada vez.
El problema no es que los ingenieros no sepan cómo estructurar un módulo. El problema es que no hay un golden path — no hay un template estándar que te dé la estructura correcta, los tests correctos, el CI correcto y la documentación correcta desde el principio.
Y si gestionas infraestructura para varios clientes (como un MSP), el problema es peor. El cliente A usa Azure. El cliente B usa Scaleway. El cliente C usa AWS. Cada cloud tiene patrones de provider diferentes, naming de recursos diferente, formas de testear diferentes. Sin templates, cada módulo es un copo de nieve.
La Solución
Templates de Backstage Scaffolder que generan módulos de Terraform con la estructura correcta para cada cloud provider. Pero no solo la estructura — también los recursos reales.
La idea clave es esta: los parámetros que le das al template son concretos. Cloud provider, tipo de recurso, features necesarias. No hay nada ambiguo. Así que podemos pedirle a la IA que genere el main.tf con recursos reales, data sources reales, outputs reales — porque el input es determinista. No le estamos diciendo “hazme algo chulo.” Le estamos diciendo “crea un módulo de Azure storage account con private endpoints y lifecycle policies usando azurerm 4.x.” La IA sigue los últimos patrones de HashiCorp y la documentación del provider.
Un click y tienes:
- Estructura de carpetas estándar (
main.tf,variables.tf,outputs.tf,versions.tf) - Bloque de provider pre-configurado con las version constraints correctas
- Recursos generados por IA en
main.tfbasados en tu descripción — no un placeholder con TODO - Un
README.mdcon tabla de inputs/outputs y ejemplo de uso - Un
catalog-info.yamlya rellenado con los metadatos correctos - Configuración de TechDocs (
mkdocs.yml+ carpetadocs/) - Una estructura básica de tests (usando Terratest o
terraform validate) - Un template de pipeline de CI (GitHub Actions, Azure DevOps o GitLab CI)
El ingeniero elige un cloud, describe lo que el módulo debe crear, y el scaffolder genera todo — estructura, código, docs, CI, entrada en el catálogo. El ingeniero revisa el código generado, ajusta si hace falta, y hace push. El 90% que es boilerplate estándar se hace en segundos.
Para MSPs, añades un parámetro “client”. El módulo se etiqueta con el nombre del cliente, se registra bajo el system correcto en el catálogo, y el pipeline de CI despliega en la subscription/project del cliente.
Execute
El Template Multi-Cloud
Este es un único template de Backstage que maneja Azure, Scaleway, AWS y GCP. La selección del cloud determina qué bloque de provider, qué ejemplos y qué template de CI se genera.
# templates/terraform-module-golden-path/template.yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: terraform-module-golden-path
title: Golden Path Terraform Module
description: Create a new Terraform module with standard structure, docs, tests, and CI
tags:
- terraform
- infrastructure
- golden-path
spec:
owner: team-platform
type: terraform-module
parameters:
- title: Module Details
required:
- name
- description
- cloud
properties:
name:
title: Module Name
type: string
description: "kebab-case name (e.g., vnet, storage-account, k8s-cluster)"
pattern: '^[a-z][a-z0-9-]*$'
description:
title: Description
type: string
description: "What does this module create?"
cloud:
title: Cloud Provider
type: string
enum: ['azure', 'scaleway', 'aws', 'gcp']
enumNames: ['Azure', 'Scaleway', 'AWS', 'GCP']
lifecycle:
title: Lifecycle
type: string
enum: ['experimental', 'production', 'deprecated']
default: experimental
owner:
title: Owner
type: string
description: "Team that owns this module"
default: team-platform
client:
title: Client (MSP only)
type: string
description: "Leave empty for internal modules"
- title: CI/CD
properties:
ciProvider:
title: CI Provider
type: string
enum: ['github-actions', 'azure-devops', 'gitlab-ci']
enumNames: ['GitHub Actions', 'Azure DevOps', 'GitLab CI']
default: github-actions
includeTests:
title: Include Terratest
type: boolean
default: true
- title: Repository
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
steps:
- id: fetch-skeleton
name: Generate module skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
description: ${{ parameters.description }}
cloud: ${{ parameters.cloud }}
owner: ${{ parameters.owner }}
client: ${{ parameters.client }}
lifecycle: ${{ parameters.lifecycle }}
ciProvider: ${{ parameters.ciProvider }}
includeTests: ${{ parameters.includeTests }}
- id: ai-generate
name: Generate Terraform resources with AI
action: forge:ai-scaffold-terraform
input:
cloud: ${{ parameters.cloud }}
name: ${{ parameters.name }}
description: ${{ parameters.description }}
workspacePath: ${{ steps['fetch-skeleton'].output.workspacePath }}
- id: publish
name: Publish to GitHub
action: publish:github
input:
allowedHosts: ['github.com']
repoUrl: ${{ parameters.repoUrl }}
description: "Terraform module: ${{ parameters.description }}"
defaultBranch: main
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Catalog Entry
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}
El Skeleton
El skeleton usa templates de Nunjucks. El parámetro cloud determina la configuración del provider:
# skeleton/versions.tf
terraform {
required_version = ">= 1.8"
required_providers {
{%- if values.cloud == 'azure' %}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
{%- elif values.cloud == 'scaleway' %}
scaleway = {
source = "scaleway/scaleway"
version = "~> 2.0"
}
{%- elif values.cloud == 'aws' %}
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
{%- elif values.cloud == 'gcp' %}
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
{%- endif %}
}
}
# skeleton/variables.tf
variable "name" {
type = string
description = "Resource name"
}
{% if values.cloud == 'azure' -%}
variable "location" {
type = string
default = "westeurope"
description = "Azure region"
}
variable "resource_group_name" {
type = string
description = "Resource group to deploy into"
}
{%- elif values.cloud == 'scaleway' -%}
variable "zone" {
type = string
default = "fr-par-1"
description = "Scaleway zone"
}
variable "project_id" {
type = string
description = "Scaleway project ID"
}
{%- elif values.cloud == 'aws' -%}
variable "region" {
type = string
default = "eu-west-1"
description = "AWS region"
}
{%- elif values.cloud == 'gcp' -%}
variable "region" {
type = string
default = "europe-west1"
description = "GCP region"
}
variable "project" {
type = string
description = "GCP project ID"
}
{%- endif %}
variable "tags" {
type = map(string)
default = {}
description = "Resource tags"
}
El main.tf del skeleton empieza vacío — es un placeholder que el paso de IA sobreescribirá:
# skeleton/main.tf
# This file will be replaced by AI-generated resources
# skeleton/outputs.tf
# This file will be replaced by AI-generated outputs
El Endpoint de IA
El servicio de IA de la serie IDP recibe un nuevo endpoint: /api/scaffold-terraform. Recibe el cloud, el nombre del módulo y la descripción, y devuelve el main.tf, variables.tf y outputs.tf completos.
El prompt es específico y limitado — no le pedimos a la IA que sea creativa. Le decimos exactamente qué provider usar, qué versión y qué debe crear el módulo. El resultado es código Terraform estándar que sigue la estructura de módulos de HashiCorp.
// In the AI service — POST /api/scaffold-terraform
app.MapPost("/api/scaffold-terraform", async (
ScaffoldTerraformRequest request,
OpenAIClient client,
IConfiguration config) =>
{
var chatClient = client.GetChatClient(
config["AI:ChatModel"] ?? "mistral-small-3.2-24b-instruct-2506");
var providerDocs = request.Cloud switch
{
"azure" => "HashiCorp azurerm provider 4.x. Use azurerm_* resources.",
"scaleway" => "Scaleway provider 2.x. Use scaleway_* resources.",
"aws" => "HashiCorp aws provider 5.x. Use aws_* resources.",
"gcp" => "HashiCorp google provider 5.x. Use google_* resources.",
_ => throw new ArgumentException($"Unknown cloud: {request.Cloud}")
};
var prompt = $"""
Generate a Terraform module for {request.Cloud}.
Module name: {request.Name}
Description: {request.Description}
Provider: {providerDocs}
Return a JSON object with three keys:
- "main": the main.tf content with all resources
- "variables": the variables.tf content (include name, tags, and cloud-specific variables)
- "outputs": the outputs.tf content with all useful outputs
Rules:
- Use the latest resource syntax for the provider
- Include descriptions for all variables and outputs
- Add sensible defaults where appropriate
- Use variable references, not hardcoded values
- Follow HashiCorp naming conventions
- Do not include provider blocks or terraform blocks (they are in versions.tf)
- Do not guess features not mentioned in the description
""";
var completion = await chatClient.CompleteChatAsync(prompt);
var content = completion.Value.Content[0].Text;
var json = ExtractJson(content);
var result = JsonSerializer.Deserialize<ScaffoldTerraformResult>(json);
return Results.Ok(result);
});
record ScaffoldTerraformRequest(string Cloud, string Name, string Description);
record ScaffoldTerraformResult(string Main, string Variables, string Outputs);
Y la custom action de Backstage que lo llama:
// plugins/ai-scaffolder/src/actions/aiScaffoldTerraform.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
export function createAiScaffoldTerraformAction(options: { aiServiceUrl: string }) {
return createTemplateAction({
id: 'forge:ai-scaffold-terraform',
description: 'Generate Terraform resources using AI',
schema: {
input: z.object({
cloud: z.string().describe('Cloud provider: azure, scaleway, aws, gcp'),
name: z.string().describe('Module name'),
description: z.string().describe('What the module should create'),
workspacePath: z.string().describe('Path to the workspace'),
}),
},
async handler(ctx) {
ctx.logger.info(`Generating Terraform for ${ctx.input.cloud}: ${ctx.input.description}`);
const response = await fetch(`${options.aiServiceUrl}/api/scaffold-terraform`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cloud: ctx.input.cloud,
name: ctx.input.name,
description: ctx.input.description,
}),
});
if (!response.ok) {
throw new Error(`AI service returned ${response.status}`);
}
const result = await response.json();
const ws = ctx.input.workspacePath || ctx.workspacePath;
// Overwrite the placeholder files with AI-generated content
fs.writeFileSync(path.join(ws, 'main.tf'), result.main);
fs.writeFileSync(path.join(ws, 'variables.tf'), result.variables);
fs.writeFileSync(path.join(ws, 'outputs.tf'), result.outputs);
ctx.logger.info('Terraform files generated by AI');
},
});
}
Este es el mismo patrón del artículo 3 de la serie IDP. La IA genera código basándose en parámetros concretos. El ingeniero revisa el resultado — la IA propone, el humano aprueba.
La Entrada en el Catálogo
El catalog-info.yaml viene pre-rellenado con metadatos del cloud:
# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: tf-${{ values.cloud }}-${{ values.name }}
title: "${{ values.description }}"
description: "${{ values.description }}"
tags:
- terraform
- ${{ values.cloud }}
{%- if values.client %}
- client-${{ values.client }}
{%- endif %}
annotations:
github.com/project-slug: {% raw %}${{ github.repository }}{% endraw %}
backstage.io/techdocs-ref: dir:.
spec:
type: terraform-module
lifecycle: ${{ values.lifecycle }}
owner: ${{ values.owner }}
{%- if values.client %}
system: client-${{ values.client }}-infrastructure
{%- else %}
system: infrastructure
{%- endif %}
El README
Generado con las secciones correctas para un módulo de Terraform:
# skeleton/README.md
# tf-${{ values.cloud }}-${{ values.name }}
${{ values.description }}
## Cloud Provider
${{ values.cloud | capitalize }}
## Usage
```hcl
module "${{ values.name | replace('-', '_') }}" {
source = "github.com/YOUR_ORG/tf-${{ values.cloud }}-${{ values.name }}"
{% if values.cloud == 'azure' %}
name = "my-resource"
location = "westeurope"
resource_group_name = "rg-my-project"
{% elif values.cloud == 'scaleway' %}
name = "my-resource"
zone = "fr-par-1"
project_id = "your-project-id"
{% elif values.cloud == 'aws' %}
name = "my-resource"
region = "eu-west-1"
{% elif values.cloud == 'gcp' %}
name = "my-resource"
region = "europe-west1"
project = "your-project-id"
{% endif %}
tags = {
environment = "production"
managed-by = "terraform"
}
}
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
name | string | Resource name | |
| {% if values.cloud == ‘azure’ -%} | |||
location | string | westeurope | Azure region |
resource_group_name | string | Resource group | |
| {%- elif values.cloud == ‘scaleway’ -%} | |||
zone | string | fr-par-1 | Scaleway zone |
project_id | string | Scaleway project ID | |
| {%- elif values.cloud == ‘aws’ -%} | |||
region | string | eu-west-1 | AWS region |
| {%- elif values.cloud == ‘gcp’ -%} | |||
region | string | europe-west1 | GCP region |
project | string | GCP project ID | |
| {%- endif %} | |||
tags | map(string) | {} | Resource tags |
Outputs
| Name | Description |
|---|---|
id | Resource ID |
### El Pipeline de CI
Para GitHub Actions:
```yaml
# skeleton/.github/workflows/terraform.yml (only if ciProvider == 'github-actions')
name: Terraform
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Format
run: terraform fmt -check -recursive
- name: Terraform Init
run: terraform init -backend=false
- name: Terraform Validate
run: terraform validate
{% if values.includeTests %}
test:
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run Terratest
working-directory: test
run: go test -v -timeout 30m
{% endif %}
TechDocs
Cada módulo incluye documentación que Backstage puede renderizar:
# skeleton/mkdocs.yml
site_name: tf-${{ values.cloud }}-${{ values.name }}
docs_dir: docs
plugins:
- techdocs-core
# skeleton/docs/index.md
# tf-${{ values.cloud }}-${{ values.name }}
${{ values.description }}
## Cloud Provider
**${{ values.cloud | capitalize }}**
## Getting Started
See the README for usage examples and input/output documentation.
## Owner
${{ values.owner }}
{% if values.client -%}
## Client
${{ values.client }}
{%- endif %}
Cómo Se Ve
Un ingeniero abre Backstage, hace click en “Create”, selecciona “Golden Path Terraform Module”:
- Module Details: name =
storage-account, cloud = Azure, description = “Creates a storage account with private endpoints”, owner = team-platform - CI/CD: GitHub Actions, include Terratest = yes
- Repository:
github.com/my-org/tf-azurerm-storage-account
Hace click en “Create”. En 15 segundos:
- Nuevo repo
tf-azurerm-storage-accounten GitHub - Estructura estándar:
main.tf,variables.tf,outputs.tf,versions.tfconazurerm ~> 4.0 main.tfya tiene el recursoazurerm_storage_account,azurerm_private_endpoint, lifecycle policy — generado por IA basándose en la descripciónvariables.tftiene todos los inputs que necesitan los recursos — no solo los defaults del cloud, sino variables específicas de storage comoaccount_tier,replication_type,allowed_subnet_idsoutputs.tfexporta el ID del storage account, el primary endpoint, la IP del private endpoint — todos los outputs útiles para módulos que dependan de este- README con ejemplo de uso y tablas de inputs/outputs
- Workflow de GitHub Actions para
terraform validate+ Terratest mkdocs.yml+ docs listos para TechDocscatalog-info.yamlregistrado en Backstage
El ingeniero revisa el código generado por IA, ajusta si hace falta, y hace push. El módulo está listo para producción desde el nacimiento — no después de una semana de trabajo de boilerplate.
Para el escenario MSP: el mismo template, pero con client = acme-corp. El módulo se etiqueta como client-acme-corp, se registra bajo el system client-acme-corp-infrastructure, y solo es visible para el equipo que trabaja con ese cliente.
Checklist
- Template registrado en Backstage (página
/create) - Bloques de provider para Azure, Scaleway, AWS y GCP generados correctamente
- La IA genera
main.tfcon recursos reales que coinciden con la descripción - La IA genera
variables.tfyoutputs.tfespecíficos para los recursos -
catalog-info.yamlincluye tag de cloud y tag de cliente (si es MSP) - README tiene ejemplo de uso con variables específicas del cloud
- El workflow de GitHub Actions ejecuta
terraform validate - La configuración de TechDocs genera docs en Backstage
- El módulo aparece en el catálogo después del scaffolding
- El código generado pasa
terraform validate
Challenge
Antes del próximo artículo:
- Crea un módulo para cada cloud que uses (Azure, Scaleway, AWS o GCP)
- Mira el catálogo — ¿puedes filtrar por cloud provider?
- Abre los TechDocs de uno de ellos — ¿se renderiza?
En el próximo artículo, construimos Multi-tenant Infrastructure — cómo la misma instancia de Backstage sirve tanto a equipos internos de DevOps como a clientes de servicios gestionados. Diferentes catálogos, diferentes templates, diferentes workflows de aprobación, una sola plataforma.
El código completo está en GitHub.
Si esta serie te ayuda, considera invitarme a un café.
Este es el artículo 2 de la serie Infrastructure Hub. Anterior: Your Infrastructure Has No Catalog. Siguiente: Multi-tenant Infrastructure — una plataforma, muchos clientes.
Loading comments...