Files
takana/scripts/fuentes/fuentes-vigia.py
T
SergioandClaude Opus 5 45b95f78b9 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
2026-08-26 18:57:40 +00:00

83 lines
4.1 KiB
Python
Executable File

#!/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())