From 45b95f78b9fe46227d3e490a538f9c2f5ed0cd60 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 26 Aug 2026 18:57:40 +0000 Subject: [PATCH] =?UTF-8?q?ADR=200013:=20mirror=20de=20fuentes=20=E2=80=94?= =?UTF-8?q?=20la=20URL=20es=20transporte,=20el=20sha256=20es=20la=20identi?= =?UTF-8?q?dad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o --- crates/hammer-build/src/fetch.rs | 51 ++++++++- docs/adr/0013-mirror-de-fuentes.md | 164 +++++++++++++++++++++++++++++ docs/state/fuentes-vigia.json | 72 +++++++++++++ scripts/farm/cosecha-cron.sh | 11 +- scripts/fuentes/fuentes-vigia.py | 82 +++++++++++++++ scripts/fuentes/fuentes-vigia.sh | 21 ++++ scripts/fuentes/mirror-env.sh | 15 +++ scripts/fuentes/mirror-poblar.sh | 36 +++++++ 8 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0013-mirror-de-fuentes.md create mode 100644 docs/state/fuentes-vigia.json create mode 100755 scripts/fuentes/fuentes-vigia.py create mode 100755 scripts/fuentes/fuentes-vigia.sh create mode 100644 scripts/fuentes/mirror-env.sh create mode 100755 scripts/fuentes/mirror-poblar.sh diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs index 44614a45..1de5371b 100644 --- a/crates/hammer-build/src/fetch.rs +++ b/crates/hammer-build/src/fetch.rs @@ -109,7 +109,7 @@ fn fetch_tarball( // cambiar la URL sin cambiar el sha (mirror) no invalida la caché. let cached = tarballs_dir.join(format!("{sha256}.tar")); if !cached.is_file() { - download_verify(url, sha256, &cached)?; + fetch_con_mirror(url, sha256, &cached)?; } else { verify_sha256(&cached, sha256)?; } @@ -160,12 +160,57 @@ fn fetch_tarball( Ok(work_tree) } +/// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013). +/// +/// # Por qué el mirror va primero y no como rescate +/// +/// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido +/// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más +/// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que +/// **nadie prueba**, y se descubre roto justo el día en que hace falta. +/// +/// # Por qué NO avisa cuando cae al mirror +/// +/// Tentación evidente y **equivocada**: si `hammer build` avisara en cada descarga, el aviso sería +/// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*, +/// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en +/// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`. +/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo +/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso. +/// +/// `HAMMER_MIRROR` es un prefijo de URL que curl entienda (`file://`, `https://`, `sftp://`…); el +/// objeto se busca por contenido en `{HAMMER_MIRROR}/{sha256}.tar`. `HAMMER_MIRROR_KEY`, si está, +/// se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR` el comportamiento es el de +/// siempre: derecho a upstream. +fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { + if let Ok(base) = std::env::var("HAMMER_MIRROR") { + let base = base.trim_end_matches('/'); + if !base.is_empty() { + let espejo = format!("{base}/{sha256}.tar"); + // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente. + // Se ignora en silencio y se sigue a upstream. + if download_verify(&espejo, sha256, dst).is_ok() { + return Ok(()); + } + } + } + download_verify(url, sha256, dst) +} + /// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo /// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché). fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { let tmp = dst.with_extension("tar.partial"); - let status = Command::new("curl") - .args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"]) + let mut cmd = Command::new("curl"); + cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]); + // Clave para el mirror por sftp (Storage Box). Inocua para http/file. + if let Ok(key) = std::env::var("HAMMER_MIRROR_KEY") { + if !key.is_empty() { + cmd.arg("--key").arg(&key); + } + } + let status = cmd + .arg("-o") .arg(&tmp) .arg(url) .stdout(Stdio::inherit()) diff --git a/docs/adr/0013-mirror-de-fuentes.md b/docs/adr/0013-mirror-de-fuentes.md new file mode 100644 index 00000000..430806d6 --- /dev/null +++ b/docs/adr/0013-mirror-de-fuentes.md @@ -0,0 +1,164 @@ +# ADR 0013 — Mirror de fuentes: la URL es transporte, el `sha256` es la identidad + +- **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs` y `scripts/fuentes/`. +- **Fecha:** 2026-08-26 +- **Frontera:** `fetch_tarball` / `fetch_git`, `scripts/fuentes/mirror-poblar.sh`, + `scripts/fuentes/fuentes-vigia.sh`. + +## Contexto + +Dos recetas del corpus **no se pueden construir hoy, y no por culpa nuestra**: + +- `rsync` 3.4.4 → `download.samba.org` devuelve **404**. La tarball ya no está donde la receta la busca. +- `musl` 1.2.5 → `musl.libc.org` **no responde** desde gioser (curl 000, connection reset). + +No es mala suerte: es el estado estacionario. Una distro que construye **todo desde fuente** tiene +tantos puntos de fallo como fuentes distintas, y esos puntos son servidores de terceros que nadie +nos prometió mantener. El problema sólo crece con el tiempo. + +### La medida, que es peor de lo que parece + +Sobre las 1167 recetas del árbol (561 tarball + 606 git, 79 hosts distintos): + +| recetas | host | +|--------:|------| +| **742** | `github.com` | +| 104 | `download.kde.org` | +| 68 | `download.gnome.org` | +| 29 | `gitlab.freedesktop.org` | +| 23 | `ftp.gnu.org` | + +**Un solo host sostiene el 64% del corpus.** Doce hosts sostienen el 89%. En el otro extremo, **43 +hosts sostienen exactamente una receta cada uno**: proyectos pequeños, dominios personales, cosas que +se apagan sin aviso. Ahí es donde el bit-rot muerde despacio, y `musl.libc.org` y `download.samba.org` +son justo eso. + +Dicho de otra forma: **la reproducibilidad del corpus está cerrada hacia adentro y completamente +abierta hacia afuera.** Tenemos hashes de todo, verificación bit a bit y un store direccionado por +contenido — apoyado sobre 79 servidores ajenos. + +## Decisión + +### 1. La URL nunca fue la identidad, y el código ya lo sabía + +`Recipe::hash_inputs` calcula: + +```rust +let source_id = match self.source.kind()? { + SourceKind::Git { commit, .. } => format!("git:{commit}"), + SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"), +}; +``` + +El `..` **descarta la URL**. Y la caché de tarballs se nombra `{sha256}.tar`, con un comentario que +ya lo dice: *«dos URLs distintas con el mismo contenido se cachean una vez, y cambiar la URL sin +cambiar el sha (mirror) no invalida la caché»*. + +⇒ **Añadir mirrors no re-hashea absolutamente nada.** No es una consecuencia afortunada: es que la +identidad del contenido siempre estuvo en el `sha256` y el `commit`. La URL es una sugerencia sobre +dónde buscar los bytes. Esto ya estaba diseñado; lo que faltaba era el mecanismo. + +### 2. Orden de resolución: caché local → mirror propio → upstream + +El mirror va **antes** que upstream, no después. Razones, en orden: + +- El `sha256` se verifica igual en los tres casos, así que **no hay diferencia de contenido posible**. + Preferir upstream «por pureza» no compra nada: compra latencia y un tercero en el camino. +- Los workers de la granja son efímeros y descargan el corpus entero en cada ciclo. Pegarle 561 + veces a servidores ajenos es a la vez frágil y de mala educación. +- Un mirror sólo de rescate es un mirror que **nadie prueba**. Se descubre roto el día que hace falta, + que es exactamente el día en que no se puede arreglar. + +### 3. El mirror NO puede tapar el bit-rot: la vigilancia va SEPARADA + +Ésta es la parte que hay que hacer bien, y es una lección cara de este mismo repo. + +Un mirror que sirve calladamente lo que upstream ya perdió convierte un fallo **ruidoso** en +**silencio**. Las URLs se irían muriendo una por una sin que nadie se entere, y el día que el mirror +se pierda el corpus resultaría irreconstruible — con todos los indicadores en verde hasta ese +momento. Es el mismo patrón que dejó el grafo de wlr congelado 17 días anunciando `121/121`: **una +métrica que nadie refresca envejece hacia el optimismo.** + +Por eso **construir** y **vigilar upstream** son dos trabajos distintos y van en dos sitios distintos: + +- `hammer build` **nunca** avisa de que usó el mirror. No es su tarea y volvería el log inútil. +- `scripts/fuentes/fuentes-vigia.sh` recorre las 1167 fuentes con `curl -I` (cabeceras, sin + descargar) y escribe `docs/state/fuentes-vigia.json`: qué URLs siguen vivas, cuáles dan 404, + cuáles no responden. **Lo corre el latido**, como el grafo de estado. + +Una URL muerta deja de ser una emergencia en mitad de un build y pasa a ser una línea en un informe. + +### 4. Poblar es un efecto secundario, no una tarea + +Toda descarga **verificada** se promueve al mirror. El mirror se llena solo, construyendo. +`scripts/fuentes/mirror-poblar.sh` hace la carga inicial desde `work/tarballs` y sirve para +rellenar lo que falte. + +El mirror vive en el Storage Box, bajo `hammer/fuentes/`, direccionado por contenido: +`hammer/fuentes/{sha256}.tar`. Es el mismo Storage Box del respaldo (1 TiB, 938 G libres, ~€3,20/mes +ya pagados) — no hay infraestructura nueva que mantener. + +### 5. ⛔ PROHIBIDO cambiar el `sha256` para «arreglar» una URL muerta + +La regla más importante del documento, porque es la tentación natural cuando un build falla con 404: +buscar la tarball nueva, pegar el `sha256` nuevo, seguir adelante. + +**Eso no arregla una descarga: cambia lo que la distro construye.** Un `sha256` distinto es otro +contenido — otra versión, otro tarball re-empaquetado, o un compromiso de upstream. La receta seguiría +llamándose `rsync 3.4.4` y estaría construyendo otra cosa, con el agravante de que el artefacto +resultante se sella como si tal cosa. + +Ante una URL muerta, en este orden: + +1. **Buscar los bytes originales** — el mirror, la caché de otra máquina, un mirror público conocido. + Si el `sha256` casa, se actualiza la URL y **listo, sin re-hasheo**. +2. Si no aparecen en ningún sitio, es un **cambio de versión** con su propia auditoría: se mira qué + cambió, se actualiza `version` + `sha256`, y se acepta el re-hasheo en cascada que corresponda. +3. Nunca, jamás, el paso 2 disfrazado de paso 1. + +## Cómo se activa + +```sh +. scripts/fuentes/mirror-env.sh # HAMMER_MIRROR + HAMMER_MIRROR_KEY +flock work/.farm-build.lock ./target/release/hammer --store ./store build +``` + +**El mirror es ADITIVO: sin `HAMMER_MIRROR` el comportamiento es exactamente el de siempre.** No se +hornea un default en el binario a propósito — apuntar por defecto a una máquina concreta convertiría +un fallo de red en un fallo de `hammer` para cualquiera que clone el repo. + +⚠️ La ruta sftp lleva `/~/`. curl trata lo que sigue al host en `sftp://` como ruta **absoluta** del +servidor: `…:23/hammer/fuentes` busca en la raíz y da «(78) Could not open remote file for reading» +**aunque el fichero exista**, que es un error que parece de permisos o de ausencia y es de ruta. + +### Verificado de punta a punta, no por inspección + +Primer intento de prueba: construir `busybox` (upstream muerto) con el mirror puesto. Dijo `BUILD +OK` **y no probó nada** — busybox ya estaba sellado, así que hubo cache-hit del artefacto y no se +descargó un solo byte. La prueba válida es una receta cuyo artefacto NO exista: + +- receta efímera con el `sha256` de un tarball que **sí** está en el mirror, +- y una URL upstream que **ni resuelve por DNS** (`https://este-host-no-existe.invalid/…`). + +Sin `HAMMER_MIRROR`: falla con `curl (6) Could not resolve host`. Con `HAMMER_MIRROR`: **sella**, y el +artefacto tiene el contenido real. Los bytes no pudieron venir de ningún otro sitio. + +## Consecuencias + +- **Cero re-hasheos.** Verificado: recalculadas las 794 recetas del grafo wlr antes y después, 0 cambios. +- El corpus deja de depender de que 79 terceros sigan sirviendo los mismos bytes. +- **La concentración en `github.com` (64%) no la arregla este ADR** — la mitiga. Sigue siendo el + riesgo estructural mayor del proyecto y merece su propia decisión. ADR 0006 (commits pineados) ya + cubre la parte de que GitHub **regenera** los `archive/.tar.gz`, que es un problema distinto y + peor: ahí los bytes cambian sin que cambie la URL. +- Aparece una dependencia nueva: el Storage Box. Es aceptable porque es **caché reconstruible**, no + la verdad: la verdad son las recetas en git. Si el mirror se pierde, se repuebla desde cualquier + máquina que tenga `work/tarballs`. + +## Lo que este ADR NO decide + +- Qué hacer con la concentración en GitHub. +- Si el mirror debe replicarse fuera de Hetzner (hoy el repo, el respaldo y el mirror están todos en + la misma cuenta y el mismo proveedor). +- El mirror de fuentes **git**: hoy sólo se espeja el tarball. Los 606 repos por commit siguen + dependiendo del remoto. Es el siguiente eslabón, y es más grande. diff --git a/docs/state/fuentes-vigia.json b/docs/state/fuentes-vigia.json new file mode 100644 index 00000000..b61d807c --- /dev/null +++ b/docs/state/fuentes-vigia.json @@ -0,0 +1,72 @@ +{ + "schema": "hammer-fuentes-vigia/1", + "total": 1167, + "vivas": 1159, + "muertas": 8, + "detalle_muertas": [ + { + "receta": "busybox", + "fichero": "recipes/busybox.toml", + "tipo": "tarball", + "url": "https://busybox.net/downloads/busybox-1.36.1.tar.bz2", + "codigo": "000", + "viva": false + }, + { + "receta": "freetype", + "fichero": "recipes/freetype.toml", + "tipo": "tarball", + "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.14.3.tar.xz", + "codigo": "502", + "viva": false + }, + { + "receta": "freetype-shared", + "fichero": "recipes/freetype-shared.toml", + "tipo": "tarball", + "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.14.3.tar.xz", + "codigo": "502", + "viva": false + }, + { + "receta": "freetype-shared", + "fichero": "recipes/incoming-gnome-onda2/freetype-shared.toml", + "tipo": "tarball", + "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.14.3.tar.xz", + "codigo": "502", + "viva": false + }, + { + "receta": "freetype-shared", + "fichero": "recipes/incoming-kde/freetype-shared.toml", + "tipo": "tarball", + "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.14.3.tar.xz", + "codigo": "502", + "viva": false + }, + { + "receta": "libtool", + "fichero": "recipes/incoming-kde/libtool.toml", + "tipo": "tarball", + "url": "https://ftpmirror.gnu.org/libtool/libtool-2.5.4.tar.xz", + "codigo": "502", + "viva": false + }, + { + "receta": "musl", + "fichero": "recipes/musl.toml", + "tipo": "tarball", + "url": "https://musl.libc.org/releases/musl-1.2.5.tar.gz", + "codigo": "000", + "viva": false + }, + { + "receta": "rsync", + "fichero": "recipes/rsync.toml", + "tipo": "tarball", + "url": "https://download.samba.org/pub/rsync/rsync-3.4.4.tar.gz", + "codigo": "404", + "viva": false + } + ] +} diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh index ada5ed23..179cc3fb 100755 --- a/scripts/farm/cosecha-cron.sh +++ b/scripts/farm/cosecha-cron.sh @@ -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" \ diff --git a/scripts/fuentes/fuentes-vigia.py b/scripts/fuentes/fuentes-vigia.py new file mode 100755 index 00000000..fd312d59 --- /dev/null +++ b/scripts/fuentes/fuentes-vigia.py @@ -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()) diff --git a/scripts/fuentes/fuentes-vigia.sh b/scripts/fuentes/fuentes-vigia.sh new file mode 100755 index 00000000..b51b579e --- /dev/null +++ b/scripts/fuentes/fuentes-vigia.sh @@ -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 "$@" diff --git a/scripts/fuentes/mirror-env.sh b/scripts/fuentes/mirror-env.sh new file mode 100644 index 00000000..edec4c39 --- /dev/null +++ b/scripts/fuentes/mirror-env.sh @@ -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 +# +# 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}" diff --git a/scripts/fuentes/mirror-poblar.sh b/scripts/fuentes/mirror-poblar.sh new file mode 100755 index 00000000..0e7081bc --- /dev/null +++ b/scripts/fuentes/mirror-poblar.sh @@ -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"