Files
takana/scripts/farm/deadman.sh
T
sergioandClaude Opus 4.8 c0a473589b granja: BLINDAJE de gioser — el token de la granja podía borrarlo
Petición explícita del usuario: gioser.net es FIJO y NO TIENE BACKUP. Vive en el
MISMO proyecto hcloud que los workers, y Hetzner no da tokens por-recurso: el
token que el dead-man switch necesita para auto-borrarse puede borrar CUALQUIER
server del proyecto. farm-down era peor: borraba por NOMBRE leído de .fleet sin
verificar NADA — un nombre equivocado en esa lista y adiós.

Dos capas, porque una sola no basta cuando el fallo es irreversible:

  1. LISTA NEGRA por nombre (gioser*) — explícita y legible.
  2. LABEL role=hammer-worker — sólo se borra lo que NACIÓ de farm-up. Ésta es la
     capa fuerte: no depende de mantener una lista al día. Un server que no es
     worker no se borra, punto.

VERIFICADO contra los servers vivos, no en teoría:
   gioser     labels=map[]                       PROTEGIDO
   hworker-4  labels=map[role:hammer-worker]    BORRABLE
Y .fleet sólo contiene hworker-4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:06:17 -04:00

155 lines
8.2 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"; }
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":[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ó"