El worker DEBE matarse solo, sin depender de la laptop (es la razón de ser del
dead-man: vive en el worker; el volumen hace que morir no pierda nada). El reaper
del hub queda sólo como último recurso. El dead-man estaba TRIPLEMENTE roto:
1. TOKEN EN EL FICHERO EQUIVOCADO: hay 2 caminos de creación con el token en
lugares distintos (farm-up → /etc/hammer-deadman.env; harkaq-vol → /root/
.hcloud-token). La service lee sólo el primero ⇒ un worker del otro camino
quedaba sin token, disparaba pero salía 1 "sin HCLOUD_TOKEN". Fix: deadman.sh
busca el token EN CADENA (volumen primero, que es lo más persistente).
2. REGEX DEL ID ROTO: la API devuelve JSON con espacio ("id": 126, no "id":126)
y el dead-man usaba '"id":[0-9]+' ⇒ NUNCA resolvía su id, aun con token
válido. Fix: '"id":[[:space:]]*[0-9]+'. (Doblemente roto: por eso NUNCA murió.)
3. FIRING ≠ CAN-DELETE: farm-up sólo verificaba que el timer dispara. Ahora
corre `deadman.sh --puede-borrar` (token en cadena + ve su id en la API) y si
NO puede matarse, DESTRUYE el worker ahí mismo. Un worker que no se autodestruye
es inadmisible ⇒ jamás debe existir.
gioser DOBLE-BLINDADO en TODOS los sitios de borrado (lista negra por nombre +
label role=hammer-worker), igual que farm-down ya tenía: reaper, farm-up-destroy,
deadman, y el helper de harkaq-vol. "Ahí está nuestra vida." Verificado vivo (104d).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
10 KiB
Bash
183 lines
10 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.
|
||
#
|
||
# AUTOMUERTE TOTAL, y por qué NO hay guarda de "cosecha pendiente" (2026-07-17): la primera versión
|
||
# abortaba el borrado si quedaba cosecha sin recoger. Eso es exactamente el bug de hworker-4 con
|
||
# otra cara: el worker se queda VIVO esperando a que alguien lo rescate. Un switch que se desarma
|
||
# solo cuando más falta hace no sirve.
|
||
#
|
||
# La salida correcta no es "no morir": es que **el store del worker VIVA EN EL VOLUMEN**
|
||
# (/mnt/cosecha/store, symlinkeado desde el store local por farm-up). Así "guardar lo generado" no
|
||
# es un paso que pueda fallar antes de morir — es la estructura misma. Cada artefacto ya está en el
|
||
# volumen persistente en el instante en que se sella. Morir no pierde NADA, y por eso puede ser
|
||
# incondicional. El volumen sobrevive al server; el hub cosecha cuando esté vivo (harkaq-vol.sh
|
||
# collect) y clausura con `close`.
|
||
#
|
||
# Antes de morir sólo queda una cosa honesta: sync(1) + umount, para que el ext4 del volumen quede
|
||
# consistente. No es "guardar" (ya está guardado), es cerrar la puerta al salir.
|
||
#
|
||
# 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"; }
|
||
|
||
# TOKEN ROBUSTO A CADA CAMINO DE CREACIÓN (incidente 2026-07-23). Hay DOS caminos que crean workers y
|
||
# ponen el token en ficheros DISTINTOS: `farm-up` → /etc/hammer-deadman.env (EnvironmentFile de la
|
||
# service); `harkaq-vol.sh` → /root/.hcloud-token (cloud-init). La service del snapshot lee sólo el
|
||
# primero ⇒ un worker nacido por el OTRO camino quedaba SIN token en su env, disparaba pero salía 1
|
||
# "sin HCLOUD_TOKEN" y NUNCA se borraba (3.5h idle quemando €). El dead-man NO puede depender del hub
|
||
# para matarse (ésa es su razón de ser: vive en el worker). Así que busca el token en CADENA — el
|
||
# volumen primero, porque es lo más persistente (sobrevive al server, la idea del volumen del usuario).
|
||
: "${HCLOUD_TOKEN:=}"
|
||
if [ -z "$HCLOUD_TOKEN" ]; then
|
||
for f in /mnt/cosecha/.hcloud-token /root/.hcloud-token /etc/hcloud-token; do
|
||
[ -r "$f" ] || continue
|
||
HCLOUD_TOKEN=$(tr -d ' \t\n\r' < "$f" 2>/dev/null || true)
|
||
[ -n "$HCLOUD_TOKEN" ] && { log "token recuperado de $f (env vacío)"; break; }
|
||
done
|
||
fi
|
||
export HCLOUD_TOKEN
|
||
|
||
# ¿PUEDE este worker borrarse? (token que VE su propio id en la API). firing ≠ can-delete: `farm-up`
|
||
# usa esto para no dejar vivo un worker que no puede matarse, en vez de descubrirlo idle 3.5h tarde.
|
||
if [ "${1:-}" = "--puede-borrar" ]; then
|
||
if [ -z "$HCLOUD_TOKEN" ]; then echo "NO: sin token en ninguna ubicación conocida"; exit 1; fi
|
||
id=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
|
||
"https://api.hetzner.cloud/v1/servers?name=$(hostname)" 2>/dev/null \
|
||
| grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||
if [ -n "$id" ]; then echo "SÍ: puedo borrar mi id=$id"; exit 0
|
||
else echo "NO: el token no resuelve mi propio id (¿nombre '$(hostname)' no está en el proyecto?)"; exit 1; fi
|
||
fi
|
||
|
||
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)"
|
||
|
||
# ── BLINDAJE (2026-07-17, a petición explícita del usuario). gioser.net es un server FIJO, en el
|
||
# MISMO proyecto hcloud, y NO TIENE BACKUP. Hetzner no da tokens por-recurso: el token que este
|
||
# worker necesita para auto-borrarse puede borrar CUALQUIER server del proyecto. Dos capas, porque
|
||
# una sola no basta cuando el fallo es irreversible:
|
||
#
|
||
# 1. LISTA NEGRA por nombre — explícita, legible, imposible de malinterpretar.
|
||
# 2. LABEL role=hammer-worker — el switch sólo se borra si ÉL MISMO es un worker de la granja.
|
||
# gioser no tiene labels (map[]), así que jamás pasa este filtro ni por accidente.
|
||
#
|
||
# La capa 2 es la fuerte: no depende de acordarse de añadir nombres a una lista. Un server que no
|
||
# nació de farm-up NO se borra, punto — que es exactamente la propiedad que quiero.
|
||
case "$self" in
|
||
gioser|gioser.net|*gioser*)
|
||
log "ABORTA: '$self' está en la LISTA NEGRA (server fijo sin backup). NO me borro."
|
||
exit 0 ;;
|
||
esac
|
||
|
||
if [ -n "${HCLOUD_TOKEN:-}" ]; then
|
||
labels=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
|
||
"https://api.hetzner.cloud/v1/servers?name=$self" 2>/dev/null | grep -o 'hammer-worker' | head -1)
|
||
if [ "$labels" != "hammer-worker" ]; then
|
||
log "ABORTA: '$self' NO tiene label role=hammer-worker ⇒ no nació de la granja."
|
||
log " Un server que no es worker NO se borra, aunque esté idle. (gioser vive acá.)"
|
||
exit 0
|
||
fi
|
||
fi
|
||
|
||
log "IDLE $ticks ticks ⇒ borrando $self (worker verificado por label)"
|
||
|
||
# CERRAR LA PUERTA AL SALIR. No es "guardar" — lo generado YA está en el volumen (el store vive
|
||
# ahí). Es dejar el ext4 consistente para que el `collect` del hub lo monte limpio. Sin guarda de
|
||
# "cosecha pendiente" A PROPÓSITO: esa guarda dejaba al worker vivo justo cuando había trabajo que
|
||
# salvar, que es el bug original con otra cara. La automuerte es TOTAL.
|
||
if mountpoint -q /mnt/cosecha 2>/dev/null; then
|
||
art=$(ls -d /mnt/cosecha/store/*/ 2>/dev/null | wc -l)
|
||
log "volumen: $art artefactos ya persistidos; sync+umount antes de morir"
|
||
sync
|
||
# -l (lazy): si algo aún tiene el FS abierto, no queremos bloquear la muerte. Los datos ya
|
||
# están en disco por el sync; el volumen se detacha solo al borrarse el server.
|
||
umount /mnt/cosecha 2>/dev/null || umount -l /mnt/cosecha 2>/dev/null || \
|
||
log "aviso: umount falló; el sync ya persistió los datos"
|
||
else
|
||
log "AVISO: /mnt/cosecha NO montado ⇒ este worker no tenía volumen. Lo que haya en su store"
|
||
log " LOCAL se pierde al borrarlo. Nació por un camino sin volumen (¿farm-up viejo?)."
|
||
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":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||
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ó"
|