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 1426Shell 142Markdown 86Rust 842929 Definitions

⎇ 75dddc0e86ee408c1b1d9976b63361df1aa970ac docs/adr/0013-mirror-de-fuentes.md Markdown · 240 lines · 13.3 KB History raw

ADR 0013: el pin de una receta no siempre es un commit ce69ea23d · Sergio ·

1# ADR 0013 — Mirror de fuentes: la URL es transporte, el `sha256` es la identidad
2
3- **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs` y `scripts/fuentes/`.
4- **Fecha:** 2026-08-26
5- **Frontera:** `fetch_tarball` / `fetch_git`, `scripts/fuentes/mirror-poblar.sh`,
6  `scripts/fuentes/fuentes-vigia.sh`.
7
8## Contexto
9
10Dos recetas del corpus **no se pueden construir hoy, y no por culpa nuestra**:
11
12- `rsync` 3.4.4 → `download.samba.org` devuelve **404**. La tarball ya no está donde la receta la busca.
13- `musl` 1.2.5 → `musl.libc.org` **no responde** desde gioser (curl 000, connection reset).
14
15No es mala suerte: es el estado estacionario. Una distro que construye **todo desde fuente** tiene
16tantos puntos de fallo como fuentes distintas, y esos puntos son servidores de terceros que nadie
17nos prometió mantener. El problema sólo crece con el tiempo.
18
19### La medida, que es peor de lo que parece
20
21Sobre las 1167 recetas del árbol (561 tarball + 606 git, 79 hosts distintos):
22
23| recetas | host |
24|--------:|------|
25| **742** | `github.com` |
26| 104 | `download.kde.org` |
27| 68 | `download.gnome.org` |
28| 29 | `gitlab.freedesktop.org` |
29| 23 | `ftp.gnu.org` |
30
31**Un solo host sostiene el 64% del corpus.** Doce hosts sostienen el 89%. En el otro extremo, **43
32hosts sostienen exactamente una receta cada uno**: proyectos pequeños, dominios personales, cosas que
33se apagan sin aviso. Ahí es donde el bit-rot muerde despacio, y `musl.libc.org` y `download.samba.org`
34son justo eso.
35
36Dicho de otra forma: **la reproducibilidad del corpus está cerrada hacia adentro y completamente
37abierta hacia afuera.** Tenemos hashes de todo, verificación bit a bit y un store direccionado por
38contenido — apoyado sobre 79 servidores ajenos.
39
40## Decisión
41
42### 1. La URL nunca fue la identidad, y el código ya lo sabía
43
44`Recipe::hash_inputs` calcula:
45
46```rust
47let source_id = match self.source.kind()? {
48    SourceKind::Git { commit, .. } => format!("git:{commit}"),
49    SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
50};
51```
52
53El `..` **descarta la URL**. Y la caché de tarballs se nombra `{sha256}.tar`, con un comentario que
54ya lo dice: *«dos URLs distintas con el mismo contenido se cachean una vez, y cambiar la URL sin
55cambiar el sha (mirror) no invalida la caché»*.
56
57⇒ **Añadir mirrors no re-hashea absolutamente nada.** No es una consecuencia afortunada: es que la
58identidad del contenido siempre estuvo en el `sha256` y el `commit`. La URL es una sugerencia sobre
59dónde buscar los bytes. Esto ya estaba diseñado; lo que faltaba era el mecanismo.
60
61### 2. Orden de resolución: caché local → mirror propio → upstream
62
63El mirror va **antes** que upstream, no después. Razones, en orden:
64
65- El `sha256` se verifica igual en los tres casos, así que **no hay diferencia de contenido posible**.
66  Preferir upstream «por pureza» no compra nada: compra latencia y un tercero en el camino.
67- Los workers de la granja son efímeros y descargan el corpus entero en cada ciclo. Pegarle 561
68  veces a servidores ajenos es a la vez frágil y de mala educación.
69- Un mirror sólo de rescate es un mirror que **nadie prueba**. Se descubre roto el día que hace falta,
70  que es exactamente el día en que no se puede arreglar.
71
72### 3. El mirror NO puede tapar el bit-rot: la vigilancia va SEPARADA
73
74Ésta es la parte que hay que hacer bien, y es una lección cara de este mismo repo.
75
76Un mirror que sirve calladamente lo que upstream ya perdió convierte un fallo **ruidoso** en
77**silencio**. Las URLs se irían muriendo una por una sin que nadie se entere, y el día que el mirror
78se pierda el corpus resultaría irreconstruible — con todos los indicadores en verde hasta ese
79momento. Es el mismo patrón que dejó el grafo de wlr congelado 17 días anunciando `121/121`: **una
80métrica que nadie refresca envejece hacia el optimismo.**
81
82Por eso **construir** y **vigilar upstream** son dos trabajos distintos y van en dos sitios distintos:
83
84- `hammer build` **nunca** avisa de que usó el mirror. No es su tarea y volvería el log inútil.
85- `scripts/fuentes/fuentes-vigia.sh` recorre las 1167 fuentes con `curl -I` (cabeceras, sin
86  descargar) y escribe `docs/state/fuentes-vigia.json`: qué URLs siguen vivas, cuáles dan 404,
87  cuáles no responden. **Lo corre el latido**, como el grafo de estado.
88
89Una URL muerta deja de ser una emergencia en mitad de un build y pasa a ser una línea en un informe.
90
91### 4. Poblar es un efecto secundario, no una tarea
92
93Toda descarga **verificada** se promueve al mirror. El mirror se llena solo, construyendo.
94`scripts/fuentes/mirror-poblar.sh` hace la carga inicial desde `work/tarballs` y sirve para
95rellenar lo que falte.
96
97El mirror vive en el Storage Box, bajo `hammer/fuentes/`, direccionado por contenido:
98`hammer/fuentes/{sha256}.tar`. Es el mismo Storage Box del respaldo (1 TiB, 938 G libres, ~€3,20/mes
99ya pagados) — no hay infraestructura nueva que mantener.
100
101### 5. ⛔ PROHIBIDO cambiar el `sha256` para «arreglar» una URL muerta
102
103La regla más importante del documento, porque es la tentación natural cuando un build falla con 404:
104buscar la tarball nueva, pegar el `sha256` nuevo, seguir adelante.
105
106**Eso no arregla una descarga: cambia lo que la distro construye.** Un `sha256` distinto es otro
107contenido — otra versión, otro tarball re-empaquetado, o un compromiso de upstream. La receta seguiría
108llamándose `rsync 3.4.4` y estaría construyendo otra cosa, con el agravante de que el artefacto
109resultante se sella como si tal cosa.
110
111Ante una URL muerta, en este orden:
112
1131. **Buscar los bytes originales** — el mirror, la caché de otra máquina, un mirror público conocido.
114   Si el `sha256` casa, se actualiza la URL y **listo, sin re-hasheo**.
1152. Si no aparecen en ningún sitio, es un **cambio de versión** con su propia auditoría: se mira qué
116   cambió, se actualiza `version` + `sha256`, y se acepta el re-hasheo en cascada que corresponda.
1173. Nunca, jamás, el paso 2 disfrazado de paso 1.
118
119## Cómo se activa
120
121```sh
122. scripts/fuentes/mirror-env.sh          # HAMMER_MIRROR + HAMMER_MIRROR_KEY
123flock work/.farm-build.lock ./target/release/hammer --store ./store build <receta>
124```
125
126**El mirror es ADITIVO: sin `HAMMER_MIRROR` el comportamiento es exactamente el de siempre.** No se
127hornea un default en el binario a propósito — apuntar por defecto a una máquina concreta convertiría
128un fallo de red en un fallo de `hammer` para cualquiera que clone el repo.
129
130⚠️ La ruta sftp lleva `/~/`. curl trata lo que sigue al host en `sftp://` como ruta **absoluta** del
131servidor: `…:23/hammer/fuentes` busca en la raíz y da «(78) Could not open remote file for reading»
132**aunque el fichero exista**, que es un error que parece de permisos o de ausencia y es de ruta.
133
134### Verificado de punta a punta, no por inspección
135
136Primer intento de prueba: construir `busybox` (upstream muerto) con el mirror puesto. Dijo `BUILD
137OK` **y no probó nada** — busybox ya estaba sellado, así que hubo cache-hit del artefacto y no se
138descargó un solo byte. La prueba válida es una receta cuyo artefacto NO exista:
139
140- receta efímera con el `sha256` de un tarball que **sí** está en el mirror,
141- y una URL upstream que **ni resuelve por DNS** (`https://este-host-no-existe.invalid/…`).
142
143Sin `HAMMER_MIRROR`: falla con `curl (6) Could not resolve host`. Con `HAMMER_MIRROR`: **sella**, y el
144artefacto tiene el contenido real. Los bytes no pudieron venir de ningún otro sitio.
145
146## Consecuencias
147
148- **Cero re-hasheos.** Verificado: recalculadas las 794 recetas del grafo wlr antes y después, 0 cambios.
149- El corpus deja de depender de que 79 terceros sigan sirviendo los mismos bytes.
150- **La concentración en `github.com` (64%) no la arregla este ADR** — la mitiga. Sigue siendo el
151  riesgo estructural mayor del proyecto y merece su propia decisión. ADR 0006 (commits pineados) ya
152  cubre la parte de que GitHub **regenera** los `archive/<tag>.tar.gz`, que es un problema distinto y
153  peor: ahí los bytes cambian sin que cambie la URL.
154- Aparece una dependencia nueva: el Storage Box. Es aceptable porque es **caché reconstruible**, no
155  la verdad: la verdad son las recetas en git. Si el mirror se pierde, se repuebla desde cualquier
156  máquina que tenga `work/tarballs`.
157
158## Fuentes git: bundles shallow por commit
159
160Los 606 repos por commit son el 52% de las fuentes y no los cubre el mirror de tarballs. Se espejan
161como **bundles**, en un espacio de nombres propio: `hammer/fuentes-git/{commit}.bundle` (571 commits
162distintos — hay commits compartidos entre colas, y se espeja uno solo).
163
164**La identidad es el commit y la verificación la hace git.** Al desempaquetar, git comprueba cada
165objeto contra su SHA: un bundle alterado no pasa. No hace falta índice ni un `sha256` aparte, igual
166que en los tarballs el nombre del fichero ES su verificación.
167
168### Shallow, no clones completos
169
170El bundle se genera desde un `git fetch --depth 1` del commit exacto. Para `act` son **9,3 MB en vez
171del repositorio entero**, y con 571 fuentes esa diferencia decide si el mirror cabe. Es legítimo
172porque **hammer nunca usa la historia**: lo único que hace con un repo es `git archive <commit> |
173tar -x`, o sea materializar un árbol.
174
175### El detalle que costó encontrar: el fichero `shallow`
176
177Un bundle hecho desde un repo shallow **no lleva la frontera de historia**. Al desempaquetarlo:
178
179```text
180fatal: Failed to traverse parents of commit 4f41128…
181error: … did not send all necessary objects
182```
183
184El mensaje dice «no envió todos los objetos necesarios» y **es engañoso**: los objetos del commit
185llegan enteros — de hecho `git archive` ya funciona pese al error. Lo que falta no es un objeto, es
186decirle a git dónde termina la historia. Se arregla escribiendo el propio commit en
187`<destino>/shallow` **antes** del fetch, y no hay que transportar nada: la frontera de un clon
188`--depth 1` es exactamente ese commit.
189
190### Y un bug propio que vale documentar
191
192`hidratar_desde_bundle` pasaba al `git fetch` la ruta **relativa** del bundle. Como `run_git` invoca
193`git -C <destino> …`, git la resolvía **dentro de `<destino>`** y no encontraba nada. Como el fallo
194del mirror se traga a propósito (para caer a upstream), el síntoma salía lejísimos del origen: el
195build moría con *«commit … no existe en \<repo\> tras fetch»*, **culpando a upstream de un error de
196ruta local**. Se arregla con `canonicalize`. Es el precio de que el mirror falle en silencio, y por
197eso el silencio se paga con comentarios explícitos en el código.
198
199## Lo que este ADR NO decide
200
201- Qué hacer con la concentración en GitHub.
202- Si el mirror debe replicarse fuera de Hetzner (hoy el repo, el respaldo y el mirror están todos en
203  la misma cuenta y el mismo proveedor).
204- El **coste de mantener el mirror git al día**: un commit nuevo en una receta es un bundle nuevo, y
205  hoy eso lo dispara una persona corriendo `mirror-git-poblar.sh`, no el latido.
206- Qué hacer con los repos cuyo servidor **no permite fetch por SHA suelto**
207  (`uploadpack.allowReachableSHA1InWant` desactivado): ahí el `--depth 1` del commit exacto falla y
208  hay que caer a un clon completo. **Todavía no ha aparecido ninguno.**
209
210  El poblador reporta el **stderr real de git**, no una conjetura. Lo escribí al revés la primera
211  vez — imprimía siempre `✗ upstream no da el commit` — y en la primera tanda marcó así a tres
212  recetas (`diffutils`, `findutils-xargs`, `kustomize`) cuyos commits **sí se traen a mano**. En
213  cuanto imprimió el error de verdad, la causa resultó ser otra por completo: el pin de esas tres no
214  es un commit (§ siguiente). Un fallo ahí puede ser el servidor negando el sha, pero también un
215  timeout, DNS, un rate-limit o —como fue— un bug propio, y cada uno se arregla distinto. **Un
216  diagnóstico hardcodeado convierte cualquier fallo en una conclusión falsa sobre upstream**, que es
217  el mismo error que ya costó caro con la ruta relativa del bundle.
218
219## El pin de una receta no siempre es un commit
220
221`diffutils`, `findutils-xargs` y `kustomize` pinean en su campo `commit` el SHA de un **objeto tag
222anotado** (`v0.5.0`, `0.9.1`, `kustomize/v5.8.1`), no de un commit. Es legítimo: `git archive` acepta
223un tag igual que un commit, y para `hash_inputs` un SHA es un SHA — `git:{commit}` no distingue el
224tipo de objeto y no tiene por qué. Pero rompía las dos puntas del mirror, y en las dos por la misma
225suposición sin escribir:
226
227- **El poblador** hacía `update-ref refs/heads/hammer <sha>`, y una rama sólo apunta a commits:
228  *«trying to write non-commit object»*. Ahora pela con `<sha>^{commit}` para la rama y, si hubo que
229  pelar, manda el objeto tag aparte en `refs/tags/hammer-objeto`. Sin ese segundo ref el bundle
230  desempaqueta bien pero el `cat-file -e <commit>` del otro lado no encuentra lo que la receta pide:
231  un mirror **correcto pero inútil justo para esas recetas**.
232- **hammer** escribía el `commit` de la receta en el fichero `shallow`, que sólo admite SHAs de
233  commit. Ya no lo supone: lee la frontera del propio bundle con `git bundle list-heads`, que imprime
234  la lista de refs **sin necesitar los objetos** y por eso sirve justo antes del fetch. Y trae
235  `refs/*:refs/*` en vez de un ref concreto, para que el objeto tag viaje con lo demás.
236
237Los bundles del formato viejo (un solo ref, los 339 ya subidos) siguen sirviendo sin tocarlos: para
238ellos `list-heads` devuelve el commit y el refspec ancho encuentra ese mismo ref único. Comprobado
239con una receta de cada forma contra un `repo` que no resuelve por DNS, para que el mirror sea la
240única fuente posible.

0013-mirror-de-fuentes.md

ADR 0013 — Mirror de fuentes: la URL es transporte, el sha256 es la identidad

Contexto

Dos recetas del corpus no se pueden construir hoy, y no por culpa nuestra:

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):

recetashost
742github.com
104download.kde.org
68download.gnome.org
29gitlab.freedesktop.org
23ftp.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:

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:

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

. scripts/fuentes/mirror-env.sh          # HAMMER_MIRROR + HAMMER_MIRROR_KEY
flock work/.farm-build.lock ./target/release/hammer --store ./store build <receta>

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:

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

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: hammer/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 hammer 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

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:

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.