The Infrastructure Hub -- Parte 7

Habla con tu infraestructura

#platform-engineering #backstage #ai #chat #infrastructure #rag

El Problema

“¿Qué versión de Kubernetes usa el cliente ACME?”

Lo sabes. Lo has visto. Pero gestionas infraestructura para 5 clientes en 3 clouds, y ahora mismo no recuerdas si ACME está en 1.29 o 1.30. Así que abres el catálogo, buscas el módulo AKS, haces clic hasta el repo, abres variables.tf, bajas hasta el default de kubernetes_version. 1.29. Te ha costado 90 segundos.

Ahora multiplica eso por las 15 preguntas que respondes cada día: “¿Cuál es el rango CIDR de la VNet de Globex?”, “¿Qué módulos cambiamos la semana pasada para el cliente Stark?”, “¿El módulo de storage account usa private endpoints?”, “¿Cuál es la política de aprobación para los cambios en producción de ACME?”

Cada respuesta está en algún sitio — en el catálogo, en el código de Terraform, en la documentación, en el historial del CAB. Pero encontrarla lleva tiempo. Y la mitad de las veces alguien pregunta en Slack, y un ingeniero para lo que está haciendo para responder.

La serie IDP construyó un widget Ask para preguntas sueltas sobre documentación. Pero las preguntas de infraestructura necesitan más contexto. Necesitan conversaciones de varios turnos. “Muéstrame los módulos de ACME” → “¿Cuáles tienen drift?” → “¿Qué cambió en el módulo de VNet el mes pasado?” Cada pregunta se apoya en la anterior.

La Solución

Un panel de chat conversacional en Backstage que:

  1. Lee el catálogo — conoce cada módulo, cada cliente, cada owner, cada estado de lifecycle
  2. Busca en la documentación a través del sistema RAG de la serie IDP
  3. Consulta el historial del CAB del artículo 6 — cambios recientes, aprobaciones, niveles de riesgo
  4. Mantiene el contexto de la conversación — cada mensaje se construye sobre los anteriores
  5. Funciona con cualquier proveedor de AI — el mismo patrón OpenAI-compatible que todos los endpoints de la serie

El chat no es un chatbot genérico. Está limitado a los datos de tu plataforma. Cada respuesta viene de tu catálogo, tus docs o tu historial de cambios — no de los datos de entrenamiento del modelo.

Execute

El Endpoint de Chat

Un nuevo endpoint en el servicio de AI que gestiona conversaciones multi-turn con contexto de infraestructura:

