The Infrastructure Hub -- Parte 8
Drift Detection: Cuando Tu Infraestructura No Coincide con Tu Código
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:
- Ejecutar
terraform planen modo solo lectura — sin apply, solo detección - Parsear el output del plan — qué recursos han derivado, qué cambió
- Enviar el diff a la IA — obtener una explicación legible de qué derivó y cuál es el riesgo
- Almacenar el resultado — vincularlo a la entidad del catálogo con una annotation de estado de drift
- 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-exitcodehace 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: trueporque 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_resultssiempre 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:
| Module | Client | Status | Resources | Risk | Summary | Last Scan |
|---|---|---|---|---|---|---|
| tf-azurerm-aks | acme | DRIFT | 3 | Medium | Node pool scaled from 3→5 during incident. Code says 3. | Apr 7 |
| tf-azurerm-sql | globex | DRIFT | 1 | Low | Firewall rule added manually for temp debugging access. | Apr 7 |
| tf-azurerm-vnet | acme | CLEAN | 0 | None | Apr 7 | |
| tf-azurerm-storage | acme | CLEAN | 0 | None | Apr 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/analyzeacepta plan output y devuelve un análisis estructurado - Endpoint
/api/drift/resultsdevuelve todos los resultados de drift ordenados por riesgo - Tabla
drift_resultscreada conUNIQUEen 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)
-
DateTimeOffsetusado correctamente paradetected_at - Datos de drift integrados en el contexto del chat de infraestructura
-
ON CONFLICT DO UPDATEmantiene solo el resultado más reciente por módulo
Reto
Antes del siguiente artículo:
- Ejecuta el drift scan en un módulo — verifica que detecta un estado limpio
- Haz un cambio manual en el portal de la nube (escala algo, añade un tag)
- Ejecuta el scan otra vez — verifica que se detecta el drift y que la explicación de la IA es precisa
- 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.
Loading comments...