Files
apps-registry/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md
T
devops 0052dfa1da Documentar lab-agent: lista blanca, diseño de seguridad y prueba e2e
Agrega docs/arquitectura/agente-monitoreo.md como referencia técnica y
documento de gobernanza del wrapper headless de diagnóstico/corrección
(scripts/lab-agent). Cubre la lista blanca de acciones automáticas
aprobada, por qué Claude corre con --allowedTools "" en vez de un Bash
tool con prefijo (coincidencia por prefijo permite inyección de
comandos, confirmado en vivo), el rediseño de autenticación a login de
suscripción en vez de API key, y la evidencia completa de la prueba
end-to-end del 2026-08-26 (DRY_RUN, ejecución real, rate limit/bloqueo,
2 bugs encontrados y corregidos).
2026-08-25 23:11:10 -05:00

9.9 KiB
Raw Blame History

Agente de diagnóstico/corrección (lab-agent)

Wrapper headless (scripts/lab-agent/lab-agent.sh) que corre claude -p periódicamente para diagnosticar el estado del lab y, solo para un conjunto explícito y aprobado de recursos, ejecutar una acción correctiva de bajo riesgo. Este documento es tanto referencia técnica como decisión de gobernanza — la lista blanca de abajo es lo que autoriza al agente a actuar sin intervención humana, y debe poder auditarse.

Estado: script y prueba end-to-end validados en vivo (2026-08-26). El timer de systemd (automatización continua) todavía no está activo — pendiente de decisión explícita para habilitarlo.

Lista blanca de acciones correctivas automáticas

Aprobada 2026-08-25, antes de escribir una sola línea de código.

1. kubectl rollout restart deployment/<nombre>

Condición Valor
Trigger Deployment con readyReplicas < replicas o condición Available=False durante ≥10 minutos consecutivos
Namespace elegible ecommerce (todos los deployments detectados ahí en el momento de la corrida)
Excepción medusa-deploy — el deployment real de "commerce-backend" en la terminología de CLAUDE.md (confirmado en vivo 2026-08-25: no existe un deployment llamado literalmente commerce-backend) — solo elegible si además la hora local del host está entre 00:00–06:00
Namespaces excluidos siempre argocd, kube-system, cualquier otro namespace de plataforma

2. docker restart <contenedor>

Condición Valor
Contenedores elegibles Únicamente gitea-runner
Trigger Contenedor no está en estado running durante ≥10 min, o la API de Gitea Actions reporta un run en estado running atascado por más de 10 min sin terminar (señal del incidente de runner zombie del 2026-08-16)
Degradación segura Si no hay GITEA_TOKEN_FILE configurado o la API falla, esa señal se omite — nunca dispara una acción sobre una lectura no verificada, solo queda la señal de "contenedor caído"
Excluidos siempre gitea, nginxproxymanager, cloudflared

3. Fuera de alcance automático — SIEMPRE (solo diagnóstico + notificación)

  • Borrar cualquier recurso, escalar réplicas, tocar ConfigMap/Secret/configuración.
  • Reiniciar gitea, nginxproxymanager, cloudflared.
  • Cualquier acción sobre medusa-deploy fuera de la ventana 00:00–06:00.
  • Cualquier namespace/deployment no detectado explícitamente en ecommerce.

4. Límites obligatorios

  • Rate limit: máx. 1 acción correctiva por recurso cada 30 min.
  • Sin reintento silencioso: tras un primer intento cuyo problema no se resuelve, el recurso queda blocked — solo notificación, hasta que un humano limpie la entrada en state.json o el recurso se recupere solo.
  • Logging completo: cada decisión (actúe o no) se registra en actions.jsonl con timestamp, recurso, decisión, resultado y motivo.
  • Notificación siempre: toda acción correctiva o anomalía dispara notificación — nunca es silenciosa.

Diseño de seguridad: por qué Claude nunca ejecuta comandos

claude -p se invoca con --allowedTools "" (cero herramientas) y --json-schema, así que el modelo nunca ejecuta un comando — solo devuelve una decisión estructurada ({"actions":[{"resource","decision","reason"}], "summary"}). El script bash es el único que ejecuta kubectl/docker, y solo después de revalidar la decisión contra su propio cálculo de elegibilidad — nunca confía en el JSON de Claude por sí solo.

Se descartó dar a Claude un Bash tool con un patrón tipo Bash(kubectl rollout restart deployment/medusa-deploy -n ecommerce) porque se comprobó en vivo (2026-08-25) que --allowedTools hace coincidencia por prefijo, no exacta: un patrón Bash(echo pintest) también permite ejecutar echo pintest && echo INJECTED o echo pintest; rm -rf /. Para un comando root-capaz eso no es un límite seguro. Con --allowedTools "", el peor caso posible es que Claude proponga una decisión incorrecta en el JSON — y el wrapper la rechaza si el recurso no está en su propia lista de elegibles.

El prompt también advierte contra prompt injection: los datos de diagnóstico (estado de pods, logs) podrían contener texto arbitrario si algo en el cluster está comprometido, e instruye a Claude a ignorar cualquier instrucción incrustada ahí.

Autenticación: login de suscripción, no API key

