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