hworker-4 estuvo 5 DÍAS idle (carga 0.00, sin crontab, sin loops, 37G de KDE sin cosechar) quemando dinero. El usuario había pedido esto explícitamente —"los vps se autoapagan cuando se detectan idle por un tiempo"— y de sus 3 puntos (volumen / auto-apagado / cosecha) implementé el 1 y el 3. Éste faltaba. EL FALLO ERA ESTRUCTURAL, no un olvido: farm-down.sh existe pero es MANUAL. El modelo "efímero" dependía de que el agente se acordara de llamarlo — y si su contexto se corta, o la sesión muere, el server queda vivo para siempre. Un invariante que necesita que alguien recuerde NO es un invariante. Por eso el switch vive EN EL WORKER: para apagarse no necesita ni al hub ni a mí. BORRA, no apaga: en Hetzner un server apagado SIGUE COBRANDO (disco + IP). Un poweroff daría sensación de ahorro y seguiría facturando. Precio: el token vive en el worker (/etc/hammer-deadman.env 0600). Riesgo real y consciente; se acepta porque la alternativa MEDIDA fue peor: 5 días de VPS idle. Cuenta por INACTIVIDAD CONTINUA, no por antigüedad: cualquier señal de trabajo (hammer build, worker-loop, heartbeat <30min) resetea los ticks. Guarda /var/lib/hammer-no-borrar aborta el borrado si hay cosecha pendiente. systemd timer y NO cron, por la lección medida: un cron "validado a mano" nunca disparó porque el PID 1 de aquella máquina (arje-zero) no tenía crond. Validar la LÍNEA no es validar que un demonio la ejecute. y el farm-up comprueban que el timer quedó ACTIVO — evidencia, no fe. Cableado en farm-up: todo worker NACE con el switch puesto. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
5.1 KiB
Bash
103 lines
5.1 KiB
Bash
#!/bin/sh
|
||
# deadman.sh — el worker se BORRA SOLO cuando lleva demasiado tiempo sin trabajo.
|
||
#
|
||
# POR QUÉ EXISTE (2026-07-17): hworker-4 estuvo **5 días idle** (carga 0.00, sin crontab, sin
|
||
# loops, 37G de artefactos KDE sin cosechar) quemando dinero. El usuario había pedido esto
|
||
# explícitamente — "los vps se autoapagan cuando se detectan idle por un tiempo" — y de los tres
|
||
# puntos de su diseño (volumen / auto-apagado / cosecha) se implementaron el 1 y el 3. Éste faltaba.
|
||
#
|
||
# EL FALLO ERA ESTRUCTURAL, no un olvido: `farm-down.sh` existe pero es MANUAL. El modelo "efímero"
|
||
# dependía de que el agente se acordara de llamarlo — y si su contexto se corta, o la sesión muere,
|
||
# o simplemente se va, el servidor queda vivo PARA SIEMPRE. Un invariante que depende de que alguien
|
||
# recuerde no es un invariante. Por eso esto vive EN EL WORKER: para apagarse no necesita que el hub
|
||
# exista, ni que yo esté vivo.
|
||
#
|
||
# POR QUÉ BORRA Y NO APAGA: en Hetzner un server **apagado sigue cobrando** (mantiene disco e IP).
|
||
# `poweroff` daría sensación de ahorro y seguiría facturando. Sólo `hcloud server delete` para el
|
||
# reloj. Ese es el precio: el token vive en el worker. Es un riesgo REAL y consciente — un worker
|
||
# comprometido puede borrar servers del proyecto. Se acepta porque la alternativa medida fue peor:
|
||
# 5 días de VPS idle. Mitigación: el worker es efímero y no guarda secretos propios.
|
||
#
|
||
# QUÉ CUENTA COMO TRABAJO: un `hammer build` en vuelo, el loop del worker, o un heartbeat fresco
|
||
# (`/run/hammer-heartbeat`, que un job largo puede tocar). Si nada de eso aparece durante
|
||
# IDLE_MAX_TICKS ticks seguidos, se borra.
|
||
#
|
||
# LA CUENTA SE REINICIA con cualquier señal de trabajo: no borra por "lleva N horas encendido",
|
||
# borra por "lleva N minutos sin hacer NADA".
|
||
#
|
||
# LECCIÓN APLICADA (el cron que nunca disparó): NO basta validar la lógica a mano. El PID 1 del
|
||
# laptop es arje-zero y no tiene crond, así que un cron "validado" jamás corrió. Acá: systemd timer
|
||
# (el worker SÍ tiene systemd) + `--verificar` imprime la evidencia de que el timer está armado y
|
||
# cuándo disparó por última vez. Si no podés ver que dispara, no está puesto.
|
||
set -eu
|
||
|
||
IDLE_MAX_TICKS="${IDLE_MAX_TICKS:-6}" # 6 ticks × 10 min = 1 h sin trabajo → borrar
|
||
ESTADO="/var/lib/hammer-deadman.ticks"
|
||
HEARTBEAT="/run/hammer-heartbeat"
|
||
HEARTBEAT_MAX_AGE=1800 # 30 min: más viejo que esto no cuenta como vivo
|
||
LOG="/var/log/hammer-deadman.log"
|
||
|
||
log() { echo "$(date -u +%FT%TZ) deadman: $*" >> "$LOG"; }
|
||
|
||
if [ "${1:-}" = "--verificar" ]; then
|
||
echo "══ ¿el dead-man switch está REALMENTE armado?"
|
||
echo "── timer:"; systemctl is-active hammer-deadman.timer 2>/dev/null || echo " INACTIVO ⚠"
|
||
systemctl list-timers hammer-deadman.timer --no-pager 2>/dev/null | head -3
|
||
echo "── ticks idle acumulados: $(cat "$ESTADO" 2>/dev/null || echo 0) / $IDLE_MAX_TICKS"
|
||
echo "── últimas líneas del log (evidencia de que DISPARA):"
|
||
tail -5 "$LOG" 2>/dev/null | sed 's/^/ /' || echo " (sin log todavía ⇒ NUNCA disparó)"
|
||
exit 0
|
||
fi
|
||
|
||
hay_trabajo() {
|
||
pgrep -f 'hammer build' >/dev/null 2>&1 && { echo "hammer build en vuelo"; return 0; }
|
||
pgrep -f 'farm-worker-loop' >/dev/null 2>&1 && { echo "worker-loop vivo"; return 0; }
|
||
if [ -f "$HEARTBEAT" ]; then
|
||
edad=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) ))
|
||
[ "$edad" -lt "$HEARTBEAT_MAX_AGE" ] && { echo "heartbeat fresco (${edad}s)"; return 0; }
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
if razon=$(hay_trabajo); then
|
||
# Trabajo ⇒ la cuenta vuelve a cero. Borrar es por INACTIVIDAD CONTINUA, no por antigüedad.
|
||
[ -f "$ESTADO" ] && [ "$(cat "$ESTADO")" != "0" ] && log "trabajo ($razon) ⇒ ticks a 0"
|
||
echo 0 > "$ESTADO"
|
||
exit 0
|
||
fi
|
||
|
||
ticks=$(( $(cat "$ESTADO" 2>/dev/null || echo 0) + 1 ))
|
||
echo "$ticks" > "$ESTADO"
|
||
log "sin trabajo: tick $ticks/$IDLE_MAX_TICKS"
|
||
|
||
[ "$ticks" -lt "$IDLE_MAX_TICKS" ] && exit 0
|
||
|
||
# ── Punto de no retorno.
|
||
self="$(hostname)"
|
||
log "IDLE $ticks ticks ⇒ borrando $self"
|
||
|
||
# GUARDA: nunca borrar con cosecha sin recoger. El worker no sabe qué cosechó el hub, pero sí puede
|
||
# negarse a morir si alguien dejó una marca explícita. `farm-run`/la campaña la ponen mientras haya
|
||
# artefactos que nadie bajó; el hub la quita al cosechar.
|
||
if [ -f /var/lib/hammer-no-borrar ]; then
|
||
log "ABORTA: /var/lib/hammer-no-borrar presente (cosecha pendiente) ⇒ sigo vivo, avisá al hub"
|
||
exit 0
|
||
fi
|
||
|
||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||
log "ERROR: sin HCLOUD_TOKEN ⇒ NO puedo borrarme. Un poweroff NO ahorra (Hetzner cobra igual)."
|
||
exit 1
|
||
fi
|
||
|
||
id=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
|
||
"https://api.hetzner.cloud/v1/servers?name=$self" 2>/dev/null \
|
||
| grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2)
|
||
if [ -z "$id" ]; then
|
||
log "ERROR: no pude resolver mi propio id de server (¿nombre '$self' no está en el proyecto?)"
|
||
exit 1
|
||
fi
|
||
log "self id=$id ⇒ DELETE"
|
||
curl -sS -X DELETE -H "Authorization: Bearer $HCLOUD_TOKEN" \
|
||
"https://api.hetzner.cloud/v1/servers/$id" >> "$LOG" 2>&1
|
||
log "DELETE enviado; si seguís leyendo esto, falló"
|