diff --git a/scripts/farm/deadman.sh b/scripts/farm/deadman.sh new file mode 100644 index 00000000..0fc99b30 --- /dev/null +++ b/scripts/farm/deadman.sh @@ -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ó" diff --git a/scripts/farm/farm-up.sh b/scripts/farm/farm-up.sh index d70d4cd7..5dd716f8 100755 --- a/scripts/farm/farm-up.sh +++ b/scripts/farm/farm-up.sh @@ -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 diff --git a/scripts/farm/hammer-deadman.service b/scripts/farm/hammer-deadman.service new file mode 100644 index 00000000..371acdd2 --- /dev/null +++ b/scripts/farm/hammer-deadman.service @@ -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 diff --git a/scripts/farm/hammer-deadman.timer b/scripts/farm/hammer-deadman.timer new file mode 100644 index 00000000..0d1cf989 --- /dev/null +++ b/scripts/farm/hammer-deadman.timer @@ -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