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.
This commit is contained in:
devops committed 2026-08-26 00:29:02 -05:00
commit 25276ad5b1
5 files changed
+286 -47

No files matched your search

+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,
+167 -34
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
@@ -443,7 +550,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 +572,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 +584,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 +662,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 +676,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 +697,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 +720,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