El vigía decía 5 fuentes muertas y sólo UNA estaba en riesgo de verdad: busybox y freetype tenían el tarball en la caché local Y en el mirror, y firefox-pgo-profile apunta a .invalid a propósito. La que no tenía copia en ningún lado era libtool (incoming-kde), con ftpmirror.gnu.org devolviendo 502 — o sea una receta que ya no se podía reconstruir y nadie lo sabía. Arreglado: el tarball se bajó de ftp.gnu.org, se verificó contra el sha256 PINEADO (coincide byte a byte), y está en la caché local y en el mirror. La URL de la receta pasa al espejo que responde, y se midió que eso no re-hashea nada — ANTES y DESPUÉS dan el mismo b3:5c09bff5..., que es el ADR 0013 comprobado en vez de citado. Y el vigía ahora cruza cada fuente muerta con la caché local y el mirror, porque mezclar 'upstream caído con el blob guardado' (noticia) con 'caído y sin copia' (emergencia) hace que el número suba, nadie lo mire, y el día que uno importe esté enterrado entre cuatro que no. A un guardián lo mata el ruido. Ahora informa EN RIESGO aparte: == vivas 1175 / 1179 muertas arriba 4 EN RIESGO 0 ⚠ Y si el mirror no se puede consultar —el worker no tiene la clave del Storage Box— dice 'indeterminado', NUNCA 'EN RIESGO': un instrumento que no pudo correr no puede parecer un hallazgo. Probado con rotura a propósito y con los dos controles, que es lo que separa un guardián que sirve de uno que nunca saltó: sha inexistente + upstream muerto → EN RIESGO ✓ salta blob en caché + upstream muerto → cache-local ✓ no salta mirror inalcanzable → indeterminado ✓ no alarma en falso Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QPJteswQvP1L7zSBrQQe2
140 lines
7.1 KiB
Python
Executable File
140 lines
7.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)
|
|
sh = re.search(r'^\s*sha256\s*=\s*"([0-9a-f]{64})"', s, re.M)
|
|
if t: out.append({"receta": nombre, "fichero": f, "tipo": "tarball", "url": t.group(1),
|
|
"sha256": sh.group(1) if sh else None})
|
|
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 respaldo(muertas):
|
|
"""¿El blob de cada fuente muerta está a salvo en algún sitio?
|
|
|
|
POR QUÉ (2026-09-08). El vigía decía «5 muertas» y sólo UNA estaba en riesgo de verdad: las
|
|
otras cuatro tenían el tarball en la caché local y en el mirror, o eran `.invalid` a propósito.
|
|
Un upstream caído con el blob guardado es una NOTICIA; uno sin copia es una EMERGENCIA — la
|
|
receta deja de poder reconstruirse. Mezclarlos hace que el número suba, nadie lo mire, y el día
|
|
que uno importe esté enterrado entre cuatro que no. A un guardián lo mata el ruido.
|
|
|
|
⚠ Si el mirror NO se puede consultar (el worker no tiene la clave del Storage Box), esto dice
|
|
`indeterminado`, NUNCA `en_riesgo`. Un instrumento que no pudo correr y un hallazgo real se
|
|
parecen demasiado como para dejar que el informe los confunda.
|
|
"""
|
|
if not muertas:
|
|
return True, set()
|
|
en_mirror, consultado = set(), True
|
|
try:
|
|
r = subprocess.run(
|
|
["ssh", "-4", "-p", os.environ.get("SB_PORT", "23"),
|
|
"-i", os.path.expanduser(os.environ.get("SB_KEY", "~/.ssh/github5")),
|
|
"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=15",
|
|
f"{os.environ.get('SB_USER','u647150')}@{os.environ.get('SB_HOST','u647150.your-storagebox.de')}",
|
|
"ls hammer/fuentes/"], capture_output=True, text=True, timeout=90)
|
|
if r.returncode == 0:
|
|
en_mirror = {l.strip() for l in r.stdout.splitlines() if l.strip().endswith(".tar")}
|
|
else:
|
|
consultado = False
|
|
except Exception:
|
|
consultado = False
|
|
for m in muertas:
|
|
sha = m.get("sha256")
|
|
blob = f"{sha}.tar" if sha else None
|
|
if blob and os.path.exists(os.path.join("work/tarballs", blob)):
|
|
m["respaldo"] = "cache-local"
|
|
elif blob and blob in en_mirror:
|
|
m["respaldo"] = "mirror"
|
|
elif not consultado:
|
|
m["respaldo"] = "indeterminado" # NO es un hallazgo: es que no pude mirar
|
|
elif not sha:
|
|
m["respaldo"] = "sin-sha" # git, o receta sin pin: no aplica el mirror
|
|
else:
|
|
m["respaldo"] = "EN RIESGO"
|
|
return consultado, en_mirror
|
|
|
|
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"]]
|
|
consultado, _ = respaldo(muertas)
|
|
en_riesgo = [m for m in muertas if m.get("respaldo") == "EN RIESGO"]
|
|
# 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"]),
|
|
# La cifra que de verdad importa: muerto arriba Y sin copia en ningún lado.
|
|
"en_riesgo": len(en_riesgo),
|
|
"mirror_consultado": consultado,
|
|
}
|
|
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 arriba {inf['muertas']} "
|
|
f"EN RIESGO {inf['en_riesgo']}" + ("" if consultado else " (mirror NO consultado)"))
|
|
for m in inf["detalle_muertas"][:40]:
|
|
print(f" {m['codigo']:>12} {m.get('respaldo','?'):<14} {m['receta']:<24} {m['url'][:60]}")
|
|
if inf["en_riesgo"]:
|
|
print(" ⚠ EN RIESGO = upstream caído Y sin blob en caché ni mirror ⇒ receta irreconstruible.")
|
|
print(" Bajar el tarball de otro espejo, verificar el sha256 pineado y "
|
|
"`scripts/fuentes/mirror-poblar.sh`.")
|
|
# Exit 0 SIEMPRE: informar, no abortar el latido. Ver la docstring.
|
|
return 0
|
|
|
|
sys.exit(main())
|