Files
apps-registry/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md
devops ce8c96d3ee Actualizar costo estimado tras bajar el timer a 60 min
17 corridas/día en vez de 68. Nota importante: el costo por corrida
sube al perder la ventaja del cache tibio de Claude Code (~1h TTL,
corridas espaciadas 60 min lo pierden casi siempre) -- el ahorro real
es ~1.5-2x, no 4x. También documenta el trade-off de latencia de
detección (~70 min en vez de ~25 min hasta detectar un problema).
2026-08-26 00:28:01 -05:00

16 KiB
Raw Permalink 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: timer de systemd ACTIVO desde 2026-08-26 (lab-agent.timer, usuario devops, cada 60 min entre 06:00–23:00 hora local — bajado de 15 a 60 min el mismo día para reducir costo). Script, Telegram y las unidades systemd, todo validado en vivo antes de activar.

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: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 — actualizada 2026-08-26, frecuencia bajada de 15 a 60 min explícitamente para reducir costo:

Corridas/día (17h activas ÷ 60 min) 17
Costo por corrida ~US$0.04–0.12 — más alto por corrida que a 15 min: el cache de prompt de Claude Code dura ~1h, y con corridas espaciadas 60 min casi siempre expira entre una y la siguiente, así que la mayoría de las corridas pagan cache "fría" en vez de "tibia"
Estimado mensual (× 30 días) ~US$20–60 (punto medio ~US$35)

Ojo con la intuición: bajar la frecuencia 4× (68→17 corridas/día) NO baja el costo 4× — el ahorro real es más cercano a 1.5–2× frente a la estimación anterior (~US$60–65/mes), porque cada corrida individual sale más cara al perder la ventaja del cache tibio. Sigue siendo menos costo total, solo que no linealmente. Basado en pocas corridas reales medidas — revisar el costo real después de la primera semana en producción (Telegram/Gitea Actions no cambian este número, el costo es 100% de la API de Claude).

Trade-off de latencia de detección: con corridas cada 60 min en vez de 15, un problema que recién cruza el umbral de 10 minutos puede tardar hasta ~70 min en ser detectado y potencialmente corregido (antes era hasta ~25 min). Aceptado a cambio del ahorro de costo — si en la práctica resulta muy lento para algún incidente real, la frecuencia se puede volver a subir sin tocar nada más (OnCalendar en lab-agent.timer).

Activación del timer (2026-08-26)

Habilitado tras confirmación explícita del propietario del lab, viendo antes el costo mensual estimado. Unidad de usuario (systemctl --user, corre como devops, no root), con límites de CPU/memoria (CPUQuota, MemoryMax) — instalada en /DATA/.config/systemd/user/, no en el ~/.config/... de una shell interactiva.

Gotcha encontrado y corregido en vivo: el manager de systemd --user de devops arranca con HOME=/DATA (el valor de /etc/passwd), no /DATA/AppData/home-devops — ese override solo se aplica al abrir una shell interactiva vía /etc/profile.d/zz-devops-home.sh, que el manager de systemd nunca carga. Sin corregirlo, claude -p habría buscado ~/.claude/.credentials.json en /DATA/.claude/ (no existe) y la autenticación habría fallado en cada corrida del timer, silenciosamente, hasta la primera vez que alguien mirara el log. lab-agent.service fija Environment=HOME=/DATA/AppData/home-devops explícitamente para evitarlo. Se detectó y corrigió antes de habilitar el timer, corriendo el servicio manualmente vía systemctl --user start con el entorno real (no desde una shell interactiva) como último paso de validación.

También requirió sudo loginctl enable-linger devops (una vez) — sin eso, systemctl --user ni siquiera conecta (no hay /run/user/<uid> sin una sesión de login o linger activo).

Pendiente, no bloqueante:

  • 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) — sigue sin resolver, el timer se activó igual porque no bloquea el resto de la lista blanca.