ADR 0013: mirror de fuentes — la URL es transporte, el sha256 es la identidad
`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
This commit is contained in:
@@ -177,6 +177,15 @@ if [ -x "$HAMMER" ]; then
|
||||
# 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
|
||||
@@ -184,7 +193,7 @@ 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 2>/dev/null || true
|
||||
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" \
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comprueba que las URLs de fuentes de las recetas siguen vivas. Ver ADR 0013 §3.
|
||||
|
||||
No descarga: pide cabeceras. Para `git` no hay HEAD que valga, así que usa `git ls-remote
|
||||
--exit-code`, que es la pregunta equivalente («¿este remoto sigue ahí y responde?»).
|
||||
|
||||
Escribe `docs/state/fuentes-vigia.json`. NO falla si hay URLs muertas: su trabajo es INFORMAR.
|
||||
Un vigía que aborta el latido en la primera URL caída deja de correr y se vuelve inútil justo
|
||||
cuando más hace falta — que es cuando hay varias caídas.
|
||||
"""
|
||||
import json, os, re, subprocess, sys, glob
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.chdir(ROOT)
|
||||
LIMITE = int(os.environ.get("LIMITE", "0"))
|
||||
JOBS = int(os.environ.get("JOBS", "12"))
|
||||
TIMEOUT = int(os.environ.get("TIMEOUT", "25"))
|
||||
|
||||
def fuentes():
|
||||
out = []
|
||||
for f in sorted(glob.glob("recipes/**/*.toml", recursive=True)):
|
||||
s = open(f, errors="ignore").read()
|
||||
nombre = (re.search(r'^\s*name\s*=\s*"([^"]+)"', s, re.M) or [None, os.path.basename(f)[:-5]])[1]
|
||||
t = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', s, re.M)
|
||||
g = re.search(r'^\s*repo\s*=\s*"([^"]+)"', s, re.M)
|
||||
if t: out.append({"receta": nombre, "fichero": f, "tipo": "tarball", "url": t.group(1)})
|
||||
elif g: out.append({"receta": nombre, "fichero": f, "tipo": "git", "url": g.group(1)})
|
||||
return out
|
||||
|
||||
def revisa(e):
|
||||
url, tipo = e["url"], e["tipo"]
|
||||
try:
|
||||
if tipo == "tarball":
|
||||
# -I pide cabeceras; algunos servidores no admiten HEAD y responden 405 -> se reintenta
|
||||
# con -r 0-0 (un rango de UN byte), que es un GET mínimo y no descarga el tarball.
|
||||
r = subprocess.run(["curl", "-sIL", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--connect-timeout", str(TIMEOUT), "--max-time", str(TIMEOUT * 2), url],
|
||||
capture_output=True, text=True, timeout=TIMEOUT * 3)
|
||||
code = (r.stdout or "").strip()[-3:]
|
||||
if code in ("405", "501", "403"):
|
||||
r = subprocess.run(["curl", "-sL", "-r", "0-0", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--connect-timeout", str(TIMEOUT), "--max-time", str(TIMEOUT * 2), url],
|
||||
capture_output=True, text=True, timeout=TIMEOUT * 3)
|
||||
code = (r.stdout or "").strip()[-3:]
|
||||
viva = code.startswith("2") or code.startswith("3")
|
||||
return {**e, "codigo": code or "000", "viva": viva}
|
||||
else:
|
||||
r = subprocess.run(["git", "ls-remote", "--exit-code", url, "HEAD"],
|
||||
capture_output=True, text=True, timeout=TIMEOUT * 2)
|
||||
return {**e, "codigo": f"git:{r.returncode}", "viva": r.returncode == 0}
|
||||
except Exception as ex:
|
||||
return {**e, "codigo": f"error:{type(ex).__name__}", "viva": False}
|
||||
|
||||
def main():
|
||||
fs = fuentes()
|
||||
if LIMITE:
|
||||
fs = fs[:LIMITE]
|
||||
print(f"== vigía de fuentes: {len(fs)} recetas ({sum(1 for f in fs if f['tipo']=='tarball')} tarball, "
|
||||
f"{sum(1 for f in fs if f['tipo']=='git')} git), {JOBS} en paralelo", flush=True)
|
||||
with ThreadPoolExecutor(max_workers=JOBS) as ex:
|
||||
res = list(ex.map(revisa, fs))
|
||||
muertas = [r for r in res if not r["viva"]]
|
||||
# El informe se ordena por receta para que el diff de git sea legible entre corridas.
|
||||
inf = {
|
||||
"schema": "hammer-fuentes-vigia/1",
|
||||
"total": len(res),
|
||||
"vivas": len(res) - len(muertas),
|
||||
"muertas": len(muertas),
|
||||
"detalle_muertas": sorted(muertas, key=lambda r: r["receta"]),
|
||||
}
|
||||
os.makedirs("docs/state", exist_ok=True)
|
||||
with open("docs/state/fuentes-vigia.json", "w") as fh:
|
||||
json.dump(inf, fh, indent=1, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
print(f"== vivas {inf['vivas']} / {inf['total']} MUERTAS {inf['muertas']}")
|
||||
for m in inf["detalle_muertas"][:40]:
|
||||
print(f" {m['codigo']:>12} {m['receta']:<24} {m['url'][:70]}")
|
||||
# Exit 0 SIEMPRE: informar, no abortar el latido. Ver la docstring.
|
||||
return 0
|
||||
|
||||
sys.exit(main())
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# fuentes-vigia.sh — comprueba que las URLs de las recetas SIGUEN VIVAS (ADR 0013 §3).
|
||||
#
|
||||
# ── POR QUÉ ESTO EXISTE APARTE Y NO DENTRO DE `hammer build` ───────────────────────────────────
|
||||
# Desde el ADR 0013 el mirror propio se consulta ANTES que upstream, y `hammer build` no avisa
|
||||
# cuando lo usa. Eso es correcto para construir y PELIGROSO sin contrapeso: un mirror que sirve
|
||||
# calladamente lo que upstream ya perdió convierte un fallo ruidoso en silencio, y las URLs se van
|
||||
# muriendo una por una sin que nadie se entere. El día que el mirror se pierda, el corpus resulta
|
||||
# irreconstruible — con todo en verde hasta ese momento.
|
||||
#
|
||||
# Es el mismo modo de fallo que dejó `build-state-wlr.json` diciendo `escritorio-sway 121/121`
|
||||
# durante 17 días: una métrica que nadie refresca envejece SIEMPRE hacia el optimismo.
|
||||
#
|
||||
# Así que construir y vigilar son dos trabajos y van en dos sitios. Éste no construye ni descarga
|
||||
# nada: pide CABECERAS (`curl -I`) y escribe un informe. Lo corre el latido.
|
||||
#
|
||||
# Uso: scripts/fuentes/fuentes-vigia.sh # todas las recetas → docs/state/fuentes-vigia.json
|
||||
# LIMITE=50 scripts/fuentes/fuentes-vigia.sh # sólo las primeras 50 (prueba rápida)
|
||||
set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
|
||||
exec python3 scripts/fuentes/fuentes-vigia.py "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
# mirror-env.sh — activa el mirror de fuentes (ADR 0013). Se hace `source`, no se ejecuta:
|
||||
#
|
||||
# . scripts/fuentes/mirror-env.sh
|
||||
# flock work/.farm-build.lock ./target/release/hammer --store ./store build <receta>
|
||||
#
|
||||
# Sin esto `hammer build` va derecho a upstream, que es el comportamiento de siempre: el mirror es
|
||||
# ADITIVO y su ausencia nunca rompe un build. Por eso no está horneado en el binario — un default
|
||||
# que apunta a una máquina concreta convertiría un fallo de red en un fallo de hammer para cualquiera
|
||||
# que clone el repo.
|
||||
#
|
||||
# La ruta lleva `/~/` a propósito. curl interpreta lo que sigue al host en una URL `sftp://` como
|
||||
# ruta ABSOLUTA del servidor, así que `…:23/hammer/fuentes` busca en la raíz y devuelve
|
||||
# «(78) Could not open remote file for reading» aunque el fichero exista. `/~/` lo ancla al home.
|
||||
export HAMMER_MIRROR="${HAMMER_MIRROR:-sftp://u647150@u647150.your-storagebox.de:23/~/hammer/fuentes}"
|
||||
export HAMMER_MIRROR_KEY="${HAMMER_MIRROR_KEY:-$HOME/.ssh/github5}"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# mirror-poblar.sh — sube la caché local de tarballs al mirror de fuentes (ADR 0013).
|
||||
#
|
||||
# El mirror está direccionado por CONTENIDO: `hammer/fuentes/{sha256}.tar`, exactamente el mismo
|
||||
# nombre que usa `work/tarballs/`. Por eso subir es un rsync plano y no hace falta índice ninguno:
|
||||
# el nombre del fichero ES su verificación.
|
||||
#
|
||||
# Es INCREMENTAL e interrumpible, como el respaldo: los ficheros nunca cambian (un sha256 identifica
|
||||
# un contenido y sólo uno), así que la comparación por tamaño+fecha de rsync es exacta y baratísima.
|
||||
#
|
||||
# Uso: scripts/fuentes/mirror-poblar.sh # sube lo que falte
|
||||
# SECO=1 scripts/fuentes/mirror-poblar.sh # dice qué subiría
|
||||
set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
|
||||
|
||||
SB_USER="${SB_USER:-u647150}"
|
||||
SB_HOST="${SB_HOST:-u647150.your-storagebox.de}"
|
||||
SB_PORT="${SB_PORT:-23}"
|
||||
KEY="${KEY:-$HOME/.ssh/github5}"
|
||||
SRC="${SRC:-work/tarballs}"
|
||||
SECO=""; [ "${SECO:-}" = "1" ] && SECO="--dry-run"
|
||||
|
||||
[ -d "$SRC" ] || { echo "no hay caché local en $SRC" >&2; exit 1; }
|
||||
SSH_CMD="ssh -4 -p $SB_PORT -i $KEY -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30"
|
||||
|
||||
n=$(ls "$SRC" | wc -l); tam=$(du -sh "$SRC" | cut -f1)
|
||||
echo "==> caché local: $n tarballs, $tam"
|
||||
echo "==> destino: $SB_USER@$SB_HOST:hammer/fuentes/ ${SECO:+(SECO)}"
|
||||
|
||||
$SSH_CMD "$SB_USER@$SB_HOST" 'mkdir hammer hammer/fuentes' >/dev/null 2>&1 || true
|
||||
|
||||
# --partial-dir: un tarball cortado a la mitad conserva el trozo y la próxima corrida lo TERMINA.
|
||||
rsync -a --info=progress2 --partial-dir=.rsync-partial $SECO \
|
||||
-e "$SSH_CMD" "$SRC"/ "$SB_USER@$SB_HOST:hammer/fuentes/"
|
||||
|
||||
echo "==> en el mirror: $($SSH_CMD "$SB_USER@$SB_HOST" 'ls hammer/fuentes | wc -l' 2>/dev/null) objetos"
|
||||
Reference in New Issue
Block a user