The Infrastructure Hub -- Parte 8

Drift Detection: Cuando Tu Infraestructura No Coincide con Tu Código

#platform-engineering #backstage #terraform #drift #infrastructure #ai

El Problema

Es lunes por la mañana. Ejecutas terraform plan en el módulo AKS del cliente ACME. Output esperado: “No changes.” Output real: 14 resources will be modified.

Nadie cambió el código. Nadie mergeó un PR. Pero alguien — quizá un ingeniero durante un incidente, quizá un admin del cliente con acceso al portal, quizá un proceso automático que olvidaste — cambió algo directamente en Azure. Ahora el recurso en la nube y el código de Terraform no coinciden.

Esto es drift. Y pasa en todas las organizaciones que gestionan infraestructura con código. No porque la gente sea descuidada — porque la realidad es complicada. Los incidentes necesitan hotfixes. Los clientes hacen cambios en el portal. Azure actualiza configuraciones por defecto. Kubernetes auto-escala y deja configuración que Terraform no esperaba.

El problema no es el drift en sí — es no saber que existe. La mayoría de equipos descubren el drift por accidente: un terraform plan que muestra cambios inesperados, o peor, un terraform apply que revierte un fix manual que alguien hizo la semana pasada. A esas alturas, el daño ya está hecho.

Lo que necesitas es un sistema que detecte el drift antes de ejecutar terraform plan, te diga qué cambió, te explique por qué importa y te deje decidir si arreglarlo — todo desde Backstage, sin abrir una terminal.

La Solución

Un pipeline de drift detection que se ejecuta de forma programada para cada módulo de Terraform en el catálogo:

  1. Ejecutar terraform plan en modo solo lectura — sin apply, solo detección
  2. Parsear el output del plan — qué recursos han derivado, qué cambió
  3. Enviar el diff a la IA — obtener una explicación legible de qué derivó y cuál es el riesgo
  4. Almacenar el resultado — vincularlo a la entidad del catálogo con una annotation de estado de drift
  5. Mostrarlo en Backstage — un dashboard con todos los módulos, su estado de drift y remediación con un clic

Qué Aporta la IA

Sin IA, la detección de drift te da un output de Terraform plan — 200 líneas de diff HCL que un ingeniero tiene que leer e interpretar. Con IA, obtienes:

“El node pool del cluster AKS fue escalado manualmente de 3 a 5 nodos (probablemente durante el incidente del 28 de marzo). El código de Terraform sigue diciendo 3. Si haces apply, Terraform lo bajará a 3. Recomendación: actualiza el código para que coincida con el estado actual (5 nodos) antes de hacer apply.”

Esa es la diferencia entre “14 resources will be modified” y saber exactamente qué pasó, por qué y qué hacer al respecto.

Ejecución

El Endpoint de Drift Detection

