El smoke de harvest-go.sh ejecutaba cada binario cosechado con el cwd en la RAÍZ DEL REPO, así que
un binario que escribe estado al arrancar dejaba basura entre las fuentes. Pasó: `gocron version`
—y `version` es justo el PRIMER flag que prueba el smoke, así que se disparaba siempre— sembró
config/{config.yaml,db.sqlite} (5 jobs de ejemplo + una sqlite) el 2026-07-04, y quedó sin trackear
hasta hoy. Medido, un flag a la vez, en cwd limpios:
[version] DEJO: ./config ./config/db.sqlite ./config/config.yaml
[--version] limpio [-v] limpio [--help] limpio [-h] limpio
Un `--help` no debería poder tocar el repo. El smoke ahora corre en un mktemp -d que se borra.
Vale para cualquier herramienta futura, no sólo gocron.
Sin regresión en el veredicto: en tmpdir `gocron version` panica (le falta web/index.html), el
smoke ya trata el panic (continue) y pasa a --version, que funciona ⇒ gocron sigue aprobando.
config/ borrado (no trackeado, sin una sola referencia en scripts/recetas/código).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
10 KiB
Bash
Executable File
184 lines
10 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? (hammer 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/hammer),
|
|
# 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="${HAMMER:-$ROOT/target/release/hammer}"
|
|
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…"
|
|
rsync -az -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, `hammer 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"
|