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 1373Shell 116Rust 74Markdown 641915 Definiciones

⎇ 3786b4de9f64ef894661d9dde2ebca6d9dc4a299 scripts/fuentes/fuentes-vigia.py Python · 139 líneas · 7.1 KB Historia Quién tocó qué crudo

fuentes: tapar el agujero de libtool y que el vigía distinga noticia de emergencia 381780aab · Sergio ·

1#!/usr/bin/env python3
2"""Comprueba que las URLs de fuentes de las recetas siguen vivas. Ver ADR 0013 §3.
3
4No descarga: pide cabeceras. Para `git` no hay HEAD que valga, así que usa `git ls-remote
5--exit-code`, que es la pregunta equivalente («¿este remoto sigue ahí y responde?»).
6
7Escribe `docs/state/fuentes-vigia.json`. NO falla si hay URLs muertas: su trabajo es INFORMAR.
8Un vigía que aborta el latido en la primera URL caída deja de correr y se vuelve inútil justo
9cuando más hace falta — que es cuando hay varias caídas.
10"""
11import json, os, re, subprocess, sys, glob
12from concurrent.futures import ThreadPoolExecutor
13
14ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15os.chdir(ROOT)
16LIMITE = int(os.environ.get("LIMITE", "0"))
17JOBS = int(os.environ.get("JOBS", "12"))
18TIMEOUT = int(os.environ.get("TIMEOUT", "25"))
19
20def fuentes():
21    out = []
22    for f in sorted(glob.glob("recipes/**/*.toml", recursive=True)):
23        s = open(f, errors="ignore").read()
24        nombre = (re.search(r'^\s*name\s*=\s*"([^"]+)"', s, re.M) or [None, os.path.basename(f)[:-5]])[1]
25        t = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', s, re.M)
26        g = re.search(r'^\s*repo\s*=\s*"([^"]+)"', s, re.M)
27        sh = re.search(r'^\s*sha256\s*=\s*"([0-9a-f]{64})"', s, re.M)
28        if t:   out.append({"receta": nombre, "fichero": f, "tipo": "tarball", "url": t.group(1),
29                            "sha256": sh.group(1) if sh else None})
30        elif g: out.append({"receta": nombre, "fichero": f, "tipo": "git", "url": g.group(1)})
31    return out
32
33def revisa(e):
34    url, tipo = e["url"], e["tipo"]
35    try:
36        if tipo == "tarball":
37            # -I pide cabeceras; algunos servidores no admiten HEAD y responden 405 -> se reintenta
38            # con -r 0-0 (un rango de UN byte), que es un GET mínimo y no descarga el tarball.
39            r = subprocess.run(["curl", "-sIL", "-o", "/dev/null", "-w", "%{http_code}",
40                                "--connect-timeout", str(TIMEOUT), "--max-time", str(TIMEOUT * 2), url],
41                               capture_output=True, text=True, timeout=TIMEOUT * 3)
42            code = (r.stdout or "").strip()[-3:]
43            if code in ("405", "501", "403"):
44                r = subprocess.run(["curl", "-sL", "-r", "0-0", "-o", "/dev/null", "-w", "%{http_code}",
45                                    "--connect-timeout", str(TIMEOUT), "--max-time", str(TIMEOUT * 2), url],
46                                   capture_output=True, text=True, timeout=TIMEOUT * 3)
47                code = (r.stdout or "").strip()[-3:]
48            viva = code.startswith("2") or code.startswith("3")
49            return {**e, "codigo": code or "000", "viva": viva}
50        else:
51            r = subprocess.run(["git", "ls-remote", "--exit-code", url, "HEAD"],
52                               capture_output=True, text=True, timeout=TIMEOUT * 2)
53            return {**e, "codigo": f"git:{r.returncode}", "viva": r.returncode == 0}
54    except Exception as ex:
55        return {**e, "codigo": f"error:{type(ex).__name__}", "viva": False}
56
57def respaldo(muertas):
58    """¿El blob de cada fuente muerta está a salvo en algún sitio?
59
60    POR QUÉ (2026-09-08). El vigía decía «5 muertas» y sólo UNA estaba en riesgo de verdad: las
61    otras cuatro tenían el tarball en la caché local y en el mirror, o eran `.invalid` a propósito.
62    Un upstream caído con el blob guardado es una NOTICIA; uno sin copia es una EMERGENCIA — la
63    receta deja de poder reconstruirse. Mezclarlos hace que el número suba, nadie lo mire, y el día
64    que uno importe esté enterrado entre cuatro que no. A un guardián lo mata el ruido.
65
66    ⚠ Si el mirror NO se puede consultar (el worker no tiene la clave del Storage Box), esto dice
67    `indeterminado`, NUNCA `en_riesgo`. Un instrumento que no pudo correr y un hallazgo real se
68    parecen demasiado como para dejar que el informe los confunda.
69    """
70    if not muertas:
71        return True, set()
72    en_mirror, consultado = set(), True
73    try:
74        r = subprocess.run(
75            ["ssh", "-4", "-p", os.environ.get("SB_PORT", "23"),
76             "-i", os.path.expanduser(os.environ.get("SB_KEY", "~/.ssh/github5")),
77             "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new",
78             "-o", "ConnectTimeout=15",
79             f"{os.environ.get('SB_USER','u647150')}@{os.environ.get('SB_HOST','u647150.your-storagebox.de')}",
80             "ls hammer/fuentes/"], capture_output=True, text=True, timeout=90)
81        if r.returncode == 0:
82            en_mirror = {l.strip() for l in r.stdout.splitlines() if l.strip().endswith(".tar")}
83        else:
84            consultado = False
85    except Exception:
86        consultado = False
87    for m in muertas:
88        sha = m.get("sha256")
89        blob = f"{sha}.tar" if sha else None
90        if blob and os.path.exists(os.path.join("work/tarballs", blob)):
91            m["respaldo"] = "cache-local"
92        elif blob and blob in en_mirror:
93            m["respaldo"] = "mirror"
94        elif not consultado:
95            m["respaldo"] = "indeterminado"      # NO es un hallazgo: es que no pude mirar
96        elif not sha:
97            m["respaldo"] = "sin-sha"            # git, o receta sin pin: no aplica el mirror
98        else:
99            m["respaldo"] = "EN RIESGO"
100    return consultado, en_mirror
101
102def main():
103    fs = fuentes()
104    if LIMITE:
105        fs = fs[:LIMITE]
106    print(f"== vigía de fuentes: {len(fs)} recetas ({sum(1 for f in fs if f['tipo']=='tarball')} tarball, "
107          f"{sum(1 for f in fs if f['tipo']=='git')} git), {JOBS} en paralelo", flush=True)
108    with ThreadPoolExecutor(max_workers=JOBS) as ex:
109        res = list(ex.map(revisa, fs))
110    muertas = [r for r in res if not r["viva"]]
111    consultado, _ = respaldo(muertas)
112    en_riesgo = [m for m in muertas if m.get("respaldo") == "EN RIESGO"]
113    # El informe se ordena por receta para que el diff de git sea legible entre corridas.
114    inf = {
115        "schema": "hammer-fuentes-vigia/1",
116        "total": len(res),
117        "vivas": len(res) - len(muertas),
118        "muertas": len(muertas),
119        "detalle_muertas": sorted(muertas, key=lambda r: r["receta"]),
120        # La cifra que de verdad importa: muerto arriba Y sin copia en ningún lado.
121        "en_riesgo": len(en_riesgo),
122        "mirror_consultado": consultado,
123    }
124    os.makedirs("docs/state", exist_ok=True)
125    with open("docs/state/fuentes-vigia.json", "w") as fh:
126        json.dump(inf, fh, indent=1, ensure_ascii=False)
127        fh.write("\n")
128    print(f"== vivas {inf['vivas']} / {inf['total']}   muertas arriba {inf['muertas']}   "
129          f"EN RIESGO {inf['en_riesgo']}" + ("" if consultado else "   (mirror NO consultado)"))
130    for m in inf["detalle_muertas"][:40]:
131        print(f"   {m['codigo']:>12}  {m.get('respaldo','?'):<14} {m['receta']:<24} {m['url'][:60]}")
132    if inf["en_riesgo"]:
133        print("   ⚠ EN RIESGO = upstream caído Y sin blob en caché ni mirror ⇒ receta irreconstruible.")
134        print("     Bajar el tarball de otro espejo, verificar el sha256 pineado y "
135              "`scripts/fuentes/mirror-poblar.sh`.")
136    # Exit 0 SIEMPRE: informar, no abortar el latido. Ver la docstring.
137    return 0
138
139sys.exit(main())