Rediseñar auth a login de suscripción y corregir bugs de la prueba e2e

Auth: ANTHROPIC_API_KEY_FILE queda vacío por defecto — claude -p usa el
login de suscripción ya activo (~/.claude/.credentials.json) en vez de
una API key root-only. Se descartó ese diseño en vivo al confirmar que
no había API key real disponible; además elimina la necesidad de crear
secretos root-only vía sudo. Consecuencia: el futuro servicio systemd
debe correr como devops, no root. CLAUDE_BIN/KUBECTL_BIN se agregan por
ruta absoluta (ninguno está en el PATH de root/systemd).

Bugs encontrados y corregidos durante la prueba end-to-end contra el
cluster real (deployment canario desechable):
- execute_corrective_action se invocaba en un subshell ($()), perdiendo
  sus variables de resultado (LAST_ACTION_*) -> crash "unbound
  variable". Ahora usa el exit status de la función, invocada directo.
- DRY_RUN marcaba blocked=true en state.json como si la acción hubiera
  sido real, contaminando una corrida real posterior sobre el mismo
  recurso. Ahora el estado solo se actualiza fuera de DRY_RUN.

También: GITEA_TOKEN_FILE pasa a /DATA (dueño devops, sin sudo), y
media.cruzcloud.net se excluye de PUBLIC_DOMAINS (uso personal, no es
parte del lab).

Prueba e2e completa documentada en apps-registry:
docs/arquitectura/agente-monitoreo.md.
This commit is contained in:
devops committed 2026-08-25 23:11:24 -05:00
1 parent 5c1cb3aa5e
commit ad53cff1e7
3 files changed
+101 -67

No files matched your search

