Files
takana/scripts/farm/farm-up.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

173 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# farm-up.sh [N] — levanta N workers EFÍMEROS desde el snapshot golden y los alinea con la cola
# actual del laptop. Modelo hub-and-spoke: el worker NO tiene secretos (ni firma ni gitea); es
# compute puro y descartable. La imagen 'hammer-golden' trae el toolchain + el store sellado
# horneados ⇒ el worker arranca con TODO el catálogo cacheado (cache-hit instantáneo), sólo
# construye lo NUEVO que el hub le empuje.
#
# Flujo: scripts/farm/farm-up.sh 3 # crea hworker-1..3, los alimenta con la cola actual
# ... (muelen) ...
# scripts/farm/farm-down.sh # cosecha su store al laptop y los DESTRUYE
#
# La flota viva se registra en scripts/farm/.fleet ("<name> <ip>" por línea; gitignored).
# Idempotente al escalar: re-correr con más N agrega workers sin pisar los vivos.
#
# Env: IMAGE (snapshot, def 408909310=hammer-golden-harkaq-6.17-2026-07-15), TYPE (def ccx23),
# LOCATION
# (def hel1), SSHKEY (llave del proyecto Hetzner, def desarrollo@jlsoltech.com), SSH_KEY
# (privada local p/ SSH, def ~/.ssh/github5), REMOTE (def /opt/hammer).
set -euo pipefail
N="${1:-1}"
IMAGE="${IMAGE:-408909310}"
TYPE="${TYPE:-ccx23}"
LOCATION="${LOCATION:-hel1}"
SSHKEY="${SSHKEY:-desarrollo@jlsoltech.com}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
REMOTE="${REMOTE:-/opt/hammer}"
# MISMO volumen que harkaq-vol.sh, a propósito: eran DOS caminos de crear workers y sólo uno tenía
# volumen+automuerte. hworker-4 nació por ÉSTE (farm-up) y por eso quedó 5 días idle con 37G que
# nadie salvó — la protección existía y este camino la esquivaba. Compartir el volumen hace que
# `harkaq-vol.sh collect` y `close` cosechen y clausuren lo de AMBOS caminos.
VOL_NAME="${VOL_NAME:-harkaq-cosecha}"
VOL_SIZE="${VOL_SIZE:-100}" # GB. ~€0.044/GB/mes ⇒ 100G ≈ €4.4/mes MIENTRAS exista.
# El baseline €0 lo da `harkaq-vol.sh close`, que es
# tuyo: el volumen es el único que sobrevive a propósito.
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
FLEET="$ROOT/scripts/farm/.fleet"
# Workers efímeros RECICLAN IPs de Hetzner ⇒ un known_hosts persistente choca ("HOST KEY CHANGED")
# y accept-new RECHAZA la clave cambiada, colgando el `until ssh true` para siempre. Como el modelo
# es hub-and-spoke (el worker NO tiene secretos: compute puro descartable), saltamos la verificación
# de host y no ensuciamos known_hosts (/dev/null). No hay superficie MITM que proteger acá.
SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR -o ConnectTimeout=10"
cd "$ROOT"
touch "$FLEET"
# índice de arranque: el mayor hworker-N vivo + 1 (para escalar sin colisión de nombres)
base=$(sed -n 's/^hworker-\([0-9]*\) .*/\1/p' "$FLEET" | sort -n | tail -1)
base="${base:-0}"
for k in $(seq 1 "$N"); do
i=$((base + k)); name="hworker-$i"
echo "==> creando $name ($TYPE @ $LOCATION desde image $IMAGE)"
hcloud server create --name "$name" --image "$IMAGE" --type "$TYPE" \
--location "$LOCATION" --ssh-key "$SSHKEY" --label role=hammer-worker >/dev/null
ip=$(hcloud server ip "$name")
echo "$name $ip" >> "$FLEET"
echo " ip=$ip; esperando SSH…"
until $SSH root@"$ip" true 2>/dev/null; do sleep 3; done
echo " alineando cola con el laptop (rsync código + recipes)…"
rsync -az --delete -e "$SSH" \
--exclude /work --exclude /store --exclude '/store-*' --exclude /target \
--exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
--exclude '*.png' --exclude '/content*' \
./ "root@$ip:$REMOTE/"
# el servicio ya arrancó en el boot (baked/enabled); reiniciar fuerza rescan inmediato de la cola
$SSH root@"$ip" 'systemctl restart hammer-farm' 2>/dev/null || true
# VOLUMEN PERSISTENTE + STORE DENTRO. Sin esto la automuerte perdería lo construido: hworker-4
# tenía 438 artefactos (37G) SÓLO en su disco local. El store vive en /mnt/cosecha/store ⇒ cada
# artefacto está en el volumen persistente EN EL INSTANTE en que se sella. "Guardar lo generado"
# deja de ser un paso que puede fallar antes de morir y pasa a ser la estructura. El volumen
# sobrevive al server; se cosecha con harkaq-vol.sh collect y se clausura con close.
echo " montando volumen persistente $VOL_NAME y anclando el store dentro…"
vid=$(hcloud volume list -o noheader -o columns=id,name 2>/dev/null | awk -v v="$VOL_NAME" '$2==v{print $1}')
if [ -z "$vid" ]; then
hcloud volume create --name "$VOL_NAME" --size "$VOL_SIZE" --location "$LOCATION" --format ext4 >/dev/null 2>&1 || true
vid=$(hcloud volume list -o noheader -o columns=id,name 2>/dev/null | awk -v v="$VOL_NAME" '$2==v{print $1}')
fi
if [ -n "$vid" ]; then
hcloud volume attach --server "$name" "$VOL_NAME" >/dev/null 2>&1 || true
dev="/dev/disk/by-id/scsi-0HC_Volume_$vid"
$SSH root@"$ip" "mkdir -p /mnt/cosecha
mount $dev /mnt/cosecha 2>/dev/null || { mkfs.ext4 -q -F $dev && mount $dev /mnt/cosecha; }
mkdir -p /mnt/cosecha/store /mnt/cosecha/verdicts /mnt/cosecha/logs
# el store del worker ES el del volumen: sellar YA es persistir.
# PERO antes hay que RESCATAR el store HORNEADO en la golden (2026-07-21): esto hacía
# \`rm -rf $REMOTE/store\` y tiraba el catálogo cacheado que es la razón de ser de la imagen
# golden ('arranca con TODO el catálogo ⇒ cache-hit instantáneo'). Sin él, el worker mide
# deuda contra un store casi vacío y se pone a reconstruir media distro: en la tanda del
# 2026-07-21 dio 716 recetas de deuda donde el hub medía 37. Es CAS (nombre = hash) ⇒
# fusionar es seguro por construcción: \`mv -n\` no pisa lo que el volumen ya tiene.
if [ -d $REMOTE/store ] && [ ! -L $REMOTE/store ]; then
n=\$(ls $REMOTE/store 2>/dev/null | wc -l)
[ \"\$n\" -gt 0 ] && mv -n $REMOTE/store/* /mnt/cosecha/store/ 2>/dev/null
echo \" ✓ rescatados \$n artefactos horneados de la golden al volumen\"
rm -rf $REMOTE/store
fi
# BIND-MOUNT, NO SYMLINK (2026-07-22). Anclar el store con \`ln -s\` rompía TODA receta con
# \`zig_version\` en el worker, y el síntoma se leía como deuda de la cascada GUI. Por qué:
# hammer deriva el lab del PADRE DEL STORE (BuildConfig::defaults_for_store ⇒
# project_root = store_root.parent(), de ahí .dev-fs/{alpine,tools/zig,cache}). Con el symlink
# el store resuelve a /mnt/cosecha/store ⇒ busca /mnt/cosecha/.dev-fs, que no existe, y muere
# con 'no encuentro el ejecutable zig en …' apuntando al lugar equivocado. Un bind-mount deja
# los datos en el volumen igual, pero \$REMOTE/store sigue siendo una ruta REAL bajo
# \$REMOTE ⇒ el padre vuelve a ser /opt/hammer y .dev-fs se encuentra. Mantiene las dos
# invariantes a la vez: sellar = persistir, y el lab donde hammer lo espera.
if [ -L $REMOTE/store ]; then rm -f $REMOTE/store; fi
mkdir -p $REMOTE/store
mountpoint -q $REMOTE/store || mount --bind /mnt/cosecha/store $REMOTE/store
grep -q /mnt/cosecha /etc/fstab || echo '$dev /mnt/cosecha ext4 defaults,nofail 0 0' >> /etc/fstab
grep -q \"$REMOTE/store\" /etc/fstab || echo '/mnt/cosecha/store $REMOTE/store none bind,nofail 0 0' >> /etc/fstab" >/dev/null 2>&1
if $SSH root@"$ip" 'mountpoint -q /mnt/cosecha && mountpoint -q '"$REMOTE"'/store' 2>/dev/null; then
echo " ✓ store anclado al volumen ($( $SSH root@"$ip" 'ls -d /mnt/cosecha/store/*/ 2>/dev/null | wc -l') artefactos ya presentes)"
else
echo " ⚠ el store NO quedó en el volumen ⇒ lo que construya se PIERDE al auto-borrarse. Revisar."
fi
else
echo " ⚠ sin volumen ⇒ este worker construiría a disco local y la automuerte perdería todo."
fi
# DEAD-MAN SWITCH: el worker se borra SOLO tras 1h sin trabajo. Va acá, en el nacimiento, porque
# el modelo "efímero" NO puede depender de que alguien llame a farm-down: hworker-4 estuvo 5 días
# idle (2026-07-17) justo por eso — el agente que lo creó se fue y el server quedó. Un invariante
# que necesita que alguien se acuerde no es un invariante.
echo " armando dead-man switch (1h idle ⇒ auto-delete)…"
tok="${HCLOUD_TOKEN:-$(grep -shoE '^ *token *= *"?[A-Za-z0-9]+' "$HOME/.config/hcloud/cli.toml" 2>/dev/null | head -1 | sed 's/.*[= "]//')}"
if [ -z "$tok" ]; then
echo " ⚠ SIN TOKEN para el dead-man switch: este worker NO se auto-borrará."
echo " (un poweroff no sirve: Hetzner cobra igual con el server apagado)"
else
scp -q -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
scripts/farm/deadman.sh "root@$ip:/usr/local/bin/deadman.sh" 2>/dev/null
scp -q -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
scripts/farm/hammer-deadman.service scripts/farm/hammer-deadman.timer \
"root@$ip:/etc/systemd/system/" 2>/dev/null
$SSH root@"$ip" "chmod +x /usr/local/bin/deadman.sh
printf 'HCLOUD_TOKEN=%s\n' '$tok' > /etc/hammer-deadman.env; chmod 600 /etc/hammer-deadman.env
systemctl daemon-reload && systemctl enable --now hammer-deadman.timer" >/dev/null 2>&1
# EVIDENCIA, no fe: que el timer esté ARMADO se comprueba, no se asume (el cron de aquella vez
# estaba "validado" y nunca disparó porque el PID 1 no tenía crond).
# FIRING ≠ CAN-DELETE (2026-07-23): un worker quedó idle 3.5h quemando € porque su token no
# estaba donde la service lo lee ⇒ el dead-man DISPARABA pero salía 1 "sin HCLOUD_TOKEN" y nunca
# se borraba. Que el timer esté activo NO prueba que pueda matarse. El worker DEBE poder matarse
# SOLO, sin el hub — así que si NO PUEDE, no lo dejamos vivo: lo DESTRUIMOS acá mismo. Un worker
# que no se puede autodestruir es exactamente lo que el usuario prohíbe. `deadman.sh --puede-borrar`
# busca el token en cadena (env, volumen, /root) y prueba que ve su id en la API.
timer_ok=$($SSH root@"$ip" 'systemctl is-active hammer-deadman.timer' 2>/dev/null | grep -q active && echo 1)
puede=$($SSH root@"$ip" '/usr/local/bin/deadman.sh --puede-borrar' 2>/dev/null)
if [ -n "$timer_ok" ] && printf '%s' "$puede" | grep -q '^SÍ'; then
echo " ✓ dead-man ARMADO y PUEDE borrarse solo ($puede) — no depende del hub"
else
echo " ⛔ $name NO puede autodestruirse (timer=${timer_ok:-no} · $puede)."
echo " Un worker que no se mata solo es INADMISIBLE ⇒ lo DESTRUYO ahora para no dejarlo idle."
# LISTA NEGRA (capa 1 de 2): gioser JAMÁS se toca. farm-up sólo crea hworker-N, pero defensa
# en profundidad — "ahí está nuestra vida". Segunda capa = el label check de abajo.
case "$name" in *gioser*) echo " ⛔ $name en LISTA NEGRA ⇒ NO lo toco. Revisá a mano."; continue;; esac
if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
hcloud server delete "$name" >/dev/null 2>&1 && echo " ☠ $name destruido (label verificado)."
else
echo " ⛔ $name sin label hammer-worker ⇒ NO lo toco (protegido); borralo a mano."
fi
grep -v "^$name " "$FLEET" > "$FLEET.tmp" 2>/dev/null && mv "$FLEET.tmp" "$FLEET"
continue
fi
fi
echo " ✓ $name muele la cola actual"
done
sort -u -o "$FLEET" "$FLEET"
echo "==> flota viva:"; cat "$FLEET"
echo "cosechar+destruir con: scripts/farm/farm-down.sh"