From ad53cff1e7779de1a7a276439e9a1a0f8626d3c8 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Tue, 25 Aug 2026 23:11:24 -0500 Subject: [PATCH] =?UTF-8?q?Redise=C3=B1ar=20auth=20a=20login=20de=20suscri?= =?UTF-8?q?pci=C3=B3n=20y=20corregir=20bugs=20de=20la=20prueba=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lab-agent/README.md | 79 ++++++++++++++++----------------- lab-agent/lab-agent.env.example | 28 +++++++++--- lab-agent/lab-agent.sh | 67 ++++++++++++++++++---------- 3 files changed, 104 insertions(+), 70 deletions(-) mode change 100644 => 100755 lab-agent/lab-agent.sh diff --git a/lab-agent/README.md b/lab-agent/README.md index 3a187dd..ce29eed 100644 --- a/lab-agent/README.md +++ b/lab-agent/README.md @@ -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 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 y su -justificación de gobernanza en -`apps-registry` → `docs/arquitectura/agente-monitoreo.md` (Paso 6, -pendiente de escribir). +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`. -Este documento cubre solo el Paso 2 (el script). Los Pasos 3 -(notificaciones), 4 (systemd timer) y 5 (prueba controlada) son -posteriores y requieren tu confirmación explícita antes de activarse en -continuo — ver instrucciones al final. +**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) @@ -98,45 +98,42 @@ cp lab-agent.env.example lab-agent.env # host distinto termine commiteada) ``` -Dos archivos root-only que este script espera pero **no crea** (por -diseño — la creación de credenciales no se hace desde una sesión -interactiva de Claude Code en este lab, ver política del proyecto): +**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 -sudo mkdir -p /etc/claude-lab-agent/secrets -sudo tee /etc/claude-lab-agent/secrets/anthropic_api_key >/dev/null # pega la key, Ctrl-D -sudo tee /etc/claude-lab-agent/secrets/gitea_token >/dev/null # token con scope read:repository (actions) -sudo chown root:root /etc/claude-lab-agent/secrets/* -sudo chmod 600 /etc/claude-lab-agent/secrets/* +mkdir -p "$(dirname "$GITEA_TOKEN_FILE")" # por defecto: /DATA/AppData/k3d-lab/lab-agent/secrets/ +echo "" > "$GITEA_TOKEN_FILE" +chmod 600 "$GITEA_TOKEN_FILE" ``` -`GITEA_TOKEN_FILE` es opcional: 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). +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). -## Probar (Paso 2 — humo, sin secretos) +## Probar ```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` -(falla ahí, correctamente, por falta de -`ANTHROPIC_API_KEY_FILE` — comportamiento esperado sin el secreto -configurado). El diagnóstico de los 3 deployments de `ecommerce`, -`gitea-runner` y los 5 dominios públicos funcionó correctamente contra -el cluster 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`. -Con los secretos configurados, `--dry-run` corre el flujo completo -(incluida la llamada real a `claude -p`, que sí tiene costo) pero -sustituye la ejecución de `kubectl rollout restart`/`docker restart` -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. +**No actives el timer de systemd (Paso 4) todavía** — requiere tu +confirmación explícita por separado, aunque la prueba end-to-end ya +pasó. diff --git a/lab-agent/lab-agent.env.example b/lab-agent/lab-agent.env.example index fe77cb6..a0e9f2c 100644 --- a/lab-agent/lab-agent.env.example +++ b/lab-agent/lab-agent.env.example @@ -1,11 +1,17 @@ # 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 -# archivos aparte, root-only (ver ANTHROPIC_API_KEY_FILE / GITEA_TOKEN_FILE). +# lab-agent.env NO debe contener secretos — lo único sensible es el token +# de Gitea Actions, que va en un archivo aparte (ver GITEA_TOKEN_FILE). # --- Alcance kubectl --- NAMESPACE=ecommerce 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) --- UNHEALTHY_THRESHOLD_MIN=10 RATE_LIMIT_MIN=30 @@ -32,15 +38,25 @@ DOCKER_RESTART_ALLOWLIST=gitea-runner GITEA_ACTIONS_REPOS="devops/apps-registry" # --- 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) --- STATE_FILE=/DATA/AppData/k3d-lab/lab-agent/state.json LOG_DIR=/DATA/AppData/k3d-lab/lab-agent/logs -# --- Secretos root-only (archivos, NO valores — ver README.md) --- -ANTHROPIC_API_KEY_FILE=/etc/claude-lab-agent/secrets/anthropic_api_key -GITEA_TOKEN_FILE=/etc/claude-lab-agent/secrets/gitea_token +# --- Autenticación de claude -p --- +# Vacío (default): usa el login de suscripción ya activo para el usuario +# 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 --- CLAUDE_MODEL=claude-sonnet-5 diff --git a/lab-agent/lab-agent.sh b/lab-agent/lab-agent.sh old mode 100644 new mode 100755 index 2a18e75..8cd27fc --- a/lab-agent/lab-agent.sh +++ b/lab-agent/lab-agent.sh @@ -26,6 +26,11 @@ source "$ENV_FILE" : "${NAMESPACE:=ecommerce}" : "${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}" : "${RATE_LIMIT_MIN:=30}" : "${COMMERCE_BACKEND_DEPLOYMENT:=medusa-deploy}" @@ -34,11 +39,16 @@ source "$ENV_FILE" : "${DOCKER_RESTART_ALLOWLIST:=gitea-runner}" : "${GITEA_ACTIONS_REPOS:=devops/apps-registry}" : "${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}" : "${LOG_DIR:=/DATA/AppData/k3d-lab/lab-agent/logs}" -: "${ANTHROPIC_API_KEY_FILE:=/etc/claude-lab-agent/secrets/anthropic_api_key}" -: "${GITEA_TOKEN_FILE:=/etc/claude-lab-agent/secrets/gitea_token}" +# Vacío por defecto: sin ANTHROPIC_API_KEY_FILE, claude -p usa el login de +# 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_MAX_BUDGET_USD:=0.50}" : "${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}) ===" -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; } done @@ -145,7 +155,7 @@ notify_send() { # title message # 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; } 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) # --------------------------------------------------------------------------- -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="" for d in $PUBLIC_DOMAINS; do @@ -429,15 +439,22 @@ EOF # Invocar claude -p (sin herramientas, salida estructurada) # --------------------------------------------------------------------------- -if [[ ! -r "$ANTHROPIC_API_KEY_FILE" ]]; then - log "FATAL: no se puede leer ${ANTHROPIC_API_KEY_FILE} (ver README.md para crearlo, root-only)" - notify_send "lab-agent: error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}." - exit 1 +CLAUDE_AUTH_ENV=() +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}." + 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 CLAUDE_JSON="$( - ANTHROPIC_API_KEY="$(<"$ANTHROPIC_API_KEY_FILE")" \ - claude -p "$PROMPT" \ + env "${CLAUDE_AUTH_ENV[@]}" \ + "$CLAUDE_BIN" -p "$PROMPT" \ --model "$CLAUDE_MODEL" \ --allowedTools "" \ --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" } -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" LAST_ACTION_STDOUT="" 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" if [[ "$DRY_RUN" == "1" ]]; then LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad" - echo "ok"; return + return 0 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:*) 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 if [[ "$found" != "1" ]]; then LAST_ACTION_STDERR="deployment '${dep}' no está entre los detectados este ciclo — rechazado" - echo "fail"; return + return 1 fi LAST_ACTION_CMD="kubectl -n ${NAMESPACE} rollout restart deployment/${dep}" if [[ "$DRY_RUN" == "1" ]]; then LAST_ACTION_STDOUT="[DRY_RUN] no se ejecutó de verdad" - echo "ok"; return + return 0 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}" - echo "fail" + return 1 ;; esac } @@ -552,12 +569,16 @@ while IFS= read -r action_json; do fi if [[ "$decision" == "restart" ]]; then - result="$(execute_corrective_action "$resource")" + if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi ACTIONS_TAKEN=1 - # Tras cualquier intento (éxito o fallo), marcar blocked=true: un solo - # intento automático por incidente, sin reintentos silenciosos. - mark_action_taken "$resource" + # Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un + # 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" + fi if [[ "$result" == "ok" ]]; then log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK"