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).
This commit is contained in:
devops committed 2026-08-26 00:00:56 -05:00
1 parent 61a951f576
commit c273056771
5 files changed
+131 -22

No files matched your search

+29 -4
View File
@@ -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 "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 +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 `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`. 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 **No actives el timer de systemd todavía** — requiere tu confirmación
confirmación explícita por separado, aunque la prueba end-to-end ya explícita por separado, después de ver el costo mensual estimado.
pasó.
+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,
+49 -14
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
@@ -77,6 +88,26 @@ 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
@@ -141,13 +172,25 @@ log_action() { # resource decision result reason
notify_send() { # title message notify_send() { # title message
local title="$1" message="$2" local title="$1" message="$2"
if [[ -z "$NTFY_URL" ]]; then if [[ ! -r "$TELEGRAM_BOT_TOKEN_FILE" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (sin canal configurado aún — Paso 3) [$title]" log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [$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
log "WARN: fallo al enviar notificación a NTFY_URL" 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 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") 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>"
+20
View File
@@ -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
+14
View File
@@ -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