app.MapPost("/api/drift/analyze", async (DriftRequest request, IConfiguration config) =>
{
    if (string.IsNullOrWhiteSpace(request.PlanOutput))
        return Results.BadRequest(new { error = "Plan output is required." });

    var endpoint = config["AI:Endpoint"];
    var apiKey = config["AI:Key"];
    var model = config["AI:ChatModel"] ?? "mistral-small-3.2-24b-instruct-2506";
    var provider = config["AI:Provider"] ?? "openai";

    ChatClient chatClient = provider.ToLowerInvariant() switch
    {
        "azure" => new AzureOpenAIClient(
            new Uri(endpoint!), new ApiKeyCredential(apiKey!))
            .GetChatClient(model),
        _ => new OpenAIClient(
            new ApiKeyCredential(apiKey!),
            new OpenAIClientOptions { Endpoint = new Uri(endpoint!) })
            .GetChatClient(model),
    };

    var systemPrompt = $"""
        You are an infrastructure drift analyst.
        A Terraform module was planned without any code changes.
        Any differences in the plan output represent drift — something
        changed in the real infrastructure that doesn't match the code.

        Module: {request.Module}
        Client: {request.Client}

        Analyze the Terraform plan output and return a JSON object with:
        - "driftDetected": boolean
        - "resourceCount": number of resources that drifted
        - "summary": 2-3 sentence explanation of what drifted and likely why
        - "risk": "none" | "low" | "medium" | "high" | "critical"
        - "resources": array of objects, each with:
          - "address": the Terraform resource address
          - "change": "update" | "create" | "destroy" | "replace"
          - "description": what changed, in plain English
          - "likelyCause": why this probably happened
        - "recommendation": what to do next (update code, apply, or investigate)

        If the plan shows "No changes", return driftDetected: false.
        Be specific about what changed. Don't guess causes you can't infer
        from the plan output.
        Return ONLY valid JSON, no markdown.
        """;

    try
    {
        var completion = await chatClient.CompleteChatAsync(
        [
            new SystemChatMessage(systemPrompt),
            new UserChatMessage($"Terraform plan output:\n\n{request.PlanOutput}"),
        ]);

        var raw = completion.Value.Content[0].Text.Trim();
        var json = raw.StartsWith("```")
            ? raw.Split('\n', 2)[1].TrimEnd('`').Trim()
            : raw;

        var analysis = JsonSerializer.Deserialize<DriftAnalysis>(
            json, SerializerOptions.Default);

        if (analysis is null)
            return Results.UnprocessableEntity(new { error = "AI returned invalid analysis." });

        // Store the drift result
        var connStr = config["Rag:PostgresConnection"];
        if (!string.IsNullOrEmpty(connStr))
        {
            await using var dataSource = NpgsqlDataSource.Create(connStr);
            await using var cmd = dataSource.CreateCommand();
            cmd.CommandText = """
                INSERT INTO drift_results
                    (module, client, drift_detected, resource_count, risk,
                     summary, analysis_json, detected_at)
                VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
                ON CONFLICT (module)
                DO UPDATE SET client = EXCLUDED.client,
                    drift_detected = EXCLUDED.drift_detected,
                    resource_count = EXCLUDED.resource_count,
                    risk = EXCLUDED.risk,
                    summary = EXCLUDED.summary,
                    analysis_json = EXCLUDED.analysis_json,
                    detected_at = NOW()
                """;
            cmd.Parameters.AddWithValue(request.Module);
            cmd.Parameters.AddWithValue(request.Client ?? (object)DBNull.Value);
            cmd.Parameters.AddWithValue(analysis.DriftDetected);
            cmd.Parameters.AddWithValue(analysis.ResourceCount);
            cmd.Parameters.AddWithValue(analysis.Risk);
            cmd.Parameters.AddWithValue(analysis.Summary);
            cmd.Parameters.AddWithValue(json);

            await cmd.ExecuteNonQueryAsync();
        }

        return Results.Ok(analysis);
    }
    catch (ClientResultException ex) when (ex.Status == 401)
    {
        return Results.Json(new { error = "AI provider authentication failed." }, statusCode: 503);
    }
    catch (Exception ex)
    {
        return Results.Json(new { error = $"AI error: {ex.Message}" }, statusCode: 502);
    }
});

record DriftRequest(string Module, string? Client, string PlanOutput);

record DriftAnalysis(
    [property: JsonPropertyName("driftDetected")] bool DriftDetected,
    [property: JsonPropertyName("resourceCount")] int ResourceCount,
    [property: JsonPropertyName("summary")] string Summary,
    [property: JsonPropertyName("risk")] string Risk,
    [property: JsonPropertyName("resources")] List<DriftedResource> Resources,
    [property: JsonPropertyName("recommendation")] string Recommendation);

record DriftedResource(
    [property: JsonPropertyName("address")] string Address,
    [property: JsonPropertyName("change")] string Change,
    [property: JsonPropertyName("description")] string Description,
    [property: JsonPropertyName("likelyCause")] string LikelyCause);

El Schema de Base de Datos

CREATE TABLE drift_results (
    id SERIAL PRIMARY KEY,
    module VARCHAR(255) NOT NULL UNIQUE,
    client VARCHAR(100),
    drift_detected BOOLEAN NOT NULL,
    resource_count INTEGER NOT NULL DEFAULT 0,
    risk VARCHAR(20) NOT NULL DEFAULT 'none',
    summary TEXT,
    analysis_json TEXT,
    detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_drift_module ON drift_results(module);
CREATE INDEX idx_drift_client ON drift_results(client);
CREATE INDEX idx_drift_risk ON drift_results(risk);

El UNIQUE en module significa que cada módulo tiene un solo resultado de drift — el más reciente. El ON CONFLICT DO UPDATE en el insert reemplaza el resultado anterior cada vez que se ejecuta el escaneo.

El Pipeline de Drift Scan

Un workflow de GitHub Actions que ejecuta terraform plan para cada módulo y envía el output al servicio de IA. Se ejecuta de forma programada — diariamente o cada 12 horas:

# .github/workflows/drift-scan.yml
name: Drift Detection

on:
  schedule:
    - cron: '0 6 * * *'  # Every day at 6am UTC
  workflow_dispatch: {}   # Manual trigger

jobs:
  scan:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        module:
          - tf-azurerm-vnet
          - tf-azurerm-aks
          - tf-azurerm-sql
          - tf-azurerm-storage
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.9'

      - name: Fetch secrets
        env:
          QUANTUMAPI_KEY: ${{ secrets.QUANTUMAPI_KEY }}
        run: |
          curl -sfL https://cli.quantumapi.eu/install.sh | sh -s -- -b /usr/local/bin
          ARM_CLIENT_ID=$(qapi vault get ${{ vars.ARM_CLIENT_ID_SECRET }})
          ARM_CLIENT_SECRET=$(qapi vault get ${{ vars.ARM_CLIENT_SECRET_ID }})
          ARM_TENANT_ID=$(qapi vault get ${{ vars.ARM_TENANT_ID_SECRET }})
          ARM_SUBSCRIPTION_ID=$(qapi vault get ${{ vars.ARM_SUBSCRIPTION_ID_SECRET }})
          echo "::add-mask::$ARM_CLIENT_ID"
          echo "::add-mask::$ARM_CLIENT_SECRET"
          echo "::add-mask::$ARM_TENANT_ID"
          echo "::add-mask::$ARM_SUBSCRIPTION_ID"
          echo "ARM_CLIENT_ID=$ARM_CLIENT_ID" >> $GITHUB_ENV
          echo "ARM_CLIENT_SECRET=$ARM_CLIENT_SECRET" >> $GITHUB_ENV
          echo "ARM_TENANT_ID=$ARM_TENANT_ID" >> $GITHUB_ENV
          echo "ARM_SUBSCRIPTION_ID=$ARM_SUBSCRIPTION_ID" >> $GITHUB_ENV

      - name: Terraform plan (drift detection)
        id: plan
        working-directory: modules/${{ matrix.module }}
        run: |
          terraform init -input=false
          set +e
          terraform plan -no-color -detailed-exitcode -out=plan.out 2>&1 | tee plan.txt
          # PIPESTATUS captures the exit code of terraform, not tee
          echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
          set -e
        continue-on-error: true

      - name: Send to AI for analysis
        if: steps.plan.outputs.exit_code == '2'
        env:
          AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }}
        run: |
          PLAN_OUTPUT=$(cat modules/${{ matrix.module }}/plan.txt)
          curl -sf "$AI_SERVICE_URL/api/drift/analyze" \
            -H "Content-Type: application/json" \
            -d "$(jq -n \
              --arg module "${{ matrix.module }}" \
              --arg client "${{ vars.CLIENT_NAME }}" \
              --arg plan "$PLAN_OUTPUT" \
              '{module: $module, client: $client, planOutput: $plan}')"

      - name: No drift
        if: steps.plan.outputs.exit_code != '2'
        env:
          AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }}
        run: |
          curl -sf "$AI_SERVICE_URL/api/drift/analyze" \
            -H "Content-Type: application/json" \
            -d "$(jq -n \
              --arg module "${{ matrix.module }}" \
              --arg client "${{ vars.CLIENT_NAME }}" \
              '{module: $module, client: $client, planOutput: "No changes. Your infrastructure matches your configuration."}')"

Detalles clave:

  • -detailed-exitcode hace que Terraform devuelva exit code 2 cuando hay cambios (drift), exit code 0 cuando está limpio y exit code 1 en errores. Así distinguimos “sin drift” de “drift detectado” sin parsear el output.
  • continue-on-error: true porque el exit code 2 normalmente haría fallar el step.
  • Los secrets usan el patrón ::add-mask:: del artículo 5 — se obtienen de QuantumVault, se enmascaran y luego se exportan.
  • Ambos resultados (drift y sin drift) se envían al servicio de IA, para que la tabla drift_results siempre tenga el estado más reciente de cada módulo.

Para Azure DevOps, reemplaza la sintaxis de GitHub Actions con un azure-pipelines.yml equivalente usando la misma estructura — el truco de terraform plan -detailed-exitcode funciona igual.

El Dashboard de Drift

