From c273056771e93b00a4c58b66f9a50e91f1113e0a Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 00:00:56 -0500 Subject: [PATCH 1/4] =?UTF-8?q?Notificaciones=20v=C3=ADa=20Telegram,=20pau?= =?UTF-8?q?sa=20nocturna=20y=20unidades=20systemd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lab-agent/README.md | 33 +++++++++++++-- lab-agent/lab-agent.env.example | 23 +++++++++-- lab-agent/lab-agent.sh | 63 ++++++++++++++++++++++------- lab-agent/systemd/lab-agent.service | 20 +++++++++ lab-agent/systemd/lab-agent.timer | 14 +++++++ 5 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 lab-agent/systemd/lab-agent.service create mode 100644 lab-agent/systemd/lab-agent.timer diff --git a/lab-agent/README.md b/lab-agent/README.md index ce29eed..4830c0c 100644 --- a/lab-agent/README.md +++ b/lab-agent/README.md @@ -118,6 +118,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 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/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 "" > "$TELEGRAM_BOT_TOKEN_FILE" +chmod 600 "$TELEGRAM_BOT_TOKEN_FILE" +# y en lab-agent.env: TELEGRAM_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 ```bash @@ -132,8 +156,9 @@ 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`. +`docs/arquitectura/agente-monitoreo.md`. La notificación real se +validó con ntfy.sh en esa prueba; el cambio a Telegram debe repetir +al menos la validación de entrega antes de habilitar el timer. -**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ó. +**No actives el timer de systemd todavía** — requiere tu confirmación +explícita por separado, después de ver el costo mensual estimado. diff --git a/lab-agent/lab-agent.env.example b/lab-agent/lab-agent.env.example index a0e9f2c..a6c776c 100644 --- a/lab-agent/lab-agent.env.example +++ b/lab-agent/lab-agent.env.example @@ -62,10 +62,25 @@ GITEA_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token 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= +# --- Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26) --- +# Token del bot: creado con @BotFather, va en un archivo aparte (ver +# README.md). NO root-only pese al patrón original — el servicio corre +# 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) --- # DRY_RUN=1: calcula elegibilidad y decisión igual que en producción, diff --git a/lab-agent/lab-agent.sh b/lab-agent/lab-agent.sh index 8cd27fc..e2f26dd 100755 --- a/lab-agent/lab-agent.sh +++ b/lab-agent/lab-agent.sh @@ -51,7 +51,18 @@ source "$ENV_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:=}" +# 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}" for arg in "$@"; do @@ -77,6 +88,26 @@ trap 'log "ERROR inesperado en línea $LINENO (comando: $BASH_COMMAND)"' ERR 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 command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; } done @@ -141,13 +172,25 @@ log_action() { # resource decision result reason notify_send() { # title message local title="$1" message="$2" - if [[ -z "$NTFY_URL" ]]; then - log "NOTIFY (sin canal configurado aún — Paso 3) [$title]" + if [[ ! -r "$TELEGRAM_BOT_TOKEN_FILE" || -z "$TELEGRAM_CHAT_ID" ]]; then + log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [$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" + local token text resp + 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. + text="[${title}] +${message}" + text="${text:0:4000}" + resp="$(curl -s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${text}" 2>&1)" + token="" + if [[ "$resp" != *'"ok":true'* ]]; then + log "WARN: fallo al enviar notificación a Telegram: ${resp}" fi } @@ -161,15 +204,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") 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 + in_time_window "$COMMERCE_BACKEND_WINDOW_START" "$COMMERCE_BACKEND_WINDOW_END" } # devuelve: "eligible" | "not_eligible:" diff --git a/lab-agent/systemd/lab-agent.service b/lab-agent/systemd/lab-agent.service new file mode 100644 index 0000000..cacd8b7 --- /dev/null +++ b/lab-agent/systemd/lab-agent.service @@ -0,0 +1,20 @@ +[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 +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 diff --git a/lab-agent/systemd/lab-agent.timer b/lab-agent/systemd/lab-agent.timer new file mode 100644 index 0000000..8859cdc --- /dev/null +++ b/lab-agent/systemd/lab-agent.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Dispara lab-agent.service cada 15 min, 06:00-23:00 hora local + +[Timer] +# Horas 06..22, minutos :00/:15/:30/:45 -> ultima corrida real 22:45. +# 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/15:00 +AccuracySec=30s +Persistent=false + +[Install] +WantedBy=timers.target From cd163e6b7c90f3a73ff3da0ef9562b3ff191096b Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 00:21:02 -0500 Subject: [PATCH 2/4] =?UTF-8?q?Telegram:=20notificaci=C3=B3n=20silenciosa?= =?UTF-8?q?=20para=20info=20y=20respuestas=20en=20hilo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lab-agent/lab-agent.sh | 154 +++++++++++++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 28 deletions(-) diff --git a/lab-agent/lab-agent.sh b/lab-agent/lab-agent.sh index e2f26dd..00c29b4 100755 --- a/lab-agent/lab-agent.sh +++ b/lab-agent/lab-agent.sh @@ -82,7 +82,11 @@ 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)] $*"; } +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 @@ -158,6 +162,14 @@ mark_action_taken() { # resource -> preserva first_unhealthy_at, marca last_acti 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 local resource="$1" decision="$2" result="$3" reason="$4" jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \ @@ -170,27 +182,74 @@ log_action() { # resource decision result reason # Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado) # --------------------------------------------------------------------------- -notify_send() { # title message - local title="$1" message="$2" +notify_level_icon() { # level -> emoji + case "$1" in + error) echo "❌" ;; + 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) [$title]" + log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [${icon} ${title}]" log "$message" return 0 fi - local token text resp + local token text resp silent="false" + # 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. - text="[${title}] + # (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}" - resp="$(curl -s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage" \ - --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ - --data-urlencode "text=${text}" 2>&1)" + 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 enviar notificación a Telegram: ${resp}" + log "WARN: fallo al adjuntar el log a Telegram: ${resp}" fi } @@ -218,7 +277,20 @@ compute_eligibility() { # resource_key is_unhealthy window_ok blocked="$(jq -r '.blocked // false' <<<"$state")" 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" return fi @@ -478,7 +550,7 @@ 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}." + notify_send error "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")") @@ -500,7 +572,8 @@ CLAUDE_JSON="$( 2>>"$RUN_LOG" )" || { 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 } @@ -511,19 +584,21 @@ 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}" + notify_send security "ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas pese a --allowedTools \"\": ${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}" + notify_send error "claude -p devolvió error" "is_error=true, ver log adjunto" + notify_attach_log "❌ claude -p devolvió error" 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}" + 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 fi @@ -587,6 +662,8 @@ execute_corrective_action() { # resource -> exit 0=ok/1=fail, deja LAST_ACTION_* NOTIFY_LINES=() 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 [[ -z "$action_json" ]] && continue @@ -599,13 +676,15 @@ while IFS= read -r action_json; do 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}") + ACTIONS_FAILED=1 + NOTIFY_LINES+=("🛑 Rechazado — ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}") continue fi if [[ "$decision" == "restart" ]]; then if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi ACTIONS_TAKEN=1 + ACTED_RESOURCES+=("$resource") # Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un # solo intento automático por incidente, sin reintentos silenciosos. @@ -618,17 +697,20 @@ while IFS= read -r action_json; do 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}") + NOTIFY_LINES+=("✅ Acción ejecutada — ${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).") + ACTIONS_FAILED=1 + NOTIFY_LINES+=("🛑 Acción falló — ${resource}: ${LAST_ACTION_STDERR} + Queda bloqueado hasta revisión humana, no se reintenta 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}") + NOTIFY_LINES+=("👀 Diagnóstico — ${resource} (${eligibility}): ${claude_reason}") fi fi done < <(jq -c '.actions[]?' <<<"$DECISION_JSON") @@ -638,13 +720,29 @@ done < <(jq -c '.actions[]?' <<<"$DECISION_JSON") # --------------------------------------------------------------------------- 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" + MSG="📋 Resumen: ${CLAUDE_SUMMARY}"$'\n\n' + for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n\n'; done + MSG="${MSG}📄 log completo adjunto abajo" + + if [[ "$ACTIONS_FAILED" == "1" ]]; then + level="action_fail"; title="Acción falló o fue rechazada" + 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 log "Sin anomalías ni acciones este ciclo — no se notifica." fi From a2717e922abee1f7bffbfa05490f6936c7fd137e Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 00:25:45 -0500 Subject: [PATCH 3/4] =?UTF-8?q?Activar=20timer:=20fix=20HOME=3D=20en=20la?= =?UTF-8?q?=20unidad,=20instrucciones=20de=20instalaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lab-agent/README.md | 44 ++++++++++++++++++++++------- lab-agent/systemd/lab-agent.service | 7 +++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/lab-agent/README.md b/lab-agent/README.md index 4830c0c..64b23a0 100644 --- a/lab-agent/README.md +++ b/lab-agent/README.md @@ -7,11 +7,13 @@ 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`. -**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. +**Estado (2026-08-26): timer de systemd ACTIVO** (`lab-agent.timer`, +usuario `devops`, cada 15 min entre 06:00–23:00 hora local). Validado +en vivo contra el cluster real: DRY_RUN → ejecución real → rate +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) @@ -156,9 +158,31 @@ 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`. La notificación real se -validó con ntfy.sh en esa prueba; el cambio a Telegram debe repetir -al menos la validación de entrega antes de habilitar el timer. +`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 todavía** — requiere tu confirmación -explícita por separado, después de ver el costo mensual estimado. +## Instalar el timer de systemd + +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`. diff --git a/lab-agent/systemd/lab-agent.service b/lab-agent/systemd/lab-agent.service index cacd8b7..9c906bb 100644 --- a/lab-agent/systemd/lab-agent.service +++ b/lab-agent/systemd/lab-agent.service @@ -6,6 +6,13 @@ 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 From 09048dd7727c2ed949d337caf561cc5e5f8d6524 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 00:27:53 -0500 Subject: [PATCH 4/4] Bajar frecuencia del timer a 60 min para reducir costo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lab-agent/README.md | 3 ++- lab-agent/systemd/lab-agent.timer | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lab-agent/README.md b/lab-agent/README.md index 64b23a0..55f8627 100644 --- a/lab-agent/README.md +++ b/lab-agent/README.md @@ -8,7 +8,8 @@ justificación de gobernanza y la evidencia de la prueba end-to-end en `apps-registry` → `docs/arquitectura/agente-monitoreo.md`. **Estado (2026-08-26): timer de systemd ACTIVO** (`lab-agent.timer`, -usuario `devops`, cada 15 min entre 06:00–23:00 hora local). Validado +usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de +15 a 60 min el 2026-08-26 para reducir costo). Validado en vivo contra el cluster real: DRY_RUN → ejecución real → rate limit/bloqueo → recuperación con hilo en Telegram → limpieza, y corrida manual vía `systemctl --user start` con el entorno real del diff --git a/lab-agent/systemd/lab-agent.timer b/lab-agent/systemd/lab-agent.timer index 8859cdc..cc1c438 100644 --- a/lab-agent/systemd/lab-agent.timer +++ b/lab-agent/systemd/lab-agent.timer @@ -1,12 +1,12 @@ [Unit] -Description=Dispara lab-agent.service cada 15 min, 06:00-23:00 hora local +Description=Dispara lab-agent.service cada 60 min, 06:00-23:00 hora local [Timer] -# Horas 06..22, minutos :00/:15/:30/:45 -> ultima corrida real 22:45. +# 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/15:00 +OnCalendar=*-*-* 06..22:00:00 AccuracySec=30s Persistent=false