tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ main scripts/farm/deadman.sh Shell · 208 líneas · 12.3 KB Historia crudo

granja: cuatro sondas quedaron ciegas tras el renombre — el informe decía «idle» compilando d79299d35 · Sergio ·

1#!/bin/sh
2# deadman.sh — el worker se BORRA SOLO cuando lleva demasiado tiempo sin trabajo.
3#
4# POR QUÉ EXISTE (2026-07-17): hworker-4 estuvo **5 días idle** (carga 0.00, sin crontab, sin
5# loops, 37G de artefactos KDE sin cosechar) quemando dinero. El usuario había pedido esto
6# explícitamente — "los vps se autoapagan cuando se detectan idle por un tiempo" — y de los tres
7# puntos de su diseño (volumen / auto-apagado / cosecha) se implementaron el 1 y el 3. Éste faltaba.
8#
9# EL FALLO ERA ESTRUCTURAL, no un olvido: `farm-down.sh` existe pero es MANUAL. El modelo "efímero"
10# dependía de que el agente se acordara de llamarlo — y si su contexto se corta, o la sesión muere,
11# o simplemente se va, el servidor queda vivo PARA SIEMPRE. Un invariante que depende de que alguien
12# recuerde no es un invariante. Por eso esto vive EN EL WORKER: para apagarse no necesita que el hub
13# exista, ni que yo esté vivo.
14#
15# POR QUÉ BORRA Y NO APAGA: en Hetzner un server **apagado sigue cobrando** (mantiene disco e IP).
16# `poweroff` daría sensación de ahorro y seguiría facturando. Sólo `hcloud server delete` para el
17# reloj. Ese es el precio: el token vive en el worker. Es un riesgo REAL y consciente — un worker
18# comprometido puede borrar servers del proyecto. Se acepta porque la alternativa medida fue peor:
19# 5 días de VPS idle. Mitigación: el worker es efímero y no guarda secretos propios.
20#
21# AUTOMUERTE TOTAL, y por qué NO hay guarda de "cosecha pendiente" (2026-07-17): la primera versión
22# abortaba el borrado si quedaba cosecha sin recoger. Eso es exactamente el bug de hworker-4 con
23# otra cara: el worker se queda VIVO esperando a que alguien lo rescate. Un switch que se desarma
24# solo cuando más falta hace no sirve.
25#
26# La salida correcta no es "no morir": es que **el store del worker VIVA EN EL VOLUMEN**
27# (/mnt/cosecha/store, symlinkeado desde el store local por farm-up). Así "guardar lo generado" no
28# es un paso que pueda fallar antes de morir — es la estructura misma. Cada artefacto ya está en el
29# volumen persistente en el instante en que se sella. Morir no pierde NADA, y por eso puede ser
30# incondicional. El volumen sobrevive al server; el hub cosecha cuando esté vivo (harkaq-vol.sh
31# collect) y clausura con `close`.
32#
33# Antes de morir sólo queda una cosa honesta: sync(1) + umount, para que el ext4 del volumen quede
34# consistente. No es "guardar" (ya está guardado), es cerrar la puerta al salir.
35#
36# QUÉ CUENTA COMO TRABAJO: un `takana build` en vuelo, el loop del worker, o un heartbeat fresco
37# (`/run/hammer-heartbeat`, que un job largo puede tocar). Si nada de eso aparece durante
38# IDLE_MAX_TICKS ticks seguidos, se borra.
39#
40# LA CUENTA SE REINICIA con cualquier señal de trabajo: no borra por "lleva N horas encendido",
41# borra por "lleva N minutos sin hacer NADA".
42#
43# LECCIÓN APLICADA (el cron que nunca disparó): NO basta validar la lógica a mano. El PID 1 del
44# laptop es arje-zero y no tiene crond, así que un cron "validado" jamás corrió. Acá: systemd timer
45# (el worker SÍ tiene systemd) + `--verificar` imprime la evidencia de que el timer está armado y
46# cuándo disparó por última vez. Si no podés ver que dispara, no está puesto.
47set -eu
48
49IDLE_MAX_TICKS="${IDLE_MAX_TICKS:-6}"          # 6 ticks × 10 min = 1 h sin trabajo → borrar
50ESTADO="/var/lib/takana-deadman.ticks"
51HEARTBEAT="/run/hammer-heartbeat"
52HEARTBEAT_MAX_AGE=1800                          # 30 min: más viejo que esto no cuenta como vivo
53LOG="/var/log/takana-deadman.log"
54
55log() { echo "$(date -u +%FT%TZ) deadman: $*" >> "$LOG"; }
56
57# TOKEN ROBUSTO A CADA CAMINO DE CREACIÓN (incidente 2026-07-23). Hay DOS caminos que crean workers y
58# ponen el token en ficheros DISTINTOS: `farm-up` → /etc/takana-deadman.env (EnvironmentFile de la
59# service); `harkaq-vol.sh` → /root/.hcloud-token (cloud-init). La service del snapshot lee sólo el
60# primero ⇒ un worker nacido por el OTRO camino quedaba SIN token en su env, disparaba pero salía 1
61# "sin HCLOUD_TOKEN" y NUNCA se borraba (3.5h idle quemando €). El dead-man NO puede depender del hub
62# para matarse (ésa es su razón de ser: vive en el worker). Así que busca el token en CADENA — el
63# volumen primero, porque es lo más persistente (sobrevive al server, la idea del volumen del usuario).
64: "${HCLOUD_TOKEN:=}"
65# La service lo recibe vía EnvironmentFile, pero corrido A MANO (farm-up --puede-borrar) ese fichero
66# NO se carga solo ⇒ hay que sourcearlo, o la verificación da falso-negativo y destruye un worker que
67# SÍ puede matarse (lo cazó el 1er worker real, 2026-07-23). Es formato KEY=value.
68if [ -z "$HCLOUD_TOKEN" ] && [ -r /etc/takana-deadman.env ]; then
69    . /etc/takana-deadman.env 2>/dev/null || true
70    : "${HCLOUD_TOKEN:=}"
71fi
72# Y ficheros de token DESNUDO (otros caminos de creación): el volumen primero, que es lo persistente.
73if [ -z "$HCLOUD_TOKEN" ]; then
74    for f in /mnt/cosecha/.hcloud-token /root/.hcloud-token /etc/hcloud-token; do
75        [ -r "$f" ] || continue
76        HCLOUD_TOKEN=$(tr -d ' \t\n\r' < "$f" 2>/dev/null || true)
77        [ -n "$HCLOUD_TOKEN" ] && { log "token recuperado de $f (env vacío)"; break; }
78    done
79fi
80export HCLOUD_TOKEN
81
82# ¿PUEDE este worker borrarse? (token que VE su propio id en la API). firing ≠ can-delete: `farm-up`
83# usa esto para no dejar vivo un worker que no puede matarse, en vez de descubrirlo idle 3.5h tarde.
84if [ "${1:-}" = "--puede-borrar" ]; then
85    if [ -z "$HCLOUD_TOKEN" ]; then echo "NO: sin token en ninguna ubicación conocida"; exit 1; fi
86    id=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
87         "https://api.hetzner.cloud/v1/servers?name=$(hostname)" 2>/dev/null \
88         | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
89    if [ -n "$id" ]; then echo "SÍ: puedo borrar mi id=$id"; exit 0
90    else echo "NO: el token no resuelve mi propio id (¿nombre '$(hostname)' no está en el proyecto?)"; exit 1; fi
91fi
92
93if [ "${1:-}" = "--verificar" ]; then
94    echo "══ ¿el dead-man switch está REALMENTE armado?"
95    echo "── timer:"; systemctl is-active takana-deadman.timer 2>/dev/null || echo "   INACTIVO ⚠"
96    systemctl list-timers takana-deadman.timer --no-pager 2>/dev/null | head -3
97    echo "── ticks idle acumulados: $(cat "$ESTADO" 2>/dev/null || echo 0) / $IDLE_MAX_TICKS"
98    echo "── últimas líneas del log (evidencia de que DISPARA):"
99    tail -5 "$LOG" 2>/dev/null | sed 's/^/   /' || echo "   (sin log todavía ⇒ NUNCA disparó)"
100    exit 0
101fi
102
103hay_trabajo() {
104    # `takana build` cubre TODO el build (fetch/vendor/compile) porque el proceso vive toda la
105    # invocación. `campana-deuda` = campaña deliberada. NO se cuenta `farm-worker-loop`: ese loop
106    # está SIEMPRE vivo (idle-loopea con la cola vacía) ⇒ contarlo como trabajo hacía que el worker
107    # NUNCA acumulara ticks y NUNCA se matara — un worker con cola seca quedaba idle para siempre
108    # (incidente 2026-07-23, 2ª vez). El loop construyendo YA se ve por su `takana build` hijo.
109    # ⚠ LOS DOS NOMBRES, y acá el precio de equivocarse es un worker BORRADO a mitad de un build:
110    # tras el renombre hammer→takana esta sonda no reconocía `./target/release/takana build`, o sea
111    # que `hay_trabajo` decía NO con el worker compilando. En el LXC no llegó a morder (el dead-man
112    # sólo borra cajas hcloud, y su timer ni siquiera está activo allá), pero la próxima caja de
113    # pago sí. `hammer` sigue compilándose como alias ⇒ se aceptan los dos.
114    pgrep -f 'release/(takana|hammer).* build ' >/dev/null 2>&1 && { echo "takana build en vuelo"; return 0; }
115    pgrep -f 'campana-deuda' >/dev/null 2>&1 && { echo "campaña deliberada en vuelo"; return 0; }
116    # UNA TRANSFERENCIA TAMBIÉN ES TRABAJO (2026-08-09). Poblar el volumen con el store del hub son
117    # horas de rsync durante las cuales no corre ningún `takana build` ⇒ el worker acumulaba ticks y
118    # se borraba A SÍ MISMO a mitad de la copia. El volumen sobrevive, pero la transferencia muere y
119    # hay que reanudarla a mano; con un enlace lento eso puede no converger nunca.
120    # `pgrep -x` (nombre EXACTO del proceso), no `pgrep -f`: `-f` mira la línea de órdenes completa y
121    # se auto-matchea con el propio dead-man si su ruta contiene la cadena. Ese error ya nos hizo
122    # informar cuatro veces procesos «vivos» que estaban muertos.
123    pgrep -x rsync >/dev/null 2>&1 && { echo "transferencia rsync en vuelo"; return 0; }
124    if [ -f "$HEARTBEAT" ]; then
125        edad=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) ))
126        [ "$edad" -lt "$HEARTBEAT_MAX_AGE" ] && { echo "heartbeat fresco (${edad}s)"; return 0; }
127    fi
128    return 1
129}
130
131if razon=$(hay_trabajo); then
132    # Trabajo ⇒ la cuenta vuelve a cero. Borrar es por INACTIVIDAD CONTINUA, no por antigüedad.
133    [ -f "$ESTADO" ] && [ "$(cat "$ESTADO")" != "0" ] && log "trabajo ($razon) ⇒ ticks a 0"
134    echo 0 > "$ESTADO"
135    exit 0
136fi
137
138ticks=$(( $(cat "$ESTADO" 2>/dev/null || echo 0) + 1 ))
139echo "$ticks" > "$ESTADO"
140log "sin trabajo: tick $ticks/$IDLE_MAX_TICKS"
141
142[ "$ticks" -lt "$IDLE_MAX_TICKS" ] && exit 0
143
144# ── Punto de no retorno.
145self="$(hostname)"
146
147# ── BLINDAJE (2026-07-17, a petición explícita del usuario). gioser.net es un server FIJO, en el
148# MISMO proyecto hcloud, y NO TIENE BACKUP. Hetzner no da tokens por-recurso: el token que este
149# worker necesita para auto-borrarse puede borrar CUALQUIER server del proyecto. Dos capas, porque
150# una sola no basta cuando el fallo es irreversible:
151#
152#   1. LISTA NEGRA por nombre — explícita, legible, imposible de malinterpretar.
153#   2. LABEL role=takana-worker — el switch sólo se borra si ÉL MISMO es un worker de la granja.
154#      gioser no tiene labels (map[]), así que jamás pasa este filtro ni por accidente.
155#
156# La capa 2 es la fuerte: no depende de acordarse de añadir nombres a una lista. Un server que no
157# nació de farm-up NO se borra, punto — que es exactamente la propiedad que quiero.
158case "$self" in
159    gioser|gioser.net|*gioser*)
160        log "ABORTA: '$self' está en la LISTA NEGRA (server fijo sin backup). NO me borro."
161        exit 0 ;;
162esac
163
164if [ -n "${HCLOUD_TOKEN:-}" ]; then
165    labels=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
166             "https://api.hetzner.cloud/v1/servers?name=$self" 2>/dev/null | grep -o 'hammer-worker' | head -1)
167    if [ "$labels" != "hammer-worker" ]; then
168        log "ABORTA: '$self' NO tiene label role=hammer-worker ⇒ no nació de la granja."
169        log "        Un server que no es worker NO se borra, aunque esté idle. (gioser vive acá.)"
170        exit 0
171    fi
172fi
173
174log "IDLE $ticks ticks ⇒ borrando $self (worker verificado por label)"
175
176# CERRAR LA PUERTA AL SALIR. No es "guardar" — lo generado YA está en el volumen (el store vive
177# ahí). Es dejar el ext4 consistente para que el `collect` del hub lo monte limpio. Sin guarda de
178# "cosecha pendiente" A PROPÓSITO: esa guarda dejaba al worker vivo justo cuando había trabajo que
179# salvar, que es el bug original con otra cara. La automuerte es TOTAL.
180if mountpoint -q /mnt/cosecha 2>/dev/null; then
181    art=$(ls -d /mnt/cosecha/store/*/ 2>/dev/null | wc -l)
182    log "volumen: $art artefactos ya persistidos; sync+umount antes de morir"
183    sync
184    # -l (lazy): si algo aún tiene el FS abierto, no queremos bloquear la muerte. Los datos ya
185    # están en disco por el sync; el volumen se detacha solo al borrarse el server.
186    umount /mnt/cosecha 2>/dev/null || umount -l /mnt/cosecha 2>/dev/null || \
187        log "aviso: umount falló; el sync ya persistió los datos"
188else
189    log "AVISO: /mnt/cosecha NO montado ⇒ este worker no tenía volumen. Lo que haya en su store"
190    log "       LOCAL se pierde al borrarlo. Nació por un camino sin volumen (¿farm-up viejo?)."
191fi
192
193if [ -z "${HCLOUD_TOKEN:-}" ]; then
194    log "ERROR: sin HCLOUD_TOKEN ⇒ NO puedo borrarme. Un poweroff NO ahorra (Hetzner cobra igual)."
195    exit 1
196fi
197
198id=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
199     "https://api.hetzner.cloud/v1/servers?name=$self" 2>/dev/null \
200     | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
201if [ -z "$id" ]; then
202    log "ERROR: no pude resolver mi propio id de server (¿nombre '$self' no está en el proyecto?)"
203    exit 1
204fi
205log "self id=$id ⇒ DELETE"
206curl -sS -X DELETE -H "Authorization: Bearer $HCLOUD_TOKEN" \
207     "https://api.hetzner.cloud/v1/servers/$id" >> "$LOG" 2>&1
208log "DELETE enviado; si seguís leyendo esto, falló"