Files
takana/scripts/farm/farm-up.sh
T
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00

280 lines
19 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 'takana-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 417847948=takana-golden-rust-go-2026-08-08), 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 ─────────────────────────────
# `takana` 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"
# ── GUARDIÁN DEL VOLUMEN (2026-08-23) ─────────────────────────────────────────────────────────
# El chequeo de más abajo YA detectaba este fallo — y sólo imprimía un ⚠ mientras el script seguía
# adelante: armaba el dead-man y sembraba la cola igual. La noche del 2026-08-22 eso costó 21 G y
# 397 artefactos sellados. hworker-2 nació sin volumen (lo tenía hworker-1: un volumen de Hetzner se
# adjunta a UN server), construyó el shard 1/2 entero en su disco raíz y el dead-man se lo llevó a
# las 03:27Z. El aviso salió impreso, con estas mismas palabras, y no lo leyó nadie: el hub estaba
# esperando una respuesta humana que llegó después del borrado.
#
# La lección no es "avisar mejor": es que un aviso que no detiene el pipeline no es un guardián
# cuando nadie mira. Sembrar trabajo en un disco efímero es peor que no crear el worker, porque
# gasta horas de CPU y las tira. Ahora CORTA.
sin_volumen() {
local name="$1" motivo="$2"
echo " ✗ $name: $motivo" >&2
echo " Sin volumen, lo que selle vive en disco EFÍMERO y el dead-man se lo lleva entero." >&2
echo " NO lo siembro. Salidas:" >&2
echo " · un volumen POR worker: VOL_NAME=harkaq-cosecha-$name scripts/farm/farm-up.sh 1" >&2
echo " · cosechar y clausurar el actual: scripts/farm/harkaq-vol.sh collect (luego close)" >&2
echo " · worker deliberadamente desechable: SIN_VOLUMEN_OK=1 scripts/farm/farm-up.sh …" >&2
if [ "${SIN_VOLUMEN_OK:-0}" = "1" ]; then
echo " SIN_VOLUMEN_OK=1 ⇒ sigo igual: este worker construye a disco efímero." >&2
return 0
fi
# El server acaba de nacer y todavía no tiene nada dentro. Dejarlo vivo sería un idle facturando
# (es el caso hworker-4, 5 días), así que se borra — con el MISMO blindaje que el dead-man y
# farm-down: sólo si lleva el label role=takana-worker. gioser no lo lleva ⇒ jamás cae por acá.
if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
if hcloud server delete "$name" >/dev/null 2>&1; then
echo " $name borrado (recién creado, vacío): sin factura colgando." >&2
sed -i "/^$name /d" "$FLEET" 2>/dev/null || true
fi
fi
exit 1
}
# í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)…"
# ── EL MANIFIESTO VIAJA ACÁ TAMBIÉN, Y ESTE ERA EL AGUJERO ────────────────────────────────────
# `farm-sync.sh` ya mandaba `work/farm-sellados.txt` para que el worker no rehiciera lo que el hub
# tiene. Este fichero NO, así que un worker recién creado arrancaba con el manifiesto RANCIO que
# venía horneado en la golden. Medido el 2026-08-09: worker 1172 vs hub 1241 ⇒ el worker construía
# `vulkan-loader` en TODOS los ciclos aunque el hub ya lo tenía sellado, y encima fallaba.
#
# Es literalmente el error de método que este repo ya se documentó a sí mismo en `farm-sync.sh`:
# «lo puse allí, di el bucle por cerrado, y la churn siguió porque hay DOS rutas». Había dos rutas
# otra vez —`farm-sync` y `farm-up`— y sólo una estaba arreglada. Si aparece una tercera, va con
# el mismo include.
# ⚠ `--exclude /work` NO excluía el contenido, sólo la entrada. Con `--include '/work/'` delante,
# rsync entraba al directorio y sus hijos no casaban con ninguna regla ⇒ **se copiaba `work/`
# entero**. Medido el 2026-08-11 montando el primer worker con el lab anclado: llegaron 3,7 G de
# `work/`, incluidos los mirrors de git A MEDIO COPIAR (el rsync abortó antes de terminarlos), y
# el worker fallaba cada receta con `git fetch … exit 128`. Encima, con `--delete`, moría con
# «cannot delete non-empty directory» sobre los `vendor/` de cargo, que quedan de SÓLO LECTURA.
# `/work/**` sí ancla el contenido. Un `work/` a medias es peor que ninguno: parece que está.
set +e
rsync -az --delete -e "$SSH" \
--include '/work/' --include '/work/farm-sellados.txt' --exclude '/work/**' \
--exclude /store --exclude '/store-*' --exclude /target \
--exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
--exclude '*.png' --exclude '/content*' \
./ "root@$ip:$REMOTE/"
rc=$?
set -e
# NO abortamos si el rsync falla. Lo que viene después es el ARMADO DEL DEAD-MAN, y un `set -e`
# aquí dejaba un server VIVO SIN poder autodestruirse — que es justo lo que este script declara
# inadmisible. El orden ideal sería armar el dead-man ANTES de cualquier paso que pueda fallar;
# mientras eso no se reordene, esto garantiza al menos que se llegue a armarlo.
[ "$rc" -ne 0 ] && echo " ⚠ rsync de código salió $rc — el worker puede tener la cola incompleta; sigo para ARMAR EL DEAD-MAN"
# 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
# UN VOLUMEN SE ADJUNTA A UN SERVER, y este script crea N workers en un bucle apuntando todos al
# MISMO VOL_NAME ⇒ del segundo en adelante el attach no tiene nada que adjuntar. Antes ese fallo
# se perdía dos veces: `>/dev/null 2>&1` escondía el mensaje y `|| true` el código de salida.
# Se pregunta ANTES, y el motivo se dice con el nombre del server que lo tiene tomado.
dueno=$(hcloud volume list -o noheader -o columns=name,server 2>/dev/null | awk -v v="$VOL_NAME" '$1==v{print $2}')
if [ -n "$dueno" ] && [ "$dueno" != "-" ] && [ "$dueno" != "$name" ]; then
sin_volumen "$name" "el volumen $VOL_NAME ya está adjunto a $dueno (un volumen = un server)"
fi
if ! err=$(hcloud volume attach --server "$name" "$VOL_NAME" 2>&1); then
sin_volumen "$name" "no pude adjuntar $VOL_NAME: ${err:-error desconocido}"
fi
dev="/dev/disk/by-id/scsi-0HC_Volume_$vid"
$SSH root@"$ip" "mkdir -p /mnt/cosecha
# ⚠ El mkfs.ext4 -F de rescate SÓLO si el dispositivo no tiene filesystem. La versión previa
# formateaba ante CUALQUIER fallo de montaje —incluida la carrera normal entre volume attach
# y la aparición del dispositivo— o sea que un hipo de segundos destruía el volumen entero.
# blkid distingue «vacío» de «no pude montarlo ahora».
if ! mount $dev /mnt/cosecha 2>/dev/null; then
if blkid $dev >/dev/null 2>&1; then
echo " ✗ el volumen TIENE filesystem y no montó — NO lo formateo. Revisá a mano." >&2; exit 1
fi
mkfs.ext4 -q -F $dev && mount $dev /mnt/cosecha
fi
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.
# ⚠ mountpoint -q NO ES OPCIONAL — sin él este bloque BORRA EL VOLUMEN.
# La guarda original sólo comprobaba «es directorio y no es symlink». Eso basta en un server
# recién creado desde la golden ORIGINAL, donde /opt/hammer/store es un directorio normal.
# Pero al re-snapshotear un worker (2026-08-08) la imagen se llevó también la CONFIGURACIÓN DE
# MONTAJE: el server nuevo monta el volumen en /opt/hammer/store al arrancar, ANTES de que
# corra este rescate. Entonces rm -rf $REMOTE/store borra A TRAVÉS DEL MONTAJE y se lleva el
# store del volumen entero — que es justo lo que el volumen existe para proteger.
# Medido: el volumen pasó de ~900 artefactos a 2. No se perdió el filesystem (lost+found es de
# julio, 17 montajes): se borró el CONTENIDO.
# ⇒ Si ya es punto de montaje, no hay nada que rescatar ni que borrar: el store ya está donde
# tiene que estar.
if [ -d $REMOTE/store ] && [ ! -L $REMOTE/store ] && ! mountpoint -q $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é:
# takana 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 takana 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
sin_volumen "$name" "el store NO quedó anclado al volumen (mountpoint dice que no)"
fi
else
sin_volumen "$name" "no existe el volumen $VOL_NAME y no pude crearlo"
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"