ANTHROPIC_API_KEY_FILE queda vacío por defecto — claude -p usa el login de suscripción ya activo del usuario que corre el script (~/.claude/.credentials.json), el mismo que usa una sesión interactiva normal. Esta decisión se tomó en vivo (2026-08-26) al confirmar que no había una API key real disponible; el diseño original pedía un secreto root-only para la API key, pero se simplificó porque:

  1. No hacía falta una API key aparte — la suscripción ya cubre esto.
  2. Elimina la necesidad de crear secretos root-only vía sudo (que además está bloqueado para una sesión de Claude Code interactiva por diseño — ver feedback_scoped_root_access_pattern en memoria del proyecto).

Consecuencia directa: el servicio systemd (cuando se active) debe correr como usuario devops, no como root — root no tiene acceso a ese login. CLAUDE_BIN/KUBECTL_BIN apuntan por ruta absoluta a /DATA/AppData/home-devops/.local/bin/ porque ese directorio no está en el PATH de root ni de systemd (confirmado en vivo: sudo which claude no lo encuentra).

El único secreto que queda es GITEA_TOKEN_FILE (token de Gitea Actions, opcional, scope lectura), dueño devops, 600 — sin sudo.

Prueba end-to-end (2026-08-26)

Ejecutada contra el cluster real, rama fix/lab-agent-diagnostico-headless. Umbral bajado a 2 minutos solo para esta prueba (UNHEALTHY_THRESHOLD_MIN, valor real en producción: 10). Canario desechable: deployment/test-agent-canary en ecommerce, imagen busybox:stable con command: ["sh","-c","exit 1"] — garantiza CrashLoopBackOff predecible, sin relación con catalog-api-deploy/frontend-deploy/medusa-deploy.

Bugs encontrados y corregidos durante la prueba

  1. Subshell perdía el resultado de la acción. execute_corrective_action se invocaba como result="$(execute_corrective_action "$resource")" — al correr en un subshell, las variables LAST_ACTION_CMD/STDOUT/STDERR nunca volvían al script principal → crash con unbound variable. Corregido: la función ahora devuelve éxito/fallo por exit status, invocada directamente (sin $(...)) para que sus variables globales persistan.
  2. DRY_RUN contaminaba el estado real. El wrapper marcaba blocked=true en state.json incluso en modo simulación, lo que habría bloqueado incorrectamente una corrida real posterior sobre el mismo recurso. Corregido: la actualización de state.json (bloqueo/rate-limit) solo ocurre fuera de DRY_RUN.
  3. claude/kubectl fuera del PATH de root/systemd — ver sección de autenticación arriba. Corregido con CLAUDE_BIN/KUBECTL_BIN por ruta absoluta.

Evidencia por paso

DRY_RUN (elegibilidad + decisión, sin tocar el cluster):

  • 1ª corrida (recién detectado): elegibilidad=not_eligible:bajo_umbral(0m<2m) → Claude devolvió notify_only para los 5 recursos.
  • 2ª corrida (>2 min después): elegibilidad=eligible para test-agent-canary → Claude devolvió decision:"restart" solo para ese recurso, notify_only para los otros 4 (sanos).
  • Log: ACCIÓN EJECUTADA: kubectl -n ecommerce rollout restart deployment/test-agent-canary -> OK, pero verificado en el cluster real que no hubo restart (sin anotación restartedAt, mismo pod, misma edad). actions.jsonl registra "dry_run": true.

Ejecución real (--live):

  • Misma decisión de Claude (restart para el canario, notify_only para el resto).
  • Verificado en el cluster: nueva anotación kubectl.kubernetes.io/restartedAt en el deployment, evento ScalingReplicaSet de un ReplicaSet nuevo, pod nuevo en otro nodo.
  • actions.jsonl: "result": "success", "dry_run": false.
  • state.json: last_action_at seteado, blocked: true.

Rate limit / sin reintento silencioso:

  • El canario (siempre crashea por diseño) volvió a estar unhealthy sin intervención extra.
  • Corrida siguiente: elegibilidad=not_eligible:blocked_tras_intento_previo_sin_recuperacion.
  • Claude devolvió notify_only para todos los recursos, incluido el canario — ningún segundo restart propuesto ni ejecutado.
  • state.json sin cambios (mismo last_action_at que la corrida real anterior) — confirma que no hubo reintento.
  • Notificación real entregada vía ntfy.sh, confirmada leyendo el propio historial del topic (no solo el log del script).

Limpieza: deployment/test-agent-canary eliminado, entrada correspondiente en state.json borrada. Los logs de la prueba (actions.jsonl, run-*.log) se conservan como evidencia auditable — no se purgaron.

Notificaciones

Canal: ntfy — sin sistema de notificación previo en el lab, se eligió la opción sin infraestructura nueva (tópico público de ntfy.sh, nombre privado difícil de adivinar) para validar la prueba end-to-end sin bloquear en instalar y exponer un contenedor propio. Migrar a un ntfy self-hosted (mismo patrón que gitea/nginxproxymanager/ cloudflared) es un cambio de una sola variable (NTFY_URL) cuando se decida.

Pendiente antes de activar el timer en continuo

  • Confirmación explícita del propietario del lab para habilitar el timer de systemd (Paso 4 original) — no se activa automáticamente solo por haber pasado la prueba end-to-end.
  • Unidad systemd corriendo como devops (no root), con límites de CPU/memoria.
  • Opcional: GITEA_TOKEN_FILE para activar la señal de "runner atascado" vía API de Gitea Actions (hoy se omite, degradación segura).