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).
9.9 KiB
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-deployfuera 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 enstate.jsono el recurso se recupere solo. - Logging completo: cada decisión (actúe o no) se registra en
actions.jsonlcon 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:
- No hacía falta una API key aparte — la suscripción ya cubre esto.
- 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 — verfeedback_scoped_root_access_patternen 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
- Subshell perdía el resultado de la acción.
execute_corrective_actionse invocaba comoresult="$(execute_corrective_action "$resource")"— al correr en un subshell, las variablesLAST_ACTION_CMD/STDOUT/STDERRnunca volvían al script principal → crash conunbound variable. Corregido: la función ahora devuelve éxito/fallo por exit status, invocada directamente (sin$(...)) para que sus variables globales persistan. - DRY_RUN contaminaba el estado real. El wrapper marcaba
blocked=trueenstate.jsonincluso en modo simulación, lo que habría bloqueado incorrectamente una corrida real posterior sobre el mismo recurso. Corregido: la actualización destate.json(bloqueo/rate-limit) solo ocurre fuera deDRY_RUN. claude/kubectlfuera del PATH de root/systemd — ver sección de autenticación arriba. Corregido conCLAUDE_BIN/KUBECTL_BINpor 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_onlypara los 5 recursos. - 2ª corrida (>2 min después):
elegibilidad=eligibleparatest-agent-canary→ Claude devolviódecision:"restart"solo para ese recurso,notify_onlypara 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ónrestartedAt, mismo pod, misma edad).actions.jsonlregistra"dry_run": true.
Ejecución real (--live):
- Misma decisión de Claude (
restartpara el canario,notify_onlypara el resto). - Verificado en el cluster: nueva anotación
kubectl.kubernetes.io/restartedAten el deployment, eventoScalingReplicaSetde un ReplicaSet nuevo, pod nuevo en otro nodo. actions.jsonl:"result": "success", "dry_run": false.state.json:last_action_atseteado,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_onlypara todos los recursos, incluido el canario — ningún segundo restart propuesto ni ejecutado. state.jsonsin cambios (mismolast_action_atque 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_FILEpara activar la señal de "runner atascado" vía API de Gitea Actions (hoy se omite, degradación segura).