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
+104 -70

No files matched your search

Regular → Executable
+44 -23
View File
@@ -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"