# 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/`. **Ampliado por [ADR 0014](0014-distribucion-multiorigen.md)**: `TAKANA_MIRROR` y `TAKANA_MIRROR_GIT` son LISTAS de orígenes, no una URL — un solo espejo propio reproducía el punto único de fallo que este documento quería quitar. - **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: - `takana 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 `takana/fuentes/`, direccionado por contenido: `takana/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 # TAKANA_MIRROR + TAKANA_MIRROR_KEY flock work/.farm-build.lock ./target/release/takana --store ./store build ``` **El mirror es ADITIVO: sin `TAKANA_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 `takana` 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 `TAKANA_MIRROR`: falla con `curl (6) Could not resolve host`. Con `TAKANA_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`. ## Fuentes git: bundles shallow por commit Los 606 repos por commit son el 52% de las fuentes y no los cubre el mirror de tarballs. Se espejan como **bundles**, en un espacio de nombres propio: `takana/fuentes-git/{commit}.bundle` (571 commits distintos — hay commits compartidos entre colas, y se espeja uno solo). **La identidad es el commit y la verificación la hace git.** Al desempaquetar, git comprueba cada objeto contra su SHA: un bundle alterado no pasa. No hace falta índice ni un `sha256` aparte, igual que en los tarballs el nombre del fichero ES su verificación. ### Shallow, no clones completos El bundle se genera desde un `git fetch --depth 1` del commit exacto. Para `act` son **9,3 MB en vez del repositorio entero**, y con 571 fuentes esa diferencia decide si el mirror cabe. Es legítimo porque **takana nunca usa la historia**: lo único que hace con un repo es `git archive | tar -x`, o sea materializar un árbol. ### El detalle que costó encontrar: el fichero `shallow` Un bundle hecho desde un repo shallow **no lleva la frontera de historia**. Al desempaquetarlo: ```text fatal: Failed to traverse parents of commit 4f41128… error: … did not send all necessary objects ``` El mensaje dice «no envió todos los objetos necesarios» y **es engañoso**: los objetos del commit llegan enteros — de hecho `git archive` ya funciona pese al error. Lo que falta no es un objeto, es decirle a git dónde termina la historia. Se arregla escribiendo el propio commit en `/shallow` **antes** del fetch, y no hay que transportar nada: la frontera de un clon `--depth 1` es exactamente ese commit. ### Y un bug propio que vale documentar `hidratar_desde_bundle` pasaba al `git fetch` la ruta **relativa** del bundle. Como `run_git` invoca `git -C …`, git la resolvía **dentro de ``** y no encontraba nada. Como el fallo del mirror se traga a propósito (para caer a upstream), el síntoma salía lejísimos del origen: el build moría con *«commit … no existe en \ tras fetch»*, **culpando a upstream de un error de ruta local**. Se arregla con `canonicalize`. Es el precio de que el mirror falle en silencio, y por eso el silencio se paga con comentarios explícitos en el código. ## 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 **coste de mantener el mirror git al día**: un commit nuevo en una receta es un bundle nuevo, y hoy eso lo dispara una persona corriendo `mirror-git-poblar.sh`, no el latido. - Qué hacer con los repos cuyo servidor **no permite fetch por SHA suelto** (`uploadpack.allowReachableSHA1InWant` desactivado): ahí el `--depth 1` del commit exacto falla y hay que caer a un clon completo. **Todavía no ha aparecido ninguno.** El poblador reporta el **stderr real de git**, no una conjetura. Lo escribí al revés la primera vez — imprimía siempre `✗ upstream no da el commit` — y en la primera tanda marcó así a tres recetas (`diffutils`, `findutils-xargs`, `kustomize`) cuyos commits **sí se traen a mano**. En cuanto imprimió el error de verdad, la causa resultó ser otra por completo: el pin de esas tres no es un commit (§ siguiente). Un fallo ahí puede ser el servidor negando el sha, pero también un timeout, DNS, un rate-limit o —como fue— un bug propio, y cada uno se arregla distinto. **Un diagnóstico hardcodeado convierte cualquier fallo en una conclusión falsa sobre upstream**, que es el mismo error que ya costó caro con la ruta relativa del bundle. ## El pin de una receta no siempre es un commit `diffutils`, `findutils-xargs` y `kustomize` pinean en su campo `commit` el SHA de un **objeto tag anotado** (`v0.5.0`, `0.9.1`, `kustomize/v5.8.1`), no de un commit. Es legítimo: `git archive` acepta un tag igual que un commit, y para `hash_inputs` un SHA es un SHA — `git:{commit}` no distingue el tipo de objeto y no tiene por qué. Pero rompía las dos puntas del mirror, y en las dos por la misma suposición sin escribir: - **El poblador** hacía `update-ref refs/heads/hammer `, y una rama sólo apunta a commits: *«trying to write non-commit object»*. Ahora pela con `^{commit}` para la rama y, si hubo que pelar, manda el objeto tag aparte en `refs/tags/hammer-objeto`. Sin ese segundo ref el bundle desempaqueta bien pero el `cat-file -e ` del otro lado no encuentra lo que la receta pide: un mirror **correcto pero inútil justo para esas recetas**. - **takana** escribía el `commit` de la receta en el fichero `shallow`, que sólo admite SHAs de commit. Ya no lo supone: lee la frontera del propio bundle con `git bundle list-heads`, que imprime la lista de refs **sin necesitar los objetos** y por eso sirve justo antes del fetch. Y trae `refs/*:refs/*` en vez de un ref concreto, para que el objeto tag viaje con lo demás. Los bundles del formato viejo (un solo ref, los 339 ya subidos) siguen sirviendo sin tocarlos: para ellos `list-heads` devuelve el commit y el refspec ancho encuentra ese mismo ref único. Comprobado con una receta de cada forma contra un `repo` que no resuelve por DNS, para que el mirror sea la única fuente posible.