Files
hammer/scripts/farm/farm-up.sh
T
sergioandClaude Opus 5 889720cac0 granja: golden NUEVA con Rust y Go — desbloquea el 76% del catálogo
Imagen 417847948 («hammer-golden-rust-go-2026-08-08») reemplaza a 408909310 como default de
farm-up.

EL PROBLEMA QUE CIERRA: hammer invoca `cargo vendor` y el toolchain Go en el HOST, no dentro
del sandbox — el vendoreo pasa ANTES de entrar a la caja. La golden vieja no los traía, así que
la granja no podía construir NINGUNA receta Rust ni Go: COSMIC entero, las 228 Rust y las 362
Go del corpus. El 76% del catálogo dependía de UNA SOLA máquina, el hub — justo lo que el
respaldo de ayer intentaba dejar de asumir.

VERIFICADO ANTES DE SNAPSHOTEAR, no después: `cosmic-bg` —que minutos antes moría con
`spawn cargo vendor: No such file`— sella en el worker. Un snapshot de una máquina a medio
arreglar es peor que ninguno.

MISMAS VERSIONES QUE EL HUB (cargo/rustc 1.96.0, go 1.26.4) a propósito: introducir un skew de
toolchain nuevo mientras se arregla otro es cómo se fabrican los fallos que nadie reproduce. Y
1.96 es además el techo MSRV del sandbox ya documentado.

NO RELAJA LA HERMETICIDAD: cargo/go son herramientas de FETCH, del mismo orden que `git` para
clonar. El build sigue ocurriendo dentro del sandbox con el árbol ya vendoreado. Es lo contrario
del caso «no engordar el rootfs del worker», que habla del rootfs DEL SANDBOX.

El PATH queda persistido en /etc/profile.d/hammer-toolchain.sh para que sobreviva a los logins
no interactivos de la granja. Y la imagen trae el store del worker al momento del snapshot (KDE
y GNOME reconstruidos hoy), así que los workers nuevos arrancan con más caché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:33:57 -04:00

189 lines
13 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}"
# ── GOLDEN 417847948 (2026-08-08): la anterior NO TENÍA RUST NI GO ─────────────────────────────
# `hammer` invoca `cargo vendor` (y el toolchain Go) en el HOST, no dentro del sandbox: el vendoreo
# pasa ANTES de entrar a la caja. La golden vieja (408909310) no los traía, así que la granja no
# podía construir NINGUNA receta Rust ni Go — o sea COSMIC entero, las 228 Rust y las 362 Go del
# corpus: **el 76% del catálogo sólo se podía construir en el hub**, una sola máquina.
#
# Esta imagen añade cargo/rustc 1.96.0 y go 1.26.4, **las mismas versiones que el hub**, para no
# introducir un skew de toolchain nuevo mientras se arregla otro. Verificado antes de snapshotear:
# `cosmic-bg` —que minutos antes moría con `spawn cargo vendor: No such file`— sella en el worker.
#
# NO relaja la hermeticidad: cargo/go son herramientas de FETCH, del mismo orden que `git` para
# clonar. El build sigue ocurriendo dentro del sandbox con el árbol ya vendoreado. Es lo contrario
# del caso «no engordar el rootfs del worker», que habla del rootfs DEL SANDBOX.
#
# Trae además el store del worker al momento del snapshot (KDE y GNOME reconstruidos el 2026-08-08),
# así que los workers nuevos arrancan con más caché que antes.
IMAGE="${IMAGE:-417847948}"
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"