Author SHA1 Message Date
devops 0bf571cb93 feat: agrega lab-session.sh para reconectar a la sesion tmux persistente
Punto de entrada unico para adjuntarse (o crear si no existe) a la
sesion "lab" dentro del contenedor tmux-host, donde corre Claude Code
de forma persistente ante cortes de SSH/cambios de dispositivo.
2026-08-27 22:12:46 -05:00
devops acf7521990 Merge fix/adr0006-meilisearch-secrets into main 2026-08-26 15:00:07 -05:00
devops 9c0a111f82 create-commerce-secrets.sh: agregar MEILI_MASTER_KEY/MEILISEARCH_API_KEY
Fase 1 de la Etapa 2 del ADR 0006 (apps-registry): el StatefulSet de
Meilisearch lee MEILI_MASTER_KEY del secret commerce-secrets, y el
plugin de Medusa leera MEILISEARCH_API_KEY con el mismo valor (Fase 2,
distinto nombre de env var por cada lado, mismo secret).

Nota operativa: este script regenera el secret completo en cada
corrida (JWT_SECRET/COOKIE_SECRET/REVALIDATE_SECRET siempre nuevos).
Para aplicar SOLO estas dos claves nuevas al secret ya en uso en el
cluster real, no correr el script completo -- rotaria credenciales de
postgres/redis/minio ya activas. Se aplico via patch puntual al Secret
en vivo, fuera de este script, para esta vez.
2026-08-26 14:59:52 -05:00
devops 1f25869c4f Merge fix/lab-agent-follow-redirects into main 2026-08-26 13:33:26 -05:00
devops cef3dd3a28 lab-agent: follow redirects on public domain health checks
commerce.cruzcloud.net's root path now 301s to /app (Medusa admin) via
a Traefik Middleware — without -L the check reported a false 404 on an
otherwise healthy service. Generic fix (curl -L) instead of
hardcoding a per-domain path, since routing now owns the redirect.
2026-08-26 13:29:34 -05:00
devops 25276ad5b1 Merge branch 'fix/lab-agent-telegram-horario' into main
Notificaciones vía Telegram (reemplaza ntfy.sh), pausa nocturna
(06:00-23:00 hora local, con doble verificación script+systemd),
timer de systemd activado (cada 60 min, bajado de 15 para reducir
costo), formato con íconos + notificación silenciosa para info +
respuestas en hilo al recuperarse + log adjunto. Incluye el fix de
Environment=HOME= para systemd --user (el manager de devops arranca
con HOME=/DATA, no con el override de shell interactiva). Todo
validado en vivo antes de activar.
2026-08-26 00:29:02 -05:00
devops 09048dd772 Bajar frecuencia del timer a 60 min para reducir costo
OnCalendar cada 60 min en vez de 15 (06:00-22:00 hora local, 17
corridas/día en vez de 68). Aplicado en vivo: unidad actualizada,
timer reiniciado, próxima corrida confirmada vía
systemctl --user list-timers.
2026-08-26 00:27:53 -05:00
devops a2717e922a Activar timer: fix HOME= en la unidad, instrucciones de instalación
El manager de systemd --user de devops arranca con HOME=/DATA (el de
/etc/passwd), no /DATA/AppData/home-devops -- ese override solo se
aplica en una shell interactiva vía /etc/profile.d/zz-devops-home.sh,
que el manager de systemd nunca carga. Sin Environment=HOME=... en
lab-agent.service, claude -p habría buscado las credenciales en
/DATA/.claude/ (no existe) y fallado en cada corrida. Detectado y
corregido en vivo corriendo el servicio manualmente vía
systemctl --user start antes de habilitar el timer.

README documenta la instalación real: unidades en
/DATA/.config/systemd/user/ (no ~/.config/... de una shell
interactiva), loginctl enable-linger devops como único paso con sudo.

Timer activo desde 2026-08-26 (lab-agent.timer, cada 15 min,
06:00-23:00 hora local), tras confirmación explícita y ver el costo
mensual estimado.
2026-08-26 00:25:45 -05:00
devops cd163e6b7c Telegram: notificación silenciosa para info y respuestas en hilo
log() pasa a escribir en stderr (transparente, sigue yendo al mismo
RUN_LOG vía el "exec > tee 2>&1"): necesario para que notify_send
pueda devolver el message_id por stdout sin que un log() interno se
cuele en la captura -- mismo patrón de bug que ya se había corregido
en execute_corrective_action.