app.MapPost("/api/infra/chat", async (InfraChatRequest request, IConfiguration config) =>
{
    if (string.IsNullOrWhiteSpace(request.Message))
        return Results.BadRequest(new { error = "Message 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),
    };

    // Build context from multiple sources
    var context = await GatherInfraContext(request, config);

    var messages = new List<ChatMessage>
    {
        new SystemChatMessage($"""
            You are an infrastructure assistant for a Backstage-based platform.
            You help engineers find information about Terraform modules, clients,
            pipelines, and infrastructure changes.

            AVAILABLE DATA:
            {context}

            RULES:
            - Answer ONLY from the data above. If the answer isn't in the data, say so.
            - When referencing a module, include its lifecycle status and owner.
            - When referencing a change, include the CR ID and risk level.
            - Be concise. Engineers are busy.
            - If asked about costs or budgets, say you don't have that data.
            """),
    };

    // Add conversation history (multi-turn)
    foreach (var turn in request.History ?? [])
    {
        messages.Add(turn.Role == "user"
            ? new UserChatMessage(turn.Content)
            : new AssistantChatMessage(turn.Content));
    }

    // Add the current message
    messages.Add(new UserChatMessage(request.Message));

    try
    {
        var completion = await chatClient.CompleteChatAsync(messages);
        var reply = completion.Value.Content[0].Text.Trim();

        return Results.Ok(new
        {
            reply,
            sourcesUsed = context.Length > 0
                ? new[] { "catalog", "cab-history", "documentation" }
                : Array.Empty<string>(),
        });
    }
    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 InfraChatRequest(
    string Message,
    string? Client,
    List<ChatTurn> History);

record ChatTurn(string Role, string Content);

Recopilando Contexto de Infraestructura

La diferencia clave con un chatbot genérico: antes de responder, leemos tus datos reales.

async Task<string> GatherInfraContext(InfraChatRequest request, IConfiguration config)
{
    var connStr = config["Rag:PostgresConnection"];
    var sections = new List<string>();

    if (string.IsNullOrEmpty(connStr))
        return "No infrastructure data configured.";

    await using var dataSource = NpgsqlDataSource.Create(connStr);

    // 1. Catalog modules (filtered by client if specified)
    await using (var cmd = dataSource.CreateCommand())
    {
        // Query the Backstage catalog database for terraform modules
        // In production, call the catalog API instead of querying directly
        cmd.CommandText = """
            SELECT metadata->>'name', metadata->>'description',
                   spec->>'lifecycle', spec->>'owner', spec->>'system'
            FROM entities
            WHERE spec->>'type' = 'terraform-module'
              AND ($1 = '' OR spec->>'system' LIKE '%' || $1 || '%')
            ORDER BY metadata->>'name'
            LIMIT 50
            """;
        cmd.Parameters.AddWithValue(request.Client ?? "");

        var modules = new List<string>();
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            modules.Add(
                $"- {reader.GetString(0)}: {reader.GetString(1)} " +
                $"(lifecycle: {reader.GetString(2)}, owner: {reader.GetString(3)})");
        }

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

    // 2. Recent CAB approvals
    await using (var cmd = dataSource.CreateCommand())
    {
        cmd.CommandText = """
            SELECT change_request_id, module, client, risk_level,
                   approved_by, approved_at
            FROM cab_approvals
            WHERE approved_at >= NOW() - INTERVAL '30 days'
              AND ($1 = '' OR client = $1)
            ORDER BY approved_at DESC
            LIMIT 20
            """;
        cmd.Parameters.AddWithValue(request.Client ?? "");

        var changes = new List<string>();
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            changes.Add(
                $"- {reader.GetString(0)}: {reader.GetString(1)} " +
                $"(client: {(reader.IsDBNull(2) ? "internal" : reader.GetString(2))}, " +
                $"risk: {reader.GetString(3)}, " +
                $"approved by: {reader.GetString(4)}, " +
                $"date: {reader.GetFieldValue<DateTimeOffset>(5):yyyy-MM-dd})");
        }

        if (changes.Count > 0)
            sections.Add($"RECENT CHANGES (last 30 days):\n{string.Join("\n", changes)}");
    }

    // 3. RAG search — if the message looks like a question, search the docs
    if (request.Message.Contains('?') || request.Message.StartsWith("what", StringComparison.OrdinalIgnoreCase)
        || request.Message.StartsWith("how", StringComparison.OrdinalIgnoreCase)
        || request.Message.StartsWith("which", StringComparison.OrdinalIgnoreCase))
    {
        var embeddingModel = config["AI:EmbeddingModel"] ?? "bge-multilingual-gemma2";
        var aiEndpoint = config["AI:Endpoint"];
        var aiKey = config["AI:Key"];

        try
        {
            var openAiClient = new OpenAIClient(
                new ApiKeyCredential(aiKey!),
                new OpenAIClientOptions { Endpoint = new Uri(aiEndpoint!) });
            var embeddingClient = openAiClient.GetEmbeddingClient(embeddingModel);

            var questionEmbedding = await embeddingClient.GenerateEmbeddingAsync(request.Message);
            var vector = questionEmbedding.Value.ToFloats();
            var vectorStr = "[" + string.Join(",",
                vector.ToArray().Select(f => f.ToString("G"))) + "]";

            await using var cmd = dataSource.CreateCommand();
            cmd.CommandText = """
                SELECT doc_path, content,
                       1 - (embedding <=> $1::vector) AS similarity
                FROM doc_chunks
                WHERE ($2 = '' OR entity_ref LIKE '%' || $2 || '%')
                ORDER BY embedding <=> $1::vector
                LIMIT 3
                """;
            cmd.Parameters.AddWithValue(vectorStr);
            cmd.Parameters.AddWithValue(request.Client ?? "");

            var docs = new List<string>();
            await using var reader = await cmd.ExecuteReaderAsync();
            while (await reader.ReadAsync())
            {
                var similarity = reader.GetFloat(2);
                if (similarity > 0.5f)
                {
                    docs.Add($"[{reader.GetString(0)}]:\n{reader.GetString(1)}");
                }
            }

            if (docs.Count > 0)
                sections.Add($"DOCUMENTATION:\n{string.Join("\n\n", docs)}");
        }
        catch
        {
            // RAG not available — continue without docs
        }
    }

    return string.Join("\n\n", sections);
}

La recopilación de contexto tiene tres capas:

  • Módulos del catálogo — siempre incluidos, filtrados por cliente si el usuario lo especifica
  • Historial del CAB — cambios recientes y aprobaciones del artículo 6
  • Documentación — búsqueda RAG del artículo 5 de la serie IDP, solo se activa para preguntas

Esto significa que la AI siempre sabe qué módulos existen y qué cambió recientemente. Para preguntas concretas (“¿cuál es la retry policy?”), también busca en los docs.

El Panel de Chat en Backstage

Un panel conversacional completo, no un widget de pregunta única:

// plugins/infra-chat/src/components/InfraChatPanel.tsx
import React, { useState, useRef, useEffect } from 'react';
import {
  Paper,
  TextField,
  IconButton,
  Typography,
  Select,
  MenuItem,
  InputLabel,
  FormControl,
  Box,
  CircularProgress,
} from '@material-ui/core';
import SendIcon from '@material-ui/icons/Send';
import { useApi, fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';

interface Message {
  role: 'user' | 'assistant';
  content: string;
}

export const InfraChatPanel = () => {
  const fetchApi = useApi(fetchApiRef);
  const discoveryApi = useApi(discoveryApiRef);
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [client, setClient] = useState('');
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  const handleSend = async () => {
    if (!input.trim() || loading) return;

    const userMessage: Message = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setLoading(true);

    try {
      const proxyUrl = await discoveryApi.getBaseUrl('proxy');
      const res = await fetchApi.fetch(`${proxyUrl}/ai-service/api/infra/chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: input,
          client: client || null,
          history: messages.slice(-10), // Keep last 10 turns for context window
        }),
      });

      if (!res.ok) throw new Error(`${res.status}`);

      const data = await res.json();
      setMessages(prev => [
        ...prev,
        { role: 'assistant', content: data.reply },
      ]);
    } catch {
      setMessages(prev => [
        ...prev,
        { role: 'assistant', content: 'Failed to get a response. Check the AI service.' },
      ]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Paper
      style={{
        display: 'flex',
        flexDirection: 'column',
        height: '600px',
        padding: 16,
      }}
    >
      <Box display="flex" alignItems="center" gap={2} mb={2}>
        <Typography variant="h6" style={{ flex: 1 }}>
          Infrastructure Chat
        </Typography>
        <FormControl size="small" style={{ minWidth: 150 }}>
          <InputLabel>Client filter</InputLabel>
          <Select
            value={client}
            onChange={e => setClient(e.target.value as string)}
            label="Client filter"
          >
            <MenuItem value="">All clients</MenuItem>
            <MenuItem value="acme">ACME</MenuItem>
            <MenuItem value="globex">Globex</MenuItem>
            <MenuItem value="stark">Stark</MenuItem>
          </Select>
        </FormControl>
      </Box>

      <Box
        style={{
          flex: 1,
          overflowY: 'auto',
          marginBottom: 16,
          padding: 8,
        }}
      >
        {messages.length === 0 && (
          <Typography
            color="textSecondary"
            style={{ textAlign: 'center', marginTop: 80 }}
          >
            Ask about your infrastructure. Try: "Which modules does ACME use?"
            or "What changed last week?"
          </Typography>
        )}
        {messages.map((msg, i) => (
          <Box
            key={i}
            style={{
              display: 'flex',
              justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
              marginBottom: 8,
            }}
          >
            <Paper
              elevation={1}
              style={{
                padding: '8px 12px',
                maxWidth: '75%',
                backgroundColor: msg.role === 'user' ? '#e3f2fd' : '#f5f5f5',
                borderRadius: 12,
              }}
            >
              <Typography
                variant="body2"
                style={{ whiteSpace: 'pre-wrap' }}
              >
                {msg.content}
              </Typography>
            </Paper>
          </Box>
        ))}
        {loading && (
          <Box display="flex" justifyContent="flex-start" mb={1}>
            <CircularProgress size={20} />
          </Box>
        )}
        <div ref={messagesEndRef} />
      </Box>

      <Box display="flex" gap={1}>
        <TextField
          fullWidth
          variant="outlined"
          size="small"
          placeholder="Ask about modules, clients, changes..."
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
          disabled={loading}
        />
        <IconButton
          color="primary"
          onClick={handleSend}
          disabled={loading || !input.trim()}
        >
          <SendIcon />
        </IconButton>
      </Box>
    </Paper>
  );
};

Registrar la Página de Chat en Backstage

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

const rootRouteRef = createRouteRef({ id: 'infra-chat' });

export const infraChatPlugin = createPlugin({
  id: 'infra-chat',
  routes: { root: rootRouteRef },
});

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

En packages/app/src/App.tsx:

import { InfraChatPage } from '@internal/plugin-infra-chat';

// Inside <FlatRoutes>:
<Route path="/infra-chat" element={<InfraChatPage />} />

Y en el sidebar:

<SidebarItem icon={ChatIcon} to="infra-chat" text="Infra Chat" />

Cómo se Ve una Conversación

Ingeniero: “¿Qué módulos usa el cliente ACME?”

Asistente: ACME tiene 4 módulos de Terraform en el catálogo:

  • tf-azurerm-vnet — Hub VNet con 3 subnets (production, owner: team-acme)
  • tf-azurerm-aks — Cluster AKS con 5 node pools (production, owner: team-acme)
  • tf-azurerm-sql — Azure SQL con private endpoints (production, owner: team-acme)
  • tf-azurerm-storage — Storage account con lifecycle policies (experimental, owner: team-acme)

Ingeniero: “¿Qué cambió en las últimas 2 semanas?”

Asistente: 2 cambios para ACME en los últimos 14 días:

  • CR-1712401234: tf-azurerm-aks — Escalado de node pool de 3 a 5 nodos (risk: medium, aprobado por victor.zaragoza, 2026-04-01)
  • CR-1712398765: tf-azurerm-storage — Añadida lifecycle policy para borrar blobs después de 90 días (risk: low, auto-approved, 2026-03-28)

Ingeniero: “¿El módulo AKS está en Kubernetes 1.30?”

Asistente: Según la documentación de tf-azurerm-aks, la variable kubernetes_version tiene como valor por defecto 1.29. El cambio reciente CR-1712401234 escaló el node pool pero no actualizó la versión de Kubernetes. ACME sigue en 1.29.

Tres preguntas, 10 segundos. La misma investigación sin el chat habría costado 5 minutos haciendo clic por Backstage, GitHub y el historial del CAB.

Gestión de la Ventana de Contexto

El chat envía los últimos 10 mensajes como historial. Esto mantiene la ventana de contexto manejable mientras se conserva la coherencia de la conversación. Para investigaciones más largas, el ingeniero puede empezar una conversación nueva.

El history se trunca en el lado del cliente:

history: messages.slice(-10), // Keep last 10 turns for context window

Esto son más o menos 5 pares de pregunta-respuesta. Para Mistral Small (32K de contexto), queda espacio de sobra para el contexto de infraestructura (catálogo + cambios + docs).

Filtrado por Cliente

El dropdown de arriba filtra todo: las consultas al catálogo, el historial del CAB y la búsqueda RAG se limitan al cliente seleccionado. Cambia a “Globex” y ves los módulos de Globex, los cambios de Globex y la documentación de Globex.

Para el equipo de plataforma, selecciona “All clients” para verlo todo. Para un team lead responsable de un solo cliente, selecciona su cliente y solo ve lo que es relevante.

Agnóstico del Proveedor

El chat usa el mismo patrón ChatClient que todos los demás endpoints:

AI:Provider = openai  → Scaleway, Mistral, OpenAI
AI:Provider = azure   → Azure AI Foundry (GPT-5, Claude Sonnet, Mistral Large)

Cambia las variables de entorno, mismo código. El chat funciona con cualquier modelo que soporte conversaciones multi-turn — que son todos.

Checklist

  • El endpoint /api/infra/chat gestiona conversaciones multi-turn
  • El contexto se recopila del catálogo, historial del CAB y docs RAG
  • El filtro de cliente limita todas las consultas
  • El historial de conversación se limita a los últimos 10 turnos
  • El panel de chat está registrado como página en Backstage (/infra-chat)
  • El item del sidebar es visible para todos los usuarios
  • La búsqueda RAG solo se activa para preguntas (no para comandos)
  • El umbral de similaridad filtra docs irrelevantes (> 0.5)
  • DateTimeOffset se usa correctamente para las fechas de aprobación del CAB
  • Devuelve 503 cuando AI o PostgreSQL no están configurados

Challenge

Antes del próximo artículo:

  1. Abre el infra chat y pregunta “¿Qué módulos están en producción?”
  2. Filtra por un cliente y pregunta “¿Qué cambió este mes?”
  3. Haz una pregunta de seguimiento que haga referencia a una respuesta anterior — verifica que el contexto se mantiene
  4. Pregunta algo que NO esté en los datos — verifica que la AI dice “No tengo esa información” en vez de inventarse algo

En el próximo artículo, construimos Drift Detection — detectar cuándo la infraestructura real no coincide con el estado de Terraform, y usar AI para explicar qué cambió, por qué importa y cómo arreglarlo. Con comparaciones de estado cifradas con PQC.

El código completo está en GitHub.

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

Este es el artículo 7 de la serie Infrastructure Hub. Anterior: CAB Automation. Siguiente: Drift Detection — encuentra y arregla infraestructura que no coincide con tu código.

Comments

Loading comments...