Files
takana/scripts/farm/deadman.sh
T
sergioandClaude Opus 4.8 3de75a5229 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>
2026-07-23 03:23:42 -04:00

183 lines
10 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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ó"