granja: el dead-man del worker se mata SOLO (sin hub) — 3 bugs + gioser doble-blindado

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>
This commit is contained in:
2026-07-23 03:23:42 -04:00
co-authored by Claude Opus 4.8
parent be06314336
commit 3de75a5229
4 changed files with 61 additions and 16 deletions
+5
View File
@@ -90,6 +90,11 @@ if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
mkdir -p "$ROOT/work/.reaper"; sobreviven=""
while read -r name ip; do
[ -n "${name:-}" ] || continue
# LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
# Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
# granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
"; continue;; esac
strike_f="$ROOT/work/.reaper/$name"
# ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
# NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
+29 -1
View File
@@ -54,6 +54,34 @@ 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 ⚠"
@@ -143,7 +171,7 @@ 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)
| 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
+22 -14
View File
@@ -139,21 +139,29 @@ for k in $(seq 1 "$N"); do
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}')"
# FIRING ≠ CAN-DELETE (2026-07-23): un worker quedó idle 3.5h quemando € porque el token en
# /etc/hammer-deadman.env estaba vacío ⇒ el dead-man DISPARABA pero salía 1 "sin HCLOUD_TOKEN"
# y nunca se borraba. Que el timer esté activo NO prueba que pueda matarse. Se verifica que su
# token VE su propio id en la API — lo MISMO que hace al morir.
if $SSH root@"$ip" '. /etc/hammer-deadman.env 2>/dev/null; [ -n "${HCLOUD_TOKEN:-}" ] && curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" "https://api.hetzner.cloud/v1/servers?name=$(hostname)" 2>/dev/null | grep -q "\"id\""' 2>/dev/null; then
echo " ✓ dead-man PUEDE borrarse (token válido, se ve en la API)"
else
echo " dead-man NO PUEDE borrarse: token vacío/inválido en /etc/hammer-deadman.env."
echo " Solo, este worker quedaría idle PARA SIEMPRE. Lo cubre el reaper del hub"
echo " (cosecha-cron), pero sólo si el latido corre. Arreglar la provisión del token."
fi
# FIRING ≠ CAN-DELETE (2026-07-23): un worker quedó idle 3.5h quemando € porque su token no
# estaba donde la service lo lee ⇒ el dead-man DISPARABA pero salía 1 "sin HCLOUD_TOKEN" y nunca
# se borraba. Que el timer esté activo NO prueba que pueda matarse. El worker DEBE poder matarse
# SOLO, sin el hub — así que si NO PUEDE, no lo dejamos vivo: lo DESTRUIMOS acá mismo. Un worker
# que no se puede autodestruir es exactamente lo que el usuario prohíbe. `deadman.sh --puede-borrar`
# busca el token en cadena (env, volumen, /root) y prueba que ve su id en la API.
timer_ok=$($SSH root@"$ip" 'systemctl is-active hammer-deadman.timer' 2>/dev/null | grep -q active && echo 1)
puede=$($SSH root@"$ip" '/usr/local/bin/deadman.sh --puede-borrar' 2>/dev/null)
if [ -n "$timer_ok" ] && printf '%s' "$puede" | grep -q '^SÍ'; then
echo " dead-man ARMADO y PUEDE borrarse solo ($puede) — no depende del hub"
else
echo " ⚠ el timer NO quedó activo ⇒ este worker puede quedarse idle para siempre. Revisar."
echo " $name NO puede autodestruirse (timer=${timer_ok:-no} · $puede)."
echo " Un worker que no se mata solo es INADMISIBLE ⇒ lo DESTRUYO ahora para no dejarlo idle."
# LISTA NEGRA (capa 1 de 2): gioser JAMÁS se toca. farm-up sólo crea hworker-N, pero defensa
# en profundidad — "ahí está nuestra vida". Segunda capa = el label check de abajo.
case "$name" in *gioser*) echo "$name en LISTA NEGRA ⇒ NO lo toco. Revisá a mano."; continue;; esac
if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
hcloud server delete "$name" >/dev/null 2>&1 && echo "$name destruido (label verificado)."
else
echo "$name sin label hammer-worker ⇒ NO lo toco (protegido); borralo a mano."
fi
grep -v "^$name " "$FLEET" > "$FLEET.tmp" 2>/dev/null && mv "$FLEET.tmp" "$FLEET"
continue
fi
fi
echo "$name muele la cola actual"
+5 -1
View File
@@ -122,7 +122,11 @@ collect)
| sed 's/^/ verdicts en el volumen: /'
mkdir -p work/harkaq-campana
rsync -az -e "$SSH" "root@$ip:/mnt/cosecha/verdicts/" work/harkaq-campana/ 2>/dev/null || true
[ -n "$helper" ] && { echo "== destruyo el helper"; hcloud server delete "$helper" >/dev/null; }
# El helper lo nombra este script, nunca es gioser — pero guarda igual ("ahí está nuestra vida").
case "$helper" in
*gioser*) echo "== ⛔ helper matchea gioser?! ABORTO el delete" ;;
?*) echo "== destruyo el helper"; hcloud server delete "$helper" >/dev/null ;;
esac
# NO se corre el harvest acá: harvest-harkaq.sh rsync-ea de un WORKER, y en este punto los
# verdicts ya están locales (los trajo el rsync del volumen). Clasificarlos es un paso aparte
# —lo hace el hub con el store COMPLETO (el worker mide, el hub clasifica)— y su edición de