granja: DEAD-MAN SWITCH — el worker se borra solo tras 1h idle
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>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
#!/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ó"
|
||||
@@ -57,6 +57,33 @@ for k in $(seq 1 "$N"); do
|
||||
./ "root@$ip:$REMOTE/"
|
||||
# el servicio ya arrancó en el boot (baked/enabled); reiniciar fuerza rescan inmediato de la cola
|
||||
$SSH root@"$ip" 'systemctl restart hammer-farm' 2>/dev/null || true
|
||||
|
||||
# DEAD-MAN SWITCH: el worker se borra SOLO tras 1h sin trabajo. Va acá, en el nacimiento, porque
|
||||
# el modelo "efímero" NO puede depender de que alguien llame a farm-down: hworker-4 estuvo 5 días
|
||||
# idle (2026-07-17) justo por eso — el agente que lo creó se fue y el server quedó. Un invariante
|
||||
# que necesita que alguien se acuerde no es un invariante.
|
||||
echo " armando dead-man switch (1h idle ⇒ auto-delete)…"
|
||||
tok="${HCLOUD_TOKEN:-$(grep -shoE '^ *token *= *"?[A-Za-z0-9]+' "$HOME/.config/hcloud/cli.toml" 2>/dev/null | head -1 | sed 's/.*[= "]//')}"
|
||||
if [ -z "$tok" ]; then
|
||||
echo " ⚠ SIN TOKEN para el dead-man switch: este worker NO se auto-borrará."
|
||||
echo " (un poweroff no sirve: Hetzner cobra igual con el server apagado)"
|
||||
else
|
||||
scp -q -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
scripts/farm/deadman.sh "root@$ip:/usr/local/bin/deadman.sh" 2>/dev/null
|
||||
scp -q -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
scripts/farm/hammer-deadman.service scripts/farm/hammer-deadman.timer \
|
||||
"root@$ip:/etc/systemd/system/" 2>/dev/null
|
||||
$SSH root@"$ip" "chmod +x /usr/local/bin/deadman.sh
|
||||
printf 'HCLOUD_TOKEN=%s\n' '$tok' > /etc/hammer-deadman.env; chmod 600 /etc/hammer-deadman.env
|
||||
systemctl daemon-reload && systemctl enable --now hammer-deadman.timer" >/dev/null 2>&1
|
||||
# EVIDENCIA, no fe: que el timer esté ARMADO se comprueba, no se asume (el cron de aquella vez
|
||||
# estaba "validado" y nunca disparó porque el PID 1 no tenía crond).
|
||||
if $SSH root@"$ip" 'systemctl is-active hammer-deadman.timer' 2>/dev/null | grep -q active; then
|
||||
echo " ✓ dead-man armado: $($SSH root@"$ip" 'systemctl list-timers hammer-deadman.timer --no-pager 2>/dev/null | sed -n 2p' | awk '{print $1, $2, $3}')"
|
||||
else
|
||||
echo " ⚠ el timer NO quedó activo ⇒ este worker puede quedarse idle para siempre. Revisar."
|
||||
fi
|
||||
fi
|
||||
echo " ✓ $name muele la cola actual"
|
||||
done
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=hammer: tick del dead-man switch (borra el worker tras 1h idle)
|
||||
# El token se lee de /etc/hammer-deadman.env (0600, lo escribe farm-up al provisionar). Sin él el
|
||||
# script LOGUEA EL ERROR y sale 1: no hace un poweroff "por si acaso", porque un server apagado en
|
||||
# Hetzner SIGUE COBRANDO y daría una falsa sensación de ahorro.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-/etc/hammer-deadman.env
|
||||
ExecStart=/usr/local/bin/deadman.sh
|
||||
@@ -0,0 +1,18 @@
|
||||
# Dispara el dead-man switch cada 10 min. 6 ticks sin trabajo = 1 h idle → el worker se borra solo.
|
||||
#
|
||||
# systemd timer y NO cron POR UNA 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. El worker sí corre systemd, y `systemctl list-timers` da la evidencia de que está
|
||||
# armado — verificable con `deadman.sh --verificar`.
|
||||
[Unit]
|
||||
Description=hammer: borra el worker si lleva 1h sin trabajo (baseline EUR 0)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=10min
|
||||
# Persistent=false a propósito: si el server estuvo apagado no queremos una ráfaga de ticks al
|
||||
# volver — la cuenta de idle debe medir tiempo REAL sin trabajo, no ticks perdidos.
|
||||
Persistent=false
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user