#!/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ó"