main
221
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e0e390c282 |
repo list/verify: hablan HTTP — un repo remoto con 173 paquetes se leía como vacío
`repo list --repo <url>` tomaba un `PathBuf`, así que trataba la URL como una ruta relativa que no existe y respondía **«repo vacío» con salida 0**. No es que no soportara el caso: es que afirmaba lo contrario de lo que pasaba, y con rc=0, que es la forma de fallo que un script no puede detectar. Salió al probar el repo del dominio nuevo. `install --repo` ya sabía hablar HTTP con `RepoSource` (ADR 0014: lista de orígenes separada por comas, probados en orden). `list` y `verify` ahora usan el MISMO resolvedor, así que la misma cadena de espejos vale en los tres verbos. `sign` se queda local a propósito: firmar es escribir. Y `verify` remoto no es un extra — es el caso de uso del ADR 0014. Verificar el release de un espejo ANTES de instalarle nada era imposible sin construir. De paso, tres estados que se daban por iguales y ahora se distinguen (CLAUDE.md §3: un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien): dir que NO EXISTE → error, rc=1, y sugiere que quizá era una URL dir sin index.json → «repo por crear» (estado válido: lo crea `pack --repo`) índice con 0 paquetes → «índice publicado y SIN paquetes» origen que no sirve → error del fetch, con los orígenes probados Y el índice se atribuye al origen que REALMENTE lo sirvió, no a la lista entera: con `<espejo-caído>,<bueno>` la salida dice el bueno. Es la misma regla que el propio `fetch_desde_algun_origen` aplica a los `.swm` —«decir bajado de la lista entera cuando sólo uno respondió es una media verdad»— que `read_index` tiraba. Medido contra el repo vivo: `list` 173 paquetes [release firmado por release], `verify` trusted. 93+93+6+1+3+4 tests del crate en verde. |
||
|
|
6809ef4be2 |
simi: el /bin/sh propio del producto — 4448 líneas, sólo libc, y CERO divergencias contra el ash
«simi» es boca/lengua en quechua. Es el sh que el frente C necesitaba: busybox está en la raíz de confianza del arranque (STAGE1_COMPONENTS, ATTEST_PATHS) y en el exec de cada card, y el candidato ajeno que se midió —brush 0.4.0— pesa 6,9 MB, arranca 4x más lento que el ash y trae dos divergencias medidas (#1394, #1396). Alcance MEDIDO, no supuesto: las 23 cards con shell (8 builtins, 13 construcciones), los 21 /init empotrados de las imágenes (+ if, funciones, case, aquí-documentos) y el POSIX de takana-live-install.sh. Resultado del banco diferencial, 159 casos contra el ash de busybox 1.36.1 (10 fixtures de regresión, 107 de tortura de comillas, 23 fragmentos de cards, 19 /init empotrados): simi DIVERGE=0 bash DIVERGE=8 brush DIVERGE=35 Incluye el bucle REAL de config.status que genera cualquier configure de autotools — el que tumbó a brush. 499 KB release, 1,4x la latencia del ash (brush: 4,1x). 47 pruebas propias en verde. Seis bugs que el banco destapó, y ninguno se veía sin un control al lado: 1. El lexer fundía lo entrecomillado con lo desnudo, así que x="a b" se ejecutaba como ORDEN y el error decía «no encontrado» — mandando a buscar al lugar equivocado. Ahora el comillado es por carácter. 2. Usaba la barra invertida como marca interna y la quitaba al final, así que una barra PRODUCIDA por una sustitución se perdía. La quita de comillas es sobre las comillas de la palabra, nunca sobre el resultado de una expansión: hace falta una máscara de protección, no un escape dentro del texto. 3. set -e no se suspendía dentro del cuerpo de una función llamada desde una condición. Ahora es un contador dinámico, que entra solo donde corresponde. 4. Los delimitadores de la sustitución de orden y de ${ } no respetaban la barra invertida: un paréntesis escapado cerraba la sustitución. 5. Las clases entre corchetes no entendían la barra invertida — y la del config.status de libtool es literalmente una clase con cuatro escapes. 6. Dentro de comillas dobles el cuerpo de un backtick sigue entre comillas, así que la barra también se elimina ante la comilla doble. Y uno que el banco NO vio y sí vio la prueba de regresión: el trap EXIT HEREDADO se disparaba en el subshell. POSIX §2.12 manda resetearlo — es la mitad complementaria del #1396 de brush, y al revés. Los dos oráculos hacen falta; el caso quedó agregado al fixture 08. |
||
|
|
26c153c149 |
overlay: el commit no podía desmontar /bin en una máquina viva — y el código prometía el perezoso sin hacerlo
La primera instalación real de un paquete en la caja (`takana install zsh`, sin --prefix) dejó el sistema A MEDIAS: `commit` desmontó 5 de 7 targets y murió con `target is busy` en /bin, con dos overlays montados y el estado sin promocionar. La causa no es un fd abierto: los procesos tienen su EJECUTABLE mapeado desde /bin y /usr/bin —empezando por PID 1, /usr/bin/arje-zero— y un ejecutable mapeado pin-ea el montaje. En un FHS vivo eso no se puede evitar esperando. El comentario de `do_umount_lenient` prometía el desmontaje perezoso «desde la Fase 2» y el código NUNCA pasaba `-l`. Ahora, ante `busy`, reintenta perezoso: desengancha el montaje ya mismo (las rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el último proceso que lo miraba se muere; los vivos ven el mismo contenido porque el commit copia el upper al lower justo después. Probado en la caja con un paquete nuevo: install tree ⇒ overlay; commit ⇒ WARN del perezoso sobre /usr/bin y «commit OK — 1 archivo(s) promocionados»; 0 overlays, /usr/bin/tree real, tree v2.3.2. El SDD 28 §5.10 anota las otras cuatro cosas que ese `sudo takana install zsh` destapó: el repo por defecto que no existía, el lab que no se encontraba desde /root, el binario del sistema incapaz de leer los .tkn nuevos, y —la peor— que la instalación queda PARTIDA porque el overlay cubre siete directorios y /usr/share no es uno: 1296 de los 1331 ficheros fueron directos al FHS real mientras 2 binarios esperaban el commit, y el mensaje decía `apply OK` igual. |
||
|
|
7be17b5a2a |
pack: el control del target_bin AVISA, no aborta — abortar dejaba 65 paquetes fuera del catálogo
El control que agregué en el commit anterior era el natural y estaba mal: publicar el perfil `servidor` entero con él dejó 65 de 175 paquetes fuera, porque son LIBRERÍAS (zlib, ncurses, openssl, musl-*, gmp…) que no traen ningún binario y existen para que otros las resuelvan como dep — un campo que su consumidor ni mira habría tirado un tercio del repo. Lo destapó el barrido, no el razonamiento: la versión que abortaba pasaba los tests igual. Ahora: corrige el path si el binario quedó en otro bindir (que es el caso que importa), y si no hay ninguno lo dice y publica igual. Medido con el perfil `servidor`: 162 publicados y firmados, 8 con target_bin corregido (bash, sed, tar, parted, dhcpcd, squid, wpa_supplicant, zsh — todos publicados hasta hoy prometiendo un path que no existe), 64 sin binario, 12 sin sellar en este store, 1 fallo (os-release, ya conocido). Contra el repo firmado por HTTP: parted (6 parches + binario en /usr/sbin) instala en 0,17 s y responde 3.7; bash responde 5.3.0(1). Queda vivo: `install <librería>` directo sigue fallando tras hidratar (el .swm exige que el target_bin exista). Hacer el campo opcional toca el formato — su propia unidad de trabajo. |
||
|
|
9fe1e010e2 |
paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza
Cerrar el lazo servir→instalar sobre `zsh` (pedido del usuario) destapó dos fallos de la familia
del `strip_debug` del SDD 28 §5.2: cosas que el `.swm` no transportaba fiel y que sólo se ven
cerrando el lazo, no leyendo el código.
1. `pack` CONCATENABA los N parches en el `patch` inline único, y `hash_inputs` mete una entrada
por parche (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra dirección y el
`expected_hash` no coincidía NUNCA. Medido: zsh anclado b3:0be3630d…, reproducido b3:d6329a62…
(idéntico al de una copia de la receta con sus 6 parches concatenados a mano). Afectaba a las
16 recetas del corpus con ≥2 parches. Ahora viajan como lista `patches`, en orden, un fichero
por parche del lado del receptor; el `patch` único se sigue leyendo para no invalidar lo
publicado. Regresión con control negativo en swm_bridge.
2. `target_bin` se adivinaba `/usr/bin/{name}`; zsh instala en `/bin` (--bindir=/bin), así que el
paquete prometía un path que el artefacto no tiene y el error salía en el cliente DESPUÉS de
hidratar 1331 ficheros. Con `--build` el artefacto está delante: se comprueba, se corrige
diciéndolo, y si no hay ningún binario con ese nombre se ABORTA al publicar.
Y el verbo que faltaba para «avisame cuándo actualizar»: `takana outdated` compara la DB de
instalados con el índice firmado (por hash, no por versión: una re-publicación con la misma
etiqueta es otro artefacto). No construye, no baja .swm y no actualiza — instalar es verificar.
Medido tras el arreglo: install zsh ⇒ cache-hit del hash anclado, 1331 ficheros en 0,14 s, zsh 5.9
corriendo.
|
||
|
|
a1ccce2b65 |
squid mudado — y la jaula arrancaba DEGRADADA cuando la arranca un init
El proxy de salida sirve desde la caja: `squid 7.7` en 2.29.29.217:1137 con la config, el `passwd` de los tres usuarios y las dos ACL de gioser tal cual, dentro de una instancia qorpa y supervisado por arje. El de gioser sigue vivo e intacto (7.6). Es un servicio con gente encima: el access.log del origen mostraba CONNECT a api.anthropic.com y a claude.ai en el minuto anterior a mudarlo. Control en los dos sentidos: desde una IP no autorizada, 407 igual que el original; desde localhost, que su propia config permite, TCP_TUNNEL/200 a claude.ai y api.anthropic.com. EL MURO QUE VALE, y es un bug de qorpa: **el rango de subuid se buscaba por `$USER`**. Una shell interactiva lo trae; un init NO. Bajo arje la tarjeta arranca con PATH y HOME, el nombre salia vacio, no habia rango, y la jaula caia al userns de UN SOLO ID — con lo que squid moria en `setgid(15): (22) Invalid argument` y el supervisor lo reintentaba 80 veces. El aviso de qorpa estaba impreso y era correcto («no hay rango para "" en /etc/subuid»), pero lo que se ve en el bucle es el error de squid: **la degradacion silenciosa la paga el de mas abajo**. Y el MISMO comando a mano funcionaba, porque la shell si pone `$USER`: el sintoma dependia de quien lo arrancaba. ⇒ `nombre_de_usuario()` deriva el nombre del UID REAL leyendo /etc/passwd y el entorno queda como ultimo recurso, con la mitad pura (`nombre_en_passwd`) probada, control negativo incluido: un uid que no esta NO inventa un nombre — devolver algo ahi haria buscar un rango ajeno. Los otros tres muros quedan documentados en §6.27: provisionar ANTES de montar la config (pacman aborta si el paquete no puede escribir sus defaults), `/dev/shm` que bwrap crea 0755 y squid no puede usar al bajar a `proxy`, y el pidfile que en una jaula con `--unshare-pid` SIEMPRE parece fresco porque cada corrida tiene su propio PID 2. Y la pregunta que corresponde —¿no va en una receta?— con su respuesta: si, y sigue pendiente. La jaula fue la via urgente. La diferencia con php-fpm importa: PHP no queremos que entre al corpus, squid es C y su sitio natural es una receta como la de gitea. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d52824efeb |
mudados tawasuyu.net y gioser.net — y los padres que inventa bwrap eran 0700
Los dos sitios que quedaban con trafico real sirven desde la caja `takana`, con TLS publico y
verificados desde fuera. Los servicios viejos SIGUEN corriendo en gioser a proposito (decision del
usuario: mover el DNS y dejar el origen vivo, para poder volver en un minuto).
· `tawasuyu.net` + `www`: la raiz en gioser era EL MONOREPO ENTERO (145 G) servido por HTTP. El log
de accesos dice que las unicas rutas con trafico son `/`, `/descargas` y `/web`, asi que se
replicaron solo los dos subarboles que el sitio usa (1,2 M + 3,8 G) con la MISMA estructura, para
que cada `rewrite` siga siendo el mismo.
· `gioser.net` + `www`: 428 M de estaticos + `/reencuentro`, que necesita PHP. `/hooks/*` NO se
muda: lo atiende `webhook-deploy.py`, que redespliega aura/sigma/summa/brahman — los cuatro
FOSILES del §6.19 — y tiene cero peticiones. Muere con la caja vieja.
· `sergio` dejo de ser `CNAME -> www` y tiene su A propio (apuntando a gioser, sin cambio visible).
Sin eso, mover `www` se lo llevaba puesto: en esa zona casi todo cuelga de `www`.
PHP no entra al corpus y no hace falta: `php-fpm 8.5.10` corre en la instancia `gioser-php` (ADR
0015), supervisado por arje. Control contra el original en los dos sentidos: POST con la trampa
anti-robots da `{"ok":true}` 200 y GET da 405, igual que en gioser.
EL MURO, que es general y no de PHP: para montar en `/work/www/gioser-web`, bwrap crea `/work` y
`/work/www` —que la imagen no trae— y los crea **0700 root**. Adentro somos root y a mano todo
funciona; pero un servicio que BAJA de privilegio (php-fpm a `http`, o cualquier instancia con
`run_as`) no puede ni atravesarlos, y el sintoma es un «File not found» sobre un fichero que ESTA.
Medido con el control que lo separa de un problema de permisos del anfitrion: como `http`, leer un
fichero de la IMAGEN funciona y leer el directorio CONCEDIDO da Permission denied.
`grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y SOLO los que la
imagen no trae: hacerlo sobre `/etc` o `/home` le cambiaria los modos a la imagen. Con test, y
probado rompiendolo a proposito (sale `["/etc","/etc/php"]` en vez de `["/etc/php"]`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
dbfb7d492b |
la jaula no viajaba en NINGUNA imagen: bwrap y harkaq-exec entran a perfil.base
Mudando `api.sergio.gioser.net` a la caja de produccion, `takana qorpa provision` aborto:
Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
construilo: gcc -O1 -Wall -static -o ... scripts/harkaq/harkaq-exec.c
El mensaje es bueno y la receta que propone es IMPOSIBLE de seguir: en una caja instalada no hay
gcc, ni `scripts/`, ni arbol de desarrollo. El binario solo existia como un `gcc` a mano en la
cache de `$HOME` del hub, o sea que la jaula del ADR 0015 funcionaba unicamente en la maquina
donde alguien la habia compilado.
Y su companero estaba igual, medido en `build-state.json`: `bwrap` sellado desde hace meses con
`"perfiles": []` — CERO perfiles. Lo invocan por PATH tanto `qorpa` como el sandbox de
`takana build` (`takana-build/src/sandbox.rs`), asi que **ninguna imagen de takana podia enjaular
nada, ni construir**. Es [[subcomando-sin-driver]] un piso mas abajo: el CLI que los llama viaja
en todas las imagenes y sus herramientas en ninguna.
Tres piezas:
· `recipes/harkaq-exec.toml` — nueva. Estatico musl, `b3:cd34954f...`, 269 K. El pin va al commit
que toco la FUENTE (`1d9ddcee`, 2026-09-03) y no a HEAD: el `.c` no se mueve desde entonces y el
repo commitea cada media hora por el cron de la cosecha — pinear HEAD re-hashearia la receta cada
media hora sin que su fuente cambiara. La fuente sigue en `scripts/harkaq/` y no en un arbol
propio porque tres scripts la compilan desde ahi y dos copias divergen en silencio.
· `qorpa.rs` — busca el binario tambien en `/usr/bin`, que es de donde sale en cualquier maquina
que no sea el hub. El orden es HARKAQ_BIN (lo que el operador declara) → arbol de desarrollo →
paquete, para que un cambio en la jaula se pruebe sin instalar nada. Y el error ya nombra las
dos salidas, no solo la del hub.
· `targets.toml` — los dos en `perfil.base`.
Medido en la caja de produccion tras aplicarlos: `bwrap 0.11.0` + harkaq-exec responden, y
`takana qorpa provision sergioh-api` instala python 3.14.7 con pacman DENTRO de la jaula
(`[harkaq] jaula puesta: ABI 9`). El 3.14 no es casualidad: el venv de gioser trae extensiones
`cpython-314-...-gnu.so`, asi que la imagen de Arch pineada da la misma serie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1728a77a3a |
go/cgo: GOTMPDIR fuera del módulo — era la única causa del no-determinismo de gocryptfs
`gocryptfs` era el último no-determinismo vigente del corpus. Ya REPRODUCE.
── La medición, que primero hice mal ──────────────────────────────────────────────────
Comparé los dos ficheros de `store/.divergen/` y salían 1.874.136 bytes distintos, con
`.text` entre ellos y funciones de OpenSSL cambiando de AVX2 a AVX-512. Era el par
EQUIVOCADO: `.divergen/<D>` es el artefacto VIEJO archivado, no una reconstrucción. El
par que define el veredicto es `<D>.r1` (1ª reconstrucción) contra el que queda en el
store (2ª).
Comparado bien, el no-determinismo son **7 bytes, todos en `.debug_str`**: los dígitos
de `go-build598798091` contra `go-build270591055`.
(Lo de AVX no era ruido: era DERIVA real contra el artefacto viejo, sellado antes de que
|
||
|
|
0d0ab79092 |
el tar de busybox no sirve para un rootfs ajeno — y takana ya no dibuja un pingüino
`qorpa pull` de un `.tar.zst` moría imprimiendo **la ayuda de busybox** y un exit 1: ni una palabra sobre `--zstd`, que es la opción que no entiende. El código ya elegía el descompresor por MAGIC y pasaba las flags correctas; lo que faltaba era un `tar` de verdad. Dos arreglos, y el segundo es de una línea: · `untar` comprueba `tar --version` y, si no es GNU, ABORTA nombrando la causa y el arreglo. Diez minutos de diagnóstico se vuelven una línea. · **`tar` (GNU) entra en `perfil.base`.** Estaba sellado desde hace meses y declarado en UN solo perfil (`escritorio-kde`). Es la lección de `foot` otra vez: el paquete existe y no viaja en la imagen que lo necesita. Ya había costado dos veces — la caja tampoco podía desempacar su propio LAB por lo mismo. Y el logo: fastfetch dibujaba **el pingüino genérico de Linux** porque elige por el `ID` de os-release y no conoce `takana`. Ahora la receta `os-release` publica también `/usr/share/takana/logo.txt` (un martillo, que es lo que significa el nombre en quechua) y `/etc/xdg/fastfetch/config.jsonc` — la config GLOBAL por XDG, no la de un usuario. Van en la receta de la IDENTIDAD y no en la de fastfetch a propósito: mañana el que dibuje puede ser otro programa y el logo seguirá siendo el mismo fichero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn |
||
|
|
467a87cdb8 |
la imagen del servidor, armada y booteada — tres fallos que sólo aparecen ahí
Se armó la imagen del perfil `servidor` con gitea y se arrancó en QEMU. Los tres hallazgos, en el orden en que aparecieron, son los que justifican probar la imagen en vez de dar por buena la receta. 1) ⚠⚠ ESCRIBIR EN EL ROOTFS HIDRATADO ES ESCRIBIR DENTRO DEL STORE `takana users --merge` hacía `fs::write` sobre `<rootfs>/etc/passwd`. Un rootfs hidratado se arma con HARDLINKS contra el store: medido, ese fichero y el del artefacto `product-rootfs` eran **el mismo inode (1225824, 2 links, modo 444)**. Un `write` habría modificado el artefacto SELLADO, y todas las imágenes futuras habrían salido con la cuenta metida dentro del producto. Acá se salvó porque el store es de sólo lectura y salió `Permission denied` — confiar en eso es confiar en un permiso. Ahora `escribir_rompiendo_hardlink()`: temporal + `rename`. Con su control, que afirma lo que importa: tras escribir, el fichero del store **conserva su contenido**, baja a 1 link y el del rootfs tiene otro inode. Verificado también sobre la imagen real. 2) LA IMAGEN TRAÍA EL BINARIO, LA CUENTA… Y NADIE LO ARRANCABA Primera imagen: `gitea` instalado, `gitea:x:916` en `/etc/passwd`, y en el `genesis` de la seed sólo `sshd`, `console-getty`, `hammerd`, `hammer-product`. `servidor-image.sh` no inyectaba las Cards — eso sólo estaba en el camino de las imágenes de escritorio. Y ninguna métrica lo dice: `--services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva esa declaración a la imagen. Ahora llama a `service-cards` + `inyectar-cards.py` (idempotente por label). 3) ⚡ EL BINARIO MORÍA CON `trap invalid opcode` — Y EL BUG ESTABA EN EL BUILDER Con la card en el genesis y la config puesta, gitea arrancaba y moría al instante: traps: gitea[91] trap invalid opcode ip:79ea992 ... in gitea[...] El sandbox exporta `CC` apuntando a un wrapper que pone `-mcpu=baseline` (`sandbox.rs` ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y la fase Go del propio builder lo PISABA con `CC="zig cc"` a secas — que por defecto es `-mcpu=native` y hornea la ISA del que compila. El binario corría perfecto en el worker y moría en QEMU-TCG. No falla al compilar ni al sellar: falla al EJECUTAR en otra CPU. Y el `ArtifactHash` no lo puede cazar, porque la CPU del builder no entra en `hash_inputs` — dos workers distintos sellan bytes distintos bajo la misma dirección. Arreglado en el builder y en la receta; re-hashea las CINCO recetas `cgo = true` (gitea, usql, sq, gocryptfs, naabu), que es correcto: lo que había sellado no es portable. gitea: `b3:391a613e…` → `b3:5edf9c16…`. Lo verificado en la VM: PID 1 = arje-zero · la cuenta de `[[user]]` en `/etc/passwd` y `/etc/group` de la imagen · la card de gitea en el `genesis` · y arje encarnándola, con la guarda saliendo 78 y `/var/log/arje/ente-gitea.log` diciendo exactamente «falta /etc/gitea/app.ini — es config del SITIO». Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn |
||
|
|
d611f8577d |
[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta
El SDD 30 sacó las Cards de una constante de Rust y las puso en la receta. Los USUARIOS se quedaron
donde estaban las Cards: `/etc/passwd` de la imagen es `takana_bootstrap::PRODUCT_PASSWD`, un literal
con `root` y `sshd`, y añadir un tercero era editar Rust y recompilar takana.
No es simetría por elegancia, es un servicio que no arranca: `gitea` se niega a correr como root, su
Card hace `setuidgid gitea`, y sin la cuenta arranca, muere y reintenta para siempre con un log que
dice `unknown user` — no «a la imagen le falta una cuenta».
[[user]]
name = "gitea"
uid = 916
home = "/var/lib/gitea"
**El uid se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre a
partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje
de reproducir SIN QUE NADA FALLE — los ficheros se ven iguales y `ls -l` dice otro número. Fuera de
`hash_inputs`, medido: el hash de gitea no se movió (`b3:391a613e…` antes y después).
`takana users <recetas…> [--merge <rootfs>]` es el gemelo de `service-cards`, y fusiona **por clave,
no por línea entera** — componer dos veces no duplica, y `grep -q` de la línea completa no serviría
porque la misma cuenta con otro GECOS se leería como nueva. Tres decisiones con su control:
· Una cuenta ya presente con OTRA línea es CONFLICTO y no se pisa: sale ≠0. Pisarla es cambiarle el
uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
· Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en `passwd` dejaba el
`group` ya escrito: media cuenta es peor que ninguna, porque parece que está. Comprobado con el
caso exacto — `group` sin la cuenta, `passwd` con otro uid — y los DOS ficheros quedan intactos.
· Un `passwd` ausente es un error, no un fichero a crear: crearlo dejaría una imagen SIN `root`.
La validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids, uid fuera de
100..=65533, `home` relativo y un `:` en cualquier campo — que partiría la línea y fallaría lejos.
Y `--user-paths` NO es `--service-paths` con otro nombre: aquél lista los servicios HABILITADOS,
éste los paquetes INSTALADOS que declaran cuentas. `postgres` instalado y sin levantar necesita su
usuario igual, porque los ficheros de la imagen ya son suyos.
`scripts/servidor-image.sh` lo aplica entre hidratar el perfil y sellar la imagen, y aborta si hay
conflicto: un rootfs con el uid equivocado produce ficheros de un dueño que no existe.
Verde: 6 tests del módulo, core 234, cli 88, bootstrap 42, `targets.py --selftest` 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
|
||
|
|
3d79cdf98b |
SDD 30 4c: GNOME con los demonios supervisados por arje — y el control arregló colord
Los demonios de sistema pasan de lanzarse con `&` desde un script de 500 líneas a ser Cards del `genesis`. El bloqueo que este frente daba por corpus se levantó solo: otro agente selló evolution-data-server y gnome-shell mientras esto se escribía, y escritorio-gnome quedó 309/309. SE CORRIÓ UN CONTROL PRIMERO, y es lo único que hace interpretable el resultado: colord control: `MURIÓ al arrancar` cards: `ya vive (pid 175)` ColorManager control: `NO apareció en 40s` cards: `OK` login1/Accounts/UPower OK en los dos compositor wayland-0 y shell vivo en los dos El fallo del control DESAPARECIÓ, y no lo buscaba: colord moría arrancado por el script y vive arrancado por arje, con el bus ya listo porque la espera está dentro de su argv. Sin el control, «ColorManager OK» sería un dato suelto en vez de una diferencia. Los PIDs lo confirman: polkit=119, colord=175, upowerd=178, accounts=180 — de antes de que el lanzador de sesión existiera. QUÉ NO SE COMPROBÓ: no hay screendump; QEMU salió por timeout y el control tampoco lo tuvo. La comparación es serial contra serial y lo que se afirma es sobre los DEMONIOS, no sobre el pintado. Las piezas donde corresponde: `takana service-cards` (UNA sola implementación de receta→Card; el formato es contrato con card_core::Card), `targets.py --service-paths` (une qué-es con si-arranca), y un inyector en FICHERO APARTE porque anidar dos heredocs de python falló en vivo — el terminador del interno cerró el externo y media cosa corrió como shell. La espera del bus va DENTRO del argv de las 5 recetas de sistema: sin ella un daemon arranca antes de que dbus escuche y queda en modo idle sin registrar su nombre — un fallo que no se ve, porque el proceso vive y el bus no lo tiene. Los 5 hashes intactos. Y el guardia de gnome-start es por «¿está corriendo?», no por una perilla: así es correcto venga de donde venga el proceso y la misma copia sirve donde no se inyectaron cards. |
||
|
|
68dc9cc2de |
SDD 30 §4b: la seed de producto ya sale de la RECETA — y openssh reproduce bit a bit
El emisor lee la receta que viaja DENTRO del artefacto sellado (el sidecar
.hammer/recipe.toml) y no de recipes/, porque seal_product_rootfs sirve a las
DOS vetas —product desde recetas locales y product_from_repo desde el repo
firmado— y sólo una tiene recipes/ a mano. El sidecar es la única fuente que
ambas comparten, que es lo que sostiene que converjan al mismo product-rootfs.
LA TRAMPA QUEDA ARMADA, NO CERRADA: el sidecar no entra al ArtifactHash, así que
un artefacto anterior al bloque [[service]] es un cache-hit válido que no se
reconstruye solo. Si el emisor devolviera lista vacía sin protestar, el producto
saldría booteando, verde y SIN SSHD. Falla ruidosamente, y hay un test que exige
que el mensaje siga explicando la causa Y dando el arreglo (regla 3 del
CLAUDE.md: un ausente falla ruidosamente, un vacío llega hasta el final diciendo
que todo fue bien).
LA MIGRACIÓN, CON CONTROL, y el hecho nuevo que dejó: openssh no estaba
certificado como reproducible ("el criterio es construye y corre", dice su
receta). Manifiesto de 33 sha256+modos ANTES de tocar nada, respaldo por
hardlink, borrado, rebuild bajo flock -o, comparación. Resultado: 33/33
ficheros, modos idénticos, y la ÚNICA diferencia en todo el árbol es
.hammer/recipe.toml. openssh REPRODUCE BIT A BIT.
Gotcha que costó el primer intento: store/ es bind-mount de /dev/sdb y work/
vive en /dev/sdc ⇒ `cp -al` cruza mounts y muere con EXDEV. El respaldo va
DENTRO del mount del store; un dot-dir ahí es invisible para store-gc.sh, que
sólo mira dirs ^[0-9a-f]{64}-.
|
||
|
|
21d44c13cd |
hydrate: instalar a otro filesystem ya no muere — EXDEV se copia, que es lo único posible
Lo encontró la instalación del modelo opcional del §6.3: `takana install --prefix` murió con
`Invalid cross-device link (os error 18)` justo en el caso que su propia ayuda promete por escrito
(«útil para tests offline o staging en otro filesystem»).
⚠ Y no hace falta tener dos discos para pegarse con esto: `linkat` da EXDEV **entre dos MOUNTS del
mismo dispositivo**, y el store de esta máquina es un bind-mount del volumen. O sea que el caso es
normal, no exótico.
Ahora, si el hardlink da EXDEV —y sólo si da EXDEV; cualquier otro error sigue siendo un error— se
copia. El precio es el espacio: el fichero deja de compartir inode con el store. Está aceptado,
porque la alternativa es no poder instalar. El encabezado del módulo, que prometía «cero copia», dice
ahora la excepción.
Test con el caso real (tmpfs de /dev/shm contra el temporal, que son mounts distintos) y comprobado
en los dos sentidos: quitando la excepción de EXDEV, el test cae con el mismo error que reportaba el
instalador. Si en alguna máquina esos dos caminos fueran el mismo mount, el test lo DICE en vez de
saltarse en silencio.
De paso: `swm_bridge.rs` tenía un constructor de test sin el campo `strip_debug` que agregó
|
||
|
|
1b56b16402 |
SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil
Lo que el script de sesión lanza con `&` ahora está declarado en las recetas y habilitado en el perfil. Ninguna receta movió su hash: 9/9 idénticos a los que los grafos ya registraban. EL HALLAZGO, y no lo buscaba: la comprobación inversa del resolutor rechazó `arje-logind-compat` y `arje-polkit-compat` porque están en CERO perfiles — y sin embargo qemu-desktop-image.sh los copia al rootfs a mano y el de COSMIC hace `exit 1` si falta logind-compat. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino declarado: la misma forma del agujero de `foot`, encontrada por una comprobación en vez de por una imagen inusable. Son raíces de escritorio-gnome (los dos) y de escritorio-cosmic (sólo logind, verificado que sus scripts no nombran polkit). DOS COSAS QUE NO SON TRANSCRIPCIÓN: - `dbus-daemon --fork` no se traduce tal cual: arje supervisa al HIJO DIRECTO y Type=forking no existe, así que un daemon que forkea y sale deja a arje viendo morir al padre con éxito y reencarnándolo para siempre. La card usa --nofork. - `scope = system|session` decide DÓNDE va la card. Las de sesión necesitan XDG_RUNTIME_DIR y usuario logueado; en el genesis arrancarían antes de que exista ninguno. Y fuera de mirada NADIE entrega cards de sesión todavía, así que salen con AVISO: el hueco queda contado, no omitido. Correcciones propias: la unicidad del label es DENTRO del perfil, no del corpus (upower vive legítimamente en dos colas); la membresía se lee de los CINCO grafos, no sólo el del corpus; una RAÍZ manda sobre el grafo, que es derivado y lo regenera el cron; y la flag nace en inglés (`--services`) como manda la regla 4, aunque `--lista` sea deuda vieja del mismo fichero. `--selftest`: 7 casos, el primero es el CONTROL que tiene que pasar en verde. |
||
|
|
a51d49a342 |
servicios de paquete: la receta ya sabe declarar su demonio — y el card sale IGUAL al hardcodeado
Un paquete con servicio no tenía dónde decirlo: los dos del producto (hammerd, sshd) vivían en constantes de Rust y los ocho de una sesión GNOME se lanzaban con `&` desde un script, sin supervisión ni backoff ni el CRASHED real — o sea sin nada de lo que arje es PID 1 para dar. `[[service]]` en la receta, fuera de `hash_inputs` como license/slots/evidence: declarar el servicio de openssh NO movió su hash, medido con el binario viejo (que ignora el bloque) contra el nuevo, b3:938835e6… en los dos. La prueba que autoriza el cambio no es que "parezca bien": el card generado desde recipes/openssh.toml se compara ENTERO contra SSHD_SERVICE_CARD, que es el que ya bootea en QEMU. Empatan ⇒ mover el servicio a la receta no cambiaría un byte de la seed ni del product_rootfs_hash. Por qué no alcanzaba `arje-absorb` (que existe y traduce systemd/openrc/runit/ dinit/sysvinit): sólo absorbe lo HABILITADO, los symlinks de <target>.wants/. Un rootfs nuestro no tiene ese estado — medido: 62 .service sellados en el store y CERO directorios .wants. Absorber devuelve vacío, y es la respuesta correcta a la pregunta que absorb contesta. El enable de una distro construida desde fuente no se lee del árbol: se declara. |
||
|
|
0a9622c7b0 |
kernel kexec: el reboot suave por kexec_file_load, sin kexec-tools
ADR 0017 §1. Se usa kexec_file_load, no el kexec_load viejo, y la diferencia es de orden de magnitud: kexec_load recibe los segmentos YA armados y el purgatory —el código que corre entre los dos kernels—, o sea que usarlo obliga a reimplementar kexec-tools entero. kexec_file_load recibe los DESCRIPTORES del kernel y del initrd y hace el trabajo adentro. Son ~50 líneas. El syscall va con asm! en vez de agregar la crate libc al workspace: es UN syscall, su número y su ABI son contrato estable de Linux, y la alternativa era arrastrar una dependencia a un workspace que comparten dos frentes. El cmdline viaja CON su NUL: el kernel cuenta cmdline_len incluyendo el terminador, y sin él lee un byte de más. Es el error clásico de esta llamada. recipes/linux-metal-kexec.toml — variante cuya ÚNICA diferencia es CONFIG_KEXEC_FILE=y. Variante y no flag en la canónica porque el .config ES la identidad del artefacto (SDD 22 §1): tocar linux-metal re-hashea el kernel que arranca las imágenes y el que reproduce bit a bit, y el ADR deja esa decisión al usuario. Comprobado que la canónica no se mueve: sigue en b3:2ed8f54a…, el que ya está sellado. Y un diagnóstico que corregí a los dos minutos de escribirlo, porque mandaba al lugar OPUESTO: decía "falta CONFIG_KEXEC_FILE" para EPERM. Medido en este hub — el kernel de Artix trae CONFIG_KEXEC_FILE=y y aun así devolvió EPERM, por no ser root. EPERM es falta de CAP_SYS_BOOT (o lockdown si ya sos root); ENOSYS es el flag que falta. Confundirlos manda a recompilar un kernel que estaba bien. La ayuda del comando dice con todas las letras que esto NO es "actualizar sin rebootear": el userspace muere igual. Ahorra los 20-30 s de POST/UEFI, que es la mitad del downtime en un servidor remoto y nada en un portátil. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
6642b93858 |
kernel A/B: BootNext hace la vuelta atrás, y la hace el firmware (ADR 0017 §2)
`takana kernel {stage,boot-status,confirm,rollback}`.
La idea que lo vuelve barato: hay DOS modos de fallo y sólo uno necesita
código nuestro.
1. El candidato NO ARRANCA (pánico temprano, EFI-stub rechazado). Lo
cubre BootNext y sale GRATIS: es una variable de UN SOLO USO que el
firmware CONSUME al leerla. Si el kernel muere, el siguiente
arranque ya no la encuentra, cae en BootOrder y vuelve al estable
sin que corra una línea nuestra. No hay contador que mantener ni
estado que se pueda corromper: el mecanismo es el firmware.
2. Arranca pero el sistema no queda sano. Eso el firmware no lo sabe ⇒
confirmación explícita, y cada arranque sin confirmar gasta un
intento.
El candidato va a una ranura PROPIA de la ESP, nunca encima del estable:
un fallo a media copia dejaría sin kernel al que volver. La copia es tmp
+ rename.
Y `stage` REPONE el BootOrder después de crear la entrada: reconcile
deja al candidato primero, y un candidato no debe volverse el default —
tiene que arrancar UNA vez. Para eso está BootNext.
Verificado con DOS KERNELES REALES del store, tres arranques en OVMF:
1º 6.16.12 estable, sin candidato → stage del 7.1.2
2º BdsDxe: starting Boot0004 "takana (candidato)" → uname = 7.1.2,
"⏳ EN PRUEBA", confirm → promovido (y la ruta fallback también)
3º arranque normal → uname = 7.1.2: el estable cambió
Un bug que cazó un test y no una revisión: `en_esp` normalizaba los
backslashes DESPUÉS de quitar la barra inicial, así que una ruta en
formato EFI salía como "/EFI/..." —absoluta— y Path::join con absoluta
DESCARTA la base: habría escrito en el /EFI de la raíz del sistema en
vez de dentro de la ESP.
Y un hallazgo del banco de pruebas que vale para el diseño: con el
estado A/B en un tmpfs, stage + reboot lo perdía y el sistema decía "sin
candidato". Falló SEGURO (no promovió nada), pero dejaba la entrada
NVRAM huérfana sin que nadie lo supiera. boot-status ahora lo detecta
preguntándole al firmware, que es la fuente de verdad, y lo dice.
88 tests del CLI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
2b87ecb50f |
respaldo del arranque: lista blanca, porque BootCurrent rompía la idempotencia
Dos arranques reales seguidos, sin tocar nada, daban dos respaldos con nombre distinto: boot-27b952c9….json y boot-f999e1e8….json. La promesa de "un arranque que no cambió produce el mismo fichero" era falsa. La causa: el filtro era «empieza por Boot», y eso deja entrar BootCurrent, que dice por dónde arrancó ESTA vez y cambia en cada arranque. Y encima es de SÓLO LECTURA, así que restore habría intentado escribirla. Ahora es lista blanca —BootOrder y Boot#### y nada más—, que también deja fuera BootNext (de un solo uso: restaurarla dispararía un arranque que nadie pidió) y BootOptionSupport (informativa). Ninguna de las tres describe cómo debe arrancar la máquina. Comprobado: cambiando BootCurrent a mano, el respaldo da el mismo fichero y lo dice — "idéntico a uno que ya estaba: el arranque no cambió". Esto sólo se veía ARRANCANDO DOS VECES. Un test del respaldo contra un efivarfs fabricado habría pasado en verde: la variable que rompía la propiedad la pone el firmware, no el código. 80/80 del CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
bd1fa8bf8a |
boot entry backup/restore: el arranque como estado reproducible (ADR 0018 §5)
Guarda las variables de NVRAM CRUDAS —se reescriben tal cual; decodificarlas para volver a codificarlas al restaurar sería una oportunidad de perder algo que no entendemos— y de la ESP un manifiesto con hashes, no los bytes: el kernel ya vive en el store y copiarlo otra vez sería churn. El manifiesto es evidencia de qué había; para lo que no esté en el store dice qué falta, en vez de prometer reponerlo. El ADR se equivocaba en DÓNDE: decía "volcar al store". No va al store, porque lo barre store-gc.sh, que clasifica por nombre de receta — un respaldo ahí sería huérfano y se borraría en el primer --huerfanos. Vive en /var/lib/hammer/boot/, que en las imágenes es su propia partición. El nombre sale del CONTENIDO, así que un arranque que no cambió produce el mismo fichero: corre en cada arranque sin llenar la partición de copias. Lo que encontró la prueba de punta a punta y no estaba en el diseño: restaurar es volver al pasado, y lo que llegó DESPUÉS no estaba en ese pasado. Al reponer el BootOrder del respaldo, el Windows instalado más tarde quedaba FUERA del orden — correcto, y justo lo que el usuario no espera de algo llamado "restaurar el arranque". Ahora se avisa antes, con nombre y apellido, y restore NO escribe por defecto: la NVRAM es lo único de la máquina que no se rehace desde el store. El lector FAT ganó lectura de ficheros, con su trampa propia: hay que truncar al tamaño DECLARADO en el directorio, no al final del último cluster. Un fichero de 1,5 MB en clusters de 1 KiB termina con relleno y hashear el relleno daría un hash distinto al del mismo fichero en disco — el síntoma sería "dos respaldos del mismo arranque difieren". Verificado contra mtools como oráculo (1 500 000 y 900 000 bytes exactos, mismo sha256 que los originales) y con un test determinista que fabrica una FAT16 a mano, sin depender de que mtools esté. 79/79 del CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
2201fac08e |
boot entry survey: el informe del terreno, leyendo la ESP SIN montarla (ADR 0018 §4)
Reporta firmware, discos, ESPs, con quién se comparten y si la
instalación entra. No escribe nada — y para poder prometer eso hubo que
leer la ESP sin montarla, que es lo que obligó al lector FAT de sólo
lectura (fat_ro.rs). Montar para averiguarlo pedía privilegios, dejaba
un efecto secundario justo cuando prometimos no tocar nada, y falla si
el vecino dejó la FAT sucia por su hibernación.
Sobre una ESP de fábrica (100 MiB) con Windows dentro:
FAT32 «ESP» — 100.0 MiB totales, 77.1 MiB usados, 22.1 MiB libres
vecinos en \EFI: Microsoft, BOOT
⚠ «Microsoft» ⇒ hay Windows en esta ESP. Es el que reordena
BootOrder al actualizarse.
✗ NO ENTRA: hacen falta 31.0 MiB y hay 22.1 MiB libres
Contrastado contra mtools como oráculo, que es lo que hace creíble el
número: mdir dice "23 221 248 bytes free" y el lector propio dice
22.1 MiB — el mismo. Los clusters libres se cuentan recorriendo la FAT y
NO se lee el FSInfo de FAT32 a propósito: ese campo lo deja
desactualizado un SO que desmontó mal, y un número optimista de más
haría fallar la instalación a mitad — justo lo que el §4 evita.
En el instalador el §4 resultó ser algo más que "cuánto espacio hay": la
rama UEFI se lleva el disco ENTERO, así que lo que hay que decir en voz
alta es con qué se lo va a llevar puesto. El survey corre antes de
particionar y nombra el \EFI\Microsoft si está. El usuario se entera
ANTES, y no después de que su Windows dejó de arrancar.
Dos cosas que habrían pasado inadvertidas sin test: el nombre largo
tiene que ganarle al 8.3 (sin juntar los LFN, "Microsoft" se lee
"MICROS~1" y la advertencia no dispara nunca), y el tipo de FAT sale del
número de clusters y no del texto del BPB, que es informativo y hay
formateadores que mienten. El primer test del tipo lo escribí mal —1999
clusters ES FAT12— y el síntoma es idéntico a un bug del lector.
78/78 del CLI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
66eaf8738f |
el reconciliador ARRANCA solo: montar sysfs, y un diagnóstico que no mentía a medias
Cierra el §2 del ADR 0018, verificado con dos arranques de la imagen
completa en OVMF:
1º (por la fallback) → "ESP detectada en /dev/vda p1",
"⚠ EL ARRANQUE ESTABA CAMBIADO — takana lo repuso",
"faltaba la entrada «takana» ⇒ escrita en Boot0004",
"BootOrder: 0000,0001,0002,0003 → 0004,0000,0001,0002,0003"
2º → BdsDxe: starting Boot0004 "takana" from HD(1,GPT,923A070F-…)
/\EFI\takana\takanax64.efi
"✓ arranque en orden: Boot0004 «takana» ya es la primera"
Un sistema recién instalado se da de alta en el firmware en su PRIMER
arranque y desde el segundo arranca por su propia entrada, sin que nadie
corra un comando. Y el segundo no escribe nada.
Lo caro no fue el reconciliador sino un diagnóstico FALSO: el primer
arranque con el hook dijo "sin firmware EFI — esta máquina no arrancó
por UEFI" en una VM que SÍ arrancó por UEFI. La causa no tenía nada que
ver con UEFI: busybox switch_root no arrastra /sys —igual que no
arrastra /dev, cosa que el script ya contemplaba— así que el directorio
de efivars no existía. El mensaje mandaba a investigar el firmware, que
estaba perfecto.
Dos arreglos:
- el wrapper monta sysfs y después efivarfs. CONFIG_EFIVAR_FS=y ya
estaba en el .config SELLADO (verificado, no supuesto) ⇒ no se
re-hashea ningún kernel.
- el mensaje distingue TRES estados que se parecen: no existe (BIOS o
/sys sin montar), existe y está VACÍO (falta el mount, y lo dice con
el comando exacto), o tiene variables. Decir "no hay UEFI" cuando
falta un mount manda a diagnosticar al lugar equivocado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
236abe48f3 |
boot entry reconcile: converger con la NVRAM en CADA arranque (ADR 0018 §2)
La NVRAM es estado compartido y el vecino la reescribe sin coordinarse. Contra eso no sirve confiar: sirve converger. `reconcile` descubre su propia ESP, repone la entrada y el BootOrder, y es idempotente. Descubre la ESP por TIPO de partición (GUID de ESP en GPT, 0xEF en MBR), que es el único dato fiable sin montar nada — un reconciliador que monta sistemas de ficheros en el arranque es un efecto secundario que no queremos. Si hay VARIAS ESP no adivina: las lista y pide --disk. Elegir mal significa escribir una entrada que apunta a un disco que puede no estar, y eso es peor que no escribir nada. No rompe el arranque por nada: sin firmware EFI (máquina por BIOS) lo dice y sale 0. Y cuando actúa, GRITA — va a /dev/tty0 además del serial, a diferencia del menú de arranque. Un reconciliador que repara en silencio deja al usuario conviviendo con una rareza intermitente que no entiende; el mensaje dice explícitamente que si se repite en cada arranque es que otro sistema operativo le está reescribiendo la NVRAM. Enganchado al wrapper de PID1 de las dos imágenes, con el mismo timeout y el mismo || true que el menú: corre ANTES del exec de arje-zero y colgarse ahí es un arranque muerto e indistinguible de un kernel colgado. El test que vale es el escenario completo: takana se instala, llega el vecino y se pone primero, y el siguiente arranque lo repone — SIN borrar la entrada del vecino (takana se pone primera, no lo echa) — y el arranque siguiente ya no escribe nada. Más el GUID de ESP, que va en orden DE DISCO y no en el legible: escribirlo "como se lee" es el error clásico y no casaría con ninguna ESP real. 15 tests en el módulo, 74/74 del CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj |
||
|
|
ba5754551f |
takana boot entry: la entrada NVRAM, sin efibootmgr — con el muro medido
ADR 0018 §1. El ADR dejaba dos vías y se intentó primero la ajena:
efivar 38 con musl/zig-cc choca con secure_getenv (no existe en musl),
sys/cdefs.h (tampoco) y -Wl,--add-needed (lld lo rechaza) — tres muros
sólo para compilar su generador de tablas, y arrastraría popt al sistema
instalado. Es el patrón "pantano de parches de distro".
Lo que efibootmgr hace en el fondo es escribir DOS ficheros, Boot#### y
BootOrder, con una estructura de la spec UEFI estable desde 2.0. Hacerlo
acá es menos código que mantener los parches, no agrega dependencias al
sistema instalado y el binario takana ya está en el disco.
crates/takana-cli/src/efi_boot.rs — EFI_LOAD_OPTION + device path
(HARD_DRIVE/FILE_PATH/END), lectura y escritura de efivarfs, y un lector
de tabla de particiones que detecta GPT o MBR solo. Los dos casos hacen
falta: install-image-efi.sh fabrica GPT y takana-live-install.sh instala
sobre MBR.
`takana boot entry {list,add}` (CLI en inglés, regla 4). `add` es
IDEMPOTENTE a propósito: está pensado para correr en CADA arranque como
reconciliador (§2), y la NVRAM tiene un número finito de escrituras, así
que si ya dice exactamente eso no se reescribe.
parse_load_option existe para poder COMPROBAR al constructor: un device
path mal formado no da error, el firmware ignora la entrada en silencio
y el usuario ve "no arranca" sin una sola pista.
10 tests, con los negativos que son los que valen: un disco sin 0x55AA
falla en vez de devolver ceros, una entrada GPT vacía no se convierte en
partición de tamaño 0, y un FilePathListLength que miente es error y no
un truncado silencioso. 69/69 del CLI en verde.
Verificado además contra una tabla GPT REAL de sfdisk: inicio LBA 2048 y
131072 sectores, los mismos números que reporta sfdisk -l.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
|
||
|
|
68d052136c |
upgrade: write_atomic era atómico pero NO DURABLE — un corte lo dejaba en 0 bytes
La máquina de estados que promete sobrevivir a un corte dependía de escrituras que no sobreviven a
un corte.
`write_atomic` hacía temporal + `rename`. Eso es atómico frente a OTROS PROCESOS, no frente a un
corte de luz: `rename` sobre un fichero cuyos datos siguen en la caché de página deja, tras el corte,
la entrada nueva apuntando a bloques que nunca se escribieron — un fichero de **CERO BYTES**.
Medido en la caja de producción (SDD 28 §6.14). Apliqué un upgrade y reinicié con
`hcloud server reset`, que es un corte DURO y no un apagado limpio. La caja volvió con:
pending.json 0 bytes
generations/2/manifest.json 0 bytes
upgrade status → Error: json: EOF while parsing a value
upgrade recover → Error: json: EOF while parsing a value
`recover` existe EXACTAMENTE para «un apply interrumpido por un corte/reinicio» y abortaba con la
huella más probable de ese corte.
Dos arreglos:
1. **`write_atomic` ahora es durable**: `fsync` del temporal ANTES del rename (los datos) y `fsync`
del DIRECTORIO después (la entrada). Hacen falta los dos; con uno solo sigue habiendo ventana.
2. **Un `pending.json` vacío se reporta como lo que es**: `Error::PendingCorrupt`, que nombra el
corte, dice que el plan se perdió y apunta al árbol de RESPALDOS, que es lo que sí queda para
restaurar a mano. Un `json: EOF while parsing a value` crudo manda a mirar el JSON en vez del
corte.
Tests con su control: sin fichero ⇒ `Ok(None)`; vacío o sólo espacios ⇒ `PendingCorrupt` nombrando
fichero y respaldos; y —el control que hace que valga— un `pending.json` VÁLIDO se sigue leyendo. 23
en verde.
Queda anotado lo que NO se arregló: cuando el manifiesto se pierde, `recover` no puede deshacer solo
(no sabe qué se tocó). El árbol de respaldos tiene la información; reconstruir desde ahí es su propia
unidad de trabajo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
36be67fa9c |
netup: levantar el LOOPBACK — nadie lo hacía, y el síntoma era un timeout de 30 s
En un sistema con systemd u OpenRC el init levanta `lo`. Con arje-zero no lo hacía **nadie**.
Medido en la caja de producción el 2026-09-10:
ip -o link show lo => lo: <LOOPBACK> DOWN (y CERO direcciones)
caddy escuchando en 0.0.0.0:80
curl http://127.0.0.1/index.json => timeout de 30 s
No «connection refused», que se diagnostica en un minuto: un CUELGUE, que parece del servidor. Se
descubrió intentando que la caja instalara un paquete de su propio repo — o sea que el primer
servicio que habló con 127.0.0.1 fue también el primero en chocarse.
Cualquier cosa que use loopback fallaba igual: un backend detrás de un proxy, un socket TCP entre
demonios, un repo local. Va en `netup` porque es quien configura la red, y no es fatal: si ya estaba
levantado los errores son EEXIST y no deben tumbar el arranque.
Probado en un netns, en los dos estados: antes `lo: <LOOPBACK>` con 0 direcciones; después
`lo: <LOOPBACK,UP,LOWER_UP>` con `inet 127.0.0.1/8 scope host`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
9bc518899e |
swm: strip_debug no viajaba en el paquete — y es ENTRADA DE HASH, así que 40 recetas no instalaban
Encontrado instalando `takana` desde su propio repo (SDD 28 §5), que es la primera vez que se hace
el viaje completo receta → `.tkn` → `install --require-signed` sobre un paquete con `strip_debug`.
Error: expected_hash no coincide:
declarado = b3:941d1857e6e93ebbfbad8240a1c30405263e5ff8fc881a923aee312160563eea
obtenido = b3:2727050ebdbd7817b53e79ffe9796569e9364454ed35474b51e881fcc6048766
`why-differs` sobre los dos artefactos lo nombró: las recetas selladas diferían en `version`,
`license` y **`strip_debug`**. Los dos binarios pesaban EXACTAMENTE lo mismo (3.374.768 bytes) y sólo
divergían en `.shstrtab` — la firma de un `strip` que corrió una vez y la otra no.
`swm_bridge` re-inyecta con cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`,
`deps`, `evidence` y `slots` («fidelidad de reconstrucción», dice su comentario) y **se olvidaba de
`strip_debug`**, que ni siquiera existía en `SwmBuild` ⇒ no viajaba en el `.swm` en absoluto. Como
ENTRA EN `hash_inputs` (`recipe.rs:572`, con el comentario «cambia el CONTENIDO del artefacto, así
que TIENE que entrar»), el receptor reconstruía con `None` y sellaba en otra dirección.
**Alcance medido: 40 recetas del corpus lo usan, 18 de las 88 del perfil `servidor`.** Para todas,
el `expected_hash` anclado por `pack --build` no coincidía NUNCA y `install --require-signed`
abortaba. O sea: una quinta parte del repo no se podía instalar, y nadie lo sabía porque nunca se
había cerrado el lazo.
Por qué no lo cazó nadie antes: `tree` —sin `strip_debug`— da cache-hit y funciona perfecto. El bug
sólo aparece en las recetas que lo declaran, y el dogfood previo no las tocaba.
Test de regresión con su control: una receta CON `strip_debug` lo lleva en el `.swm`, y una SIN él no
gana un `false` inventado. 221 tests de takana-core en verde.
Verificado end-to-end tras el arreglo: `install takana --repo http://<caja> --require-signed` da
cache-hit en `b3:941d1857…` —el mismo hash anclado— e hidrata los 3 ficheros.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
be383ea485 |
netup: la puerta puede estar FUERA del prefijo — sin ruta on-link la caja queda con IP y sin salida
Medido en la caja `takana` (Hetzner Cloud, hel1) el 2026-09-10. Hetzner entrega la IPv4 así:
inet 2.29.29.217/32 scope global eth0
default via 172.31.1.1 dev eth0
172.31.1.1 dev eth0 scope link <-- ESTA es la que faltaba
La dirección es un /32 y la puerta no pertenece a ninguna red conectada. `add_default_route` mandaba
`RTA_GATEWAY` con scope UNIVERSE y nada más, así que el kernel rechazaba la ruta.
**Y el modo de fallo es el peor**: la máquina arranca, `netup` consigue el lease, se pone la IP,
arje-zero levanta sshd... y no se puede entrar, porque las respuestas no tienen por dónde salir. Un
`ssh` desde fuera da `Connection timed out` y desde dentro no hay nada que se vea roto.
Se agrega `add_onlink_host_route` (dst=/32, scope LINK) y se llama ANTES del default. Es lo que hace
`ip route add <gw> dev <if> scope link`, y es literalmente lo que el propio rescue de Hetzner tiene
en su tabla. No es fatal si falla —un EEXIST no debe tumbar la red—; el default sí lo sigue siendo.
**Control causal**, en un netns con una dummy y una /32:
ip route add default via 172.31.1.1 dev dummy0 => Error: Nexthop has invalid gateway.
ip route add 172.31.1.1 dev dummy0 scope link => ok
ip route add default via 172.31.1.1 dev dummy0 => ACEPTADO
O sea: no es que "ayude", es que era exactamente eso. netup se validaba contra el DHCP de QEMU slirp,
que da un /24 con la puerta dentro — por eso nunca apareció hasta tocar una nube de verdad.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
008dd3925e |
renombre: los instaladores pasan a takana-* — y el peligro no era el fichero, era el PROTOCOLO
ADR 0016 los listaba entre los CONGELADOS; el usuario pidió descongelarlos al abrir el SDD 28. La
enmienda queda escrita en el propio ADR, que si no la etiqueta deja de describir el hecho.
`hammer-install.sh` → `takana-install.sh`, `hammer-live-install.sh` → `takana-live-install.sh`,
`hammer-banner.txt` → `takana-banner.txt`, `BRIEFING-hammer.md` → `BRIEFING-takana.md`.
**Lo que hacía caro esto no es el nombre del fichero.** El instalador se inyecta en el ISO como
`/usr/bin/hammer-install` y su éxito se detecta con un `grep` de `HAMMER-INSTALL-OK` desde TRES
scripts de prueba. Renombrar un solo lado los deja casando NADA — sin fallar —, que es literalmente
el modo en que `atribuir-fallos.py` quedó mudo cuando el renombre movió el target de `tracing`.
Se renombraron las dos puntas en el mismo commit (`/usr/bin/takana-install`,
`TAKANA-INSTALL-OK/FAIL`, `TAKANA_INSTALL_*`, `work/takana-install.img`, `/run/takana-install`),
se comprobó por `grep` que no quede ningún token viejo fuera del ADR, y —lo que decide— se CORRIÓ
`install-tui-test.sh`: 4/4 casos verdes.
Las tres `TAKANA_INSTALL_*` caen al nombre viejo (`${TAKANA_INSTALL_X:-${HAMMER_INSTALL_X:-}}`):
el llamador puede ser un ISO anterior al renombre. Misma convención que `TAKANA_ROOT_PW` unas
líneas más arriba en ese mismo script.
NO se tocó `/usr/sbin/hammer-recover` ni su hook de arranque —renombrarlo rompe máquinas YA
INSTALADAS, no el repo—: sobrevive intacto dentro del script renombrado, verificado por conteo
antes y después (8 ocurrencias). Tampoco `hammerd`, `hammer-edit` (su `name` está en la ruta del
store), `/var/lib/hammer`, `HAMMER_LIVE` ni los siete literales de hash.
De paso: `scripts/.hammer-banner.txt.kate-swp` era un swap de editor commiteado por error. Fuera.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
|
||
|
|
e20a5f44d8 |
takana: --takana-bin, con --hammer-bin como alias
Era la ultima superficie de CLI que quedaba con el nombre viejo. Mismo patron que forja: el canonico es el nuevo, el viejo sigue aceptandose como alias. El default pasa a .../release/takana. El DESTINO no se toca: el binario se sigue instalando en /usr/bin/hammer dentro del rootfs del producto, porque ese rootfs se hashea. Moverlo cambia el hash del producto y obliga a rehacer el baseline del selfhost — es etapa 6 y decision aparte, no efecto colateral de renombrar un flag. Verificado en el subcomando correcto (bootstrap builder, no product): las dos formas se aceptan. 605 tests en verde. |
||
|
|
b818f5249f |
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
205 lineas en 73 ficheros de crates, mas la prosa de CLAUDE.md y del skill, que se me habian quedado afuera de los barridos anteriores (no eran ni recetas ni docs/ ni scripts/). EL BARRIDO ANCHO ESTUVO A UN COMMIT DE ROMPER EL CORPUS ENTERO. El primer intento reescribia los .rs completos, no solo los comentarios. Entre las lineas de codigo que tocaba estaban SIETE etiquetas de separacion de dominio, que son ENTRADA DE HASH: b"hammer-tree-v1" <- el prefijo de ArtifactHash::of_tree (hash.rs:60) b"hammer-seed-v1" la funcion que hashea TODOS los artefactos: b"hammer-stage1-rootfs-v2" cambiarla mueve los 4750 hashes del store b"hammer-product-rootfs-v3" b"hammer-product-attested-v2" b"hammer-builder-rootfs-v1" b"hammer-attest-dev-rootkey-0001!!" <- clave raiz de atestacion, [u8;32] Revertido y rehecho solo sobre comentarios, esquivando ademas las cadenas crudas de Rust (r#"..."#) porque el SYSTEM_PROMPT del traductor tiene lineas que empiezan como comentario. Controles: las 7 etiquetas siguen ahi, el diff toca CERO lineas de codigo, 605 tests en verde y el hash de zlib sigue en b3:dc363f26. La leccion es la misma de toda esta etapa: un literal que parece prosa puede ser entrada de hash, y la unica forma de saberlo es mirar donde se usa. |
||
|
|
1c3e185167 |
takana: las variables de entorno leen las dos formas, gana la nueva
TAKANA_X con caída a HAMMER_X (ADR 0016). No es un sed: estas variables son
contrato de usuario —knobs del instalador, el entorno del worker, mirror-env.sh—
y viven en perfiles de shell y units FUERA del repo, así que un renombre duro no
falla ruidosamente: la variable no aparece, se toma el default y el build se
comporta distinto sin que nada lo diga.
Rust: takana_core::env::{var,var_os} toma el nombre canónico y deriva el viejo
cambiando el prefijo — se le pasa TAKANA_* para que un grep del nombre nuevo
encuentre todas las lecturas. 5 tests, con DOS controles negativos: sin ninguna
de las dos no hay valor, y un nombre sin prefijo no inventa una caída.
Migrados los 17 sitios directos y los indirectos que el grep no mostraba
(bases_de_mirror, las constantes de kernel_cmd, ROOT_ENV de qorpa, env_path de
recover). takana-recover lleva la caída inline: es un mini-binario que se copia
a /usr/sbin y no vale arrastrarle una dep entera por dos líneas.
Scripts: 30 lecturas pasan a default, manteniendo el
nombre INTERNO de la variable para no tocar sus 190 usos.
Y donde el script EXPORTA en vez de leer, se ponen LAS DOS (mirror-env.sh y el
fragmento in-VM de bootstrap): ahí el lector puede ser un binario viejo —worker
sin recompilar, el /usr/bin/hammer pinado del baseline— que sólo conoce HAMMER_*.
La caída sirve para lectores nuevos; los viejos necesitan que la vieja siga puesta.
Verificado de punta a punta con el binario, no sólo con unit tests: HAMMER_LAB
sigue surtiendo efecto, TAKANA_LAB hace lo mismo, y con las dos gana TAKANA_LAB.
605 tests en verde y el hash de zlib sigue en b3:dc363f26… , intacto.
Cambio de comportamiento que va aparte y hay que decir: el hostname por defecto
de una instalación nueva pasa de 'hammer' a 'takana' (sólo si no se fija ninguna
de las dos variables).
NO se tocan: las rutas /var/lib/hammer de sistemas instalados, el volid
HAMMER_LIVE del ISO, ni el namespace HARKAQ_*, que es de otro subsistema.
|
||
|
|
24bcf1783c |
takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.
VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.
DOS BINARIOS SE CONGELAN, y no por prolijidad:
- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
`PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.
- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
instalados y hornea un hook de arranque que lo invoca por ese nombre:
renombrarlo rompe máquinas instaladas, no el repo.
Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.
Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
|
||
|
|
80319d9bab |
takana: etapa 2 del renombre + los dos puntos de la hoja de marca
Etapa 2 (ADR 0016): el binario canónico es `takana` y `hammer` se sigue emitiendo. Son DOS [[bin]] al mismo main.rs, no un symlink: la siembra de la granja excluye /target (un symlink del hub no existiría en el worker) y `cargo clean` lo borraría. Ningún llamador tocado; los 124 siguen andando. Adoptados los dos puntos de la hoja de marca que chocaban con contratos: - `forja` como ALIAS de clap sobre `build`, no como reemplazo. El canónico sigue siendo el inglés, que es lo que usan scripts, cron y runbooks. Y se enmienda la regla 4 de CLAUDE.md en el mismo commit: cambiar el comportamiento dejando escrito el contrato viejo es lo peor de las dos opciones, porque el otro agente del repo aplica lo que lee. - `.tkn` como extensión de paquete. Salió barato y por una razón medida: la extensión no es lógica sino salida — se escribe en UN solo lugar (main.rs:1800) y el descubrimiento va por índice, no por glob (PackageEntry.file, repo.rs:75). Los repos con entradas .swm siguen resolviendo y un repo mixto es válido; cero ficheros .swm versionados. Los tipos Swm/SwmBuild/swm_bridge no se tocan: son internos, van en la etapa 4. 287 tests en verde (hammer-cli + hammer-core), incluidos los que fabrican repos con nombres .swm a mano — que son justamente la prueba de que la compatibilidad hacia atrás se sostiene. |
||
|
|
d13af44ddb |
kernel: el contrato admite capacidades de INTERFAZ — el test llevaba rojo desde el 03-09
`cargo test` del workspace fallaba en `el_contrato_del_repo_cierra_sobre_si_mismo`: «proceso-por-descriptor no declara símbolos». No es un descuido del alta: `abda7d3` dio de alta pidfd con `symbols = []` y dedicó ocho líneas a explicar por qué va vacío — pidfd no depende de ningún `CONFIG_*`, es interfaz del core desde Linux 5.3. Lo que no se actualizó fue el invariante, así que el dato quedó bien y el test quedó rojo. Dos días sin que nadie lo viera, que es lo que pasa cuando una suite falla por algo que «ya se sabe»: deja de mirarse entera. El invariante correcto no es «toda capacidad tiene símbolos» sino **«toda capacidad afirma algo comprobable»**: sin símbolos vale, pero entonces hay que nombrar la INTERFAZ. Sin una ni la otra, la capacidad no dice nada que se pueda verificar y lo más probable es que sea un campo a medias — que es el caso que el assert original quería atrapar y sigue atrapando. Hoy hay exactamente una capacidad así y declara `interface = ["pidfd_open(2)", "pidfd_send_signal(2)"]`. Workspace: 541 tests en verde, 0 fallos. |
||
|
|
114aeba514 |
fetch: materializa los submódulos git, que git archive no exporta
`fetch_git` clona a un mirror y materializa el árbol con `git archive | tar -x`, a propósito, para
no pagar un worktree. Pero `git archive` NO emite nada por una entrada gitlink (modo 160000): un
árbol con submódulos salía INCOMPLETO y el fallo aparecía recién en la fase configure, minutos
después y hablando de un fichero "que no existe".
Por eso un `--recurse-submodules` en el clone no arreglaba nada: el que los pierde es el archive,
no el clon. Lo que hace falta es leer `.gitmodules` + los SHA de gitlink DEL COMMIT, mirrorear cada
submódulo y archivarlo en su subruta, recursando. Encaja con el ADR 0006 sin ceder determinismo: un
submódulo ya viene pineado por SHA en el commit del padre.
Dos detalles que no son obvios:
· La URL se reescribe SSH→HTTPS. `.gitmodules` suele declarar `git@github.com:X/Y.git` y nuestro
fetch es anónimo, sin claves. Apunta al mismo repo y el commit está pineado, así que el
contenido no puede diferir: no añade nada que verificar. Las relativas (`../l10n.git`) se
resuelven contra la URL del padre tratada como DIRECTORIO, que es lo que hace git — `..` se
come el nombre del propio repo, no el del directorio que lo contiene.
· `.gitmodules` se lee del COMMIT (`git config --blob`), no del working tree, porque no hay
working tree. Y los gitlinks se listan con `ls-tree -z`: sin `-z` git escapa las rutas con
espacios entre comillas.
El test reproduce el caso completo con repos locales: comprueba primero que sin esto el gitlink
queda como directorio VACÍO —el síntoma exacto que costó el diagnóstico— y después que con esto el
fichero del submódulo llega.
|
||
|
|
54fa4a941c |
hammer: source.dir — el modo de fuente que faltaba para las recetas DERIVADAS
Al ir a escribir `recipes/atuq.toml` apareció un muro que el SDD 26 no había visto: `[source]` era
obligatoriamente git o tarball (`Source::kind` no tiene tercera salida), así que una receta cuyo
contenido no viene de upstream sino de NOSOTROS —un envoltorio sobre otro artefacto, un tema, una
configuración— no se podía ni escribir. Los rodeos posibles eran todos peores: fetchear una fuente
upstream que después se ignora MIENTE sobre la identidad del artefacto, y colgar el overlay de un
repo aparte obliga a que el worker tenga acceso de lectura a un repo privado.
`dir = "atuq"` apunta a un árbol dentro del propio repo, relativo al directorio de la receta.
SE HASHEA POR CONTENIDO, NO POR RUTA. `ArtifactHash::of_tree` ya existía (lo usa Stage 2 para
verificar bit-reproducibilidad) y hace exactamente lo que hace falta: rutas ordenadas, bit de
ejecución, contenido, sin seguir symlinks. Es la misma disciplina que ya tenían los `patches`, que
entran al hash por bytes y no por nombre. Comprobado a mano: editar un CSS del overlay mueve el
ArtifactHash y revertirlo lo devuelve exacto.
`dir` es EXCLUYENTE con repo/tarball y se comprueba primero. Declarar las dos cosas no es una
ambigüedad para resolver por precedencia: es un error de quien escribió la receta, y decirlo antes
del fetch evita bajar algo que después se pisa.
Los cuatro sitios que hacían match sobre `SourceKind` se cierran a mano y no con un `_`:
- `swm.rs` (×2) y `swm_bridge.rs` (×2): un `.swm` es un manifiesto COMPARTIBLE y necesita un puntero
que el otro lado pueda resolver (commit o sha256). El árbol de una derivada vive en este repo y no
hay puntero que mandar ⇒ error explícito en vez de emitir un manifiesto con el source vacío, que
viajaría bien y rompería del otro lado. Error y no `unreachable!`: esto es librería, y un panic
mataría al llamador por una receta mal escrita.
- `hammer pin`: una derivada ya está anclada por contenido ⇒ no hay ref flotante que fijar, lo dice
y sale con 0.
- `hammer` → file_drop: mismo tratamiento que un source que no se puede expresar.
Dos tests: que `dir` resuelve y excluye a los otros dos, y que el mensaje de «source vacío» nombra
los TRES modos — ese texto es la única guía de quien escribe una receta a mano, y si sumamos un modo
sin tocarlo mandamos a la gente a buscar un campo que no existe.
⚠ Al correr la suite aparece un fallo AJENO a esto y que NO toqué:
`kernel::contract::tests::el_contrato_del_repo_cierra_sobre_si_mismo` — «proceso-por-descriptor no
declara símbolos», del frente kernel (commit
|
||
|
|
fdce080d96 |
qorpa D8: sniper sellado al store, con la marca que evita que la cifra mienta
D8 decía que sniper «entra al store por `file_drop`». Dos correcciones, y la primera es de vocabulario: **`file_drop` en hammer es otra cosa** — una operación de `hammer apply` que coloca un fichero en el sistema instalado verificando su hash. No tenía nada que ver con sellar. Lo que sella es lo de siempre, una receta. Queda escrito en el ADR: un término inventado que suena a mecanismo existente manda a buscar el código donde no está. `recipes/steam-runtime-sniper.toml` sella el árbol del runtime (11196 ficheros) pineado por el sha256 que ya estaba verificado. Entra donde Arch y Ubuntu no pueden por una propiedad, no por simpatía: **no muta** —nadie le instala nada adentro— así que el mismo tarball da siempre el mismo árbol y sellarlo es una afirmación verdadera. **La marca: `foreign = true`.** No cambia el build en un byte y **no entra en `hash_inputs`** (describe procedencia, no identidad — hay test). Lo que cambia es contable: `build-state.py` la clasifica `ajeno`, la resta del denominador de las imágenes y la deja fuera del recuento de recetas. Sin eso, sellar un prebuilt habría subido la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que el ADR escribió antes de que existiera la primera instancia. Verificado: sigue diciendo 821 recetas, y aparte `de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`. Y la diferencia con el otro ajeno: `xwayland` no se hashea (no hay receta, y un hash afirmaría que lo reproducimos); el sellado **sí conserva su hash**, porque está en el store y que un artefacto exista mientras el grafo lo niega sería otra forma de mentir. Comparten el estado, que es lo que protege la cifra. **`hammer qorpa import --from-store <hash>`** lo consume, y ahí está el detalle que hace que valga: la imagen se registra bajo el **sha256 del archivo de upstream**, no bajo el ArtifactHash. Al revés, la imagen del store y la traída con `pull` serían dos imágenes distintas con los mismos bytes y las instancias de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. El árbol se **enlaza**: una imagen nunca se escribe (lo que escribe la instancia va a su `upper`), así que compartir inodos con un artefacto sellado y de sólo lectura es correcto por construcción y la imagen cuesta ~0 bytes. La contracara conocida de `.dmerge`: mientras el artefacto siga en el store, borrar la imagen no libera disco; `--copy` lo evita. Licencia `LicenseRef-qorpa-ajena-no-enumerable` a propósito: adentro hay cientos de paquetes Debian y no podemos enumerarlos; vacío se leería como «todavía no la poblamos». SDD 20 lo recoge y afila la distinción: replicarla a nuestras máquinas es lo que ya hace ADR 0013 con las fuentes; publicarla a terceros sigue pidiendo licencia y marca. 29 tests verdes. El sellado en sí corre aparte, esperando el lock de la granja. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q |
||
|
|
4a68dfe761 |
qorpa D9: el canal de evidencia, cableado — la única forma de auditar el montón B
De un binario ajeno no hay fuente que leer. Lo único observable es lo que el
kernel le NIEGA y anota, y hasta acá ese canal existía en el build pero ninguna
instancia lo abría. `hammer qorpa run <id> --evidence` levanta el lector
(`harkaq-audit`) en el HOST —el audit no está namespaceado— antes de que arranque
la instancia, y al terminar imprime uno de tres estados. Los tres, medidos:
HERMÉTICO 0 denegaciones Y el canario las respalda
IMPURO `touch /usr/INTRUSO; mkdir /opt/INTRUSO` →
fs.make_reg · /usr fs.make_dir · /opt
SIN EVIDENCIA quitándole las capabilities al lector. NO es «limpio»
**El canario es lo que hace que «cero denegaciones» valga algo:** un fichero
donde la política no alcanza; al leerlo, el kernel emite una denegación que
revela el `domain=` de ESTE dominio Landlock, un número que desde fuera no se
adivina. Sin él, `denials=[]` sería el instrumento callado.
**Dos condiciones estructurales, y se FALLA en vez de dar un veredicto vacío:**
con `nesting` no hay Landlock (D9 conflicto 1) ⇒ o anidás o auditás; y sin
`seal_image` la política es `rw /` ⇒ no hay NADA denegable y el veredicto sería
limpio por construcción, no por mérito. No es un defecto de la implementación:
**la evidencia sólo existe donde algo puede ser negado.**
**Un bug del propio instrumento, que sólo salió usándolo:** sin CAP_AUDIT_READ
el kernel RESPONDE que no (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa
respuesta esperando una que no iba a llegar — 8 s por consulta, 16 s en su
compuerta. Como `qorpa run` lo despierta al terminar, moría por señal dentro de
la compuerta **sin emitir nada**: un «no» tardío se parecía demasiado a un
cuelgue. Ahora atiende el NLMSG_ERROR y dice su motivo en 2 s. Y si aun así el
veredicto sale vacío, se reporta con el código de salida del lector, que es el
único dato que queda.
Guardián: `scripts/qorpa/evidence-probe.sh`, con las tres aserciones. La 2 es la
que sostiene a la 1 — sin algo que TIENE que salir sucio, «HERMÉTICO» lo cumple
igual un canal muerto. Comprueba también las capabilities del lector, que **se
pierden en cada recompilación** y son la forma más probable de que el canal
muera en silencio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
|
||
|
|
50d1dbcc31 |
qorpa D3: packages se instala solo — el manifiesto deja de ser un adorno
Hasta acá el ADR afirmaba «el manifiesto es la verdad, el upper es caché» mientras `recreate` confesaba en su propia salida que «instalarlos todavía es a mano». Con eso el `upper` SÍ era el activo: un blob irreemplazable, que es justo lo que hammer existe para no tener. `hammer qorpa provision <id>` instala lo declarado, y **`recreate` lo llama solo** (`--no-provision` para saltarlo). Cuatro decisiones, cada una con su porqué: - **El gestor se DETECTA** en la vista merged (apt, pacman, dnf, apk), no se configura: cada imagen trae el suyo. Y no se multiplexa detrás de un comando único —el `pmm` de Bedrock que el ADR rechaza—: se elige cuál correr. - **`provision` ensancha la política y lo dice en la cara.** Instalar pide las tres cosas que una instancia bien declarada no tiene: red, root y la imagen sin sellar. Se ensancha SÓLO durante esa operación, el manifiesto no se toca y el siguiente `run` vuelve a lo escrito. En silencio sería lo que D7 prohíbe. - **El registro vive FUERA del `upper`** (`provisioned.toml`): dentro se iría con la capa. Por eso `recreate` lo borra — un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3. - **Los nombres se validan y se comillan**: salen de un fichero que escribe una persona, así que `strace; rm -rf /` no llega al guión. **Las dos manías que sólo salen provisionando de verdad:** el bootstrap de Arch trae la mirrorlist ENTERA comentada (pacman muere con «no servers configured») y el llavero sin inicializar (toda firma inválida). El guión pone el mirror geo oficial avisando cuál, y hace `pacman-key --init && --populate` sólo si falta. `apt` no necesita ni un workaround: es el dividendo del rango de subuid. Probado de punta a punta en los dos gestores —apt sobre Ubuntu base, pacman sobre el bootstrap de Arch—: instalan, el binario corre después con la red apagada, y un `recreate` tira la capa y la deja igual. 27 tests verdes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q |
||
|
|
74fec60281 |
qorpa §Orden 6: pressure-vessel ANIDA bajo la jaula — sin cuenta, sin juego y sin pantalla
El paso 6 quedaba parcial por esta frase: «pressure-vessel con un juego real sigue sin ejercitarse», porque el runtime sniper sólo se baja al instalar un juego y eso pide credenciales. Era un techo falso: el depot está publicado y ahora pineado, así que se coloca a mano y la pregunta que ordenaba D6 se responde entera. **La evidencia**, dentro de una instancia qorpa sobre Ubuntu base, con `nesting` y la jaula puesta: os-release Ubuntu 24.04.3 LTS → Steam Runtime 3 (sniper) ns de montaje mnt:[4026532468] → mnt:[4026532526] /usr/lib/x86_64-linux-gnu → 675 libs de sniper, libSDL2 incluida Contenedor de Valve anidado dentro del nuestro, con el runtime real adentro. **Y la concesión resultó de verdad:** con `nesting = false` el mismo comando muere en `bwrap: Creating new namespace failed: Operation not permitted`. Queda como guardián (`scripts/qorpa/pressure-vessel-probe.sh`), que exige las DOS mitades — sin la negativa, «no salió sniper» lo cumpliría también un cuelgue. Tres cicatrices del camino, todas en los comentarios: 1. **`ldd --version` es la comprobación equivocada** y es la primera que uno escribe: pressure-vessel importa la libc del host cuando es más nueva que la del runtime, así que ver la glibc de afuera adentro es lo correcto y no prueba nada. El veredicto es `os-release`. 2. **`SALIDA=$(timeout … qorpa run …)` se cuelga para siempre** aunque timeout mate al hijo: la sustitución no espera al PROCESO, espera a que se cierre el PIPE, y pressure-vessel deja descendientes con el fd abierto. A fichero termina y devuelve su código. 3. **Dos `run` seguidos sobre la misma instancia fallaban** con `Can't make overlay mount … Device or resource busy`. El kernel niega dos overlays vivos con el mismo `upper` porque eso corrompe la capa — o sea que el EBUSY es un guardián correcto a destiempo: la corrida anterior ya devolvió el prompt y su namespace no terminó de reaparse. `run` reintenta acotado y, si sigue tomado, dice la causa en vez de soltar el mensaje crudo de bwrap. El punto 3 salió porque el guardián exige evidencia POSITIVA de la denegación: con «no apareció sniper» habría dado OK tapando un fallo distinto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q |
||
|
|
3f560de8af |
qorpa D8: el pin de sniper verificado, y el pin deja de ser una cita
Cierra el ⚠ que quedaba en la tabla de D8. Tres cosas, y la del medio es la que más duele. 1. **Sniper pineado, y por la versión correcta.** No se pinea «la última» sino la que `latest-container-runtime-depot.txt` dice que despliega el cliente de Steam — 3.0.20260805.254768. Pinear otra sería pinear algo que nadie corre. Se pinean DOS artefactos: la imagen (rootfs, 302 MB, 11196 ficheros) y el depot `SteamLinuxRuntime_sniper.tar.xz` con pressure-vessel adentro. El segundo es el que destraba el paso 6: ese runtime sólo se baja al instalar un juego (credenciales), y pineado se coloca a mano ⇒ pressure-vessel se puede ejercitar sin cuenta, sin juego y sin pantalla. 2. **Los digests estaban en prosa y ABREVIADOS, y eso no es un pin.** Cuando la poda se llevó las imágenes, `895661bd…` no alcanzó para volver a traerlas: hubo que ir a buscar los sha256 otra vez a upstream. Ahora enteros y verificados en `docs/state/qorpa-imagenes.toml`, con de qué lista salieron y **si esa lista está firmada** — que no todas: Ubuntu firma su SHA256SUMS, Arch firma el tarball pero no la lista, y Valve no firma nada. 3. **`--retry` de curl no cubría el fallo que de verdad pasa.** Los 302 MB de sniper murieron al 73% con `HTTP/2 INTERNAL_ERROR` y curl NO reintentó: sin `--retry-all-errors` sólo considera transitorios los timeouts y los 5xx. Y aun reintentando, sin `-C -` cada intento vuelve a empezar de cero. Con las dos banderas el mismo pull sobrevivió dos cortes más y llegó. El parcial ya no se borra al fallar (es lo que permite reanudar); es seguro porque quien decide es el sha256 de después, y `prune` ya lo barre. De paso, dos comprobaciones en vez de suposiciones: el rootfs de sniper viene en `files/` con un hermano `metadata` y el anclaje por estructura lo elevó solo (tercera forma real de empaquetado), y traer el depot como rootfs FALLA en vez de adivinar («no encuentro un rootfs en el archivo»). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q |
||
|
|
ce76796b9b |
qorpa D11: el proxy de Wayland — el socket deja de ser un cheque en blanco
Paso 8 del ADR 0015, el que el propio ADR llamaba el más valioso, y cierra su §NO-resuelve 1. Pasar el socket de Wayland crudo NO es abrir un caño: es un borde de privilegio. El compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie concedió — screencopy captura la pantalla, virtual_keyboard sintetiza teclas, data_control lee el portapapeles sin que nadie copie, y layer_shell dibuja encima de todo. Una instancia ajena con el socket crudo podía las cuatro, y lo único que teníamos era un aviso. El proxy relaya todo salvo dos mensajes: - `wl_registry.global` del compositor: si el interface no está en la lista, no se reenvía y el cliente nunca se entera de que existe. - `wl_registry.bind` del cliente: si el `name` es uno de los ocultos, se manda un wl_display.error y se corta. Sin esto el filtro sería decorativo — el `name` es un número y se puede adivinar. LISTA BLANCA, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón: el conjunto PELIGROSO de Wayland crece —cada compositor inventa protocolos privilegiados— mientras que el NECESARIO para dibujar una ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el compositor, y sin un solo error visible. La lista incluye a propósito lo que los juegos piden y suele olvidarse: pointer_constraints, relative_pointer, tearing_control, idle_inhibit. Lo que hace esto viable en un fichero: ningún mensaje que se descarta lleva descriptores, así que los fds —que Wayland pasa por SCM_RIGHTS para wl_shm y dmabuf, y sin los cuales no se dibuja nada— se relayan en orden de llegada sin tener que asociarlos a su mensaje. PROBADO SIN COMPOSITOR, con uno falso que anuncia siete globals (tres permitidos y cuatro peligrosos) y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear por número el name que nunca se anunció corta la conexión. Es la clase de prueba que no necesita GPU y responde la pregunta entera. Un test más cubre el `size` menor que la cabecera, que haría avanzar 0 bytes y colgar el proxy en un bucle. El socket crudo sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos. Y si no hay compositor, `run` FALLA en vez de degradar. Las features `socket`/`uio` de nix son aditivas: no cambian lo que compilan los demás crates. 54/54. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
d77ad15d01 |
qorpa prune: la poda nace con el subsistema, no después
Paso 7 del ADR 0015, sus dos mitades. PODA. Un rootfs son cientos de MB o varios GB y no lo alcanzan ni store-gc ni la caché .dmerge: es un tercer montón sin dueño, como ya lo fue work/sources. `hammer qorpa prune` barre restos de pulls a medias, imágenes que ninguna instancia usa —se re-traen por digest, que es justo la propiedad que da el pin— y, con --upper, las capas mutables, que por D3 siempre se pueden tirar. Con las dos cicatrices del repo cableadas: - SIN --yes es un simulacro. Y tras borrar COMPRUEBA que el directorio se fue, porque store-gc reportaba borrados que no ocurrían y eso se descubrió tarde. Si dice que sí y sigue ahí, sale ≠0 diciendo que el número de arriba no es lo que se liberó. - El tamaño de un árbol con directorios ilegibles sale MENOR de lo que es: un upper con ficheros de los subuid no se puede recorrer entero. Ahora se cuentan los directorios ciegos y la cifra se marca con `≥`. Un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar. Y --upper deja la instancia USABLE: sin upper/ y work/ no vuelve a arrancar. LICENCIAS (SDD 20). Las imágenes ajenas quedan fuera del catálogo publicable y del reporte de licencias por escrito, y la razón no es pereza: no podemos enumerarlas — un `pacman -S` dentro de una instancia trae paquetes que nadie declaró acá, y afirmar una licencia sobre eso sería inventarla. Lo que sí se hace: contarlas aparte en clase `ajeno` (el riesgo real del ADR es que en seis meses alguien las cuente como corpus), y dejar dicho que si algún día se espejan hay que mirar licencia Y MARCA antes, igual que con Firefox. Más el corolario que faltaba escribir donde se lea: el claim «hammer reproduce bit a bit» hay que acotarlo desde el día que exista una instancia. 2 tests nuevos (la poda no toca una imagen en uso y sí los restos; con --upper la capa se va pero la instancia queda usable). 50/50. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
1d9ddcee37 |
qorpa D10: Steam en la mano, y los tres muros que sólo se ven así
Paso 6 del ADR 0015, PARCIAL y dicho como parcial. Steam 1.0.0.87 instalado de verdad desde el multilib de Arch —las libs de 32 bits que la F2 del plan de juegos daba por «una campaña entera»—, su cliente i386 bajado y desempacado por el bootstrap de Valve junto con el Steam Runtime, y el BWRAP ANIDADO VERIFICADO EXPLÍCITAMENTE (24 montajes propios dentro de la instancia), que es lo que este paso pedía. Lo que NO se pudo: la máquina no tiene sesión gráfica, y el runtime sniper sólo se baja al instalar un juego, que exige credenciales ⇒ pressure-vessel con un juego real sigue sin ejercitarse. Se dice, no se insinúa. Tres muros, ninguno en el ADR: 1. LA JAULA MATABA TODOS LOS BINARIOS DE 32 BITS. El filtro seccomp comprueba arch == x86_64 y MATA lo que no lo sea; para un build es la defensa clásica y correcta, para el montón B es fatal porque el cliente de Steam es un ELF i386. El síntoma fue `ldd: exited with unknown exit code (159)` = 128+31 = SIGSYS, que no se parece en nada a la causa. La salida no es aflojar el check sino darle a i386 su propia tabla con la MISMA política. Los 25 números se verificaron uno por uno contra /usr/include/asm/unistd_32.h: 24 bien y UNO MAL — kexec_file_load no existe en i386 y su número de x86_64 (320) es ahí `utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que toque una marca de tiempo. Es la diferencia entre una tabla de memoria y una verificada. 2. STEAM SE NIEGA A CORRER COMO ROOT, y cambiar el mapa para evitarlo CORROMPE la instancia: un fichero creado bajo un mapa aparece con otro uid bajo el otro, así que el useradd de la preparación deja un /home que su propio dueño no puede escribir. ⇒ el mapa es parte de la IDENTIDAD de la instancia. La vía correcta es la de cualquier runtime de contenedores: un solo mapa y se BAJA de privilegio adentro — campo `run_as`, setpriv, con el CAP_SETUID que ya tenemos en el namespace. 3. EL XDG_RUNTIME_DIR ES DEL USUARIO QUE CORRE, no del uid del mapa: con `run_as`, apuntarlo al de root deja al Steam Runtime sin poder crear su temporal. Se lee como un aviso menor hasta que algo deja de andar sin decir por qué. Lo que sí quedó probado, y es el corazón del ADR: un userland glibc ajeno con su cadena de 32 bits completa corre enjaulado sobre nuestro kernel, con seccomp y no_new_privs puestos, y un contenedor anidado funciona adentro — la forma exacta en que Valve prueba Proton. 1 test nuevo (run_as resuelve uid/gid/home del passwd de la imagen, y un run_as inexistente NO cae a root). 48/48. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
f64859bade |
qorpa export: shims generados, y la clase ajeno para que nadie los cuente mal
Paso 5 del ADR 0015, sus dos mitades. SHIMS. `hammer qorpa export <id>` genera lanzadores finos y `.desktop` en el espacio del host, desde lo DECLARADO en `[export]` — nunca todo: exportar todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock (arbitra en tiempo de exec, por heurística). Gana en tres cosas contra un FUSE: cero costo en runtime, `cat` al shim y ves qué hace, y se revoca borrándolos. Se GENERAN, no se copian. El `.desktop` se arma con lista BLANCA de claves, así que `Exec`, `TryExec`, `Path` y `DBusActivatable` quedan fuera por definición y no por enumeración — una lista negra dejaría entrar la próxima clave ejecutable que invente el estándar. El Exec original se cita en un comentario del fichero generado, para que se vea qué decía y qué no se copió. El icono se busca en la vista merged (upper primero, imagen después: si no, se perdería lo que instaló el gestor de paquetes) y se copia al host, porque un icono que el host no resuelve se ve como un cuadrito gris. Y `exported.json` registra cada fichero escrito, para que `--remove` borre EXACTAMENTE eso y no por patrón sobre el ~/.local/bin de alguien. Probado de punta a punta con un .desktop ajeno real de la imagen de Arch: el shim corre `pacman -Q` del huésped desde el host, el X-KDE-Wayland-Interfaces quedó fuera, el icono viajó, y --remove dejó 0 ficheros con la instancia intacta. CLASE `ajeno`. build-state.py inyecta los nodos declarados en el nuevo docs/state/qorpa-ajenos.toml ANTES que los `wanted`, y ese orden es la mitad del punto: un nodo que provee una imagen ajena no es una receta por escribir. Con eso el `xwayland` de escritorio-kde deja de ser deuda y pasa a contarse aparte: escritorio-kde 187/188 listo falta 1 (raíces 14, + 1 ajenas) Dos decisiones que sostienen esa cifra: los ajenos se RESTAN del denominador (si entraran, el número que se lee como "cuánto construimos" crecería solo cada vez que alguien enjaula una app), y la declaración vive en el REPO y no se lee de /var/lib/hammer — build-state.json se commitea y lo regenera el cron en dos máquinas; si la clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada cosecha. Es el error que ya se cometió con sealed_remoto. Qué provee una imagen ajena es diseño; qué tenés instalado, no. Un ajeno tampoco se hashea, y no por comodidad: no tiene procedencia de fuente, así que un hash afirmaría que lo reproducimos. 2 tests nuevos (que del .desktop ajeno no sobreviva nada ejecutable; que el shim no se rompa con rutas raras). 47/47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
89da9c822f |
qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga
Resuelve §NO-resuelve 2 del ADR 0015, que era el ticket que más desbloqueaba. Tres síntomas que parecían distintos —apt sin poder bajar a `_apt`, pacman sin poder chownear a `alpm`, pressure-vessel sin poder escribir su uid_map— eran la misma causa: bwrap crea el userns con UN SOLO id. La cura resultó tener TRES partes, y ninguna sobra: 1. `setcap cap_setuid+ep newuidmap` (+ cap_setgid en newgidmap). shadow.toml los instala pero no los provisiona; sin la capability no escriben el mapa. 2. Crear el userns nosotros, mapear el rango de /etc/subuid con newuidmap y pasárselo a bwrap con `--userns FD`. bwrap crea el suyo con un solo id A PROPÓSITO y nunca llama a newuidmap: el trabajo es de quien lo invoca. El fd lo abre la shell (`exec 3<…`), porque un fd sólo cruza el exec si no es CLOEXEC y no valía la pena una dep de C para un fcntl. 3. Devolver las capabilities DENTRO del namespace. Ésta no estaba en el plan y es la que costó: bwrap las tira todas, y en Linux ser root es tener CAP_SETUID, no tener uid 0. Sin ella apt seguía sin poder seteuid(42) — un síntoma que parecía de subuid y no lo era. Son seguras por construcción: dentro de un userns sólo alcanzan lo que ese namespace posee, o sea nuestros propios subuid. CAP_SYS_ADMIN queda fuera y sigue colgando de `nesting`. MEDIDO después: uid_map de 65537 ids, setgroups: allow, apt instala SIN el APT::Sandbox::User=root, pacman sincroniza con DownloadUser=alpm INTACTO, y el userns anidado monta con root=true ⇒ el conflicto 2 de D9 se disuelve solo. Dos cosas más que salieron por medir, no por pensar: - El guardián MENTÍA. qorpa-preflight envolvía al hijo en `timeout`, que forkea, así que newuidmap apuntaba al PID equivocado y el kernel respondía "Operation not permitted" — un falso negativo idéntico a un fallo real. Decía que subuid no andaba cuando a mano andaba. Ahora sale exit 0. - Quitar el impuesto MUEVE el problema: el upper pasa a contener ficheros de los subuid (apt deja los suyos con uid 165577) que nuestro uid no puede borrar ⇒ recreate entra a un userns mapeado para limpiar. Y cuando no hay rango, se degrada diciendo la causa exacta en vez de quedar en misterio. Y un detalle que no es cosmético: `--perms 1777` antes del `--tmpfs /tmp`, o el _apt al que apt baja no puede escribir su fichero temporal. Un /tmp que no es 1777 no es /tmp. 2 tests nuevos (el rango se lee por usuario; CAP_SYS_ADMIN NO está en las caps de root, o `nesting` dejaría de ser una decisión). 45/45. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
5b82474079 |
qorpa D9: harkaq enjaula la instancia, y midiendo salieron dos conflictos
Paso 4 del ADR 0015. harkaq-exec entra como último eslabón dentro de bwrap, igual que en el sandbox del build. Cruza el borde un binario ESTÁTICO, no una librería, así que D2 sigue en pie: lo único compartido es la ABI del kernel. Honestidad primero, y está escrita en el código: en el eje del sistema de ficheros harkaq casi no agrega nada, porque el namespace de montaje de bwrap ya es una lista blanca. Escribir reglas `ro` que repiten eso sería un sello de goma, así que la política de una instancia no sellada es UNA línea (`rw /`) y no finge. Lo que sí aporta: seccomp (bwrap no instala filtro alguno — hoy una instancia podía io_uring, bpf, ptrace, userfaultfd, keyctl, perf_event_open), no_new_privs, el canal de evidencia, y `seal_image`, que congela /usr /bin /lib /opt aunque adentro seas root. Verificado contra el kernel, no contra el log: Landlock ABI 9, logging post-exec ON, NoNewPrivs 1, Seccomp 2. Y sellando, `/usr/bin` y `/bin` denegados mientras /etc y /var siguen escribibles. DOS CONFLICTOS que sólo se ven midiendo, y ninguno estaba en el ADR: 1. Landlock y los contenedores anidados son INCOMPATIBLES hoy: con un dominio activo, `mount` falla con EACCES aunque seccomp lo permita — el kernel no admite montajes nuevos bajo un dominio porque escaparían de sus reglas por-ruta. ⇒ pressure-vessel no arranca bajo Landlock. Por eso `nesting` pasa `--allow-nesting --no-landlock` y lo dice a gritos; seccomp y no_new_privs siguen puestos, que es lo que más pesa con un binario ajeno. 2. `root` adentro y anidar se pelean: con --uid 0, un userns anidado no puede escribir su uid_map. Sin remapear anida, pero el gestor de paquetes se queja. La instancia de juegos y la de paquetes quieren mapeos OPUESTOS, y ahora el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que el impuesto de apt: un rango real de subuid con newuidmap + --userns FD. Ése es el ticket que más desbloquea. En harkaq-exec, dos flags ADITIVOS y apagados por defecto (--allow-nesting, --no-landlock): el camino del build no cambia ni un byte, que es requisito duro con 700+ artefactos sellados. La lista de syscalls del anidamiento se separó de la base y el _Static_assert del techo de salto BPF ahora suma las dos. 3 tests nuevos: que la política sin sellar no finja, que sellando el `rw /` no sobreviva (uniría derechos por ancestro y anularía el sellado), y que `root` sea lo único que nace encendido. 43/43. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |
||
|
|
ba09fdb403 |
qorpa create/recreate/run: la instancia nace sin ver nada
Paso 3 del ADR 0015. El overlay lo monta bwrap dentro de su propio namespace (--overlay-src + --overlay), así que no hace falta root ni se monta nada en el host. `run` entra con --clearenv y con --unshare-net salvo que se declare `network`: el entorno del host TAMBIÉN es una concesión, y lo que no se declara no entra (D2/D7). `--dry-run` imprime el bwrap entero, una línea por concesión, porque una jaula que no se puede leer no se puede auditar. `list` ahora enumera también las instancias con lo que abre cada una — una instancia sin política y una con la pantalla abierta se ven IGUAL desde fuera y no son lo mismo. Los campos del manifiesto van en inglés (regla 4); el ADR los tenía en castellano y quedan corregidos, igual que las rutas images/ e instances/. D3 VALIDADO en la mano, no en el papel: la escritura va al upper, la imagen base no se toca, y `recreate` tira la capa y la instancia sigue siendo la misma. Y se midió la otra mitad del paso 1, que el ADR daba por «lo primero que va a fallar». Falla, sí, pero el veredicto es MEJOR de lo que decía: uid_map: 0 1001 1 · setgroups: deny - apt: el método http hace setgroups para bajar a _apt ⇒ para. Salteándolo con -o APT::Sandbox::User=root baja 34 MB, instala y corre los triggers de dpkg enteros; el único residuo es un AVISO de chown a root:adm. - pacman: chownea el directorio de descarga a `alpm` ⇒ para en duro. Con DownloadUser comentado sincroniza, y tras pacman-key --init/--populate instala y el binario corre. ⇒ subuid no es un muro, es un IMPUESTO: un solo id alcanza para instalar paquetes reales en los dos gestores, y lo que rompe es el chown/setgroups a OTRO id, que cada gestor hace en un sitio distinto. Y quitarlo pide algo que el ADR no decía: bwrap crea el userns con un solo id A PROPÓSITO y no llama a newuidmap, así que además del setcap hay que crear el namespace aparte, mapear el rango y pasárselo con --userns FD. 5 tests nuevos (nace sin concesiones, rechaza imagen vacía, el upper es caché, sin grants la red queda fuera, y que el aviso de wayland no sea tibio). 40/40. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 |