645 líneas. Los ADR entran porque en este repo SON documentos vivos, no registros inmutables: el 0013 tiene 5 commits, el 0009 dos. Eso se comprobó antes de decidir, no se asumió por convención general. EXCLUIDOS por ser REGISTRO o generado: docs/evidencia/ (6), el HANDOFF de la noche de KDE (1) y docs/state/ (24, se regenera solo). Reescribir un comando dentro de una evidencia la falsifica. Y el ADR 0016 se excluye de todo barrido, con un aviso adentro para el próximo que barra: habla SOBRE el renombre, así que necesita seguir diciendo 'hammer'. El barrido se lo llevó puesto y lo dejó titulado 'Renombre del sistema: takana → takana'; revertido. Congelados, verificados uno por uno con controles: /opt/hammer, /var/lib/hammer, /usr/bin/hammer, /mnt/vvv/hammer, la URL de gitea, hammer-farm.service, hammer-live-install.sh, BRIEFING-hammer.md, hammerd, hammer-recover y HAMMER_LIVE.
14 KiB
ADR 0013 — Mirror de fuentes: la URL es transporte, el sha256 es la identidad
- Estado: ACEPTADO — implementado en
crates/hammer-build/src/fetch.rsyscripts/fuentes/. Ampliado por ADR 0014:TAKANA_MIRRORyTAKANA_MIRROR_GITson 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:
rsync3.4.4 →download.samba.orgdevuelve 404. La tarball ya no está donde la receta la busca.musl1.2.5 →musl.libc.orgno 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:
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
sha256se 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 buildnunca avisa de que usó el mirror. No es su tarea y volvería el log inútil.scripts/fuentes/fuentes-vigia.shrecorre las 1167 fuentes concurl -I(cabeceras, sin descargar) y escribedocs/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:
- Buscar los bytes originales — el mirror, la caché de otra máquina, un mirror público conocido.
Si el
sha256casa, se actualiza la URL y listo, sin re-hasheo. - 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. - Nunca, jamás, el paso 2 disfrazado de paso 1.
Cómo se activa
. scripts/fuentes/mirror-env.sh # TAKANA_MIRROR + TAKANA_MIRROR_KEY
flock work/.farm-build.lock ./target/release/takana --store ./store build <receta>
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
sha256de 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 losarchive/<tag>.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 <commit> | 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:
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
<destino>/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 <destino> …, git la resolvía dentro de <destino> 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 <repo> 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.allowReachableSHA1InWantdesactivado): ahí el--depth 1del 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 <sha>, y una rama sólo apunta a commits: «trying to write non-commit object». Ahora pela con<sha>^{commit}para la rama y, si hubo que pelar, manda el objeto tag aparte enrefs/tags/hammer-objeto. Sin ese segundo ref el bundle desempaqueta bien pero elcat-file -e <commit>del otro lado no encuentra lo que la receta pide: un mirror correcto pero inútil justo para esas recetas. - takana escribía el
commitde la receta en el ficheroshallow, que sólo admite SHAs de commit. Ya no lo supone: lee la frontera del propio bundle congit bundle list-heads, que imprime la lista de refs sin necesitar los objetos y por eso sirve justo antes del fetch. Y traerefs/*: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.