OnCalendar cada 60 min en vez de 15 (06:00-22:00 hora local, 17 corridas/día en vez de 68). Aplicado en vivo: unidad actualizada, timer reiniciado, próxima corrida confirmada vía systemctl --user list-timers.
190 lines
9.3 KiB
Markdown
190 lines
9.3 KiB
Markdown
# 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): timer de systemd ACTIVO** (`lab-agent.timer`,
|
||
usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de
|
||
15 a 60 min el 2026-08-26 para reducir costo). Validado
|
||
en vivo contra el cluster real: DRY_RUN → ejecución real → rate
|
||
limit/bloqueo → recuperación con hilo en Telegram → limpieza, y
|
||
corrida manual vía `systemctl --user start` con el entorno real del
|
||
timer antes de habilitarlo. Ver `docs/arquitectura/agente-monitoreo.md`
|
||
para la evidencia completa y el costo mensual estimado.
|
||
|
||
## 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`. Telegram (silencioso para
|
||
`info`, hilo de respuestas al recuperarse) también se validó en vivo
|
||
con el mismo canario.
|
||
|
||
## Instalar el timer de systemd
|
||
|
||
Unidades de **usuario** (`systemctl --user`), corren como `devops` —
|
||
necesario porque `claude -p` usa su login de suscripción. Ojo: el
|
||
manager de `systemd --user` de `devops` arranca con `HOME=/DATA` (el
|
||
de `/etc/passwd`), no `/DATA/AppData/home-devops` — por eso
|
||
`lab-agent.service` fija `Environment=HOME=...` explícitamente; sin
|
||
eso, `claude -p` no encuentra las credenciales. Los archivos van en
|
||
`/DATA/.config/systemd/user/`, no en el `~/.config/...` de una shell
|
||
interactiva (que resuelve a otra ruta por el mismo motivo).
|
||
|
||
```bash
|
||
mkdir -p /DATA/.config/systemd/user
|
||
cp systemd/lab-agent.service systemd/lab-agent.timer /DATA/.config/systemd/user/
|
||
sudo loginctl enable-linger devops # una vez — si no, systemctl --user ni conecta
|
||
export XDG_RUNTIME_DIR=/run/user/$(id -u devops)
|
||
systemctl --user daemon-reload
|
||
systemctl --user start lab-agent.service # probar una corrida manual primero
|
||
systemctl --user enable --now lab-agent.timer
|
||
systemctl --user list-timers lab-agent.timer
|
||
```
|
||
|
||
Activo desde 2026-08-26. Ver el costo mensual estimado y el detalle
|
||
completo en `docs/arquitectura/agente-monitoreo.md`.
|