notify_send ahora acepta un reply_to_message_id opcional y manda
disable_notification=true para nivel "info" (ruido de rutina no debe
sonar; fallas/acciones/alertas de seguridad sí). Cuando ejecuta una
acción correctiva, el message_id enviado se guarda por recurso en
state.json (attach_telegram_message_id); si ese recurso se recupera
solo en una corrida posterior, compute_eligibility manda un aviso
silencioso como respuesta al mensaje original antes de limpiar el
estado -- agrupa visualmente todo un incidente en un hilo.

Validado en vivo con el canario desechable: acción real -> message_id
capturado en state.json -> parcheado a imagen sana -> reply en hilo
confirmado al recuperarse, silencioso.
2026-08-26 00:21:02 -05:00
devops c273056771 Notificaciones vía Telegram, pausa nocturna y unidades systemd
Reemplaza ntfy.sh por Telegram Bot API (preferencia del usuario, no
reduce el costo — el costo real es de la API de Claude/Sonnet, no del
canal). Texto plano sin parse_mode: el motivo que devuelve Claude es
texto libre y puede traer caracteres que rompen el parser Markdown de
Telegram. Token en TELEGRAM_BOT_TOKEN_FILE (dueño devops, no root-only
-- el servicio corre como devops por el login de suscripción, mismo
criterio ya aplicado a GITEA_TOKEN_FILE). Validado en vivo: entrega
confirmada con el mismo código de notify_send().

Agrega pausa nocturna (ACTIVE_HOURS_START/END, default 06:00-23:00
hora local) para no acumular costo de API sin necesidad: el script
chequea el horario ANTES de tocar kubectl/docker/curl o invocar
claude -p, no solo antes de notificar. Nota documentada: esta ventana
no se solapa con COMMERCE_BACKEND_WINDOW_* (00:00-06:00), por lo que
medusa-deploy queda efectivamente inalcanzable para auto-restart en la
práctica -- pendiente de decisión aparte.

