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:
2026-07-17 08:00:24 -04:00
co-authored by Claude Opus 4.8
parent 7d86dbf0ae
commit 0d35044b53
4 changed files with 157 additions and 0 deletions
+102
View File
@@ -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ó"
+27
View File
@@ -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
+10
View File
@@ -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
+18
View File
@@ -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