Files
apps-registry/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md
T
devops 7c6f3d9c27 Documentar notificación silenciosa, hilos y adjunto de log en Telegram
Actualiza la sección de Notificaciones: formato con íconos por tipo de
evento, log completo adjunto como documento, nivel "info" silencioso
(disable_notification), y respuestas en hilo cuando un recurso
accionado se recupera solo -- con la limitación conocida de que un
desbloqueo manual (borrando la entrada de state.json) pierde el
message_id y no queda threadeado. Validado en vivo con el canario.
2026-08-26 00:21:28 -05:00

15 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: Telegram Bot API (reemplaza a ntfy, cambiado 2026-08-26 por preferencia — no por costo, ver sección de costo abajo). Bot creado manualmente vía @BotFather; el token vive en TELEGRAM_BOT_TOKEN_FILE (dueño devops, 600 — no root-only, mismo criterio que GITEA_TOKEN_FILE: el servicio corre como devops por el login de suscripción, un archivo root:root le sería ilegible). TELEGRAM_CHAT_ID no es secreto, va directo en lab-agent.env.

El mensaje se manda como texto plano, sin parse_mode: el motivo que devuelve Claude es texto libre y puede traer _, *, [, ] — con Markdown/MarkdownV2 esos caracteres rompen el parser de Telegram (400 Bad Request) si no se escapan con cuidado. Se prioriza entrega confiable sobre formato.

Validado en vivo 2026-08-26 con el mismo código que usa notify_send() (lectura del token, sendMessage vía curl --data-urlencode, formato [title]\nmessage): entrega confirmada ("ok":true) y contenido recibido tal cual en Telegram. Gotcha encontrado durante la prueba: el primer intento falló con 400 chat not found porque el chat_id pegado correspondía a otro bot — el chat_id de un usuario es el mismo across bots, pero Telegram igual devuelve chat not found si ese bot puntual nunca recibió un mensaje de esa persona. Se resolvió confirmando el chat_id real vía getUpdates del bot correcto.

Formato: cada notificación lleva un ícono según su naturaleza (✅ acción ok, 🛑 acción falló/rechazada, 🚨 alerta de seguridad, ❌ error operativo, 👀 solo diagnóstico) y el log completo de esa corrida va adjunto como documento (sendDocument), no solo como ruta — útil para revisar desde el celular sin acceso a shell.

Notificación silenciosa y respuestas en hilo (2026-08-26): el nivel info (nada elegible, todo diagnóstico rutinario) se manda con disable_notification=true — no interrumpe; acciones, fallas y alertas de seguridad sí suenan. Cuando se ejecuta una acción correctiva, el message_id de Telegram queda guardado por recurso en state.json (telegram_message_id); si ese recurso se recupera solo en una corrida posterior, se manda un aviso silencioso como respuesta (hilo) al mensaje original antes de limpiar el estado — agrupa visualmente todo un incidente. Limitación conocida: si un humano desbloquea el recurso a mano borrando su entrada de state.json (ver más abajo), el message_id se pierde con esa entrada, así que ese caso puntual no queda threadeado — solo el "se recuperó solo" está cubierto hoy. Validado en vivo con el canario: acción real → message_id capturado → canario parcheado a una imagen sana → reply en hilo confirmado, silencioso, al recuperarse.

Horario activo y costo

Pausa nocturna (ACTIVE_HOURS_START/END, hora local del host): activo 06:00–23:00, pausado 23:00–06:00. El script chequea esto como lo primero que hace tras inicializar logging — si está fuera de horario, sale antes de tocar kubectl/docker/curl o invocar claude -p, así que la pausa ahorra el costo completo de la corrida, no solo la notificación. El timer de systemd además solo dispara dentro de esa ventana (OnCalendar=*-*-* 06..22:00/15:00) — la doble verificación es deliberada: si alguien edita la unidad de systemd sin tocar lab-agent.env (o corre el script a mano), el chequeo interno sigue protegiendo.

Tensión sin resolver con la ventana de medusa-deploy: la ventana de auto-restart para medusa-deploy (COMMERCE_BACKEND_WINDOW_*, 00:00–06:00) no se solapa con el horario activo del agente (06:00–23:00). Esto significa que, en la práctica, medusa-deploy nunca es candidato real a auto-restart: la única ventana en la que la política lo permitiría es exactamente cuando el agente está pausado y ni siquiera corre. La regla sigue viva en el código (por si el horario activo cambia más adelante) pero hoy es efectivamente inalcanzable. Pendiente de decisión: mover la ventana de medusa-deploy a que caiga dentro del horario activo, o aceptar que ese recurso queda cubierto solo por diagnóstico/notificación manual.

Estimación de costo mensual:

Corridas/día (17h activas ÷ 15 min) 68
Costo por corrida (medido en la prueba e2e, Sonnet) ~US$0.018–0.045 con cache tibia; ~US$0.12 en frío (primera corrida del día, tras 7h de pausa el cache de prompt ya expiró)
Estimado diario (1 corrida fría + 67 tibias) ~US$2.10
Estimado mensual (× 30 días) ~US$60–65 (rango US$35–90 según variación real de cache)

Basado en solo ~7 corridas reales medidas durante la prueba — no es un promedio estadísticamente robusto, es una primera estimación para decidir si el timer se activa. Revisar el costo real después de la primera semana en producción y ajustar frecuencia/modelo si hace falta.

Pendiente antes de activar el timer en continuo

  • Confirmación explícita del propietario del lab para habilitar el timer de systemd — no se activa automáticamente solo por haber pasado la prueba end-to-end ni por tener el costo estimado.
  • Unidad systemd de usuario (systemctl --user, corre como devops, no root), con límites de CPU/memoria (CPUQuota, MemoryMax). Requiere loginctl enable-linger devops (una vez, con sudo) para que seguir corriendo sin sesión interactiva activa.
  • Opcional: GITEA_TOKEN_FILE para activar la señal de "runner atascado" vía API de Gitea Actions (hoy se omite, degradación segura).
  • Decidir la tensión con la ventana de medusa-deploy (ver arriba).