+38 -41
View File
@@ -1,17 +1,17 @@
# lab-agent — agente de diagnóstico/corrección del lab (Paso 2) # lab-agent — agente de diagnóstico/corrección del lab
Wrapper headless que corre `claude -p` para diagnosticar el estado del Wrapper headless que corre `claude -p` para diagnosticar el estado del
lab (kubectl, docker, curl a endpoints públicos, API de Gitea Actions) y, 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 solo para un conjunto explícito y aprobado de recursos, ejecutar una
acción correctiva de bajo riesgo. Ver la lista blanca completa y su acción correctiva de bajo riesgo. Ver la lista blanca completa, la
justificación de gobernanza en justificación de gobernanza y la evidencia de la prueba end-to-end en
`apps-registry` → `docs/arquitectura/agente-monitoreo.md` (Paso 6, `apps-registry` → `docs/arquitectura/agente-monitoreo.md`.
pendiente de escribir).
Este documento cubre solo el Paso 2 (el script). Los Pasos 3 **Estado (2026-08-26): script y prueba end-to-end validados en vivo**
(notificaciones), 4 (systemd timer) y 5 (prueba controlada) son contra el cluster real (deployment canario desechable, ciclo completo
posteriores y requieren tu confirmación explícita antes de activarse en DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de
continuo — ver instrucciones al final. 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) ## Diseño de seguridad (por qué Claude no ejecuta comandos directamente)
@@ -98,45 +98,42 @@ cp lab-agent.env.example lab-agent.env
# host distinto termine commiteada) # host distinto termine commiteada)
``` ```
Dos archivos root-only que este script espera pero **no crea** (por **Autenticación de `claude -p`:** `ANTHROPIC_API_KEY_FILE` queda vacío
diseño — la creación de credenciales no se hace desde una sesión por defecto — el script usa el login de suscripción ya activo del
interactiva de Claude Code en este lab, ver política del proyecto): 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 ```bash
sudo mkdir -p /etc/claude-lab-agent/secrets mkdir -p "$(dirname "$GITEA_TOKEN_FILE")" # por defecto: /DATA/AppData/k3d-lab/lab-agent/secrets/
sudo tee /etc/claude-lab-agent/secrets/anthropic_api_key >/dev/null # pega la key, Ctrl-D echo "<tu-token>" > "$GITEA_TOKEN_FILE"
sudo tee /etc/claude-lab-agent/secrets/gitea_token >/dev/null # token con scope read:repository (actions) chmod 600 "$GITEA_TOKEN_FILE"
sudo chown root:root /etc/claude-lab-agent/secrets/*
sudo chmod 600 /etc/claude-lab-agent/secrets/*
``` ```
`GITEA_TOKEN_FILE` es opcional: si no existe, el agente sigue Si no existe, el agente sigue funcionando pero pierde la señal de
funcionando pero pierde la señal de "runner atascado" vía API de Gitea "runner atascado" vía API de Gitea Actions (solo detecta el contenedor
Actions (solo detecta el contenedor caído/reiniciando). caído/reiniciando).
## Probar (Paso 2 — humo, sin secretos) ## Probar
```bash ```bash
./lab-agent.sh --dry-run ./lab-agent.sh --dry-run # simulación, no toca el cluster
./lab-agent.sh --live # ejecución real
``` ```
Ya validado en vivo 2026-08-25 hasta el paso de invocar `claude -p` Validado en vivo 2026-08-26 con un deployment canario desechable
(falla ahí, correctamente, por falta de (`test-agent-canary`, `busybox:stable` con `exit 1` — CrashLoopBackOff
`ANTHROPIC_API_KEY_FILE` — comportamiento esperado sin el secreto garantizado): DRY_RUN detecta y "ejecuta" sin tocar el cluster, `--live`
configurado). El diagnóstico de los 3 deployments de `ecommerce`, ejecuta de verdad (confirmado con `kubectl get events` y la anotación
`gitea-runner` y los 5 dominios públicos funcionó correctamente contra `restartedAt`), y una segunda corrida sobre el mismo recurso caído
el cluster real. 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`.
Con los secretos configurados, `--dry-run` corre el flujo completo **No actives el timer de systemd (Paso 4) todavía** — requiere tu
(incluida la llamada real a `claude -p`, que sí tiene costo) pero confirmación explícita por separado, aunque la prueba end-to-end ya
sustituye la ejecución de `kubectl rollout restart`/`docker restart` pasó.
por un mensaje `[DRY_RUN]` — no toca el cluster ni el contenedor de
verdad. `DRY_RUN=1` es el valor por defecto en `lab-agent.env.example`;
pasar `--live` (o `DRY_RUN=0` en el env) para el modo real.
**No actives el timer de systemd (Paso 4) todavía.** Falta:
Paso 3 (canal de notificación real), Paso 5 (prueba controlada con un
escenario real de CrashLoopBackOff inducido, mostrándote los logs antes
de habilitar el timer en continuo), y Paso 6 (documentar todo esto en
docs-portal). Este script se puede correr manualmente para más pruebas
de humo mientras tanto.
+22 -6
View File
@@ -1,11 +1,17 @@
# Copia este archivo a lab-agent.env (sin .example) y ajusta valores. # Copia este archivo a lab-agent.env (sin .example) y ajusta valores.
# lab-agent.env NO debe contener secretos — API keys y tokens van en # lab-agent.env NO debe contener secretos — lo único sensible es el token
# archivos aparte, root-only (ver ANTHROPIC_API_KEY_FILE / GITEA_TOKEN_FILE). # de Gitea Actions, que va en un archivo aparte (ver GITEA_TOKEN_FILE).
# --- Alcance kubectl --- # --- Alcance kubectl ---
NAMESPACE=ecommerce NAMESPACE=ecommerce
KUBECONFIG_PATH=/DATA/.kube/config KUBECONFIG_PATH=/DATA/.kube/config
# claude y kubectl viven bajo el HOME de devops, no en un PATH estándar de
# root/systemd (confirmado en vivo 2026-08-26: `sudo which claude` no lo
# encuentra). Rutas absolutas, no confiar en PATH.
CLAUDE_BIN=/DATA/AppData/home-devops/.local/bin/claude
KUBECTL_BIN=/DATA/AppData/home-devops/.local/bin/kubectl
# --- Umbrales (lista blanca aprobada 2026-08-25) --- # --- Umbrales (lista blanca aprobada 2026-08-25) ---
UNHEALTHY_THRESHOLD_MIN=10 UNHEALTHY_THRESHOLD_MIN=10
RATE_LIMIT_MIN=30 RATE_LIMIT_MIN=30
@@ -32,15 +38,25 @@ DOCKER_RESTART_ALLOWLIST=gitea-runner
GITEA_ACTIONS_REPOS="devops/apps-registry" GITEA_ACTIONS_REPOS="devops/apps-registry"
# --- Endpoints públicos a chequear (solo diagnóstico, no gatillan acciones) --- # --- Endpoints públicos a chequear (solo diagnóstico, no gatillan acciones) ---
PUBLIC_DOMAINS="shop.cruzcloud.net commerce.cruzcloud.net media.cruzcloud.net gitea.cruzcloud.net docs.cruzcloud.net" # media.cruzcloud.net excluido a propósito: no es parte del lab, es uso
# personal aparte (confirmado 2026-08-26).
PUBLIC_DOMAINS="shop.cruzcloud.net commerce.cruzcloud.net gitea.cruzcloud.net docs.cruzcloud.net"
# --- Rutas de estado/logs (escribibles por devops, NO root-only) --- # --- Rutas de estado/logs (escribibles por devops, NO root-only) ---
STATE_FILE=/DATA/AppData/k3d-lab/lab-agent/state.json STATE_FILE=/DATA/AppData/k3d-lab/lab-agent/state.json
LOG_DIR=/DATA/AppData/k3d-lab/lab-agent/logs LOG_DIR=/DATA/AppData/k3d-lab/lab-agent/logs
# --- Secretos root-only (archivos, NO valores — ver README.md) --- # --- Autenticación de claude -p ---
ANTHROPIC_API_KEY_FILE=/etc/claude-lab-agent/secrets/anthropic_api_key # Vacío (default): usa el login de suscripción ya activo para el usuario
GITEA_TOKEN_FILE=/etc/claude-lab-agent/secrets/gitea_token # que corre el script (~/.claude/.credentials.json) — por eso el servicio
# systemd del Paso 4 corre como `devops`, no como root: root no tiene ese
# login. Solo completar esta ruta si en algún momento se prefiere
# autenticar con una API key real en vez del login de suscripción.
ANTHROPIC_API_KEY_FILE=
# Token de Gitea Actions (opcional, scope read:repository). Dueño devops,
# 600 — ya no root-only, no hace falta sudo para crearlo.
GITEA_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token
# --- Modelo e invocación de Claude Code headless --- # --- Modelo e invocación de Claude Code headless ---
CLAUDE_MODEL=claude-sonnet-5 CLAUDE_MODEL=claude-sonnet-5
Regular → Executable
+41 -20
View File
@@ -26,6 +26,11 @@ source "$ENV_FILE"
: "${NAMESPACE:=ecommerce}" : "${NAMESPACE:=ecommerce}"
: "${KUBECONFIG_PATH:=/DATA/.kube/config}" : "${KUBECONFIG_PATH:=/DATA/.kube/config}"
# claude y kubectl viven bajo /DATA/AppData/home-devops/.local/bin, que NO
# está en el PATH de root ni del futuro servicio systemd (confirmado en
# vivo 2026-08-26) — por eso van por ruta explícita, no por búsqueda en PATH.
: "${CLAUDE_BIN:=claude}"
: "${KUBECTL_BIN:=kubectl}"
: "${UNHEALTHY_THRESHOLD_MIN:=10}" : "${UNHEALTHY_THRESHOLD_MIN:=10}"
: "${RATE_LIMIT_MIN:=30}" : "${RATE_LIMIT_MIN:=30}"
: "${COMMERCE_BACKEND_DEPLOYMENT:=medusa-deploy}" : "${COMMERCE_BACKEND_DEPLOYMENT:=medusa-deploy}"
@@ -34,11 +39,16 @@ source "$ENV_FILE"
: "${DOCKER_RESTART_ALLOWLIST:=gitea-runner}" : "${DOCKER_RESTART_ALLOWLIST:=gitea-runner}"
: "${GITEA_ACTIONS_REPOS:=devops/apps-registry}" : "${GITEA_ACTIONS_REPOS:=devops/apps-registry}"
: "${GITEA_API_BASE:=https://gitea.cruzcloud.net/api/v1}" : "${GITEA_API_BASE:=https://gitea.cruzcloud.net/api/v1}"
: "${PUBLIC_DOMAINS:=shop.cruzcloud.net commerce.cruzcloud.net media.cruzcloud.net gitea.cruzcloud.net docs.cruzcloud.net}" : "${PUBLIC_DOMAINS:=shop.cruzcloud.net commerce.cruzcloud.net gitea.cruzcloud.net docs.cruzcloud.net}"
: "${STATE_FILE:=/DATA/AppData/k3d-lab/lab-agent/state.json}" : "${STATE_FILE:=/DATA/AppData/k3d-lab/lab-agent/state.json}"
: "${LOG_DIR:=/DATA/AppData/k3d-lab/lab-agent/logs}" : "${LOG_DIR:=/DATA/AppData/k3d-lab/lab-agent/logs}"
: "${ANTHROPIC_API_KEY_FILE:=/etc/claude-lab-agent/secrets/anthropic_api_key}" # Vacío por defecto: sin ANTHROPIC_API_KEY_FILE, claude -p usa el login de
: "${GITEA_TOKEN_FILE:=/etc/claude-lab-agent/secrets/gitea_token}" # suscripción ya activo para el usuario que corre este script (el mismo
# ~/.claude/.credentials.json de una sesión interactiva normal) — no hace
# falta una API key aparte. Solo configurar esto si en algún momento se
# quiere autenticar con una API key real en vez del login de suscripción.
: "${ANTHROPIC_API_KEY_FILE:=}"
: "${GITEA_TOKEN_FILE:=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token}"
: "${CLAUDE_MODEL:=claude-sonnet-5}" : "${CLAUDE_MODEL:=claude-sonnet-5}"
: "${CLAUDE_MAX_BUDGET_USD:=0.50}" : "${CLAUDE_MAX_BUDGET_USD:=0.50}"
: "${NTFY_URL:=}" : "${NTFY_URL:=}"
@@ -67,7 +77,7 @@ trap 'log "ERROR inesperado en línea $LINENO (comando: $BASH_COMMAND)"' ERR
log "=== lab-agent inicio (DRY_RUN=${DRY_RUN}) ===" log "=== lab-agent inicio (DRY_RUN=${DRY_RUN}) ==="
for c in kubectl docker curl jq flock claude date; do for c in "$KUBECTL_BIN" docker curl jq flock "$CLAUDE_BIN" date; do
command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; } command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; }
done done
@@ -145,7 +155,7 @@ notify_send() { # title message
# Diagnóstico: deployments de ecommerce # Diagnóstico: deployments de ecommerce
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DEPLOY_JSON="$(kubectl -n "$NAMESPACE" get deployment -o json 2>/dev/null)" \ DEPLOY_JSON="$("$KUBECTL_BIN" -n "$NAMESPACE" get deployment -o json 2>/dev/null)" \
|| { log "FATAL: no se pudo consultar deployments en namespace ${NAMESPACE}"; exit 1; } || { log "FATAL: no se pudo consultar deployments en namespace ${NAMESPACE}"; exit 1; }
mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON") mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON")
@@ -328,7 +338,7 @@ log "elegibilidad ${GITEA_KEY}: ${GITEA_ELIG}"
# Diagnóstico: contexto adicional (no gatilla acciones, solo informativo) # Diagnóstico: contexto adicional (no gatilla acciones, solo informativo)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CLUSTER_WIDE_SNAPSHOT="$(kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,READY:.status.containerStatuses[0].ready' 2>/dev/null || echo "no disponible")" CLUSTER_WIDE_SNAPSHOT="$("$KUBECTL_BIN" get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,READY:.status.containerStatuses[0].ready' 2>/dev/null || echo "no disponible")"
PUBLIC_CHECKS="" PUBLIC_CHECKS=""
for d in $PUBLIC_DOMAINS; do for d in $PUBLIC_DOMAINS; do
@@ -429,15 +439,22 @@ EOF
# Invocar claude -p (sin herramientas, salida estructurada) # Invocar claude -p (sin herramientas, salida estructurada)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ ! -r "$ANTHROPIC_API_KEY_FILE" ]]; then CLAUDE_AUTH_ENV=()
log "FATAL: no se puede leer ${ANTHROPIC_API_KEY_FILE} (ver README.md para crearlo, root-only)" if [[ -n "$ANTHROPIC_API_KEY_FILE" ]]; then
if [[ ! -r "$ANTHROPIC_API_KEY_FILE" ]]; then
log "FATAL: ANTHROPIC_API_KEY_FILE está configurado (${ANTHROPIC_API_KEY_FILE}) pero no se puede leer"
notify_send "lab-agent: error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}." notify_send "lab-agent: error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
exit 1 exit 1
fi
CLAUDE_AUTH_ENV=("ANTHROPIC_API_KEY=$(<"$ANTHROPIC_API_KEY_FILE")")
log "usando ANTHROPIC_API_KEY_FILE (${ANTHROPIC_API_KEY_FILE})"
else
log "ANTHROPIC_API_KEY_FILE no configurado — usando el login de suscripción ya activo para este usuario"
fi fi
CLAUDE_JSON="$( CLAUDE_JSON="$(
ANTHROPIC_API_KEY="$(<"$ANTHROPIC_API_KEY_FILE")" \ env "${CLAUDE_AUTH_ENV[@]}" \
claude -p "$PROMPT" \ "$CLAUDE_BIN" -p "$PROMPT" \
--model "$CLAUDE_MODEL" \ --model "$CLAUDE_MODEL" \
--allowedTools "" \ --allowedTools "" \
--disallowedTools "Bash,Read,Write,Edit,WebFetch,WebSearch" \ --disallowedTools "Bash,Read,Write,Edit,WebFetch,WebSearch" \
@@ -496,7 +513,7 @@ run_and_capture() { # cmd... -> setea LAST_ACTION_STDOUT/STDERR, devuelve el exi
return "$rc" return "$rc"
} }
execute_corrective_action() { # resource -> imprime "ok"|"fail" y deja stdout/stderr en variables globales execute_corrective_action() { # resource -> exit 0=ok/1=fail, deja LAST_ACTION_* en variables globales
local resource="$1" local resource="$1"
LAST_ACTION_STDOUT="" LAST_ACTION_STDOUT=""
LAST_ACTION_STDERR="" LAST_ACTION_STDERR=""
@@ -507,9 +524,9 @@ execute_corrective_action() { # resource -> imprime "ok"|"fail" y deja stdout/st
LAST_ACTION_CMD="docker restart gitea-runner" LAST_ACTION_CMD="docker restart gitea-runner"
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad" LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad"
echo "ok"; return return 0
fi fi
if run_and_capture docker restart gitea-runner; then echo "ok"; else echo "fail"; fi if run_and_capture docker restart gitea-runner; then return 0; else return 1; fi
;; ;;
deploy:*) deploy:*)
local dep="${resource#deploy:}" local dep="${resource#deploy:}"
@@ -517,18 +534,18 @@ execute_corrective_action() { # resource -> imprime "ok"|"fail" y deja stdout/st
for d in "${DETECTED_DEPLOYMENTS[@]}"; do [[ "$d" == "$dep" ]] && found=1; done for d in "${DETECTED_DEPLOYMENTS[@]}"; do [[ "$d" == "$dep" ]] && found=1; done
if [[ "$found" != "1" ]]; then if [[ "$found" != "1" ]]; then
LAST_ACTION_STDERR="deployment '${dep}' no está entre los detectados este ciclo — rechazado" LAST_ACTION_STDERR="deployment '${dep}' no está entre los detectados este ciclo — rechazado"
echo "fail"; return return 1
fi fi
LAST_ACTION_CMD="kubectl -n ${NAMESPACE} rollout restart deployment/${dep}" LAST_ACTION_CMD="kubectl -n ${NAMESPACE} rollout restart deployment/${dep}"
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad" LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad"
echo "ok"; return return 0
fi fi
if run_and_capture kubectl -n "$NAMESPACE" rollout restart "deployment/${dep}"; then echo "ok"; else echo "fail"; fi if run_and_capture "$KUBECTL_BIN" -n "$NAMESPACE" rollout restart "deployment/${dep}"; then return 0; else return 1; fi
;; ;;
*) *)
LAST_ACTION_STDERR="recurso desconocido: ${resource}" LAST_ACTION_STDERR="recurso desconocido: ${resource}"
echo "fail" return 1
;; ;;
esac esac
} }
@@ -552,12 +569,16 @@ while IFS= read -r action_json; do
fi fi
if [[ "$decision" == "restart" ]]; then if [[ "$decision" == "restart" ]]; then
result="$(execute_corrective_action "$resource")" if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi
ACTIONS_TAKEN=1 ACTIONS_TAKEN=1
# Tras cualquier intento (éxito o fallo), marcar blocked=true: un solo # Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un
# intento automático por incidente, sin reintentos silenciosos. # solo intento automático por incidente, sin reintentos silenciosos.
# En DRY_RUN no se toca el estado — es una simulación, no debe consumir
# el "único intento" ni el rate limit de una corrida real posterior.
if [[ "$DRY_RUN" != "1" ]]; then
mark_action_taken "$resource" mark_action_taken "$resource"
fi
if [[ "$result" == "ok" ]]; then if [[ "$result" == "ok" ]]; then
log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK" log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK"