From 0052dfa1da0524ec922c911149f031f2dac61e7c Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Tue, 25 Aug 2026 23:11:10 -0500 Subject: [PATCH] =?UTF-8?q?Documentar=20lab-agent:=20lista=20blanca,=20dis?= =?UTF-8?q?e=C3=B1o=20de=20seguridad=20y=20prueba=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../docs/arquitectura/agente-monitoreo.md | 191 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 2 files changed, 192 insertions(+) create mode 100644 workloads/docs-portal/docs/arquitectura/agente-monitoreo.md diff --git a/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md b/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md new file mode 100644 index 0000000..dc23d10 --- /dev/null +++ b/workloads/docs-portal/docs/arquitectura/agente-monitoreo.md @@ -0,0 +1,191 @@ +# 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/` + +| 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 ` + +| 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](https://ntfy.sh) — 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). diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 11fbcc0..0b3a1ce 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -84,6 +84,7 @@ nav: - Visión general: arquitectura/vision-general.md - Red y exposición: arquitectura/red-y-exposicion.md - Flujo GitOps: arquitectura/gitops-flujo.md + - Agente de monitoreo: arquitectura/agente-monitoreo.md - Glosario: arquitectura/glosario.md - Decisiones (ADRs): - "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md