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

191 lines
11 KiB
Bash
Executable File

#!/bin/sh
# harvest-go.sh — COSECHA-ONLY automática del frente Go (modo finde desatendido). Determinista, sin
# IA: pensado para un cron en el laptop (el hub). NO elige categorías ni diagnostica fallos; sólo
# convierte en release firmado lo que el worker YA selló y que PASA un smoke-test del binario.
#
# Ciclo (idempotente, seguro de re-correr):
# 1. git pull --rebase (sincroniza con el otro agente que pushea al mismo main)
# 2. baja el store sellado del worker (rsync)
# 3. por cada receta en recipes/incoming-go/: ¿está sellada? (takana build con `timeout` ⇒ cache-hit
# instantáneo = sí; si se pasa del timeout sería un build local ⇒ se SALTA, no compilamos en el hub)
# 4. SMOKE-TEST del binario instalado: existe + ELF estático + corre version/--help sin panic/segfault.
# - PASA ⇒ promueve (mv recipes/, pack --build) — se firma+commitea al final.
# - FALLA ⇒ mueve a tandas/needs-review-weekend/ con el motivo (no reintenta cada cosecha).
# 5. si promovió algo: firma el repo 1 vez + git add EXPLÍCITO (nunca -A: respeta al otro agente) +
# commit + push (con pull --rebase de reintento).
# Todo se loguea a work/harvest-go.log para revisar el lunes.
#
# Uso (cron): */DohubVPS cd /home/sergio/hammer && scripts/farm/harvest-go.sh root@1.2.3.4 >>work/harvest-go.log 2>&1
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer), STORE (def ./store), REPO (def dist/repo),
# KEY (def dist/keys/release.ed25519), DISTRO (def dev), HAMMER (def target/release/takana),
# SEAL_TIMEOUT (def 60s: techo del cache-hit; más = sería build local ⇒ skip).
set -u
VPS="${1:?uso: harvest-go.sh user@host}"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
REMOTE="${REMOTE:-/opt/hammer}"
STORE="${STORE:-$ROOT/store}"
REPO="${REPO:-$ROOT/dist/repo}"
KEY="${KEY:-$ROOT/dist/keys/release.ed25519}"
DISTRO="${DISTRO:-dev}"
HAMMER="${TAKANA:-${HAMMER:-$ROOT/target/release/takana}}"
SEAL_TIMEOUT="${SEAL_TIMEOUT:-60}"
QUEUE="recipes/incoming-go"
REVIEW="tandas/needs-review-weekend"
SSH="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
# git push/pull a gitea (origin) usa la MISMA clave que el farm (github5 = clave "tawasuyu"). Fijarla
# explícita para que el cron funcione sin ssh-agent.
export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
STAMP() { date -u +%FT%TZ; }
# Lock: una sola cosecha a la vez (el cron no se solapa). STALE-AWARE: si el proceso dueño murió sin
# correr su trap (matado / teardown), el lock quedaría huérfano y BLOQUEARÍA todo cron futuro (pasó:
# un harvest viejo murió con el lock tomado → 16h de cron no-op). Por eso guardamos el PID y, si el
# dueño ya no vive, robamos el lock.
LOCK="$ROOT/work/.harvest-go.lock"
mkdir -p "$ROOT/work"
if ! mkdir "$LOCK" 2>/dev/null; then
oldpid=$(cat "$LOCK/pid" 2>/dev/null)
if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
echo "$(STAMP) harvest-go: ya hay una cosecha en curso (pid $oldpid) ⇒ salgo"; exit 0
fi
echo "$(STAMP) harvest-go: lock huérfano (pid ${oldpid:-?} muerto) ⇒ lo robo"
rm -rf "$LOCK"; mkdir "$LOCK" 2>/dev/null || { echo "$(STAMP) no pude tomar el lock"; exit 0; }
fi
echo "$$" > "$LOCK/pid"
trap 'rm -rf "$LOCK" 2>/dev/null' EXIT INT TERM
echo "=================================================================="
echo "$(STAMP) harvest-go: arranco (VPS=$VPS)"
# 0. GUARD: si la cola está vacía, no hay nada que cosechar ⇒ salgo ANTES del rsync del store (que ya
# es de varios GB y tardaría minutos en balde). El cron no-opea barato hasta que haya combustible nuevo.
ls "$QUEUE"/*.toml >/dev/null 2>&1 || { echo "$(STAMP) cola $QUEUE vacía ⇒ nada que cosechar, salgo"; exit 0; }
# 1. sincronizar con el otro agente (mismo main). --autostash por si quedó algo sin commitear.
git pull --rebase --autostash origin main 2>&1 | tail -2 || echo "$(STAMP) WARN: git pull falló (sigo)"
# 2. bajar el store sellado del worker.
echo "$(STAMP) bajando store del worker…"
# ⚠ `--exclude-from` del registro de podados: el worker NO se poda, así que devolvería los
# artefactos superados y desharía la poda (24 G por vuelta). Misma protección que en
# `cosecha-cron.sh`, `farm-sync.sh` y `store-gc.sh` — son CUATRO rutas que bajan el store del
# worker y la protección tiene que estar en las cuatro. Se descubrió tras arreglar sólo una y
# comprobar que la churn seguía.
LEDGER_GC="$(cd "$(dirname "$0")/../.." && pwd)/work/store-gc-superados.txt"
EXCL_GC=""; [ -s "$LEDGER_GC" ] && EXCL_GC="--exclude-from=$LEDGER_GC"
rsync -az $EXCL_GC -e "$SSH" "$VPS:$REMOTE/store/" "$STORE/" 2>&1 | tail -1 || { echo "$(STAMP) ERROR: rsync store falló"; exit 1; }
mkdir -p "$REVIEW"
promoted=""; rejected=""; skipped=""
# 3+4. por cada receta de la cola aislada Go.
for f in "$QUEUE"/*.toml; do
[ -e "$f" ] || { echo "$(STAMP) cola vacía"; break; }
n=$(basename "$f" .toml)
# El artefacto del store se llama por el `name` INTERNO de la receta, que puede diferir del nombre
# del fichero (prometheus-blackbox-exporter.toml → name="blackbox_exporter"); sin esto el pre-check
# nunca los ve y quedan "no-sellados" para siempre.
rn=$(sed -n 's/^name *= *"\(.*\)".*/\1/p' "$f" | head -1); [ -n "$rn" ] || rn="$n"
# Pre-check BARATO: ¿el worker selló algo para este nombre? (tras el rsync el store local lo tiene).
# Sin esto, `takana build` de una receta NO sellada ARRANCARÍA un build local (vendor+compile) y lo
# mataría el timeout ⇒ CPU del hub desperdiciada cada cosecha. Si no hay seal `*-<rn>`, salto ya.
ls "$STORE"/*-"$rn" >/dev/null 2>&1 || { skipped="$skipped $n"; continue; }
# ¿sellada con el hash ACTUAL? cache-hit instantáneo imprime el hash; un build real (hash distinto,
# raro) se pasa del timeout ⇒ skip (no compilamos en el hub).
H=$(timeout "$SEAL_TIMEOUT" "$HAMMER" --store "$STORE" build "$f" 2>/dev/null | tail -1)
case "$H" in
b3:*) : ;;
*) skipped="$skipped $n"; continue ;; # no sellada aún (o tardaría en compilar) ⇒ el worker sigue
esac
# NOMBRE ESPERADO del binario: el de la receta, o un alias conocido (pname nixpkgs ≠ binario).
case "$n" in
opentofu) want=tofu ;; minio-client) want=mc ;; natscli) want=nats ;; delve) want=dlv ;;
fluxcd) want=flux ;; chart-testing) want=ct ;; sealed-secrets|kubeseal) want=kubeseal ;;
prometheus-node-exporter|node_exporter) want=node_exporter ;;
prometheus-blackbox-exporter|blackbox_exporter) want=blackbox_exporter ;;
argo-rollouts) want=kubectl-argo-rollouts ;; step-cli) want=step ;; soft-serve) want=soft ;; go-mockery) want=mockery ;;
temporal-cli) want=temporal ;;
seaweedfs) want=weed ;; go-migrate) want=migrate ;; jsonnet-bundler) want=jb ;; fabric-ai) want=fabric ;;
crossplane-cli) want=crossplane ;;
*) want="$rn" ;; # def: el name interno de la receta (== binario en la vía Go auto)
esac
# SMOKE-TEST: el seal debe instalar un binario cuyo nombre == $want. Si instaló otro (un helper:
# functional-test, protoc-gen-buf-lint, gosecutil…), detect_go_main eligió mal el main ⇒ NO es la
# herramienta correcta aunque "corra" → a REVIEW (no firmamos un binario equivocado). Lección eksctl.
d="$STORE/${H#b3:}-$rn"
allbins=$(find "$d" -type f -path '*/bin/*' -perm -100 2>/dev/null)
bin=$(printf '%s\n' "$allbins" | while read -r b; do [ "$(basename "$b")" = "$want" ] && { echo "$b"; break; }; done)
if [ -z "$bin" ]; then
got=$(printf '%s\n' "$allbins" | sed 's#.*/##' | tr '\n' ',' | sed 's/,$//')
{ echo "# REVIEW $(STAMP): binario instalado='$got' ≠ esperado='$want' (detect_go_main eligió otro main); fijar flags=[\"./cmd/$want\"]"; cat "$f"; } > "$REVIEW/$n.toml"
rm -f "$f"; rejected="$rejected $n(bin=$got)"; continue
fi
# ELF estático (los del lab son musl estáticos; uno dinámico es sospechoso pero no lo rechazo solo por eso).
isstatic=$(file "$bin" 2>/dev/null | grep -c 'statically linked')
# corre sin panic/segfault: probamos varios flags benignos bajo timeout.
#
# CWD DESECHABLE: el smoke corría con el cwd en la RAÍZ DEL REPO, así que cualquier binario que
# escriba estado al arrancar lo dejaba commiteable entre las fuentes. Pasó de verdad: `gocron`
# sembró un `config/{config.yaml,db.sqlite}` (5 jobs de ejemplo + una sqlite) el 2026-07-04, que
# quedó de basura sin trackear hasta hoy. Un `--help` no debería poder tocar el repo: lo corremos
# en un tmpdir que se borra. Vale para cualquier herramienta futura, no sólo gocron.
smokedir=$(mktemp -d) || smokedir=/tmp
ran_ok=0; out=""
for sub in version --version -v --help -h; do
out=$(cd "$smokedir" && timeout 10 "$bin" $sub 2>&1); rc=$?
# panic de Go / segfault ⇒ malo; rc 124 = timeout (se colgó, p.ej. abrió server) ⇒ probamos otro flag.
case "$out" in *panic:*|*"runtime error"*|*SIGSEGV*|*"signal SIGABRT"*) continue ;; esac
[ "$rc" = 124 ] && continue
ran_ok=1; break
done
[ "$smokedir" = /tmp ] || rm -rf "$smokedir"
if [ "$ran_ok" = 1 ]; then
bn=$(basename "$bin")
echo "$(STAMP) ✓ smoke $n (bin=$bn static=$isstatic): ${out%%
*}"
mv "$f" "recipes/$n.toml"
# los patches VIAJAN con la receta (lección 4517a98, faltaba acá): pack los lee de recipes/
for p in $(sed -n 's/^patches *= *\[\(.*\)\]/\1/p' "recipes/$n.toml" | tr -d '",' ); do
[ -f "$QUEUE/$p" ] && cp -n "$QUEUE/$p" "recipes/$p"
done
if "$HAMMER" --store "$STORE" pack "recipes/$n.toml" --repo "$REPO" --distro-version "$DISTRO" --build >/dev/null 2>&1; then
promoted="$promoted $n"
else
echo "$(STAMP) ✗ pack falló para $n ⇒ a review"
mv "recipes/$n.toml" "$REVIEW/$n.toml"; rejected="$rejected $n(pack)"
fi
else
{ echo "# REVIEW $(STAMP): smoke-test falló (panic/segfault/cuelga). bin=$(basename "$bin") última salida:"; echo "$out" | sed 's/^/# /' | head -5; cat "$f"; } > "$REVIEW/$n.toml"
rm -f "$f"; rejected="$rejected $n(smoke)"
fi
done
echo "$(STAMP) promovidos:$promoted"
echo "$(STAMP) a-review:$rejected"
echo "$(STAMP) aún-no-sellados (worker sigue):$skipped"
# 5. firmar + commit + push sólo si hubo promociones o movimientos a review.
if [ -n "$promoted" ] || [ -n "$rejected" ]; then
[ -n "$promoted" ] && "$HAMMER" repo sign --repo "$REPO" --key "$KEY" >/dev/null 2>&1 && echo "$(STAMP) repo re-firmado"
# add EXPLÍCITO: jamás `git add -A` (otro agente comparte el working-tree).
git add "$QUEUE" "$REVIEW" 2>/dev/null
for n in $promoted; do git add "recipes/$n.toml" 2>/dev/null; done
if ! git diff --cached --quiet; then
git commit -q -m "Etapa G: cosecha-go finde (auto) — promovidos:$promoted | review:$rejected"
# push con reintento ante el push concurrente del otro agente.
for try in 1 2 3; do
git pull --rebase --autostash origin main >/dev/null 2>&1
if git push origin main >/dev/null 2>&1; then echo "$(STAMP) ✓ pusheado (intento $try)"; break; fi
echo "$(STAMP) push rechazado, reintento $try"; sleep 5
done
fi
fi
echo "$(STAMP) harvest-go: fin"