`rsync` (404 de samba.org) y `musl` (musl.libc.org no responde) no se pueden construir hoy, y no por
culpa nuestra. Es el estado estacionario: una distro que construye TODO desde fuente tiene tantos
puntos de fallo como fuentes, y son servidores de terceros que nadie nos prometió mantener.
LA MEDIDA, peor de lo que parecía. Sobre 1167 fuentes (561 tarball + 606 git, 79 hosts):
github.com sostiene 742 — el 64% del corpus depende de UN host. Doce hosts sostienen el 89%. Y 43
hosts sostienen exactamente UNA receta cada uno: ahí es donde muerde el bit-rot lento.
El vigía, en su primera corrida: 8 URLs muertas de 1167. Tres de ellas —busybox, freetype,
freetype-shared— están SELLADAS Y EN USO: son el shell y las fuentes del escritorio que se capturó
hoy. Se salvan sólo porque el tarball sigue en la caché local de esta máquina.
LA URL NUNCA FUE LA IDENTIDAD, y el código ya lo sabía: `hash_inputs` usa `tarball:{sha256}` /
`git:{commit}` y el `..` descarta la URL; la caché se nombra `{sha256}.tar` con un comentario que
dice literalmente que cambiar de mirror no la invalida. Añadir mirrors NO re-hashea NADA. Faltaba el
mecanismo, no el diseño.
Orden: caché local → mirror propio → upstream. El mirror va ANTES, no como rescate: el sha256 se
verifica igual, así que no hay diferencia de contenido posible, y un mirror que sólo se usa cuando
upstream falla es un mirror que nadie prueba — se descubre roto el día que hace falta.
LO QUE HAY QUE HACER BIEN. Un mirror que sirve calladamente lo que upstream perdió convierte un
fallo ruidoso en silencio. Por eso construir y vigilar van SEPARADOS: `hammer build` nunca avisa
(sería ruido en 561 recetas), y `fuentes-vigia.sh` pide cabeceras, escribe
docs/state/fuentes-vigia.json y lo corre el latido. Sin ese contrapeso las URLs se mueren una a una
y el corpus queda irreconstruible con todo en verde — el mismo modo de fallo que dejó el grafo de
wlr 17 días anunciando un 121/121 falso.
⛔ PROHIBIDO cambiar el sha256 para "arreglar" una URL muerta. Es la tentación natural ante un 404 y
no arregla una descarga: cambia lo que la distro construye. Otro sha256 es otro contenido, y la
receta seguiría diciendo `rsync 3.4.4` mientras construye otra cosa.
VERIFICADO DE PUNTA A PUNTA. El primer intento —construir busybox con el mirror puesto— dijo BUILD
OK y NO PROBÓ NADA: cache-hit del artefacto, cero bytes descargados. La prueba válida usa una receta
efímera con un sha256 que sí está en el mirror y una URL que ni resuelve por DNS. Sin HAMMER_MIRROR:
`curl (6) Could not resolve host`. Con él: sella, con el contenido real.
Mirror poblado: 126 objetos en el Storage Box que ya se paga. `cargo test -p hammer-build`: 5/5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
206 lines
14 KiB
Bash
Executable File
206 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# cosecha-cron.sh — el LATIDO de la granja: recoger + sembrar, en bucle, sin agente ni tokens.
|
||
#
|
||
# QUÉ HACE cada ciclo, por cada worker vivo en scripts/farm/.fleet:
|
||
# 1. SIEMBRA (sube): rsync de código + recetas laptop→worker (el laptop es canónico; si autoré
|
||
# recetas nuevas —p.ej. openrc— el worker las empieza a moler sin que yo intervenga).
|
||
# 2. RECOGE (baja): rsync del store sellado worker→laptop (CAS ⇒ merge seguro, sin conflicto).
|
||
# 3. Regenera el GRAFO DE ESTADO (build-state{,-kde,-gnome,-cosmic}.json) desde el store ya
|
||
# cosechado, y commitea SÓLO esos ficheros firmes. El `git log` de docs/state/ ES el avance
|
||
# que el humano ve y sigue (huecos, fallos, qué se selló entre dos ciclos).
|
||
#
|
||
# LO QUE **NO** HACE (a propósito): NO promueve incoming-kde→recipes/ canónico (esa es decisión de
|
||
# clasificación humana — "el hub clasifica", ver granja-promote-colisiones) ni firma el índice. Sólo
|
||
# captura al store del laptop y deja el veredicto para la mañana. Idempotente y re-corrible.
|
||
#
|
||
# INFINITO: vive en el crontab hasta que lo saques (`crontab -e`, borrar la línea cosecha-cron).
|
||
# Robusto a worker-ausente: si el dead-man ya mató al worker (cola seca ⇒ €0), el paso 1/2 falla
|
||
# suave, se loguea y igual regenera+commitea el estado local. NO relanza workers (eso gasta €;
|
||
# es decisión explícita con farm-up).
|
||
#
|
||
# Uso: scripts/farm/cosecha-cron.sh # un ciclo (lo que dispara el cron)
|
||
# */30 * * * * cd /home/sergio/hammer && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
|
||
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer), NO_COMMIT=1 (regenera pero no commitea)
|
||
set -uo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||
cd "$ROOT"
|
||
|
||
# LOCK (2026-07-22): el latido ya no viene sólo del crontab — `latido.sh` lo lanza desde la sesión
|
||
# porque el laptop arranca a veces con arje-zero y a veces con OpenRC, y cronie sólo existe en uno
|
||
# de los dos. Con dos disparadores posibles, dos ciclos pueden solaparse: ambos hacen rsync sobre el
|
||
# mismo store y, peor, `git commit`+`push` a la vez (index.lock, o un push que pisa al otro). El lock
|
||
# va ACÁ y no en el llamador para que valga venga de donde venga: cron, latido o a mano.
|
||
# -n = no esperar: si ya hay un ciclo corriendo, este sobra (el próximo tick recoge lo mismo).
|
||
LOCKFILE="${LOCKFILE:-$ROOT/work/.cosecha-cron.lock}"
|
||
mkdir -p "$(dirname "$LOCKFILE")"
|
||
exec 9>"$LOCKFILE"
|
||
if ! flock -n 9; then
|
||
echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)"
|
||
exit 0
|
||
fi
|
||
|
||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
|
||
REMOTE="${REMOTE:-/opt/hammer}"
|
||
FLEET="$ROOT/scripts/farm/.fleet"
|
||
HAMMER="${HAMMER:-$ROOT/target/release/hammer}"
|
||
# Workers efímeros reciclan IPs ⇒ no fijar known_hosts (hub-and-spoke, worker sin secretos).
|
||
SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=15"
|
||
|
||
ts() { date -u +%FT%TZ; }
|
||
echo "── $(ts) cosecha-cron arranca"
|
||
|
||
if [ ! -s "$FLEET" ]; then
|
||
echo " flota vacía (scripts/farm/.fleet) — nada que cosechar este ciclo"
|
||
else
|
||
while read -r name ip; do
|
||
[ -n "${name:-}" ] || continue
|
||
echo "==> $name ($ip)"
|
||
# 1. SIEMBRA: código + recetas laptop→worker (--delete: el laptop manda; nunca toca work/store/target).
|
||
if 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/" 2>&1 | tail -2; then
|
||
echo " siembra ✓"
|
||
else
|
||
echo " ⚠ siembra falló (worker ausente/idle-muerto?) — sigo con el siguiente"
|
||
continue
|
||
fi
|
||
# 2. RECOGE: store sellado worker→laptop (CAS, merge seguro).
|
||
# ── ⚠ EXCLUIR LO YA PODADO, O LA POda SE DESHACE SOLA ─────────────────────────────────────
|
||
# El worker NO se poda nunca, así que conserva los artefactos superados y este rsync los
|
||
# devolvía enteros. Con el latido cada 30 min, una poda de 24 G quedaba deshecha en menos de
|
||
# una hora: medido el 2026-08-07, tres podas seguidas borraron LOS MISMOS 362 artefactos.
|
||
#
|
||
# El mismo arreglo está en `farm-sync.sh` — y ahí estuvo el error de método: lo puse allí, di el
|
||
# bucle por cerrado, y la churn siguió porque **hay DOS rutas que bajan el store** y el latido
|
||
# usa ésta. Arreglar una de dos copias y declarar victoria es peor que no arreglar: el síntoma
|
||
# se atenúa lo justo para dejar de mirar. Si aparece una tercera copia, va con el mismo exclude.
|
||
#
|
||
# ── 2026-08-09: EL STORE YA NO BAJA. BAJA EL MANIFIESTO ───────────────────────────────────
|
||
# El store se mudó al VOLUMEN de la granja y el laptop se quedó con lo que la granja no puede
|
||
# rehacer. Seguir bajando el store acá desharía esa mudanza cada 30 minutos — la misma forma
|
||
# exacta del bucle de churn que este bloque documenta arriba, y la razón por la que el exclude
|
||
# de abajo dejó de alcanzar: ya no se trata de filtrar QUÉ baja, sino de que no baja.
|
||
#
|
||
# Lo que el hub sí necesita es SABER qué hay sellado, para que el grafo de estado no reporte
|
||
# `never` sobre artefactos que existen. Eso son kilobytes: la lista de nombres. Se acumula en
|
||
# el manifiesto (nunca se pisa) porque el volumen es una sola foto y el hub recuerda todas.
|
||
if $SSH "root@$ip" "ls $REMOTE/store 2>/dev/null | grep -E '^[0-9a-f]{64}-'" \
|
||
> "$ROOT/work/.sellados-worker" 2>/dev/null && [ -s "$ROOT/work/.sellados-worker" ]; then
|
||
N_W=$(wc -l < "$ROOT/work/.sellados-worker")
|
||
cat "$ROOT/work/farm-sellados.txt" "$ROOT/work/.sellados-worker" 2>/dev/null \
|
||
| grep -E '^[0-9a-f]{64}-' | sort -u > "$ROOT/work/farm-sellados.txt.new"
|
||
mv "$ROOT/work/farm-sellados.txt.new" "$ROOT/work/farm-sellados.txt"
|
||
rm -f "$ROOT/work/.sellados-worker"
|
||
echo " manifiesto ✓ (worker: $N_W · total: $(wc -l < "$ROOT/work/farm-sellados.txt"))"
|
||
else
|
||
echo " ⚠ no pude leer el manifiesto del worker — sigo"
|
||
fi
|
||
# ⚠ Lo que NO cubre esto: los artefactos nuevos quedan SÓLO en el volumen hasta que alguien
|
||
# haga una pasada de respaldo. El volumen tiene borrado protegido, pero es UNA copia.
|
||
done < "$FLEET"
|
||
fi
|
||
|
||
# 2.5 REAPER HUB-SIDE (2026-07-23). El dead-man DEL WORKER probó FRÁGIL: un worker quedó 3.5h idle
|
||
# quemando € porque su `/etc/hammer-deadman.env` no tenía token válido ⇒ el dead-man llegaba a
|
||
# matarse pero salía 1 "sin HCLOUD_TOKEN" cada tick. La garantía "un vps idle es inadmisible" NO
|
||
# puede depender de que cada worker se auto-provisione bien un token (falla en silencio). El HUB sí
|
||
# tiene un token que funciona, y este latido corre cada 30 min aunque no haya sesión (setsid) ⇒ el
|
||
# hub es la AUTORIDAD de vida del worker. Es defensa en profundidad: el dead-man del worker cubre
|
||
# "hub caído"; esto cubre "dead-man del worker roto". Un worker sin trabajo activo REAPER_MAX ciclos
|
||
# seguidos se BORRA desde acá, con el MISMO blindaje que el dead-man (label role=hammer-worker;
|
||
# gioser no tiene label ⇒ jamás pasa, ni por accidente).
|
||
REAPER_MAX="${REAPER_MAX:-2}" # 2 ciclos × 30 min ≈ 1 h idle, igual que el dead-man del worker
|
||
if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
|
||
mkdir -p "$ROOT/work/.reaper"; sobreviven=""
|
||
while read -r name ip; do
|
||
[ -n "${name:-}" ] || continue
|
||
# LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
|
||
# Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
|
||
# granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
|
||
case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue;; esac
|
||
strike_f="$ROOT/work/.reaper/$name"
|
||
# ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
|
||
# NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
|
||
if ! hcloud server describe "$name" -o format='{{.Name}}' >/dev/null 2>&1; then
|
||
echo "==> reaper: $name ya no existe en hcloud ⇒ lo saco de .fleet"; rm -f "$strike_f"; continue
|
||
fi
|
||
if $SSH root@"$ip" 'pgrep -f "hammer build|campana-deuda|farm-worker-loop" >/dev/null 2>&1'; then
|
||
echo 0 > "$strike_f"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue
|
||
fi
|
||
strikes=$(( $(cat "$strike_f" 2>/dev/null || echo 0) + 1 )); echo "$strikes" > "$strike_f"
|
||
echo "==> reaper: $name SIN trabajo activo — strike $strikes/$REAPER_MAX"
|
||
if [ "$strikes" -lt "$REAPER_MAX" ]; then sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue; fi
|
||
# BLINDAJE (igual que el dead-man): sólo se borra un server con label role=hammer-worker.
|
||
if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
|
||
echo " ☠ IDLE $strikes ciclos ⇒ el HUB borra $name (label verificado; gioser protegido)"
|
||
if hcloud server delete "$name" >/dev/null 2>&1; then rm -f "$strike_f"
|
||
else echo " ⚠ delete falló — sobrevive, reintenta próximo ciclo"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; fi
|
||
else
|
||
echo " ⛔ $name SIN label hammer-worker ⇒ NO se borra (protegido)"; sobreviven="${sobreviven}${name} ${ip}
|
||
"
|
||
fi
|
||
done < "$FLEET"
|
||
printf '%s' "$sobreviven" | grep -v '^[[:space:]]*$' > "$FLEET.tmp" 2>/dev/null; mv "$FLEET.tmp" "$FLEET" 2>/dev/null || true
|
||
fi
|
||
|
||
# 3. Regenerar el grafo de estado desde el store ya cosechado (usa el hammer del laptop, que tiene
|
||
# el subcomando `hash`). Barato (~14s cada uno). El corpus canónico y el frente KDE, por separado.
|
||
echo "==> regenerando grafo de estado"
|
||
if [ -x "$HAMMER" ]; then
|
||
scripts/build-state.py >/dev/null 2>&1 && echo " build-state.json ✓" || echo " ⚠ build-state.py falló"
|
||
scripts/build-state.py --kde >/dev/null 2>&1 && echo " build-state-kde.json ✓" || echo " ⚠ build-state.py --kde falló"
|
||
# GNOME faltaba acá (2026-07-27): el latido regeneraba base+KDE y NUNCA el grafo GNOME, así que
|
||
# build-state-gnome.json envejecía en silencio y reportaba `never` sobre recetas selladas hacía
|
||
# días (gobject-introspection, toda la onda 2). Un grafo viejo miente con la misma cara que uno
|
||
# fresco — por eso lo regenera el latido y no la mano.
|
||
scripts/build-state.py --gnome >/dev/null 2>&1 && echo " build-state-gnome.json ✓" || echo " ⚠ build-state.py --gnome falló"
|
||
# COSMIC (2026-08-03), por la MISMA razón por la que se agregó GNOME arriba: un frente que no
|
||
# regenera el latido envejece en silencio, y un grafo viejo miente con la misma cara que uno fresco.
|
||
scripts/build-state.py --cosmic >/dev/null 2>&1 && echo " build-state-cosmic.json ✓" || echo " ⚠ build-state.py --cosmic falló"
|
||
# wlr/sway (2026-08-26), TERCERA vez que cae el mismo cable. El flag `--wlr` se escribió el día que
|
||
# se abrió el frente, y su propio comentario en build-state.py dice «sin esto el frente es INVISIBLE
|
||
# para el khipu» — pero la línea nunca se agregó ACÁ. Resultado medido: build-state-wlr.json se
|
||
# quedó congelado el 2026-08-09 diciendo `escritorio-sway 121/121`, y 17 días después la realidad
|
||
# era 108/122: freetype, make, python3 y libpng-pic se re-hashearon en el medio y arrastraron a 14
|
||
# dependientes a deuda. Nadie lo vio porque el número que todo el mundo mira estaba perfecto.
|
||
# Un grafo viejo miente con la misma cara que uno fresco — y el que nadie regenera miente MÁS,
|
||
# porque envejece en la dirección optimista: sólo puede sobreestimar lo que hay sellado.
|
||
scripts/build-state.py --wlr >/dev/null 2>&1 && echo " build-state-wlr.json ✓" || echo " ⚠ build-state.py --wlr falló"
|
||
# Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
|
||
# ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
|
||
# lista escrita a mano. Barato (lee JSON, no hashea).
|
||
scripts/drenar.py --todos >/dev/null 2>&1 && echo " drenaje.json ✓" || echo " ⚠ drenar.py falló"
|
||
# VIGÍA DE FUENTES (ADR 0013 §3). Desde que el mirror propio se consulta ANTES que upstream,
|
||
# `hammer build` deja de avisar cuando una URL de terceros muere: sirve los bytes del mirror y
|
||
# sigue. Eso es lo correcto para construir y sería CIEGO sin este contrapeso — las URLs se irían
|
||
# muriendo una a una y el corpus resultaría irreconstruible el día que falte el mirror, con todo
|
||
# en verde hasta ese momento. Igual que el grafo de wlr, que pasó 17 días anunciando un 121/121
|
||
# que ya era falso: lo que nadie refresca envejece hacia el optimismo.
|
||
# No descarga: pide cabeceras. Primera corrida (2026-08-26): 8 muertas de 1167, y TRES de ellas
|
||
# —busybox, freetype, freetype-shared— selladas y en uso.
|
||
scripts/fuentes/fuentes-vigia.sh >/dev/null 2>&1 && echo " fuentes-vigia.json ✓" || echo " ⚠ fuentes-vigia falló"
|
||
else
|
||
echo " ⚠ sin binario hammer en $HAMMER — no regenero estado"
|
||
fi
|
||
|
||
# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
|
||
# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
|
||
if [ "${NO_COMMIT:-}" != "1" ]; then
|
||
git add docs/state/build-state.json docs/state/build-state-kde.json docs/state/build-state-gnome.json docs/state/build-state-cosmic.json docs/state/build-state-wlr.json docs/state/drenaje.json docs/state/fuentes-vigia.json 2>/dev/null || true
|
||
if ! git diff --cached --quiet 2>/dev/null; then
|
||
git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" 2>/dev/null \
|
||
&& { git push -q origin main 2>/dev/null && echo "==> estado commiteado+pusheado" \
|
||
|| echo "==> estado commiteado (push falló, reintenta próximo ciclo)"; }
|
||
else
|
||
echo "==> sin cambios de estado este ciclo"
|
||
fi
|
||
fi
|
||
echo "── $(ts) cosecha-cron fin"
|