Files
scripts/lab-agent/README.md
T
devops c273056771 Notificaciones vía Telegram, pausa nocturna y unidades systemd
Reemplaza ntfy.sh por Telegram Bot API (preferencia del usuario, no
reduce el costo — el costo real es de la API de Claude/Sonnet, no del
canal). Texto plano sin parse_mode: el motivo que devuelve Claude es
texto libre y puede traer caracteres que rompen el parser Markdown de
Telegram. Token en TELEGRAM_BOT_TOKEN_FILE (dueño devops, no root-only
-- el servicio corre como devops por el login de suscripción, mismo
criterio ya aplicado a GITEA_TOKEN_FILE). Validado en vivo: entrega
confirmada con el mismo código de notify_send().

Agrega pausa nocturna (ACTIVE_HOURS_START/END, default 06:00-23:00
hora local) para no acumular costo de API sin necesidad: el script
chequea el horario ANTES de tocar kubectl/docker/curl o invocar
claude -p, no solo antes de notificar. Nota documentada: esta ventana
no se solapa con COMMERCE_BACKEND_WINDOW_* (00:00-06:00), por lo que
medusa-deploy queda efectivamente inalcanzable para auto-restart en la
práctica -- pendiente de decisión aparte.

Agrega unidades systemd de usuario (lab-agent/systemd/*.service,
*.timer) con límites de CPU/memoria: OnCalendar cada 15 min dentro del
horario activo, corre como devops (systemctl --user), sin habilitar
todavía -- requiere confirmación explícita tras ver el costo mensual
estimado (~US$60-65, documentado en agente-monitoreo.md).
2026-08-26 00:00:56 -05:00

165 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# lab-agent — agente de diagnóstico/corrección del lab
Wrapper headless que corre `claude -p` para diagnosticar el estado del
lab (kubectl, docker, curl a endpoints públicos, API de Gitea Actions) y,
solo para un conjunto explícito y aprobado de recursos, ejecutar una
acción correctiva de bajo riesgo. Ver la lista blanca completa, la
justificación de gobernanza y la evidencia de la prueba end-to-end en
`apps-registry` → `docs/arquitectura/agente-monitoreo.md`.
**Estado (2026-08-26): script y prueba end-to-end validados en vivo**
contra el cluster real (deployment canario desechable, ciclo completo
DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de
systemd (Paso 4) **todavía no está activo** — pendiente de tu
confirmación explícita.
## Diseño de seguridad (por qué Claude no ejecuta comandos directamente)
`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"}`). Este 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 (rate limit, umbral de tiempo,
ventana horaria, bloqueo tras intento previo).
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 que `--allowedTools` hace *coincidencia por
prefijo*, no coincidencia 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 ""`, Claude no tiene ninguna superficie de
ejecución — el peor caso posible es que proponga una decisión
incorrecta en el JSON, y el wrapper la rechaza si el recurso no está en
su propia lista de elegibles (ver `RESOURCE_ELIGIBILITY` en el script).
El prompt también advierte explícitamente contra prompt injection: los
datos de diagnóstico (logs, estado de pods) podrían contener texto
arbitrario si algo en el cluster está comprometido, e instruye a Claude
a ignorar cualquier instrucción incrustada ahí.
## Qué hace cada corrida
1. Lee `kubectl -n ecommerce get deployment -o json` y calcula salud por
deployment (readyReplicas vs replicas, condición `Available`).
2. Chequea `docker inspect gitea-runner` (estado del contenedor) y,
si existe `GITEA_TOKEN_FILE`, la API de Gitea Actions
(`/repos/{owner}/{repo}/actions/runs`) buscando runs en estado
`running` atascados por más de `UNHEALTHY_THRESHOLD_MIN` minutos —
la señal específica del incidente de runner zombie del 2026-08-16.
Si el token no está o la API falla, esta señal se omite (fail-safe:
nunca dispara una acción sobre una lectura no verificada) y solo
queda la señal de "contenedor no está `running`".
3. Curl a los dominios públicos — puramente informativo, nunca gatilla
una acción.
4. Calcula elegibilidad por recurso contra el estado persistido
(`STATE_FILE`): umbral de 10 min, rate limit de 30 min entre
acciones sobre el mismo recurso, ventana horaria 00:00–06:00 para
`medusa-deploy` (el deployment real de "commerce-backend" en
CLAUDE.md — confirmado en vivo 2026-08-25, no existe un deployment
llamado literalmente `commerce-backend`), y bloqueo permanente tras
un primer intento cuyo problema no se resolvió (sin reintentos
silenciosos — requiere intervención humana para desbloquear, ver
abajo).
5. Invoca `claude -p` con el diagnóstico + la lista de qué es elegible
este ciclo. Cero herramientas, salida JSON validada por schema.
6. Para cada decisión `"restart"`: revalida que el recurso esté REALMENTE
en la lista de elegibles del wrapper (no confía en el JSON de Claude
por sí solo); si pasa, ejecuta el comando fijo correspondiente
(`kubectl rollout restart deployment/<nombre> -n ecommerce` o
`docker restart gitea-runner`), captura el resultado real (no lo que
Claude cree que pasó) y marca el recurso como bloqueado para
reintentos automáticos futuros.
7. Registra todo en `${LOG_DIR}/run-<timestamp>.log` (log completo de la
corrida) y `${LOG_DIR}/actions.jsonl` (una línea JSON por decisión:
timestamp, recurso, decisión, resultado, motivo — el registro
auditable pedido en la lista blanca).
8. Notifica (Paso 3) si hubo alguna anomalía o acción — nunca en
silencio si actuó.
## Desbloquear un recurso tras un intento fallido
El estado vive en `STATE_FILE` (JSON). Para que un recurso vuelva a ser
elegible sin esperar a que se recupere solo:
```bash
jq 'del(.resources["deploy:medusa-deploy"])' "$STATE_FILE" > /tmp/s.json && mv /tmp/s.json "$STATE_FILE"
```
(cambiar la clave por `docker:gitea-runner` u otro `deploy:<nombre>`).
## Configuración
```bash
cp lab-agent.env.example lab-agent.env
# editar umbrales/rutas si hace falta — lab-agent.env NO lleva secretos,
# está en .gitignore a propósito (evita que una copia con paths de un
# host distinto termine commiteada)
```
**Autenticación de `claude -p`:** `ANTHROPIC_API_KEY_FILE` queda vacío
por defecto — el script usa el login de suscripción ya activo del
usuario que lo corre (`~/.claude/.credentials.json`), no una API key
aparte. Por eso el servicio systemd (cuando se active) debe correr
como `devops`, no como `root`. Ver la sección "Autenticación" en
`docs/arquitectura/agente-monitoreo.md` para el porqué completo.
El único secreto real es `GITEA_TOKEN_FILE` (opcional, token de Gitea
Actions con scope lectura) — dueño `devops`, sin sudo:
```bash
mkdir -p "$(dirname "$GITEA_TOKEN_FILE")" # por defecto: /DATA/AppData/k3d-lab/lab-agent/secrets/
echo "<tu-token>" > "$GITEA_TOKEN_FILE"
chmod 600 "$GITEA_TOKEN_FILE"
```
Si no existe, el agente sigue funcionando pero pierde la señal de
"runner atascado" vía API de Gitea Actions (solo detecta el contenedor
caído/reiniciando).
**Notificaciones (Telegram, reemplaza a ntfy.sh desde 2026-08-26):**
creá el bot con [@BotFather](https://t.me/BotFather) (`/newbot`) y
obtené tu `chat_id` (mandale un mensaje al bot primero, después
`https://api.telegram.org/bot<TOKEN>/getUpdates`). El token va en un
archivo aparte, dueño `devops` — **no** root-only pese al patrón
original: el servicio corre como `devops` (necesita su login de
suscripción para `claude -p`), así que un archivo `root:root 600` le
sería ilegible, mismo criterio ya aplicado a `GITEA_TOKEN_FILE`.
```bash
mkdir -p "$(dirname "$TELEGRAM_BOT_TOKEN_FILE")"
echo "<tu-token-de-botfather>" > "$TELEGRAM_BOT_TOKEN_FILE"
chmod 600 "$TELEGRAM_BOT_TOKEN_FILE"
# y en lab-agent.env: TELEGRAM_CHAT_ID=<tu-chat-id> (no es secreto)
```
**Horario activo:** `ACTIVE_HOURS_START`/`ACTIVE_HOURS_END` (hora local,
default `06:00`–`23:00`). Fuera de esa ventana el script sale antes de
tocar kubectl/docker/curl o invocar `claude -p` — no gasta nada, no
solo omite la notificación. **Ojo:** esta ventana no se solapa con
`COMMERCE_BACKEND_WINDOW_*` (`00:00`–`06:00`) — mientras el agente está
pausado, `medusa-deploy` nunca es elegible para auto-restart en la
práctica. Ver `docs/arquitectura/agente-monitoreo.md`.
## Probar
```bash
./lab-agent.sh --dry-run # simulación, no toca el cluster
./lab-agent.sh --live # ejecución real
```
Validado en vivo 2026-08-26 con un deployment canario desechable
(`test-agent-canary`, `busybox:stable` con `exit 1` — CrashLoopBackOff
garantizado): DRY_RUN detecta y "ejecuta" sin tocar el cluster, `--live`
ejecuta de verdad (confirmado con `kubectl get events` y la anotación
`restartedAt`), y una segunda corrida sobre el mismo recurso caído
respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se
encontraron y corrigieron en esa prueba — ver el detalle en
`docs/arquitectura/agente-monitoreo.md`. La notificación real se
validó con ntfy.sh en esa prueba; el cambio a Telegram debe repetir
al menos la validación de entrega antes de habilitar el timer.
**No actives el timer de systemd todavía** — requiere tu confirmación
explícita por separado, después de ver el costo mensual estimado.