Files
takana/scripts/farm/deadman.sh
T
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00

204 lines
12 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 `takana 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:=}"
# La service lo recibe vía EnvironmentFile, pero corrido A MANO (farm-up --puede-borrar) ese fichero
# NO se carga solo ⇒ hay que sourcearlo, o la verificación da falso-negativo y destruye un worker que
# SÍ puede matarse (lo cazó el 1er worker real, 2026-07-23). Es formato KEY=value.
if [ -z "$HCLOUD_TOKEN" ] && [ -r /etc/hammer-deadman.env ]; then
. /etc/hammer-deadman.env 2>/dev/null || true
: "${HCLOUD_TOKEN:=}"
fi
# Y ficheros de token DESNUDO (otros caminos de creación): el volumen primero, que es lo persistente.
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() {
# `takana build` cubre TODO el build (fetch/vendor/compile) porque el proceso vive toda la
# invocación. `campana-deuda` = campaña deliberada. NO se cuenta `farm-worker-loop`: ese loop
# está SIEMPRE vivo (idle-loopea con la cola vacía) ⇒ contarlo como trabajo hacía que el worker
# NUNCA acumulara ticks y NUNCA se matara — un worker con cola seca quedaba idle para siempre
# (incidente 2026-07-23, 2ª vez). El loop construyendo YA se ve por su `takana build` hijo.
pgrep -f 'release/hammer.* build ' >/dev/null 2>&1 && { echo "hammer build en vuelo"; return 0; }
pgrep -f 'campana-deuda' >/dev/null 2>&1 && { echo "campaña deliberada en vuelo"; return 0; }
# UNA TRANSFERENCIA TAMBIÉN ES TRABAJO (2026-08-09). Poblar el volumen con el store del hub son
# horas de rsync durante las cuales no corre ningún `takana build` ⇒ el worker acumulaba ticks y
# se borraba A SÍ MISMO a mitad de la copia. El volumen sobrevive, pero la transferencia muere y
# hay que reanudarla a mano; con un enlace lento eso puede no converger nunca.
# `pgrep -x` (nombre EXACTO del proceso), no `pgrep -f`: `-f` mira la línea de órdenes completa y
# se auto-matchea con el propio dead-man si su ruta contiene la cadena. Ese error ya nos hizo
# informar cuatro veces procesos «vivos» que estaban muertos.
pgrep -x rsync >/dev/null 2>&1 && { echo "transferencia rsync en vuelo"; 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=takana-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ó"