Primera tanda de los ADR 0017/0018, con las mediciones que la corrigieron. ADR 0018 §3 — install-image-efi.sh y takana-live-install.sh escriben el kernel en \EFI\takana\takanax64.efi ADEMAS de la ruta fallback, con un guardián de capacidad que comprueba ANTES y con los números a la vista (duplicar el kernel cuesta, y el costo se dice). En el live-install la verificación es por TAMAÑO, no por presencia: un cp truncado en FAT32 deja el fichero ahí y test -e diría que todo salió bien. Verificado con imagen real en OVMF: las dos copias dan el mismo sha256 que el bzImage del store, y la imagen arranca hasta arje-zero PID1. CONTROL NEGATIVO: pisando la fallback con 4K de basura el firmware dice "No bootable option or device was found" y no aparece HAMMER-EFI ni una vez ⇒ el escenario de Windows, reproducido. Y lo que NO se pudo verificar cambia el alcance, así que va en el ADR: sin entrada NVRAM el firmware NO busca el vendor path. La copia vendor es hoy un seguro que no se cobra solo — el §3 es necesario y NO suficiente sin el §1. Eso asciende la receta efibootmgr a bloqueante. ADR 0017 §4 — store-gc.sh suma como raíz el kernel EN EJECUCIÓN, identificado por .config byte a byte. Sin esto el artefacto del kernel vivo cae en "superados" cuando la receta se movió, y se borra: el sistema sigue andando perfecto hasta el día que hace falta volver atrás. Probado en los tres sentidos, incluido el control que TIENE que seguir condenado. Además, dos bugs preexistentes que aparecieron al ir a medir: - install-image-efi.sh abortaba con "ROOTFS sin /sbin/init" en TODO rootfs sano: /sbin/init es un symlink ABSOLUTO (→/usr/bin/arje-zero) y [ -e ] lo sigue contra la raíz del HOST. Un chequeo que validaba algo distinto de lo que creía validar. Arreglado resolviendo el destino dentro del rootfs. - (no arreglado, es del entorno) el cp -al del staging da EXDEV si ROOTFS y STAGE no están en el MISMO MOUNT — y el bind-mount del store cuenta como otro mount aunque sea el mismo /dev/sdb. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
226 lines
14 KiB
Bash
Executable File
226 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# store-gc.sh — recolector de basura del store content-addressed. `takana gc` no existe; esto es.
|
||
#
|
||
# ── EL PROBLEMA QUE RESUELVE ────────────────────────────────────────────────────────────────────
|
||
# El store acumula un artefacto por cada SELLADO, no uno por receta. Cuando una dep cambia, el
|
||
# ArtifactHash de todo lo que cuelga de ella cambia, y el sellado nuevo se suma al viejo en vez de
|
||
# reemplazarlo. Medido el 2026-08-05: 1996 artefactos para 1130 recetas — 12 copias de
|
||
# `libqalculate`, 9 de `qt6-qtdeclarative`, 8 de `gtk4`. 51G de los 74G eran versiones anteriores.
|
||
#
|
||
# ── EL CRITERIO, Y POR QUÉ TIENE DOS ESCALONES ──────────────────────────────────────────────────
|
||
# El conjunto VIVO es `takana hash` sobre TODAS las recetas de TODAS las colas: es la respuesta a
|
||
# «¿cuál es el hash vigente de esta receta HOY?», que el store solo no puede dar. Lo que no está en
|
||
# ese conjunto es RANCIO — pero rancio se parte en dos cosas muy distintas:
|
||
#
|
||
# SUPERADO — su nombre de receta TIENE un artefacto vigente presente en el store. Es una versión
|
||
# anterior de algo que ya está sellado al día. Borrarlo no pierde nada: si hiciera
|
||
# falta, se reconstruye desde la receta, que está en git.
|
||
#
|
||
# HUÉRFANO — ningún artefacto con ese nombre es el hash vigente. O sea que el hash de HOY no está
|
||
# sellado y éste es el ÚNICO ejemplar que existe. Borrarlo sí pierde: es la diferencia
|
||
# entre «reconstruible en 2 minutos» y «hay que rehacer la torre». En la medición del
|
||
# 2026-08-05 eran 255 artefactos (17G) y casi todos KDE (`kio`×8, `kparts`×8,
|
||
# `kcmutils`×8) — o sea que las recetas KDE se movieron después del último sellado y el
|
||
# escritorio hidratado vive de artefactos que ya no son vigentes.
|
||
#
|
||
# Por eso el default borra SÓLO los superados. `--huerfanos` existe para cuando lo que se quiera sea
|
||
# exactamente eso, y hay que quererlo a propósito.
|
||
#
|
||
# ⚠ EL ESPACIO NO SIEMPRE SE LIBERA. Los rootfs hidratados (`work/*-rootfs`) son HARDLINKS al store.
|
||
# Borrar el directorio del store no rompe el rootfs —los datos siguen vivos por el otro enlace— pero
|
||
# tampoco libera el bloque hasta que el rootfs también se vaya. Es una propiedad, no un fallo.
|
||
#
|
||
# ⚠ NO BARRE `store-rust/` NI `store-kern/`: son stores aparte (frentes selfhost y kernel) con sus
|
||
# propias recetas. Sus `.hammer` son manifiestos de artefacto, no recetas de entrada.
|
||
#
|
||
# Uso: scripts/store-gc.sh [--aplicar] [--huerfanos]
|
||
# (sin --aplicar es un DRY-RUN: mide y no borra)
|
||
# Env: STORE (def ./store) HAMMER (def ./target/release/takana) JOBS (def 8)
|
||
set -uo pipefail
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
|
||
STORE="${STORE:-./store}"; HAMMER="${TAKANA:-${HAMMER:-./target/release/takana}}"; JOBS="${JOBS:-8}"
|
||
APLICAR=0; HUERFANOS=0
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
--aplicar) APLICAR=1 ;;
|
||
--huerfanos) HUERFANOS=1 ;;
|
||
*) echo "opción desconocida: $a" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||
|
||
# ── 1. el conjunto VIVO ─────────────────────────────────────────────────────────────────────────
|
||
# Todas las colas, más las recetas puestas en escena en `tandas/`. Las de tandas suelen no resolver
|
||
# sus deps (la resolución es hermano→padre y viven fuera de `recipes/`) y su hash falla; se incluyen
|
||
# igual porque las que SÍ resuelven son vivas de pleno derecho y omitirlas las volvería rancias.
|
||
{ ls recipes/*.toml recipes/incoming-*/*.toml 2>/dev/null
|
||
find tandas -name '*.toml' 2>/dev/null | xargs grep -l '^\[source\]' 2>/dev/null
|
||
} | sort -u > "$TMP/recetas.txt"
|
||
echo "==> recetas barridas: $(wc -l < "$TMP/recetas.txt")"
|
||
|
||
xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" 2>/dev/null | tail -1" < "$TMP/recetas.txt" \
|
||
| grep '^b3:' | sed 's/^b3://' | sort -u > "$TMP/vivos.txt"
|
||
echo " hashes vigentes calculables: $(wc -l < "$TMP/vivos.txt")"
|
||
|
||
# Cuántas NO dieron hash. No es fatal (ver arriba) pero es el número que dice cuánta confianza tiene
|
||
# la clasificación: si sube mucho, algo se rompió en la resolución de deps y NO hay que borrar.
|
||
FALLOS=$(( $(wc -l < "$TMP/recetas.txt") - $(
|
||
xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" >/dev/null 2>&1 && echo ok" < "$TMP/recetas.txt" | wc -l) ))
|
||
echo " recetas cuyo hash FALLA: $FALLOS"
|
||
|
||
# ── 2. clasificar el store ──────────────────────────────────────────────────────────────────────
|
||
ls "$STORE" | grep -E '^[0-9a-f]{64}-' > "$TMP/dirs.txt"
|
||
python3 - "$TMP" <<'PY'
|
||
import sys, os
|
||
t = sys.argv[1]
|
||
vivos = set(open(f"{t}/vivos.txt").read().split())
|
||
dirs = [l.strip() for l in open(f"{t}/dirs.txt") if l.strip()]
|
||
part = lambda d: (d.split('-', 1)[0], d.split('-', 1)[1])
|
||
nombres_vivos = {n for h, n in map(part, dirs) if h in vivos}
|
||
sup, hue, viv = [], [], []
|
||
for d in dirs:
|
||
h, n = part(d)
|
||
(viv if h in vivos else (sup if n in nombres_vivos else hue)).append(d)
|
||
for f, xs in (("vigentes", viv), ("superados", sup), ("huerfanos", hue)):
|
||
open(f"{t}/{f}.txt", "w").write("".join(x + "\n" for x in xs))
|
||
print(f" artefactos: {len(dirs)} · vigentes {len(viv)} · superados {len(sup)} · huérfanos {len(hue)}")
|
||
PY
|
||
|
||
# ── 2 bis. RAÍZ EXTRA: el kernel EN EJECUCIÓN (ADR 0017 §4) ─────────────────────────────────────
|
||
# El conjunto vivo de arriba sale de `takana hash` sobre las recetas: es «el hash VIGENTE de hoy».
|
||
# El kernel que la máquina está CORRIENDO no tiene por qué ser ese. Si la receta se movió después
|
||
# del último sellado, el artefacto del kernel vivo queda clasificado SUPERADO y el default lo borra.
|
||
#
|
||
# Y se pierde en silencio: el sistema sigue andando perfecto —el kernel ya está en RAM— hasta el día
|
||
# que hace falta volver atrás, y ese día el artefacto al que volver no existe. Es el mismo patrón
|
||
# que ya costó caro en el CAS: lo que no es alcanzable desde una raíz desaparece sin avisar.
|
||
#
|
||
# Cómo se identifica hoy, que es lo que se puede: por el `.config`. El kernel vivo expone el suyo en
|
||
# /proc/config.gz (o /boot/config-$(uname -r)), y cada artefacto de kernel guarda el suyo en
|
||
# boot/config-*. Si coinciden BYTE A BYTE, ese artefacto es el kernel que corre — o uno idéntico, y
|
||
# proteger de más es el lado correcto para un GC.
|
||
# ⚠ Es una identificación por CONTENIDO DEL CONFIG, no por hash del artefacto: dos artefactos con el
|
||
# mismo .config y distinta toolchain se protegen los dos. Cuando el hash del artefacto viaje en el
|
||
# kernel vivo (ADR 0017 §3) esto se vuelve exacto y esta función se simplifica.
|
||
python3 - "$TMP" "$STORE" <<'PYK'
|
||
import sys, os, gzip, hashlib
|
||
tmp, store = sys.argv[1], sys.argv[2]
|
||
|
||
def config_vivo():
|
||
try:
|
||
with gzip.open("/proc/config.gz", "rb") as f: return f.read()
|
||
except Exception: pass
|
||
try:
|
||
with open("/boot/config-" + os.uname().release, "rb") as f: return f.read()
|
||
except Exception: return None
|
||
|
||
cfg = config_vivo()
|
||
if cfg is None:
|
||
print(" kernel vivo: sin .config legible (ni /proc/config.gz ni /boot/config-<uname -r>)")
|
||
print(" ⇒ NO se protege ningún kernel por esta vía. Si vas a borrar en una máquina")
|
||
print(" que arranca desde este store, comprobalo a mano antes de --aplicar.")
|
||
raise SystemExit(0)
|
||
|
||
vivo = hashlib.sha256(cfg).hexdigest()
|
||
protegidos = []
|
||
for d in sorted(os.listdir(store)):
|
||
b = os.path.join(store, d, "boot")
|
||
if not os.path.isdir(b): continue
|
||
for f in os.listdir(b):
|
||
if not f.startswith("config-"): continue
|
||
try:
|
||
with open(os.path.join(b, f), "rb") as fh: h = hashlib.sha256(fh.read()).hexdigest()
|
||
except OSError: continue
|
||
if h == vivo: protegidos.append(d); break
|
||
|
||
if not protegidos:
|
||
print(f" kernel vivo: .config sha256 {vivo[:12]} — NINGUN artefacto del store lo tiene")
|
||
print(" (normal en el hub: acá corre un kernel ajeno, no uno de takana)")
|
||
raise SystemExit(0)
|
||
|
||
# Sacar los protegidos de superados/huérfanos y contarlos como vigentes.
|
||
movidos = 0
|
||
for nombre in ("superados", "huerfanos"):
|
||
ruta = f"{tmp}/{nombre}.txt"
|
||
xs = [l.strip() for l in open(ruta) if l.strip()]
|
||
quedan = [x for x in xs if x not in protegidos]
|
||
movidos += len(xs) - len(quedan)
|
||
open(ruta, "w").write("".join(x + "\n" for x in quedan))
|
||
with open(f"{tmp}/vigentes.txt", "a") as f:
|
||
for d in protegidos: f.write(d + "\n")
|
||
|
||
print(f" kernel vivo: .config sha256 {vivo[:12]} ⇒ {len(protegidos)} artefacto(s) RAIZ: {', '.join(protegidos)}")
|
||
if movidos:
|
||
print(f" !! {movidos} de ellos estaban marcados para BORRAR y se rescataron (ADR 0017 §4)")
|
||
PYK
|
||
|
||
espacio() { # du sobre una lista de dirs, tolerante a lista vacía
|
||
[ -s "$1" ] || { echo "0"; return; }
|
||
sed "s|^|$STORE/|" "$1" | tr '\n' '\0' | du -sch --files0-from=- 2>/dev/null | tail -1 | cut -f1
|
||
}
|
||
echo "==> espacio: superados $(espacio "$TMP/superados.txt") · huérfanos $(espacio "$TMP/huerfanos.txt")"
|
||
|
||
# ── 3. borrar ───────────────────────────────────────────────────────────────────────────────────
|
||
OBJETIVO="$TMP/superados.txt"
|
||
if [ "$HUERFANOS" = 1 ]; then
|
||
cat "$TMP/superados.txt" "$TMP/huerfanos.txt" > "$TMP/objetivo.txt"; OBJETIVO="$TMP/objetivo.txt"
|
||
echo "!! --huerfanos: se borran TAMBIÉN los únicos ejemplares de su receta"
|
||
fi
|
||
|
||
if [ "$APLICAR" != 1 ]; then
|
||
echo "==> DRY-RUN: no se borró nada. Repetí con --aplicar."
|
||
exit 0
|
||
fi
|
||
|
||
MANIFIESTO="work/store-gc-$(date +%Y-%m-%dT%H%M%S).txt"
|
||
mkdir -p work; cp "$OBJETIVO" "$MANIFIESTO"
|
||
echo "==> manifiesto de lo borrado: $MANIFIESTO"
|
||
ANTES=$(df -h /home 2>/dev/null | tail -1 | awk '{print $4}')
|
||
sed "s|^|$STORE/|" "$OBJETIVO" | xargs rm -rf
|
||
|
||
# ── GUARDIÁN: NO reportar «borrado» sin comprobarlo ─────────────────────────────────────────────
|
||
# 2026-08-07: este script informó «364 artefactos borrados · libres: 24G → 48G» y NO había borrado
|
||
# NINGUNO — los 364 del manifiesto seguían enteros en el store, y los 24G que se liberaron esa vez
|
||
# vinieron de otro lado. `xargs rm -rf` salió con 0 y el `echo` de abajo se lo creyó. (Se reprodujo:
|
||
# corriendo el script en SEGUNDO PLANO el borrado no se materializa; en primer plano sí. Da igual la
|
||
# causa: un `rm` que devuelve 0 no es prueba de que el fichero se fue.)
|
||
#
|
||
# Consecuencia real: la válvula de escape del disco estaba rota EN SILENCIO, y encima reportaba éxito,
|
||
# que es peor que fallar. El disco llegó al 98%.
|
||
#
|
||
# Por eso ahora se RECUENTA sobre el filesystem y el script sale distinto de cero si sobrevivió algo.
|
||
QUEDAN=0
|
||
while IFS= read -r l; do [ -e "$STORE/$l" ] && QUEDAN=$((QUEDAN+1)); done < "$OBJETIVO"
|
||
PEDIDOS=$(wc -l < "$OBJETIVO")
|
||
AHORA=$(df -h /home 2>/dev/null | tail -1 | awk '{print $4}')
|
||
if [ "$QUEDAN" -gt 0 ]; then
|
||
echo "!! BORRADO INCOMPLETO: $QUEDAN de $PEDIDOS artefactos SIGUEN en $STORE."
|
||
echo "!! No te fíes del espacio libre de abajo. Reintentá en PRIMER PLANO y volvé a verificar."
|
||
echo "==> libres: $ANTES → $AHORA"
|
||
exit 1
|
||
fi
|
||
# ── REGISTRO ACUMULADO, para que lo podado NO VUELVA ───────────────────────────────────────────
|
||
# 2026-08-07: se podaron 362 artefactos (24 G) por la mañana y por la tarde volvieron a aparecer LOS
|
||
# MISMOS 362 — solapamiento del 100%. La causa no estaba acá sino en `farm/farm-sync.sh`, que bajaba
|
||
# el store del worker entero; el worker no se poda, así que nos devolvía lo borrado en cada cosecha.
|
||
# Podar → cosechar → vuelven. Este registro es la memoria que le faltaba al sistema: `farm-sync` lo
|
||
# usa como `--exclude-from`. Sin él, el gc es una rueda de hámster que informa éxito cada vez.
|
||
# ⚠ RUTA ABSOLUTA Y `touch` PREVIO. El 2026-08-21 esta línea murió con
|
||
# «work/store-gc-superados.txt: No such file or directory» DESPUÉS de haber borrado ya los 256
|
||
# artefactos — o sea que la poda ocurrió y su registro NO se escribió, que es la combinación exacta
|
||
# que reabre el bucle churn: `farm-sync` no tiene qué excluir y la cosecha los devuelve. El script
|
||
# hace `cd "$ROOT"` al arrancar, así que la ruta relativa DEBERÍA valer; que fallara igual es razón
|
||
# de sobra para no depender de ello en el paso que evita repetir el trabajo de una tanda entera.
|
||
LEDGER="$ROOT/work/store-gc-superados.txt"
|
||
mkdir -p "$ROOT/work" && touch "$LEDGER"
|
||
if cat "$OBJETIVO" "$LEDGER" 2>/dev/null | sort -u > "$LEDGER.tmp" && mv "$LEDGER.tmp" "$LEDGER"; then
|
||
echo "==> registro acumulado: $(wc -l < "$LEDGER") artefactos que NO deben volver ($LEDGER)"
|
||
else
|
||
# Ruidoso a propósito: sin ledger la poda es una rueda de hámster que informa éxito cada vez.
|
||
echo "!! NO pude escribir el registro $LEDGER — los $PEDIDOS borrados VOLVERÁN en la próxima" >&2
|
||
echo "!! cosecha. Reconstruilo con: sort -u $MANIFIESTO > $LEDGER" >&2
|
||
exit 1
|
||
fi
|
||
echo "==> $PEDIDOS artefactos borrados y VERIFICADOS (0 sobrevivientes) · libres: $ANTES → $AHORA"
|