gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ 98edf488bbffd2dae507a6317a78bb61d5a9c07a scripts/store-gc.sh Shell · 241 líneas · 14.6 KB Historia crudo

store-gc: `xargs -d` tampoco existe en busybox — el arreglo anterior imprimía «huérfanos ?» 1ca210a31 · Sergio ·

1#!/usr/bin/env bash
2# store-gc.sh — recolector de basura del store content-addressed. `takana gc` no existe; esto es.
3#
4# ── EL PROBLEMA QUE RESUELVE ────────────────────────────────────────────────────────────────────
5# El store acumula un artefacto por cada SELLADO, no uno por receta. Cuando una dep cambia, el
6# ArtifactHash de todo lo que cuelga de ella cambia, y el sellado nuevo se suma al viejo en vez de
7# reemplazarlo. Medido el 2026-08-05: 1996 artefactos para 1130 recetas — 12 copias de
8# `libqalculate`, 9 de `qt6-qtdeclarative`, 8 de `gtk4`. 51G de los 74G eran versiones anteriores.
9#
10# ── EL CRITERIO, Y POR QUÉ TIENE DOS ESCALONES ──────────────────────────────────────────────────
11# El conjunto VIVO es `takana hash` sobre TODAS las recetas de TODAS las colas: es la respuesta a
12# «¿cuál es el hash vigente de esta receta HOY?», que el store solo no puede dar. Lo que no está en
13# ese conjunto es RANCIO — pero rancio se parte en dos cosas muy distintas:
14#
15#   SUPERADO — su nombre de receta TIENE un artefacto vigente presente en el store. Es una versión
16#              anterior de algo que ya está sellado al día. Borrarlo no pierde nada: si hiciera
17#              falta, se reconstruye desde la receta, que está en git.
18#
19#   HUÉRFANO — ningún artefacto con ese nombre es el hash vigente. O sea que el hash de HOY no está
20#              sellado y éste es el ÚNICO ejemplar que existe. Borrarlo sí pierde: es la diferencia
21#              entre «reconstruible en 2 minutos» y «hay que rehacer la torre». En la medición del
22#              2026-08-05 eran 255 artefactos (17G) y casi todos KDE (`kio`×8, `kparts`×8,
23#              `kcmutils`×8) — o sea que las recetas KDE se movieron después del último sellado y el
24#              escritorio hidratado vive de artefactos que ya no son vigentes.
25#
26# Por eso el default borra SÓLO los superados. `--huerfanos` existe para cuando lo que se quiera sea
27# exactamente eso, y hay que quererlo a propósito.
28#
29# ⚠ EL ESPACIO NO SIEMPRE SE LIBERA. Los rootfs hidratados (`work/*-rootfs`) son HARDLINKS al store.
30# Borrar el directorio del store no rompe el rootfs —los datos siguen vivos por el otro enlace— pero
31# tampoco libera el bloque hasta que el rootfs también se vaya. Es una propiedad, no un fallo.
32#
33# ⚠ NO BARRE `store-rust/` NI `store-kern/`: son stores aparte (frentes selfhost y kernel) con sus
34# propias recetas. Sus `.hammer` son manifiestos de artefacto, no recetas de entrada.
35#
36# Uso:   scripts/store-gc.sh [--aplicar] [--huerfanos]
37#        (sin --aplicar es un DRY-RUN: mide y no borra)
38# Env:   STORE (def ./store)   HAMMER (def ./target/release/takana)   JOBS (def 8)
39set -uo pipefail
40ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
41STORE="${STORE:-./store}"; HAMMER="${TAKANA:-${HAMMER:-./target/release/takana}}"; JOBS="${JOBS:-8}"
42APLICAR=0; HUERFANOS=0
43for a in "$@"; do
44  case "$a" in
45    --aplicar) APLICAR=1 ;;
46    --huerfanos) HUERFANOS=1 ;;
47    *) echo "opción desconocida: $a" >&2; exit 2 ;;
48  esac
49done
50
51TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
52
53# ── 1. el conjunto VIVO ─────────────────────────────────────────────────────────────────────────
54# Todas las colas, más las recetas puestas en escena en `tandas/`. Las de tandas suelen no resolver
55# sus deps (la resolución es hermano→padre y viven fuera de `recipes/`) y su hash falla; se incluyen
56# igual porque las que SÍ resuelven son vivas de pleno derecho y omitirlas las volvería rancias.
57{ ls recipes/*.toml recipes/incoming-*/*.toml 2>/dev/null
58  find tandas -name '*.toml' 2>/dev/null | xargs grep -l '^\[source\]' 2>/dev/null
59} | sort -u > "$TMP/recetas.txt"
60echo "==> recetas barridas: $(wc -l < "$TMP/recetas.txt")"
61
62xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" 2>/dev/null | tail -1" < "$TMP/recetas.txt" \
63  | grep '^b3:' | sed 's/^b3://' | sort -u > "$TMP/vivos.txt"
64echo "    hashes vigentes calculables: $(wc -l < "$TMP/vivos.txt")"
65
66# Cuántas NO dieron hash. No es fatal (ver arriba) pero es el número que dice cuánta confianza tiene
67# la clasificación: si sube mucho, algo se rompió en la resolución de deps y NO hay que borrar.
68FALLOS=$(( $(wc -l < "$TMP/recetas.txt") - $(
69  xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" >/dev/null 2>&1 && echo ok" < "$TMP/recetas.txt" | wc -l) ))
70echo "    recetas cuyo hash FALLA: $FALLOS"
71
72# ── 2. clasificar el store ──────────────────────────────────────────────────────────────────────
73ls "$STORE" | grep -E '^[0-9a-f]{64}-' > "$TMP/dirs.txt"
74python3 - "$TMP" <<'PY'
75import sys, os
76t = sys.argv[1]
77vivos = set(open(f"{t}/vivos.txt").read().split())
78dirs = [l.strip() for l in open(f"{t}/dirs.txt") if l.strip()]
79part = lambda d: (d.split('-', 1)[0], d.split('-', 1)[1])
80nombres_vivos = {n for h, n in map(part, dirs) if h in vivos}
81sup, hue, viv = [], [], []
82for d in dirs:
83    h, n = part(d)
84    (viv if h in vivos else (sup if n in nombres_vivos else hue)).append(d)
85for f, xs in (("vigentes", viv), ("superados", sup), ("huerfanos", hue)):
86    open(f"{t}/{f}.txt", "w").write("".join(x + "\n" for x in xs))
87print(f"    artefactos: {len(dirs)} · vigentes {len(viv)} · superados {len(sup)} · huérfanos {len(hue)}")
88PY
89
90# ── 2 bis. RAÍZ EXTRA: el kernel EN EJECUCIÓN (ADR 0017 §4) ─────────────────────────────────────
91# El conjunto vivo de arriba sale de `takana hash` sobre las recetas: es «el hash VIGENTE de hoy».
92# El kernel que la máquina está CORRIENDO no tiene por qué ser ese. Si la receta se movió después
93# del último sellado, el artefacto del kernel vivo queda clasificado SUPERADO y el default lo borra.
94#
95# Y se pierde en silencio: el sistema sigue andando perfecto —el kernel ya está en RAM— hasta el día
96# que hace falta volver atrás, y ese día el artefacto al que volver no existe. Es el mismo patrón
97# que ya costó caro en el CAS: lo que no es alcanzable desde una raíz desaparece sin avisar.
98#
99# Cómo se identifica hoy, que es lo que se puede: por el `.config`. El kernel vivo expone el suyo en
100# /proc/config.gz (o /boot/config-$(uname -r)), y cada artefacto de kernel guarda el suyo en
101# boot/config-*. Si coinciden BYTE A BYTE, ese artefacto es el kernel que corre — o uno idéntico, y
102# proteger de más es el lado correcto para un GC.
103# ⚠ Es una identificación por CONTENIDO DEL CONFIG, no por hash del artefacto: dos artefactos con el
104# mismo .config y distinta toolchain se protegen los dos. Cuando el hash del artefacto viaje en el
105# kernel vivo (ADR 0017 §3) esto se vuelve exacto y esta función se simplifica.
106python3 - "$TMP" "$STORE" <<'PYK'
107import sys, os, gzip, hashlib
108tmp, store = sys.argv[1], sys.argv[2]
109
110def config_vivo():
111    try:
112        with gzip.open("/proc/config.gz", "rb") as f: return f.read()
113    except Exception: pass
114    try:
115        with open("/boot/config-" + os.uname().release, "rb") as f: return f.read()
116    except Exception: return None
117
118cfg = config_vivo()
119if cfg is None:
120    print("    kernel vivo: sin .config legible (ni /proc/config.gz ni /boot/config-<uname -r>)")
121    print("                 ⇒ NO se protege ningún kernel por esta vía. Si vas a borrar en una máquina")
122    print("                 que arranca desde este store, comprobalo a mano antes de --aplicar.")
123    raise SystemExit(0)
124
125vivo = hashlib.sha256(cfg).hexdigest()
126protegidos = []
127for d in sorted(os.listdir(store)):
128    b = os.path.join(store, d, "boot")
129    if not os.path.isdir(b): continue
130    for f in os.listdir(b):
131        if not f.startswith("config-"): continue
132        try:
133            with open(os.path.join(b, f), "rb") as fh: h = hashlib.sha256(fh.read()).hexdigest()
134        except OSError: continue
135        if h == vivo: protegidos.append(d); break
136
137if not protegidos:
138    print(f"    kernel vivo: .config sha256 {vivo[:12]} — NINGUN artefacto del store lo tiene")
139    print("                 (normal en el hub: acá corre un kernel ajeno, no uno de takana)")
140    raise SystemExit(0)
141
142# Sacar los protegidos de superados/huérfanos y contarlos como vigentes.
143movidos = 0
144for nombre in ("superados", "huerfanos"):
145    ruta = f"{tmp}/{nombre}.txt"
146    xs = [l.strip() for l in open(ruta) if l.strip()]
147    quedan = [x for x in xs if x not in protegidos]
148    movidos += len(xs) - len(quedan)
149    open(ruta, "w").write("".join(x + "\n" for x in quedan))
150with open(f"{tmp}/vigentes.txt", "a") as f:
151    for d in protegidos: f.write(d + "\n")
152
153print(f"    kernel vivo: .config sha256 {vivo[:12]} ⇒ {len(protegidos)} artefacto(s) RAIZ: {', '.join(protegidos)}")
154if movidos:
155    print(f"    !! {movidos} de ellos estaban marcados para BORRAR y se rescataron (ADR 0017 §4)")
156PYK
157
158espacio() {  # du sobre una lista de dirs, tolerante a lista vacía
159  # ⚠ `du --files0-from=-` es de GNU coreutils y **busybox no lo tiene** (medido en la caja takana
160  # el 2026-09-17): devolvía vacío, así que el gc anunciaba «espacio: superados  · huérfanos » y
161  # nadie podía saber si valía la pena correrlo. Un número que falta se lee como un número chico.
162  # `xargs -0 du -sk` anda en los dos mundos; el total lo suma awk, en KiB, y se imprime humano.
163  # ⚠ `xargs -d '\n'` NO sirve: `-d` también es extensión GNU y busybox la rechaza — el primer
164  # intento de este arreglo imprimió «huérfanos ?» en la caja. El `?` es a propósito: un total que
165  # no se pudo calcular se DICE, no se muestra como 0.
166  [ -s "$1" ] || { echo "0"; return; }
167  sed "s|^|$STORE/|" "$1" \
168    | tr '\n' '\0' | xargs -0 du -sk 2>/dev/null \
169    | awk '{t+=$1} END {
170        if (t=="") {print "?"; exit}
171        if (t>1048576) printf "%.1fG\n", t/1048576;
172        else if (t>1024) printf "%.0fM\n", t/1024;
173        else printf "%dK\n", t }'
174}
175echo "==> espacio: superados $(espacio "$TMP/superados.txt") · huérfanos $(espacio "$TMP/huerfanos.txt")"
176
177# ── 3. borrar ───────────────────────────────────────────────────────────────────────────────────
178OBJETIVO="$TMP/superados.txt"
179if [ "$HUERFANOS" = 1 ]; then
180  cat "$TMP/superados.txt" "$TMP/huerfanos.txt" > "$TMP/objetivo.txt"; OBJETIVO="$TMP/objetivo.txt"
181  echo "!!  --huerfanos: se borran TAMBIÉN los únicos ejemplares de su receta"
182fi
183
184if [ "$APLICAR" != 1 ]; then
185  echo "==> DRY-RUN: no se borró nada. Repetí con --aplicar."
186  exit 0
187fi
188
189MANIFIESTO="work/store-gc-$(date +%Y-%m-%dT%H%M%S).txt"
190mkdir -p work; cp "$OBJETIVO" "$MANIFIESTO"
191echo "==> manifiesto de lo borrado: $MANIFIESTO"
192ANTES=$(df -h "$STORE" 2>/dev/null | tail -1 | awk "{print \$4}")
193sed "s|^|$STORE/|" "$OBJETIVO" | xargs rm -rf
194
195# ── GUARDIÁN: NO reportar «borrado» sin comprobarlo ─────────────────────────────────────────────
196# 2026-08-07: este script informó «364 artefactos borrados · libres: 24G → 48G» y NO había borrado
197# NINGUNO — los 364 del manifiesto seguían enteros en el store, y los 24G que se liberaron esa vez
198# vinieron de otro lado. `xargs rm -rf` salió con 0 y el `echo` de abajo se lo creyó. (Se reprodujo:
199# corriendo el script en SEGUNDO PLANO el borrado no se materializa; en primer plano sí. Da igual la
200# causa: un `rm` que devuelve 0 no es prueba de que el fichero se fue.)
201#
202# Consecuencia real: la válvula de escape del disco estaba rota EN SILENCIO, y encima reportaba éxito,
203# que es peor que fallar. El disco llegó al 98%.
204#
205# Por eso ahora se RECUENTA sobre el filesystem y el script sale distinto de cero si sobrevivió algo.
206QUEDAN=0
207while IFS= read -r l; do [ -e "$STORE/$l" ] && QUEDAN=$((QUEDAN+1)); done < "$OBJETIVO"
208PEDIDOS=$(wc -l < "$OBJETIVO")
209# ⚠ `df -h /home` estaba CABLEADO, y el store casi nunca vive ahí: en gioser es un bind-mount del
210# volumen y en una caja takana es `/store`, su propia partición. O sea que el «libres: antes →
211# después» medía un sistema de ficheros que el gc ni toca. Se mide el del STORE.
212AHORA=$(df -h "$STORE" 2>/dev/null | tail -1 | awk '{print $4}')
213if [ "$QUEDAN" -gt 0 ]; then
214  echo "!!  BORRADO INCOMPLETO: $QUEDAN de $PEDIDOS artefactos SIGUEN en $STORE."
215  echo "!!  No te fíes del espacio libre de abajo. Reintentá en PRIMER PLANO y volvé a verificar."
216  echo "==> libres: $ANTES → $AHORA"
217  exit 1
218fi
219# ── REGISTRO ACUMULADO, para que lo podado NO VUELVA ───────────────────────────────────────────
220# 2026-08-07: se podaron 362 artefactos (24 G) por la mañana y por la tarde volvieron a aparecer LOS
221# MISMOS 362 — solapamiento del 100%. La causa no estaba acá sino en `farm/farm-sync.sh`, que bajaba
222# el store del worker entero; el worker no se poda, así que nos devolvía lo borrado en cada cosecha.
223# Podar → cosechar → vuelven. Este registro es la memoria que le faltaba al sistema: `farm-sync` lo
224# usa como `--exclude-from`. Sin él, el gc es una rueda de hámster que informa éxito cada vez.
225# ⚠ RUTA ABSOLUTA Y `touch` PREVIO. El 2026-08-21 esta línea murió con
226# «work/store-gc-superados.txt: No such file or directory» DESPUÉS de haber borrado ya los 256
227# artefactos — o sea que la poda ocurrió y su registro NO se escribió, que es la combinación exacta
228# que reabre el bucle churn: `farm-sync` no tiene qué excluir y la cosecha los devuelve. El script
229# hace `cd "$ROOT"` al arrancar, así que la ruta relativa DEBERÍA valer; que fallara igual es razón
230# de sobra para no depender de ello en el paso que evita repetir el trabajo de una tanda entera.
231LEDGER="$ROOT/work/store-gc-superados.txt"
232mkdir -p "$ROOT/work" && touch "$LEDGER"
233if cat "$OBJETIVO" "$LEDGER" 2>/dev/null | sort -u > "$LEDGER.tmp" && mv "$LEDGER.tmp" "$LEDGER"; then
234  echo "==> registro acumulado: $(wc -l < "$LEDGER") artefactos que NO deben volver ($LEDGER)"
235else
236  # Ruidoso a propósito: sin ledger la poda es una rueda de hámster que informa éxito cada vez.
237  echo "!!  NO pude escribir el registro $LEDGER — los $PEDIDOS borrados VOLVERÁN en la próxima" >&2
238  echo "!!  cosecha. Reconstruilo con: sort -u $MANIFIESTO > $LEDGER" >&2
239  exit 1
240fi
241echo "==> $PEDIDOS artefactos borrados y VERIFICADOS (0 sobrevivientes) · libres: $ANTES → $AHORA"