From 5c1cb3aa5ef007d8d36b2f303d9c88c7961182ec Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Tue, 25 Aug 2026 22:18:31 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Agregar=20lab-agent:=20wrapper=20headless?= =?UTF-8?q?=20de=20diagn=C3=B3stico/correcci=C3=B3n=20(Paso=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoca claude -p con --allowedTools "" y --json-schema para que el modelo solo devuelva una decisión estructurada, nunca ejecute comandos directamente. Se descartó dar un Bash tool con patrón por prefijo tras confirmar en vivo que --allowedTools "Bash(cmd)" también permite "cmd && inyectado" (coincidencia por prefijo, no exacta) — inseguro para comandos root-capaces como kubectl/docker. El wrapper bash es el único ejecutor, y revalida cada decisión contra su propio cálculo de elegibilidad (umbral 10min, rate limit 30min, ventana horaria para medusa-deploy, bloqueo tras intento sin recuperación) antes de actuar. Validado en vivo (dry-run) contra el cluster real hasta el paso de invocar claude -p, que falla correctamente por falta del secreto ANTHROPIC_API_KEY_FILE (no creado a propósito, ver README). --- .gitignore | 3 + lab-agent/README.md | 142 ++++++++ lab-agent/lab-agent.env.example | 58 ++++ lab-agent/lab-agent.sh | 596 ++++++++++++++++++++++++++++++++ 4 files changed, 799 insertions(+) create mode 100644 .gitignore create mode 100644 lab-agent/README.md create mode 100644 lab-agent/lab-agent.env.example create mode 100644 lab-agent/lab-agent.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3799fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +lab-agent/lab-agent.env +lab-agent/logs/ +lab-agent/*.log diff --git a/lab-agent/README.md b/lab-agent/README.md new file mode 100644 index 0000000..3a187dd --- /dev/null +++ b/lab-agent/README.md @@ -0,0 +1,142 @@ +# lab-agent — agente de diagnóstico/corrección del lab (Paso 2) + +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). + +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. + +## 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/ -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-.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:`). + +## 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) +``` + +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): + +```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/* +``` + +`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). + +## Probar (Paso 2 — humo, sin secretos) + +```bash +./lab-agent.sh --dry-run +``` + +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. + +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. diff --git a/lab-agent/lab-agent.env.example b/lab-agent/lab-agent.env.example new file mode 100644 index 0000000..fe77cb6 --- /dev/null +++ b/lab-agent/lab-agent.env.example @@ -0,0 +1,58 @@ +# 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). + +# --- Alcance kubectl --- +NAMESPACE=ecommerce +KUBECONFIG_PATH=/DATA/.kube/config + +# --- Umbrales (lista blanca aprobada 2026-08-25) --- +UNHEALTHY_THRESHOLD_MIN=10 +RATE_LIMIT_MIN=30 + +# Deployment que corresponde a "commerce-backend" en la terminología de +# CLAUDE.md. En el cluster real (verificado 2026-08-25) es medusa-deploy, +# no un deployment llamado literalmente "commerce-backend". +COMMERCE_BACKEND_DEPLOYMENT=medusa-deploy +# Ventana horaria (hora LOCAL del host ZimaOS) en la que medusa-deploy +# es elegible para auto-restart. Fuera de esta ventana: solo diagnóstico. +COMMERCE_BACKEND_WINDOW_START=00:00 +COMMERCE_BACKEND_WINDOW_END=06:00 + +# --- Alcance docker --- +# Único contenedor con auto-restart aprobado (incidente 2026-08-16). +DOCKER_RESTART_ALLOWLIST=gitea-runner + +# --- Señal de "runner atascado" vía API de Gitea Actions (mejor esfuerzo) --- +# Repos a consultar por runs con status=running que llevan más de +# UNHEALTHY_THRESHOLD_MIN minutos sin terminar. Si GITEA_TOKEN_FILE no +# existe o la API falla, esta señal se omite (fail-safe: nunca dispara +# una acción por una señal que no se pudo verificar) y solo queda la +# señal de "contenedor no está running". +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" + +# --- 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 + +# --- Modelo e invocación de Claude Code headless --- +CLAUDE_MODEL=claude-sonnet-5 +CLAUDE_MAX_BUDGET_USD=0.50 + +# --- Notificaciones (Paso 3 — dejar vacío hasta tener un canal real) --- +# Ej. self-hosted: https://ntfy.cruzcloud.net/lab-agent +# ntfy.sh público: https://ntfy.sh/ +NTFY_URL= + +# --- Pruebas controladas (Paso 5) --- +# DRY_RUN=1: calcula elegibilidad y decisión igual que en producción, +# pero NO ejecuta kubectl/docker de verdad — registra y notifica como si +# lo hubiera hecho, marcado [DRY_RUN]. Poner en 0 para el modo real. +DRY_RUN=1 diff --git a/lab-agent/lab-agent.sh b/lab-agent/lab-agent.sh new file mode 100644 index 0000000..2a18e75 --- /dev/null +++ b/lab-agent/lab-agent.sh @@ -0,0 +1,596 @@ +#!/usr/bin/env bash +# Agente de diagnóstico/corrección del lab GitOps de cruzcloud. +# +# Diseño de seguridad (importante, ver README.md #Diseño de seguridad): +# `claude -p` se invoca con --allowedTools "" (CERO herramientas) y +# --json-schema, así que Claude NUNCA ejecuta un comando directamente — +# solo devuelve una decisión estructurada. Este script (bash, confiable, +# revisado por humano) es el único que ejecuta kubectl/docker, y solo +# tras revalidar la decisión de Claude contra su propio cálculo de +# elegibilidad. Esto evita depender de --allowedTools como sandbox de +# comandos: se probó en vivo que "Bash(echo pintest)" también permite +# "echo pintest && echo INJECTED" (coincidencia por prefijo, no exacta), +# así que un Bash tool con prefijo nunca habría sido un límite seguro +# para comandos root-capaces como kubectl/docker. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${LAB_AGENT_ENV_FILE:-${SCRIPT_DIR}/lab-agent.env}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Falta ${ENV_FILE} — copia lab-agent.env.example y ajústalo." >&2 + exit 1 +fi +# shellcheck source=/dev/null +source "$ENV_FILE" + +: "${NAMESPACE:=ecommerce}" +: "${KUBECONFIG_PATH:=/DATA/.kube/config}" +: "${UNHEALTHY_THRESHOLD_MIN:=10}" +: "${RATE_LIMIT_MIN:=30}" +: "${COMMERCE_BACKEND_DEPLOYMENT:=medusa-deploy}" +: "${COMMERCE_BACKEND_WINDOW_START:=00:00}" +: "${COMMERCE_BACKEND_WINDOW_END:=06:00}" +: "${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}" +: "${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}" +: "${CLAUDE_MODEL:=claude-sonnet-5}" +: "${CLAUDE_MAX_BUDGET_USD:=0.50}" +: "${NTFY_URL:=}" +: "${DRY_RUN:=1}" + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --live) DRY_RUN=0 ;; + *) echo "Argumento desconocido: $arg" >&2; exit 1 ;; + esac +done + +export KUBECONFIG="$KUBECONFIG_PATH" + +mkdir -p "$LOG_DIR" "$(dirname "$STATE_FILE")" +RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)" +RUN_LOG="${LOG_DIR}/run-${RUN_TS}.log" +ACTIONS_LOG="${LOG_DIR}/actions.jsonl" +exec > >(tee -a "$RUN_LOG") 2>&1 + +iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; } +log() { echo "[$(iso_now)] $*"; } + +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 + command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; } +done + +[[ -f "$STATE_FILE" ]] || echo '{"resources":{}}' > "$STATE_FILE" +STATE_LOCK="${STATE_FILE}.lock" + +# --------------------------------------------------------------------------- +# Estado persistente (rate limit + "un solo intento" tras fallo) +# --------------------------------------------------------------------------- + +state_read() { # key -> objeto JSON (o {} si no existe) + local key="$1" + flock "$STATE_LOCK" jq -c --arg k "$key" '.resources[$k] // {}' "$STATE_FILE" +} + +state_write() { # key, json-string + local key="$1" value_json="$2" + local tmp valfile + tmp="$(mktemp "${STATE_FILE}.XXXXXX")" + valfile="$(mktemp)" + printf '%s' "$value_json" > "$valfile" + ( + flock -x 9 + jq --arg k "$key" --slurpfile v "$valfile" '.resources[$k] = $v[0]' "$STATE_FILE" > "$tmp" + mv "$tmp" "$STATE_FILE" + ) 9>"$STATE_LOCK" + rm -f "$valfile" +} + +state_clear() { # key + local key="$1" + local tmp + tmp="$(mktemp "${STATE_FILE}.XXXXXX")" + ( + flock -x 9 + jq --arg k "$key" 'del(.resources[$k])' "$STATE_FILE" > "$tmp" + mv "$tmp" "$STATE_FILE" + ) 9>"$STATE_LOCK" +} + +mark_action_taken() { # resource -> preserva first_unhealthy_at, marca last_action_at=ahora y blocked=true + local resource="$1" prev now new_state + prev="$(state_read "$resource")" + now="$(iso_now)" + new_state="$(jq -nc --argjson prev "$prev" --arg t "$now" \ + '{first_unhealthy_at: ($prev.first_unhealthy_at // $t), last_action_at: $t, blocked: true}')" + state_write "$resource" "$new_state" +} + +log_action() { # resource decision result reason + local resource="$1" decision="$2" result="$3" reason="$4" + jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \ + --arg result "$result" --arg reason "$reason" --arg dry_run "$DRY_RUN" \ + '{ts:$ts, resource:$resource, decision:$decision, result:$result, reason:$reason, dry_run:($dry_run=="1")}' \ + >> "$ACTIONS_LOG" +} + +# --------------------------------------------------------------------------- +# Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado) +# --------------------------------------------------------------------------- + +notify_send() { # title message + local title="$1" message="$2" + if [[ -z "$NTFY_URL" ]]; then + log "NOTIFY (sin canal configurado aún — Paso 3) [$title]" + log "$message" + return 0 + fi + if ! curl -s -m 10 -H "Title: ${title}" -d "$message" "$NTFY_URL" >/dev/null 2>&1; then + log "WARN: fallo al enviar notificación a NTFY_URL" + fi +} + +# --------------------------------------------------------------------------- +# Diagnóstico: deployments de ecommerce +# --------------------------------------------------------------------------- + +DEPLOY_JSON="$(kubectl -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") + +in_commerce_backend_window() { + local now_hm start end + now_hm="$(date +%H:%M)" + start="$COMMERCE_BACKEND_WINDOW_START" + end="$COMMERCE_BACKEND_WINDOW_END" + if [[ "$start" < "$end" ]]; then + [[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]] + else + [[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]] + fi +} + +# devuelve: "eligible" | "not_eligible:" +compute_eligibility() { # resource_key is_unhealthy window_ok + local resource_key="$1" is_unhealthy="$2" window_ok="$3" + local state first_unhealthy_at last_action_at blocked + + state="$(state_read "$resource_key")" + first_unhealthy_at="$(jq -r '.first_unhealthy_at // empty' <<<"$state")" + last_action_at="$(jq -r '.last_action_at // empty' <<<"$state")" + blocked="$(jq -r '.blocked // false' <<<"$state")" + + if [[ "$is_unhealthy" != "1" ]]; then + [[ -n "$first_unhealthy_at" ]] && state_clear "$resource_key" + echo "not_eligible:healthy" + return + fi + + if [[ -z "$first_unhealthy_at" ]]; then + first_unhealthy_at="$(iso_now)" + state_write "$resource_key" "$(jq -nc --arg t "$first_unhealthy_at" '{first_unhealthy_at:$t, blocked:false}')" + fi + + if [[ "$blocked" == "true" ]]; then + echo "not_eligible:blocked_tras_intento_previo_sin_recuperacion" + return + fi + + if [[ "$window_ok" != "1" ]]; then + echo "not_eligible:fuera_de_ventana_horaria_permitida" + return + fi + + local since_epoch now_e minutes_unhealthy + since_epoch="$(date -u -d "$first_unhealthy_at" +%s)" + now_e="$(date -u +%s)" + minutes_unhealthy=$(( (now_e - since_epoch) / 60 )) + + if (( minutes_unhealthy < UNHEALTHY_THRESHOLD_MIN )); then + echo "not_eligible:bajo_umbral(${minutes_unhealthy}m<${UNHEALTHY_THRESHOLD_MIN}m)" + return + fi + + if [[ -n "$last_action_at" ]]; then + local last_e minutes_since_action + last_e="$(date -u -d "$last_action_at" +%s)" + minutes_since_action=$(( (now_e - last_e) / 60 )) + if (( minutes_since_action < RATE_LIMIT_MIN )); then + echo "not_eligible:rate_limited(${minutes_since_action}m<${RATE_LIMIT_MIN}m)" + return + fi + fi + + echo "eligible" +} + +declare -A RESOURCE_ELIGIBILITY # resource_key -> eligible | not_eligible:motivo +declare -A RESOURCE_SUMMARY # resource_key -> línea de texto para el prompt + +DEPLOY_SUMMARY_LINES=() +while IFS= read -r dep; do + desired="$(jq -r --arg n "$dep" '.items[] | select(.metadata.name==$n) | (.spec.replicas // 0)' <<<"$DEPLOY_JSON")" + ready="$(jq -r --arg n "$dep" '.items[] | select(.metadata.name==$n) | (.status.readyReplicas // 0)' <<<"$DEPLOY_JSON")" + available="$(jq -r --arg n "$dep" '.items[] | select(.metadata.name==$n) | ([.status.conditions[]? | select(.type=="Available") | .status] | first // "Unknown")' <<<"$DEPLOY_JSON")" + + unhealthy=0 + [[ "$ready" -lt "$desired" || "$available" != "True" ]] && unhealthy=1 + + window_ok=1 + if [[ "$dep" == "$COMMERCE_BACKEND_DEPLOYMENT" ]]; then + in_commerce_backend_window && window_ok=1 || window_ok=0 + fi + + key="deploy:${dep}" + elig="$(compute_eligibility "$key" "$unhealthy" "$window_ok")" + RESOURCE_ELIGIBILITY["$key"]="$elig" + + line="deployment/${dep} (ns=${NAMESPACE}): ready=${ready}/${desired}, Available=${available}, unhealthy=${unhealthy}, elegibilidad=${elig}" + RESOURCE_SUMMARY["$key"]="$line" + DEPLOY_SUMMARY_LINES+=("$line") + log "$line" +done < <(printf '%s\n' "${DETECTED_DEPLOYMENTS[@]}") + +# --------------------------------------------------------------------------- +# Diagnóstico: contenedor gitea-runner (docker) +# --------------------------------------------------------------------------- + +check_gitea_runner_stall() { + # Señal vía API de Gitea Actions: runs con status=running que llevan + # más de UNHEALTHY_THRESHOLD_MIN minutos sin terminar. Fail-safe: si el + # token no existe o la API falla/tiene forma inesperada, se omite esta + # señal (nunca dispara una acción sobre una lectura no verificada). + if [[ ! -r "$GITEA_TOKEN_FILE" ]]; then + echo "omitida:sin_token_en_${GITEA_TOKEN_FILE}" + return + fi + local token repo resp http_code body stuck_total=0 detail="" + token="$(<"$GITEA_TOKEN_FILE")" + for repo in $GITEA_ACTIONS_REPOS; do + resp="$(curl -s -w '\n%{http_code}' --max-time 10 \ + -H "Authorization: token ${token}" \ + "${GITEA_API_BASE}/repos/${repo}/actions/runs?limit=10" 2>/dev/null)" || resp=$'\n000' + http_code="$(tail -n1 <<<"$resp")" + body="$(sed '$d' <<<"$resp")" + if [[ "$http_code" != "200" ]]; then + log "WARN: API de Gitea Actions para ${repo} devolvió HTTP ${http_code}; se omite señal de stall para ese repo" + continue + fi + local stuck + stuck="$(jq -r --argjson th "$UNHEALTHY_THRESHOLD_MIN" ' + [ (.workflow_runs // .runs // [])[] + | select(.status=="running") + | select( ( (now - ((.updated_at // .run_started_at // .created_at) | fromdateiso8601)) / 60 ) > $th ) + ] | length' <<<"$body" 2>/dev/null)" || stuck="" + if [[ -z "$stuck" ]]; then + log "WARN: no se pudo interpretar la respuesta de Gitea Actions para ${repo}; se omite señal de stall" + continue + fi + if (( stuck > 0 )); then + stuck_total=$(( stuck_total + stuck )) + detail="${detail}${repo}:${stuck} run(s) atascado(s) >${UNHEALTHY_THRESHOLD_MIN}m; " + fi + done + token="" + if (( stuck_total > 0 )); then + echo "stalled:${detail}" + else + echo "ok" + fi +} + +DOCKER_UNHEALTHY=0 +DOCKER_DETAIL="docker:gitea-runner no está en la lista blanca (DOCKER_RESTART_ALLOWLIST)" +if [[ " $DOCKER_RESTART_ALLOWLIST " == *" gitea-runner "* ]]; then + runner_status="$(docker inspect gitea-runner --format '{{.State.Status}}' 2>/dev/null || echo "no_encontrado")" + stall_signal="$(check_gitea_runner_stall)" + + crash_signal=0 + [[ "$runner_status" != "running" ]] && crash_signal=1 + + DOCKER_UNHEALTHY=0 + reason_bits=() + if [[ "$crash_signal" == "1" ]]; then + DOCKER_UNHEALTHY=1 + reason_bits+=("estado_contenedor=${runner_status}") + fi + if [[ "$stall_signal" == stalled:* ]]; then + DOCKER_UNHEALTHY=1 + reason_bits+=("$stall_signal") + fi + DOCKER_DETAIL="docker/gitea-runner: estado=${runner_status}, señal_gitea_actions=${stall_signal}, unhealthy=${DOCKER_UNHEALTHY}" + [[ "${#reason_bits[@]}" -gt 0 ]] && DOCKER_DETAIL="${DOCKER_DETAIL}, motivo=[${reason_bits[*]}]" +fi +log "$DOCKER_DETAIL" + +GITEA_KEY="docker:gitea-runner" +GITEA_ELIG="not_eligible:no_esta_en_lista_blanca" +if [[ " $DOCKER_RESTART_ALLOWLIST " == *" gitea-runner "* ]]; then + GITEA_ELIG="$(compute_eligibility "$GITEA_KEY" "$DOCKER_UNHEALTHY" "1")" +fi +RESOURCE_ELIGIBILITY["$GITEA_KEY"]="$GITEA_ELIG" +RESOURCE_SUMMARY["$GITEA_KEY"]="$DOCKER_DETAIL, elegibilidad=${GITEA_ELIG}" +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")" + +PUBLIC_CHECKS="" +for d in $PUBLIC_DOMAINS; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://${d}/" 2>/dev/null || echo "000")" + PUBLIC_CHECKS="${PUBLIC_CHECKS}${d} -> HTTP ${code}\n" + log "curl https://${d}/ -> HTTP ${code}" +done + +# --------------------------------------------------------------------------- +# Construir prompt para claude -p (Claude NO recibe herramientas — ver +# cabecera del archivo) +# --------------------------------------------------------------------------- + +ELIGIBLE_LIST="" +NOT_ELIGIBLE_LIST="" +for key in "${!RESOURCE_ELIGIBILITY[@]}"; do + val="${RESOURCE_ELIGIBILITY[$key]}" + if [[ "$val" == "eligible" ]]; then + ELIGIBLE_LIST="${ELIGIBLE_LIST}- ${key}\n" + else + NOT_ELIGIBLE_LIST="${NOT_ELIGIBLE_LIST}- ${key}: ${val}\n" + fi +done +[[ -z "$ELIGIBLE_LIST" ]] && ELIGIBLE_LIST="(ninguno este ciclo)\n" + +JSON_SCHEMA='{ + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": {"type": "string"}, + "decision": {"type": "string", "enum": ["restart", "notify_only"]}, + "reason": {"type": "string"} + }, + "required": ["resource", "decision", "reason"] + } + }, + "summary": {"type": "string"} + }, + "required": ["actions", "summary"] +}' + +PROMPT="$(cat <>"$RUN_LOG" +)" || { + log "FATAL: la invocación a claude -p falló" + notify_send "lab-agent: error" "claude -p terminó con error, ver ${RUN_LOG}" + exit 1 +} + +log "--- respuesta cruda de claude -p ---" +log "$CLAUDE_JSON" + +IS_ERROR="$(jq -r '.is_error // false' <<<"$CLAUDE_JSON")" +PERMISSION_DENIALS="$(jq -c '.permission_denials // []' <<<"$CLAUDE_JSON")" +if [[ "$PERMISSION_DENIALS" != "[]" ]]; then + log "ALERTA: claude intentó usar herramientas pese a --allowedTools \"\": ${PERMISSION_DENIALS}" + notify_send "lab-agent: ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas: ${PERMISSION_DENIALS}" +fi + +if [[ "$IS_ERROR" == "true" ]]; then + log "FATAL: claude -p devolvió is_error=true" + notify_send "lab-agent: error" "claude -p devolvió un error, ver ${RUN_LOG}" + exit 1 +fi + +DECISION_JSON="$(jq -r '.structured_output // empty' <<<"$CLAUDE_JSON")" +if [[ -z "$DECISION_JSON" ]]; then + log "FATAL: no se recibió structured_output válido" + notify_send "lab-agent: error" "Respuesta sin structured_output, ver ${RUN_LOG}" + exit 1 +fi + +CLAUDE_SUMMARY="$(jq -r '.summary // ""' <<<"$DECISION_JSON")" +log "resumen de claude: ${CLAUDE_SUMMARY}" + +# --------------------------------------------------------------------------- +# Ejecutar (o simular, en DRY_RUN) solo lo que el wrapper valida como +# elegible — nunca confiar en la palabra de Claude por sí sola. +# --------------------------------------------------------------------------- + +run_and_capture() { # cmd... -> setea LAST_ACTION_STDOUT/STDERR, devuelve el exit code real del comando + local errfile rc + errfile="$(mktemp)" + if LAST_ACTION_STDOUT="$("$@" 2>"$errfile")"; then + rc=0 + else + rc=$? + fi + LAST_ACTION_STDERR="$(cat "$errfile" 2>/dev/null || true)" + rm -f "$errfile" + return "$rc" +} + +execute_corrective_action() { # resource -> imprime "ok"|"fail" y deja stdout/stderr en variables globales + local resource="$1" + LAST_ACTION_STDOUT="" + LAST_ACTION_STDERR="" + LAST_ACTION_CMD="" + + case "$resource" in + docker:gitea-runner) + 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 + fi + if run_and_capture docker restart gitea-runner; then echo "ok"; else echo "fail"; fi + ;; + deploy:*) + local dep="${resource#deploy:}" + local found=0 + 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 + 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 + fi + if run_and_capture kubectl -n "$NAMESPACE" rollout restart "deployment/${dep}"; then echo "ok"; else echo "fail"; fi + ;; + *) + LAST_ACTION_STDERR="recurso desconocido: ${resource}" + echo "fail" + ;; + esac +} + +NOTIFY_LINES=() +ACTIONS_TAKEN=0 + +while IFS= read -r action_json; do + [[ -z "$action_json" ]] && continue + resource="$(jq -r '.resource' <<<"$action_json")" + decision="$(jq -r '.decision' <<<"$action_json")" + claude_reason="$(jq -r '.reason' <<<"$action_json")" + + eligibility="${RESOURCE_ELIGIBILITY[$resource]:-not_eligible:recurso_no_reconocido_por_el_wrapper}" + + if [[ "$decision" == "restart" && "$eligibility" != "eligible" ]]; then + log "ANOMALÍA: claude propuso restart en '${resource}' pero el wrapper lo marca '${eligibility}' — RECHAZADO, no se ejecuta" + log_action "$resource" "restart_rejected" "rejected_not_eligible" "claude_reason=${claude_reason} | wrapper_eligibility=${eligibility}" + NOTIFY_LINES+=("[RECHAZADO] ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}") + continue + fi + + if [[ "$decision" == "restart" ]]; then + result="$(execute_corrective_action "$resource")" + 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" + + if [[ "$result" == "ok" ]]; then + log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK" + log_action "$resource" "restart" "success" "$claude_reason" + NOTIFY_LINES+=("[ACCIÓN OK] ${resource}: ${LAST_ACTION_CMD}. Motivo: ${claude_reason}") + else + log "ACCIÓN FALLIDA: ${LAST_ACTION_CMD:-N/A} -> ${LAST_ACTION_STDERR}" + log_action "$resource" "restart" "failure" "$claude_reason | stderr=${LAST_ACTION_STDERR}" + NOTIFY_LINES+=("[ACCIÓN FALLÓ] ${resource}: ${LAST_ACTION_STDERR}. Queda bloqueado hasta que un humano lo revise (no se reintentará solo).") + fi + else + log "notify_only: ${resource} — ${claude_reason}" + log_action "$resource" "notify_only" "n/a" "$claude_reason" + if [[ "$eligibility" != "not_eligible:healthy" ]]; then + NOTIFY_LINES+=("[SOLO DIAGNÓSTICO] ${resource} (${eligibility}): ${claude_reason}") + fi + fi +done < <(jq -c '.actions[]?' <<<"$DECISION_JSON") + +# --------------------------------------------------------------------------- +# Notificación final +# --------------------------------------------------------------------------- + +if [[ "${#NOTIFY_LINES[@]}" -gt 0 || "$ACTIONS_TAKEN" == "1" ]]; then + MSG="Resumen: ${CLAUDE_SUMMARY}"$'\n\n' + for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n'; done + MSG="${MSG}"$'\n'"log completo: ${RUN_LOG}" + title="lab-agent" + [[ "$ACTIONS_TAKEN" == "1" ]] && title="lab-agent: acción correctiva ejecutada" + [[ "$DRY_RUN" == "1" ]] && title="[DRY_RUN] ${title}" + notify_send "$title" "$MSG" +else + log "Sin anomalías ni acciones este ciclo — no se notifica." +fi + +log "=== lab-agent fin ===" 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 2/2] =?UTF-8?q?Redise=C3=B1ar=20auth=20a=20login=20de=20su?= =?UTF-8?q?scripci=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"