// plugins/drift-dashboard/src/components/DriftDashboard.tsx
import React, { useEffect, useState } from 'react';
import {
  Page, Header, Content, Table, TableColumn,
  StatusOK, StatusError, StatusWarning, StatusPending,
} from '@backstage/core-components';
import { Chip } from '@material-ui/core';
import { useApi, fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';

interface DriftResult {
  module: string;
  client: string | null;
  driftDetected: boolean;
  resourceCount: number;
  risk: string;
  summary: string;
  detectedAt: string;
}

const RiskIndicator = ({ risk }: { risk: string }) => {
  switch (risk) {
    case 'none': return <StatusOK>Clean</StatusOK>;
    case 'low': return <StatusOK>Low</StatusOK>;
    case 'medium': return <StatusWarning>Medium</StatusWarning>;
    case 'high': return <StatusError>High</StatusError>;
    case 'critical': return <StatusError>Critical</StatusError>;
    default: return <StatusPending>Unknown</StatusPending>;
  }
};

export const DriftDashboard = () => {
  const fetchApi = useApi(fetchApiRef);
  const discoveryApi = useApi(discoveryApiRef);
  const [results, setResults] = useState<DriftResult[]>([]);

  useEffect(() => {
    const load = async () => {
      const proxyUrl = await discoveryApi.getBaseUrl('proxy');
      const res = await fetchApi.fetch(
        `${proxyUrl}/ai-service/api/drift/results`,
      );
      if (res.ok) {
        const data = await res.json();
        setResults(data);
      }
    };
    load();
  }, [fetchApi, discoveryApi]);

  const columns: TableColumn<DriftResult>[] = [
    { title: 'Module', field: 'module' },
    { title: 'Client', field: 'client',
      render: row => row.client || 'internal' },
    { title: 'Status', field: 'driftDetected',
      render: row => row.driftDetected
        ? <Chip label="DRIFT" color="secondary" size="small" />
        : <Chip label="CLEAN" color="default" size="small" /> },
    { title: 'Resources', field: 'resourceCount', type: 'numeric' },
    { title: 'Risk', field: 'risk',
      render: row => <RiskIndicator risk={row.risk} /> },
    { title: 'Summary', field: 'summary',
      render: row => row.summary?.substring(0, 120) + (row.summary?.length > 120 ? '...' : '') },
    { title: 'Last Scan', field: 'detectedAt',
      render: row => new Date(row.detectedAt).toLocaleDateString() },
  ];

  const driftCount = results.filter(r => r.driftDetected).length;

  return (
    <Page themeId="tool">
      <Header
        title="Infrastructure Drift"
        subtitle={`${driftCount} module${driftCount !== 1 ? 's' : ''} with drift detected`}
      />
      <Content>
        <Table
          columns={columns}
          data={results}
          title={`${results.length} modules scanned`}
          options={{
            paging: true,
            pageSize: 20,
            search: true,
            sorting: true,
          }}
        />
      </Content>
    </Page>
  );
};

El Endpoint de Resultados

El dashboard necesita un endpoint para obtener todos los resultados de drift:

app.MapGet("/api/drift/results", async (string? client, IConfiguration config) =>
{
    var connStr = config["Rag:PostgresConnection"];
    if (string.IsNullOrEmpty(connStr))
        return Results.Json(new { error = "Not configured." }, statusCode: 503);

    await using var dataSource = NpgsqlDataSource.Create(connStr);
    await using var cmd = dataSource.CreateCommand();
    cmd.CommandText = """
        SELECT module, client, drift_detected, resource_count, risk,
               summary, detected_at
        FROM drift_results
        WHERE ($1 = '' OR client = $1)
        ORDER BY
            CASE risk
                WHEN 'critical' THEN 0
                WHEN 'high' THEN 1
                WHEN 'medium' THEN 2
                WHEN 'low' THEN 3
                ELSE 4
            END,
            detected_at DESC
        """;
    cmd.Parameters.AddWithValue(client ?? "");

    var results = new List<object>();
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        results.Add(new
        {
            Module = reader.GetString(0),
            Client = reader.IsDBNull(1) ? null : reader.GetString(1),
            DriftDetected = reader.GetBoolean(2),
            ResourceCount = reader.GetInt32(3),
            Risk = reader.GetString(4),
            Summary = reader.IsDBNull(5) ? null : reader.GetString(5),
            DetectedAt = reader.GetFieldValue<DateTimeOffset>(6),
        });
    }

    return Results.Ok(results);
});

Los resultados se ordenan por riesgo (critical primero, luego high, medium, low, clean) para que los drifts más importantes aparezcan arriba.

Lo Que Muestra el Dashboard

El equipo de plataforma abre /drift y ve:

ModuleClientStatusResourcesRiskSummaryLast Scan
tf-azurerm-aksacmeDRIFT3MediumNode pool scaled from 3→5 during incident. Code says 3.Apr 7
tf-azurerm-sqlglobexDRIFT1LowFirewall rule added manually for temp debugging access.Apr 7
tf-azurerm-vnetacmeCLEAN0NoneApr 7
tf-azurerm-storageacmeCLEAN0NoneApr 7

Dos módulos tienen drift. Uno es de riesgo medio (un cambio de escalado que Terraform revertiría). Otro es de riesgo bajo (una regla de firewall que debería o commitearse o eliminarse). El ingeniero ve el resumen, entiende el impacto y decide qué hacer — sin ejecutar un solo terraform plan en local.

Conectar el Drift al Chat

El chat de infraestructura del artículo 7 ahora puede responder preguntas sobre drift. Añade drift_results a la recopilación de contexto:

// In GatherInfraContext, add after the CAB history section:

// 3. Drift status
await using (var cmd = dataSource.CreateCommand())
{
    cmd.CommandText = """
        SELECT module, drift_detected, resource_count, risk, summary
        FROM drift_results
        WHERE drift_detected = true
          AND ($1 = '' OR client = $1)
        ORDER BY detected_at DESC
        LIMIT 10
        """;
    cmd.Parameters.AddWithValue(request.Client ?? "");

    var drifts = new List<string>();
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        drifts.Add(
            $"- {reader.GetString(0)}: {reader.GetInt32(2)} resources drifted " +
            $"(risk: {reader.GetString(3)}) — {reader.GetString(4)}");
    }

    if (drifts.Count > 0)
        sections.Add($"DRIFT STATUS:\n{string.Join("\n", drifts)}");
}

Ahora un ingeniero puede preguntar al chat: “Which modules have drift?” o “Is ACME’s AKS clean?” y obtener una respuesta basada en el último escaneo.

Registrar el Dashboard

// plugins/drift-dashboard/src/plugin.ts
import {
  createPlugin,
  createRouteRef,
  createRoutableExtension,
} from '@backstage/core-plugin-api';

const rootRouteRef = createRouteRef({ id: 'drift-dashboard' });

export const driftDashboardPlugin = createPlugin({
  id: 'drift-dashboard',
  routes: { root: rootRouteRef },
});

export const DriftPage = driftDashboardPlugin.provide(
  createRoutableExtension({
    name: 'DriftPage',
    component: () =>
      import('./components/DriftDashboard').then(m => m.DriftDashboard),
    mountPoint: rootRouteRef,
  }),
);

En packages/app/src/App.tsx:

import { DriftPage } from '@internal/plugin-drift-dashboard';

// Inside <FlatRoutes>:
<Route path="/drift" element={<DriftPage />} />

Sidebar:

<SidebarItem icon={CompareArrowsIcon} to="drift" text="Drift" />

Checklist

  • Endpoint /api/drift/analyze acepta plan output y devuelve un análisis estructurado
  • Endpoint /api/drift/results devuelve todos los resultados de drift ordenados por riesgo
  • Tabla drift_results creada con UNIQUE en module
  • Pipeline de drift scan se ejecuta de forma programada (terraform plan -detailed-exitcode)
  • Exit code 2 (cambios) lanza el análisis con IA
  • Exit code 0 (sin cambios) actualiza el estado a clean
  • Secrets del pipeline obtenidos de QuantumVault con ::add-mask::
  • Dashboard muestra todos los módulos con estado de drift, riesgo y resumen
  • Resultados ordenados por riesgo (critical primero)
  • DateTimeOffset usado correctamente para detected_at
  • Datos de drift integrados en el contexto del chat de infraestructura
  • ON CONFLICT DO UPDATE mantiene solo el resultado más reciente por módulo

Reto

Antes del siguiente artículo:

  1. Ejecuta el drift scan en un módulo — verifica que detecta un estado limpio
  2. Haz un cambio manual en el portal de la nube (escala algo, añade un tag)
  3. Ejecuta el scan otra vez — verifica que se detecta el drift y que la explicación de la IA es precisa
  4. Abre el chat de infraestructura y pregunta “Which modules have drift?” — verifica que la respuesta incluye el módulo que acabas de cambiar

En el último artículo, lo unimos todo: la Reference Architecture del Infrastructure Hub completo — los 8 artículos conectados, desplegados en Kubernetes, con el mapa completo de plugins y una guía para extender la plataforma.

El código completo está en GitHub.

Si esta serie te ayuda, considera invitarme a un café.

Este es el artículo 8 de la serie Infrastructure Hub. Anterior: Chat with Your Infrastructure. Siguiente: Reference Architecture — el mapa completo del Infrastructure Hub.

Comments

Loading comments...