log() pasa a escribir en stderr (transparente, sigue yendo al mismo RUN_LOG vía el "exec > tee 2>&1"): necesario para que notify_send pueda devolver el message_id por stdout sin que un log() interno se cuele en la captura -- mismo patrón de bug que ya se había corregido en execute_corrective_action. notify_send ahora acepta un reply_to_message_id opcional y manda disable_notification=true para nivel "info" (ruido de rutina no debe sonar; fallas/acciones/alertas de seguridad sí). Cuando ejecuta una acción correctiva, el message_id enviado se guarda por recurso en state.json (attach_telegram_message_id); si ese recurso se recupera solo en una corrida posterior, compute_eligibility manda un aviso silencioso como respuesta al mensaje original antes de limpiar el estado -- agrupa visualmente todo un incidente en un hilo. Validado en vivo con el canario desechable: acción real -> message_id capturado en state.json -> parcheado a imagen sana -> reply en hilo confirmado al recuperarse, silencioso.
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
- Lee
kubectl -n ecommerce get deployment -o jsony calcula salud por deployment (readyReplicas vs replicas, condiciónAvailable). - Chequea
docker inspect gitea-runner(estado del contenedor) y, si existeGITEA_TOKEN_FILE, la API de Gitea Actions (/repos/{owner}/{repo}/actions/runs) buscando runs en estadorunningatascados por más deUNHEALTHY_THRESHOLD_MINminutos — 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". - Curl a los dominios públicos — puramente informativo, nunca gatilla una acción.
- 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 paramedusa-deploy(el deployment real de "commerce-backend" en CLAUDE.md — confirmado en vivo 2026-08-25, no existe un deployment llamado literalmentecommerce-backend), y bloqueo permanente tras un primer intento cuyo problema no se resolvió (sin reintentos silenciosos — requiere intervención humana para desbloquear, ver abajo). - Invoca
claude -pcon el diagnóstico + la lista de qué es elegible este ciclo. Cero herramientas, salida JSON validada por schema. - 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 ecommerceodocker 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. - 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). - 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:
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
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:
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 (/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.
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
./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.