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.
204 lines
13 KiB
Markdown
204 lines
13 KiB
Markdown
# ADR 0014 — Distribución multi-origen: el origen no necesita confianza
|
|
|
|
- **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs`,
|
|
`crates/hammer-core/src/repo.rs` y `crates/hammer-cli/src/main.rs` (`RepoSource`).
|
|
- **Fecha:** 2026-09-01
|
|
- **Frontera:** `bases_de_mirror` / `rotar_bases`, `PackageEntry::digest`, `repo::verify_digest`,
|
|
`RepoSource::{parse,read_index,materialize}`.
|
|
- **Continúa:** [ADR 0013](0013-mirror-de-fuentes.md) (mirror de fuentes) y
|
|
[SDD 19 §3](../19-lanzamiento-publico.md) (infraestructura pública).
|
|
|
|
## Contexto
|
|
|
|
**Todavía no hay usuarios.** Ése es exactamente el momento de decidir esto: el protocolo de
|
|
distribución toca el formato del índice y el comportamiento de clientes ya instalados en máquinas
|
|
ajenas. Cambiarlo después de la primera descarga pública significa romper a alguien o arrastrar dos
|
|
formatos para siempre; cambiarlo hoy no cuesta nada.
|
|
|
|
La pregunta que lo dispara es la correcta: *no quiero apoyarme en un solo servidorcito.* Y hoy nos
|
|
apoyamos exactamente en eso, en tres capas a la vez:
|
|
|
|
| capa | qué sirve | orígenes soportados **antes** de este ADR |
|
|
|---|---|---|
|
|
| fuentes tarball | `{sha256}.tar` | **1** (`TAKANA_MIRROR`) |
|
|
| fuentes git | `{commit}.bundle` | **1** (`TAKANA_MIRROR_GIT`) |
|
|
| repo de paquetes | `index.json` + `.swm` | **1** (`--repo <URL>`) |
|
|
|
|
El ADR 0013 existe precisamente para no depender de 79 servidores ajenos — y lo resolvió creando una
|
|
dependencia de **uno nuestro**. Es el mismo punto único de fallo con nuestro nombre encima.
|
|
|
|
### La medida: qué habría que servir, y cuánto pesa
|
|
|
|
| qué | tamaño | naturaleza |
|
|
|---|---:|---|
|
|
| tarballs de fuentes (`work/tarballs`, 393 objetos) | 2,9 G | inmutable, direccionado por `sha256` |
|
|
| bundles git de fuentes (571 commits) | ~2,3 G | inmutable, direccionado por `commit` |
|
|
| artefactos / paquetes | 36 G en el hub; **~51 G** el espejo publicable tras el split de debug ([SDD 23](../23-plan-rehasheo.md)) | inmutable, direccionado por BLAKE3 |
|
|
| imágenes de instalación | unos pocos G por release | inmutable |
|
|
| **índice firmado del repo** | **KB** | **mutable, y lo único que debe ser fresco y auténtico** |
|
|
|
|
Es decir: **el 99,99% de los bytes es inmutable y verificable por contenido, y el 0,01% restante es
|
|
lo único que necesita confianza.** Esa asimetría es la decisión entera.
|
|
|
|
## Decisión
|
|
|
|
### 1. La partición: raíz chica y firmada, cuerpo grande y sin confianza
|
|
|
|
- **Raíz** — el `index.json` firmado. Debe ser fresca y auténtica. Es de kilobytes, así que puede
|
|
vivir en sitios que no escalan pero sí se controlan: el propio git (que ya se espeja a gitea **y**
|
|
a GitHub, ver `scripts/espejo-setup.sh`), o cualquier host.
|
|
- **Cuerpo** — tarballs, bundles, `.swm`, artefactos, imágenes. Decenas de gigas, y **cero
|
|
necesidad de confianza**: el nombre del objeto ES su verificación, o su hash está anclado en la
|
|
raíz firmada.
|
|
|
|
⇒ El cuerpo puede servirse desde **cualquier host, incluso uno hostil**. Eso convierte «conseguir
|
|
espejos» de un problema de infraestructura y de contratos a un problema de a quién pedírselo.
|
|
|
|
### 2. Todo camino de descarga acepta una LISTA de orígenes, no uno
|
|
|
|
`TAKANA_MIRROR` y `TAKANA_MIRROR_GIT` pasan a ser listas separadas por comas. `--repo` acepta
|
|
`https://a,https://b`. Añadir orígenes **no re-hashea nada**: la URL nunca estuvo en `hash_inputs`
|
|
(ADR 0013 §1), y para el repo el `digest` no es un input de build sino un campo del índice.
|
|
|
|
### 3. Las fuentes ROTAN el orden; el repo lo respeta
|
|
|
|
Distinto a propósito, y la razón importa:
|
|
|
|
- **Fuentes** — es la granja moliendo el corpus sin nadie mirando, cientos de objetos por tanda. Si
|
|
se probara siempre en orden, el segundo origen no se tocaría jamás hasta la emergencia: sería *un
|
|
espejo que nadie prueba*, la lección literal del ADR 0013 §2 un nivel más arriba. Se rota con una
|
|
semilla **determinista tomada del propio hash del objeto**: el mismo objeto sale siempre del mismo
|
|
origen (un fallo se reproduce y se depura), pero objetos distintos se reparten entre todos, así
|
|
que **una tanda del corpus ejercita la lista entera**. Tras el elegido se recorren los demás.
|
|
- **Repo** — es una persona instalando un paquete. El orden es una preferencia suya (el espejo más
|
|
cercano primero) y la previsibilidad vale más que el reparto; además son un puñado de ficheros, no
|
|
una tanda. Se prueban en el orden que escribió.
|
|
|
|
### 4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR
|
|
|
|
La distinción que hace que una lista de orígenes sea una mejora y no un agujero:
|
|
|
|
- Un origen que **no tiene** el objeto (404, host caído, DNS) es normal: se pasa al siguiente en
|
|
silencio. Un espejo puede ir por detrás.
|
|
- Un origen que devuelve **otro contenido** del que la raíz firmada declara es manipulación o
|
|
corrupción. **Se aborta ahí mismo**, no se cae al siguiente origen.
|
|
|
|
Caer al siguiente sería la tentación natural («total, del bueno lo bajo igual») y es justo el patrón
|
|
que este repo ya pagó caro: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo
|
|
que todo fue bien* (CLAUDE.md §3). El paquete acabaría instalado desde el espejo bueno y **nadie se
|
|
enteraría nunca de que uno de los espejos miente**.
|
|
|
|
### 5. El índice firmado ancla el `digest` de cada `.swm` — el hueco que había
|
|
|
|
Antes de este ADR la cadena estaba **rota en el último eslabón**, y no era evidente. La firma del
|
|
índice cubre la lista de entradas: nombre, versión, `file`, deps. Pero `file` es una **ruta, no un
|
|
contenido**: quien sirviera los bytes podía devolver otro `.swm` bajo el mismo nombre y la firma del
|
|
índice seguía casando perfectamente.
|
|
|
|
Había red aguas abajo — `apply` reconstruye desde fuente y compara contra `expected_hash` — pero no
|
|
alcanza: `expected_hash` es **opcional**, y el `.swm` se lee mucho antes de llegar ahí (el gate de
|
|
colisiones de ficheros de `install` ya decide con su contenido). El comentario de `download.rs`
|
|
decía que el `patch_url` «se cubre indirectamente»; *indirectamente* no es una cadena de confianza.
|
|
|
|
Se cierra con `PackageEntry::digest` (BLAKE3, `b3:…`), sellado al publicar y verificado antes de
|
|
escribir un byte. La cadena queda: **clave raíz → firma del índice → `digest` → bytes**.
|
|
|
|
> **CORRECCIÓN (mismo día).** Este párrafo decía que el `digest` usaba «la misma función que sella
|
|
> artefactos», o sea `of_inputs(&[bytes])` — length-prefijado. **Estaba mal, y la razón importa.**
|
|
> Con una sola entrada el length-prefijado no desambigua ninguna concatenación: sólo hace que
|
|
> `b3:<hex>` **deje de ser el hash que obtiene un tercero** con `b3sum` sobre el mismo archivo.
|
|
>
|
|
> Lo destapó escribir la contraparte de este ADR para churay (`02_ruway/churay/SDD-DISTRIBUCION.md`
|
|
> §5 en tawasuyu): churay direcciona sus blobs con `blake3(bytes)` pelado y takana iba a hacerlo
|
|
> length-prefijado, **bajo el mismo prefijo `b3:`** ⇒ el mismo archivo con dos hex distintos, y un
|
|
> CAS compartido que no falla ruidosamente sino que busca nombres distintos para los mismos bytes.
|
|
>
|
|
> Ahora usa `ArtifactHash::of_bytes` — que **ya existía en takana** y ya era blake3 pelado; es la
|
|
> convención de `of_file` y la del `expected_hash` de un `.swm`, así que `of_inputs` era además la
|
|
> pieza fuera de sitio *dentro* del propio repo. Coste del cambio: una línea, porque ningún índice
|
|
> publicado llevaba todavía el campo. Es el argumento de este ADR aplicado a sí mismo — decidir
|
|
> antes de que haya usuarios. Hay guardián: `digest_es_blake3_pelado_y_no_length_prefijado`.
|
|
|
|
El campo es `Option` con `skip_serializing_if`, así que **un índice firmado antes de este cambio
|
|
serializa idéntico y su firma sigue siendo válida** (hay test). Un índice sin digests no bloquea,
|
|
pero **dice cuántos paquetes no verificó**: un índice viejo servido desde un espejo ajeno no es
|
|
seguro, y el silencio lo haría parecer verificado.
|
|
|
|
### 6. Cloudflare R2 como primer origen público — instancia, no arquitectura
|
|
|
|
El criterio no es el precio del almacenamiento (51 G no cuestan nada en ningún sitio): es el
|
|
**egress**, que es lo que quiebra a los proyectos que reparten binarios. R2 no cobra salida.
|
|
|
|
Órdenes de magnitud, **a verificar el día de contratar** — no son cotizaciones:
|
|
|
|
| origen | almacenamiento | egress |
|
|
|---|---|---|
|
|
| Storage Box BX11 (ya pagado) | €3,20/mes por 1 TiB | sin cargo, pero no es un CDN |
|
|
| Cloudflare R2 | ~US$0,015/GB-mes ⇒ **<US$1/mes por 51 G** | **0** |
|
|
| Backblaze B2 + Cloudflare | ~US$6/TB-mes | 0 vía Bandwidth Alliance |
|
|
| Hetzner Object Storage | ~€6/mes (1 TB + 1 TB) | incluido hasta el TB |
|
|
|
|
**Lo que decide este ADR no es «usamos R2»: es que R2 sea una entrada más de una lista.** Si mañana
|
|
cierra la cuenta, se añade otra base a la lista y no cambia una línea de código ni se re-hashea un
|
|
artefacto. La condición de no depender de un servidorcito no se cumple contratando un proveedor
|
|
grande — se cumple pudiendo perder cualquiera de ellos sin enterarse.
|
|
|
|
Regla que se deriva: **nunca menos de dos orígenes en dos proveedores distintos**. Hoy el repo, el
|
|
respaldo y el mirror de fuentes están los tres en Hetzner y en la misma cuenta — riesgo que el ADR
|
|
0013 ya dejó anotado sin resolver, y que R2 resuelve por ser de otra casa.
|
|
|
|
### 7. ⛔ Lo que NUNCA sale hacia el CDN
|
|
|
|
La **clave raíz de firma**, que es offline y es lo único no delegable de todo el lanzamiento
|
|
([SDD 19 §3.2](../19-lanzamiento-publico.md), [SDD 20](../20-catalogo-publicable-y-completa.md)). Un
|
|
CDN sirve bytes públicos; firmar es lo contrario de publicar. Si la clave estuviera en el sitio desde
|
|
donde se sirve, los N espejos dejarían de ser «orígenes sin confianza» y pasarían a ser N copias de
|
|
la autoridad.
|
|
|
|
## Verificado de punta a punta, no por inspección
|
|
|
|
Dos servidores HTTP locales con el **mismo `index.json`** (mismo nombre de paquete, mismo `file`,
|
|
mismo `digest`) y `.swm` distintos: el bueno y una copia con **un solo byte cambiado** — el mismo
|
|
tamaño, el mismo nombre de fichero. Es exactamente lo que puede hacer un espejo que no controlamos.
|
|
|
|
| escenario | resultado | qué prueba |
|
|
|---|---|---|
|
|
| 1. sólo el origen bueno | pasa el digest, baja, carga el `.swm`, y muere después en el `config_edit` (el paquete sintético toca un `/etc/network.conf` que no existe) | la descarga y la verificación se cruzan de verdad |
|
|
| 2. sólo el origen manipulado | `Error: digest de 'demo' no coincide … el origen entregó un .swm distinto del que se firmó`, exit 1 | el byte cambiado se detecta |
|
|
| 3. origen **muerto** primero (:9999), bueno detrás | llega al mismo punto que (1), y reporta `bajados 1 .swm de http://127.0.0.1:8801` | **la lista hace failover ante ausencia** |
|
|
| 4. origen **manipulado** primero, bueno detrás | aborta con el mismo error que (2), exit 1 | **NO cae al siguiente ante contenido distinto** — §4 |
|
|
|
|
(3) y (4) son el par que importa: sin el segundo, la lista sería un mecanismo para tapar espejos
|
|
mentirosos en vez de para sobrevivir a espejos caídos.
|
|
|
|
Además, el mensaje de (3) nombra **el origen que sirvió de verdad**, no la lista entera. Decir
|
|
«bajado de \<lista\>» cuando sólo uno respondió es una media verdad que esconde justo el dato con el
|
|
que se depura un espejo caído — el mismo error de diagnóstico que el ADR 0013 documenta en el
|
|
poblador de bundles.
|
|
|
|
## Consecuencias
|
|
|
|
- **Cero re-hasheos.** La URL no está en `hash_inputs` y `digest` no es un input de build.
|
|
- **Cero roturas de firmas existentes.** Verificado por test: sin `digest`, `PackageEntry` serializa
|
|
idéntico a antes.
|
|
- Un espejo nuevo pasa a ser un `rsync` plano sin índice, sin coordinación y sin confianza. La
|
|
barrera de entrada para que un tercero nos espeje es más baja que la de una distro clásica, y eso
|
|
es una consecuencia directa del direccionamiento por contenido, no un favor que pedimos.
|
|
- Aparece deuda de vigilancia (ver abajo): más orígenes = más sitios que pueden pudrirse en silencio.
|
|
|
|
## Lo que este ADR NO decide
|
|
|
|
- **El vigía de los espejos.** `scripts/fuentes/fuentes-vigia.sh` vigila **upstream**, no nuestros
|
|
orígenes. Con una lista de N, hace falta comprobar que **cada uno** tiene lo que dice tener — si no,
|
|
volvemos a la métrica que envejece hacia el optimismo. Es el trabajo pendiente más claro que deja
|
|
este documento.
|
|
- **Qué origen es la verdad al publicar** (cómo se propaga un release de uno a los demás, y qué pasa
|
|
si la propagación se corta a media tanda: un espejo con índice nuevo y `.swm` viejo).
|
|
- **Las imágenes de instalación**: son ficheros grandes de un solo tiro y el reparto natural es
|
|
torrent con webseed, que no comparte mecánica con esto.
|
|
- **La rotación de claves de firma** y el procedimiento ante filtración — sigue siendo de
|
|
[SDD 19 §3.2](../19-lanzamiento-publico.md), y sin él la firma sigue siendo teatro por muchos
|
|
orígenes que haya.
|
|
- **La concentración en `github.com`** (64% del corpus como fuente). Este ADR no la toca; sigue
|
|
pendiente desde el 0013.
|