The Infrastructure Hub -- Parte 2

Módulos Terraform con Golden Path

#platform-engineering #backstage #terraform #golden-path #scaffolder

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.tf basados en tu descripción — no un placeholder con TODO
  • Un README.md con tabla de inputs/outputs y ejemplo de uso
  • Un catalog-info.yaml ya rellenado con los metadatos correctos
  • Configuración de TechDocs (mkdocs.yml + carpeta docs/)
  • 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.

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

NameTypeDefaultDescription
namestringResource name
{% if values.cloud == ‘azure’ -%}
locationstringwesteuropeAzure region
resource_group_namestringResource group
{%- elif values.cloud == ‘scaleway’ -%}
zonestringfr-par-1Scaleway zone
project_idstringScaleway project ID
{%- elif values.cloud == ‘aws’ -%}
regionstringeu-west-1AWS region
{%- elif values.cloud == ‘gcp’ -%}
regionstringeurope-west1GCP region
projectstringGCP project ID
{%- endif %}
tagsmap(string){}Resource tags

Outputs

NameDescription
idResource 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”:

  1. Module Details: name = storage-account, cloud = Azure, description = “Creates a storage account with private endpoints”, owner = team-platform
  2. CI/CD: GitHub Actions, include Terratest = yes
  3. Repository: github.com/my-org/tf-azurerm-storage-account

Hace click en “Create”. En 15 segundos:

  • Nuevo repo tf-azurerm-storage-account en GitHub
  • Estructura estándar: main.tf, variables.tf, outputs.tf, versions.tf con azurerm ~> 4.0
  • main.tf ya tiene el recurso azurerm_storage_account, azurerm_private_endpoint, lifecycle policy — generado por IA basándose en la descripción
  • variables.tf tiene todos los inputs que necesitan los recursos — no solo los defaults del cloud, sino variables específicas de storage como account_tier, replication_type, allowed_subnet_ids
  • outputs.tf exporta 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 TechDocs
  • catalog-info.yaml registrado 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.tf con recursos reales que coinciden con la descripción
  • La IA genera variables.tf y outputs.tf específicos para los recursos
  • catalog-info.yaml incluye 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:

  1. Crea un módulo para cada cloud que uses (Azure, Scaleway, AWS o GCP)
  2. Mira el catálogo — ¿puedes filtrar por cloud provider?
  3. 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.

Comments

Loading comments...