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>
189 lines
13 KiB
Bash
Executable File
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"
|