Agrega unidades systemd de usuario (lab-agent/systemd/*.service,
*.timer) con límites de CPU/memoria: OnCalendar cada 15 min dentro del
horario activo, corre como devops (systemctl --user), sin habilitar
todavía -- requiere confirmación explícita tras ver el costo mensual
estimado (~US$60-65, documentado en agente-monitoreo.md).
2026-08-26 00:00:56 -05:00
devops 61a951f576 Merge branch 'fix/lab-agent-diagnostico-headless' into main
Agente headless de diagnóstico/corrección del lab (lab-agent), con
lista blanca aprobada, decisión de Claude sin acceso a herramientas
(claude -p --allowedTools "" + json-schema, el wrapper ejecuta y
revalida), y prueba end-to-end validada en vivo 2026-08-26.
2026-08-25 23:15:37 -05:00
7 changed files with 319 additions and 48 deletions

No files matched your search

+8
View File
@@ -32,6 +32,12 @@ JWT_SECRET="$(random_secret)"
COOKIE_SECRET="$(random_secret)" COOKIE_SECRET="$(random_secret)"
REVALIDATE_SECRET="$(random_secret)" REVALIDATE_SECRET="$(random_secret)"
# Mismo valor para las dos claves: MEILI_MASTER_KEY la lee el propio
# contenedor getmeili/meilisearch (servidor), MEILISEARCH_API_KEY la lee
# el plugin @rokmohar/medusa-plugin-meilisearch en Medusa (cliente) --
# cada lado usa su propia convencion de nombre de env var.
MEILI_MASTER_KEY="$(random_secret)"
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres-svc:5432/${POSTGRES_DB}?sslmode=disable" DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres-svc:5432/${POSTGRES_DB}?sslmode=disable"
REDIS_URL="redis://:${REDIS_PASSWORD}@redis-svc:6379" REDIS_URL="redis://:${REDIS_PASSWORD}@redis-svc:6379"
@@ -49,6 +55,8 @@ kubectl -n "${NAMESPACE}" create secret generic commerce-secrets \
--from-literal=JWT_SECRET="${JWT_SECRET}" \ --from-literal=JWT_SECRET="${JWT_SECRET}" \
--from-literal=COOKIE_SECRET="${COOKIE_SECRET}" \ --from-literal=COOKIE_SECRET="${COOKIE_SECRET}" \
--from-literal=REVALIDATE_SECRET="${REVALIDATE_SECRET}" \ --from-literal=REVALIDATE_SECRET="${REVALIDATE_SECRET}" \
--from-literal=MEILI_MASTER_KEY="${MEILI_MASTER_KEY}" \
--from-literal=MEILISEARCH_API_KEY="${MEILI_MASTER_KEY}" \
--dry-run=client \ --dry-run=client \
-o yaml | -o yaml |
kubectl apply -f - kubectl apply -f -
+59 -9
View File
@@ -7,11 +7,14 @@ 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 justificación de gobernanza y la evidencia de la prueba end-to-end en
`apps-registry` → `docs/arquitectura/agente-monitoreo.md`. `apps-registry` → `docs/arquitectura/agente-monitoreo.md`.
**Estado (2026-08-26): script y prueba end-to-end validados en vivo** **Estado (2026-08-26): timer de systemd ACTIVO** (`lab-agent.timer`,
contra el cluster real (deployment canario desechable, ciclo completo usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de
DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de 15 a 60 min el 2026-08-26 para reducir costo). Validado
systemd (Paso 4) **todavía no está activo** — pendiente de tu en vivo contra el cluster real: DRY_RUN → ejecución real → rate
confirmación explícita. limit/bloqueo → recuperación con hilo en Telegram → limpieza, y
corrida manual vía `systemctl --user start` con el entorno real del
timer antes de habilitarlo. Ver `docs/arquitectura/agente-monitoreo.md`
para la evidencia completa y el costo mensual estimado.
## Diseño de seguridad (por qué Claude no ejecuta comandos directamente) ## Diseño de seguridad (por qué Claude no ejecuta comandos directamente)
@@ -118,6 +121,30 @@ 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 "runner atascado" vía API de Gitea Actions (solo detecta el contenedor
caído/reiniciando). caído/reiniciando).
**Notificaciones (Telegram, reemplaza a ntfy.sh desde 2026-08-26):**
creá el bot con [@BotFather](https://t.me/BotFather) (`/newbot`) y
obtené tu `chat_id` (mandale un mensaje al bot primero, después
`https://api.telegram.org/bot<TOKEN>/getUpdates`). El token va en un
archivo aparte, dueño `devops` — **no** root-only pese al patrón
original: el servicio corre como `devops` (necesita su login de
suscripción para `claude -p`), así que un archivo `root:root 600` le
sería ilegible, mismo criterio ya aplicado a `GITEA_TOKEN_FILE`.
```bash
mkdir -p "$(dirname "$TELEGRAM_BOT_TOKEN_FILE")"
echo "<tu-token-de-botfather>" > "$TELEGRAM_BOT_TOKEN_FILE"
chmod 600 "$TELEGRAM_BOT_TOKEN_FILE"
# y en lab-agent.env: TELEGRAM_CHAT_ID=<tu-chat-id> (no es secreto)
```
**Horario activo:** `ACTIVE_HOURS_START`/`ACTIVE_HOURS_END` (hora local,
default `06:00`–`23:00`). Fuera de esa ventana el script sale antes de
tocar kubectl/docker/curl o invocar `claude -p` — no gasta nada, no
solo omite la notificación. **Ojo:** esta ventana no se solapa con
`COMMERCE_BACKEND_WINDOW_*` (`00:00`–`06:00`) — mientras el agente está
pausado, `medusa-deploy` nunca es elegible para auto-restart en la
práctica. Ver `docs/arquitectura/agente-monitoreo.md`.
## Probar ## Probar
```bash ```bash
@@ -132,8 +159,31 @@ ejecuta de verdad (confirmado con `kubectl get events` y la anotación
`restartedAt`), y una segunda corrida sobre el mismo recurso caído `restartedAt`), y una segunda corrida sobre el mismo recurso caído
respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se
encontraron y corrigieron en esa prueba — ver el detalle en encontraron y corrigieron en esa prueba — ver el detalle en
`docs/arquitectura/agente-monitoreo.md`. `docs/arquitectura/agente-monitoreo.md`. Telegram (silencioso para
`info`, hilo de respuestas al recuperarse) también se validó en vivo
con el mismo canario.
**No actives el timer de systemd (Paso 4) todavía** — requiere tu ## Instalar el timer de systemd
confirmación explícita por separado, aunque la prueba end-to-end ya
pasó. Unidades de **usuario** (`systemctl --user`), corren como `devops` —
necesario porque `claude -p` usa su login de suscripción. Ojo: el
manager de `systemd --user` de `devops` arranca con `HOME=/DATA` (el
de `/etc/passwd`), no `/DATA/AppData/home-devops` — por eso
`lab-agent.service` fija `Environment=HOME=...` explícitamente; sin
eso, `claude -p` no encuentra las credenciales. Los archivos van en
`/DATA/.config/systemd/user/`, no en el `~/.config/...` de una shell
interactiva (que resuelve a otra ruta por el mismo motivo).
```bash
mkdir -p /DATA/.config/systemd/user
cp systemd/lab-agent.service systemd/lab-agent.timer /DATA/.config/systemd/user/
sudo loginctl enable-linger devops # una vez — si no, systemctl --user ni conecta
export XDG_RUNTIME_DIR=/run/user/$(id -u devops)
systemctl --user daemon-reload
systemctl --user start lab-agent.service # probar una corrida manual primero
systemctl --user enable --now lab-agent.timer
systemctl --user list-timers lab-agent.timer
```
Activo desde 2026-08-26. Ver el costo mensual estimado y el detalle
completo en `docs/arquitectura/agente-monitoreo.md`.
+19 -4
View File
@@ -62,10 +62,25 @@ 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
# --- Notificaciones (Paso 3 — dejar vacío hasta tener un canal real) --- # --- Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26) ---
# Ej. self-hosted: https://ntfy.cruzcloud.net/lab-agent # Token del bot: creado con @BotFather, va en un archivo aparte (ver
# ntfy.sh público: https://ntfy.sh/<topic-privado-dificil-de-adivinar> # README.md). NO root-only pese al patrón original — el servicio corre
NTFY_URL= # como `devops` (necesita el login de suscripción de ese usuario para
# claude -p), así que un archivo root:root 600 sería ilegible para él.
# Mismo criterio ya aplicado a GITEA_TOKEN_FILE.
TELEGRAM_BOT_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/telegram_bot_token
# chat_id NO es secreto (no autentica nada sin el token) — va acá directo.
TELEGRAM_CHAT_ID=
# --- Horario activo (hora LOCAL del host) ---
# Fuera de esta ventana el script aborta ANTES de tocar kubectl/docker/
# curl o invocar claude -p — pausa nocturna para no acumular costo de
# API. OJO: esta ventana NO se solapa con COMMERCE_BACKEND_WINDOW_*
# (00:00-06:00) — mientras el agente está pausado, medusa-deploy nunca
# es elegible para auto-restart en la práctica, aunque la regla siga
# activa en el código. Ver docs/arquitectura/agente-monitoreo.md.
ACTIVE_HOURS_START=06:00
ACTIVE_HOURS_END=23:00
# --- Pruebas controladas (Paso 5) --- # --- Pruebas controladas (Paso 5) ---
# DRY_RUN=1: calcula elegibilidad y decisión igual que en producción, # DRY_RUN=1: calcula elegibilidad y decisión igual que en producción,
+172 -35
View File
@@ -51,7 +51,18 @@ source "$ENV_FILE"
: "${GITEA_TOKEN_FILE:=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token}" : "${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:=}" # Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26).
# TELEGRAM_BOT_TOKEN_FILE: secreto, NO versionado. TELEGRAM_CHAT_ID no es
# secreto (solo identifica a quién mandar, no autentica nada por sí solo).
: "${TELEGRAM_BOT_TOKEN_FILE:=}"
: "${TELEGRAM_CHAT_ID:=}"
# Horario activo (hora LOCAL del host): fuera de esta ventana el script
# aborta ANTES de tocar kubectl/docker/curl o invocar claude -p — pausa
# nocturna para no acumular costo de API sin necesidad. Ver
# docs/arquitectura/agente-monitoreo.md para el tradeoff con la ventana
# de medusa-deploy (00:00-06:00).
: "${ACTIVE_HOURS_START:=06:00}"
: "${ACTIVE_HOURS_END:=23:00}"
: "${DRY_RUN:=1}" : "${DRY_RUN:=1}"
for arg in "$@"; do for arg in "$@"; do
@@ -71,12 +82,36 @@ ACTIONS_LOG="${LOG_DIR}/actions.jsonl"
exec > >(tee -a "$RUN_LOG") 2>&1 exec > >(tee -a "$RUN_LOG") 2>&1
iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; } iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
log() { echo "[$(iso_now)] $*"; } log() { echo "[$(iso_now)] $*" >&2; } # stderr a propósito: el stdout de una función
# queda limpio para $(...) (ej. capturar un
# message_id de Telegram) sin que un log()
# interno se cuele en la captura. Va al mismo
# RUN_LOG igual, por el "exec > tee 2>&1" de arriba.
trap 'log "ERROR inesperado en línea $LINENO (comando: $BASH_COMMAND)"' ERR 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}) ==="
in_time_window() { # start end (HH:MM, hora local) -> 0 si "ahora" cae adentro
local start="$1" end="$2" now_hm
now_hm="$(date +%H:%M)"
if [[ "$start" < "$end" ]]; then
[[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]]
else
[[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]]
fi
}
# Pausa nocturna: si estamos fuera del horario activo, salir ANTES de tocar
# kubectl/docker/curl o invocar claude -p — ni siquiera se gasta el costo
# de diagnóstico, no solo el de la llamada a claude. Sin notificación (sería
# spam cada 15 min durante toda la pausa) — solo queda en el log del run.
if ! in_time_window "$ACTIVE_HOURS_START" "$ACTIVE_HOURS_END"; then
log "fuera de horario activo (${ACTIVE_HOURS_START}-${ACTIVE_HOURS_END} hora local) — se omite esta corrida sin costo de API"
log "=== lab-agent fin (omitido por horario) ==="
exit 0
fi
for c in "$KUBECTL_BIN" docker curl jq flock "$CLAUDE_BIN" 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
@@ -127,6 +162,14 @@ mark_action_taken() { # resource -> preserva first_unhealthy_at, marca last_acti
state_write "$resource" "$new_state" state_write "$resource" "$new_state"
} }
attach_telegram_message_id() { # resource message_id -> guarda el id para poder responder en hilo cuando se recupere
local resource="$1" msg_id="$2" prev new_state
[[ -z "$msg_id" ]] && return 0
prev="$(state_read "$resource")"
new_state="$(jq -nc --argjson prev "$prev" --arg mid "$msg_id" '$prev + {telegram_message_id: ($mid | tonumber)}')"
state_write "$resource" "$new_state"
}
log_action() { # resource decision result reason log_action() { # resource decision result reason
local resource="$1" decision="$2" result="$3" reason="$4" local resource="$1" decision="$2" result="$3" reason="$4"
jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \ jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \
@@ -139,15 +182,74 @@ log_action() { # resource decision result reason
# Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado) # Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
notify_send() { # title message notify_level_icon() { # level -> emoji
local title="$1" message="$2" case "$1" in
if [[ -z "$NTFY_URL" ]]; then error) echo "❌" ;;
log "NOTIFY (sin canal configurado aún — Paso 3) [$title]" security) echo "🚨" ;;
action_ok) echo "✅" ;;
action_fail) echo "🛑" ;;
info) echo "👀" ;;
*) echo "🤖" ;;
esac
}
notify_send() { # level title message [reply_to_message_id] -> imprime el message_id enviado (stdout), o nada si falló/no configurado
local level="$1" title="$2" message="$3" reply_to="${4:-}" icon
icon="$(notify_level_icon "$level")"
if [[ ! -r "$TELEGRAM_BOT_TOKEN_FILE" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [${icon} ${title}]"
log "$message" log "$message"
return 0 return 0
fi fi
if ! curl -s -m 10 -H "Title: ${title}" -d "$message" "$NTFY_URL" >/dev/null 2>&1; then local token text resp silent="false"
log "WARN: fallo al enviar notificación a NTFY_URL" # Silenciosa (sin sonido/vibración) solo para "info" — ruido de rutina
# (nada elegible, todo sano) no debería interrumpir; fallas/acciones/
# alertas de seguridad sí hacen sonido.
[[ "$level" == "info" ]] && silent="true"
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
# Texto plano, sin parse_mode: el "reason" de claude es texto libre y
# puede traer _, *, [, ] etc. que rompen el parser Markdown de Telegram
# (400 Bad Request) — más vale entrega confiable que formato. Los
# emojis andan bien en texto plano, no dependen de parse_mode.
text="🤖 lab-agent
${icon} ${title}
──────────────
${message}"
text="${text:0:4000}"
local curl_args=(-s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage"
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}"
--data-urlencode "text=${text}"
--data-urlencode "disable_notification=${silent}")
[[ -n "$reply_to" ]] && curl_args+=(--data-urlencode "reply_to_message_id=${reply_to}")
resp="$(curl "${curl_args[@]}" 2>&1)"
token=""
if [[ "$resp" == *'"ok":true'* ]]; then
jq -r '.result.message_id // empty' <<<"$resp" 2>/dev/null
else
log "WARN: fallo al enviar notificación a Telegram: ${resp}"
fi
}
notify_attach_log() { # caption -> adjunta RUN_LOG como documento (best-effort)
local caption="$1"
[[ -r "$TELEGRAM_BOT_TOKEN_FILE" && -n "$TELEGRAM_CHAT_ID" ]] || return 0
if [[ ! -r "$RUN_LOG" ]]; then
log "WARN: no se pudo adjuntar el log a Telegram, no es legible: ${RUN_LOG}"
return 0
fi
local token resp
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
# sendDocument, no sendMessage: el log completo va como archivo, no como
# texto. Nota: al momento de subirlo el archivo todavía se está
# escribiendo (exec > tee), así que le faltan sus últimas 2-3 líneas
# (el cierre "=== lab-agent fin ===") — cosmético, no afecta el resto.
resp="$(curl -s -m 30 -X POST "https://api.telegram.org/bot${token}/sendDocument" \
-F "chat_id=${TELEGRAM_CHAT_ID}" \
-F "caption=${caption:0:1000}" \
-F "document=@${RUN_LOG}" 2>&1)"
token=""
if [[ "$resp" != *'"ok":true'* ]]; then
log "WARN: fallo al adjuntar el log a Telegram: ${resp}"
fi fi
} }
@@ -161,15 +263,7 @@ DEPLOY_JSON="$("$KUBECTL_BIN" -n "$NAMESPACE" get deployment -o json 2>/dev/null
mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON") mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON")
in_commerce_backend_window() { in_commerce_backend_window() {
local now_hm start end in_time_window "$COMMERCE_BACKEND_WINDOW_START" "$COMMERCE_BACKEND_WINDOW_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:<motivo>" # devuelve: "eligible" | "not_eligible:<motivo>"
@@ -183,7 +277,20 @@ compute_eligibility() { # resource_key is_unhealthy window_ok
blocked="$(jq -r '.blocked // false' <<<"$state")" blocked="$(jq -r '.blocked // false' <<<"$state")"
if [[ "$is_unhealthy" != "1" ]]; then if [[ "$is_unhealthy" != "1" ]]; then
[[ -n "$first_unhealthy_at" ]] && state_clear "$resource_key" if [[ -n "$first_unhealthy_at" ]]; then
local prev_msg_id
prev_msg_id="$(jq -r '.telegram_message_id // empty' <<<"$state")"
# Si hubo una acción notificada sobre este recurso y ahora se
# recuperó solo, avisar como respuesta al mensaje original (hilo).
# >/dev/null a propósito: no necesitamos el message_id de ESTE aviso,
# y notify_send también se usa en otros lados vía $(...) -- no
# mezclar su stdout con el "not_eligible:healthy" que devuelve esta
# función.
if [[ -n "$prev_msg_id" ]]; then
notify_send "info" "Recuperado" "✅ ${resource_key} volvió a estar sano por su cuenta." "$prev_msg_id" >/dev/null
fi
state_clear "$resource_key"
fi
echo "not_eligible:healthy" echo "not_eligible:healthy"
return return
fi fi
@@ -342,7 +449,11 @@ CLUSTER_WIDE_SNAPSHOT="$("$KUBECTL_BIN" get pods -A -o custom-columns='NS:.metad
PUBLIC_CHECKS="" PUBLIC_CHECKS=""
for d in $PUBLIC_DOMAINS; do 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")" # -L: sigue redirects (ej. commerce.cruzcloud.net/ -> /app vía Middleware
# de Traefik) para reportar el estado final real, sin tener que
# hardcodear una ruta distinta por dominio en este script — el routing
# decide el path, no este check.
code="$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 "https://${d}/" 2>/dev/null || echo "000")"
PUBLIC_CHECKS="${PUBLIC_CHECKS}${d} -> HTTP ${code}\n" PUBLIC_CHECKS="${PUBLIC_CHECKS}${d} -> HTTP ${code}\n"
log "curl https://${d}/ -> HTTP ${code}" log "curl https://${d}/ -> HTTP ${code}"
done done
@@ -443,7 +554,7 @@ CLAUDE_AUTH_ENV=()
if [[ -n "$ANTHROPIC_API_KEY_FILE" ]]; then if [[ -n "$ANTHROPIC_API_KEY_FILE" ]]; then
if [[ ! -r "$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" 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 error "Error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
exit 1 exit 1
fi fi
CLAUDE_AUTH_ENV=("ANTHROPIC_API_KEY=$(<"$ANTHROPIC_API_KEY_FILE")") CLAUDE_AUTH_ENV=("ANTHROPIC_API_KEY=$(<"$ANTHROPIC_API_KEY_FILE")")
@@ -465,7 +576,8 @@ CLAUDE_JSON="$(
2>>"$RUN_LOG" 2>>"$RUN_LOG"
)" || { )" || {
log "FATAL: la invocación a claude -p falló" log "FATAL: la invocación a claude -p falló"
notify_send "lab-agent: error" "claude -p terminó con error, ver ${RUN_LOG}" notify_send error "claude -p falló" "La invocación terminó con error, ver log adjunto"
notify_attach_log "❌ claude -p falló"
exit 1 exit 1
} }
@@ -476,19 +588,21 @@ IS_ERROR="$(jq -r '.is_error // false' <<<"$CLAUDE_JSON")"
PERMISSION_DENIALS="$(jq -c '.permission_denials // []' <<<"$CLAUDE_JSON")" PERMISSION_DENIALS="$(jq -c '.permission_denials // []' <<<"$CLAUDE_JSON")"
if [[ "$PERMISSION_DENIALS" != "[]" ]]; then if [[ "$PERMISSION_DENIALS" != "[]" ]]; then
log "ALERTA: claude intentó usar herramientas pese a --allowedTools \"\": ${PERMISSION_DENIALS}" 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}" notify_send security "ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas pese a --allowedTools \"\": ${PERMISSION_DENIALS}"
fi fi
if [[ "$IS_ERROR" == "true" ]]; then if [[ "$IS_ERROR" == "true" ]]; then
log "FATAL: claude -p devolvió is_error=true" log "FATAL: claude -p devolvió is_error=true"
notify_send "lab-agent: error" "claude -p devolvió un error, ver ${RUN_LOG}" notify_send error "claude -p devolvió error" "is_error=true, ver log adjunto"
notify_attach_log "❌ claude -p devolvió error"
exit 1 exit 1
fi fi
DECISION_JSON="$(jq -r '.structured_output // empty' <<<"$CLAUDE_JSON")" DECISION_JSON="$(jq -r '.structured_output // empty' <<<"$CLAUDE_JSON")"
if [[ -z "$DECISION_JSON" ]]; then if [[ -z "$DECISION_JSON" ]]; then
log "FATAL: no se recibió structured_output válido" log "FATAL: no se recibió structured_output válido"
notify_send "lab-agent: error" "Respuesta sin structured_output, ver ${RUN_LOG}" notify_send error "Respuesta sin structured_output" "claude -p no devolvió salida estructurada válida, ver log adjunto"
notify_attach_log "❌ Respuesta sin structured_output"
exit 1 exit 1
fi fi
@@ -552,6 +666,8 @@ execute_corrective_action() { # resource -> exit 0=ok/1=fail, deja LAST_ACTION_*
NOTIFY_LINES=() NOTIFY_LINES=()
ACTIONS_TAKEN=0 ACTIONS_TAKEN=0
ACTIONS_FAILED=0
ACTED_RESOURCES=() # recursos con decision=="restart" este ciclo -> para hilo de Telegram
while IFS= read -r action_json; do while IFS= read -r action_json; do
[[ -z "$action_json" ]] && continue [[ -z "$action_json" ]] && continue
@@ -564,13 +680,15 @@ while IFS= read -r action_json; do
if [[ "$decision" == "restart" && "$eligibility" != "eligible" ]]; then 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 "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}" 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}") ACTIONS_FAILED=1
NOTIFY_LINES+=("🛑 Rechazado — ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}")
continue continue
fi fi
if [[ "$decision" == "restart" ]]; then if [[ "$decision" == "restart" ]]; then
if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi
ACTIONS_TAKEN=1 ACTIONS_TAKEN=1
ACTED_RESOURCES+=("$resource")
# Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un # Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un
# solo intento automático por incidente, sin reintentos silenciosos. # solo intento automático por incidente, sin reintentos silenciosos.
@@ -583,17 +701,20 @@ while IFS= read -r action_json; do
if [[ "$result" == "ok" ]]; then if [[ "$result" == "ok" ]]; then
log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK" log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK"
log_action "$resource" "restart" "success" "$claude_reason" log_action "$resource" "restart" "success" "$claude_reason"
NOTIFY_LINES+=("[ACCIÓN OK] ${resource}: ${LAST_ACTION_CMD}. Motivo: ${claude_reason}") NOTIFY_LINES+=("✅ Acción ejecutada — ${resource}: ${LAST_ACTION_CMD}
Motivo: ${claude_reason}")
else else
log "ACCIÓN FALLIDA: ${LAST_ACTION_CMD:-N/A} -> ${LAST_ACTION_STDERR}" log "ACCIÓN FALLIDA: ${LAST_ACTION_CMD:-N/A} -> ${LAST_ACTION_STDERR}"
log_action "$resource" "restart" "failure" "$claude_reason | stderr=${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).") ACTIONS_FAILED=1
NOTIFY_LINES+=("🛑 Acción falló — ${resource}: ${LAST_ACTION_STDERR}
Queda bloqueado hasta revisión humana, no se reintenta solo.")
fi fi
else else
log "notify_only: ${resource} — ${claude_reason}" log "notify_only: ${resource} — ${claude_reason}"
log_action "$resource" "notify_only" "n/a" "$claude_reason" log_action "$resource" "notify_only" "n/a" "$claude_reason"
if [[ "$eligibility" != "not_eligible:healthy" ]]; then if [[ "$eligibility" != "not_eligible:healthy" ]]; then
NOTIFY_LINES+=("[SOLO DIAGNÓSTICO] ${resource} (${eligibility}): ${claude_reason}") NOTIFY_LINES+=("👀 Diagnóstico — ${resource} (${eligibility}): ${claude_reason}")
fi fi
fi fi
done < <(jq -c '.actions[]?' <<<"$DECISION_JSON") done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
@@ -603,13 +724,29 @@ done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ "${#NOTIFY_LINES[@]}" -gt 0 || "$ACTIONS_TAKEN" == "1" ]]; then if [[ "${#NOTIFY_LINES[@]}" -gt 0 || "$ACTIONS_TAKEN" == "1" ]]; then
MSG="Resumen: ${CLAUDE_SUMMARY}"$'\n\n' MSG="📋 Resumen: ${CLAUDE_SUMMARY}"$'\n\n'
for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n'; done for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n\n'; done
MSG="${MSG}"$'\n'"log completo: ${RUN_LOG}" MSG="${MSG}📄 log completo adjunto abajo"
title="lab-agent"
[[ "$ACTIONS_TAKEN" == "1" ]] && title="lab-agent: acción correctiva ejecutada" if [[ "$ACTIONS_FAILED" == "1" ]]; then
[[ "$DRY_RUN" == "1" ]] && title="[DRY_RUN] ${title}" level="action_fail"; title="Acción falló o fue rechazada"
notify_send "$title" "$MSG" elif [[ "$ACTIONS_TAKEN" == "1" ]]; then
level="action_ok"; title="Acción correctiva ejecutada"
else
level="info"; title="Novedades del ciclo"
fi
[[ "$DRY_RUN" == "1" ]] && title="🧪 [DRY_RUN] ${title}"
SENT_MSG_ID="$(notify_send "$level" "$title" "$MSG")"
notify_attach_log "$(notify_level_icon "$level") ${title}"
# Guardar el message_id contra cada recurso accionado este ciclo, para
# poder responder en hilo el día que se recupere solo (ver
# compute_eligibility). No aplica en DRY_RUN, ya que no se toca el
# estado real en modo simulación.
if [[ -n "$SENT_MSG_ID" && "$DRY_RUN" != "1" ]]; then
for r in "${ACTED_RESOURCES[@]}"; do
attach_telegram_message_id "$r" "$SENT_MSG_ID"
done
fi
else else
log "Sin anomalías ni acciones este ciclo — no se notifica." log "Sin anomalías ni acciones este ciclo — no se notifica."
fi fi
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=lab-agent: diagnostico/correccion del lab GitOps cruzcloud
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent
# El manager de systemd --user de devops arranca con HOME=/DATA (el de
# /etc/passwd), no /DATA/AppData/home-devops -- ese override solo se
# aplica al abrir una shell interactiva via /etc/profile.d/zz-devops-home.sh,
# que el manager de systemd nunca carga. Sin esto, claude -p buscaria
# ~/.claude/.credentials.json en /DATA/.claude/ (no existe) y fallaria
# la autenticacion. Confirmado en vivo 2026-08-26.
Environment=HOME=/DATA/AppData/home-devops
ExecStart=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent/lab-agent.sh --live
TimeoutStartSec=300
# Limites de recursos (pedido explicito de gobernanza, nunca irrestricto).
# El propio script casi siempre corta antes por la pausa nocturna o por
# no tener nada elegible, asi que en la practica rara vez se acerca a
# estos topes -- estan para contener un caso patologico (claude -p
# colgado, kubectl en loop), no para el uso normal.
CPUQuota=100%
MemoryMax=1G
MemorySwapMax=256M
Nice=10
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Dispara lab-agent.service cada 60 min, 06:00-23:00 hora local
[Timer]
# Horas 06..22, minuto :00 -> ultima corrida real 22:00 (17 corridas/dia).
# El propio script vuelve a chequear ACTIVE_HOURS_START/END al arrancar
# (defensa en profundidad si esta unidad se edita a mano sin tocar el
# .env, o si corre manualmente fuera de horario).
OnCalendar=*-*-* 06..22:00:00
AccuracySec=30s
Persistent=false
[Install]
WantedBy=timers.target
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Reconecta a la sesion tmux persistente "lab" dentro del contenedor
# tmux-host. Si la sesion no existe (p.ej. el contenedor se acaba de
# recrear), la crea. El comando es siempre el mismo sin importar el
# estado previo.
#
# Ver docs-portal: arquitectura/sesion-tmux-persistente.md
CONTAINER="tmux-host"
SESSION="lab"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "El contenedor '$CONTAINER' no esta corriendo. Verifica con 'docker ps -a'." >&2
exit 1
fi
docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
docker exec -it "$CONTAINER" tmux new -s "$SESSION"