Files
takana/docs/adr/0014-distribucion-multiorigen.md
Sergio 97ceb72411 takana etapa 5b: los 59 docs de diseño, runbooks y ADR
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.
2026-09-09 19:25:51 +00:00

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.