# SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo takana»). Todo número marcado *(medido)* salió de un comando corrido ese día sobre `gioser`. Continúa [SDD 19](19-lanzamiento-publico.md) (lanzamiento público) y [SDD 27](27-perfiles-instalables-y-el-instalador.md) (metapaquetes e instalador). **No repite el instalador**: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega. --- ## 0. La corrección de encuadre: ya estamos en Hetzner `gioser` **es** un servidor Hetzner (hcloud id `126324694`, hel1, 4 cores / 7,6 GiB) *(medido)*. La pregunta no era «cómo llegar a Hetzner» sino **cómo tener una caja de takana** en vez de ser un inquilino más de una máquina compartida que: - sirve **19 dominios** por Caddy, - tiene `/mnt/vvv` al **89 %** (27 G libres) *(medido)*, - es **fija y sin backup** por pedido explícito del usuario (blindaje anti-borrado en `deadman.sh` y `farm-down.sh`, ver [ADR 0014](adr/0014-distribucion-multiorigen.md) y la memoria del proyecto), - y **no publica nada de takana**: cero entradas de takana en el Caddyfile *(medido)*. ### 0.1 El hallazgo que cambia el tamaño del problema ``` $ ps -p 1 -o comm=,args= arje-zero /usr/local/sbin/arje-zero $ cat /proc/cmdline BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \ init=/usr/local/sbin/arje-zero loglevel=4 ``` **El init de takana ya gobierna un servidor de producción real.** Caddy, gitea, sshd, qdrant y act-runner cuelgan directo de PID 1 *(medido: `ps -eo pid,ppid,comm | awk '$2==1'`)*. Pero **eso no es takana puro**: es Artix con nuestro init inyectado por `init=` en el cmdline. El kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado. ### 0.2 ⚠ La declaración del arranque no existe en ningún sitio legible `rc-status` de OpenRC dice `stopped` para **caddy, gitea, sshd, cronie y act-runner**, y los cinco están **vivos** *(medido, los dos comandos)*. `/etc/arje/cards.d` tiene **4 tarjetas** (`acpid`, `openrc-openclaw`, `squid`, `zram-swap`) y ninguna de ellas los levanta. ⇒ **La secuencia de arranque de esta caja no está escrita.** Es lo menos reproducible de toda la mudanza y lo primero que hay que capturar — ver §4. --- ## 1. Inventario de lo que hay que mudar *(medido 2026-09-09)* ### Datos en `/mnt/vvv` (217 G de 255) | | | |---|---| | `tawasuyu` | **124 G** | | `takana` | **91 G** — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios | | `rustup` | 29 G | | `act-runner` · `gioserv` · `_respaldo` | 6,5 · 5,4 · 4,1 G | | ~20 dirs `*-standalone` | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… | ### El volumen `harkaq-cosecha` (250 G, 148 G usados) | | | |---|---| | `store` | **55 G**, **1327 artefactos** — lo irreemplazable | | `work-sources` + `cargo-home` + `gopath` | ~85 G — **caché regenerable**, no se muda | | `work-tarballs` · `devfs-cache` · `work-out` | 3,2 G · 4,5 G · 397 M | **El store no se copia por red.** Los dos volúmenes están en `hel1`; un volumen hcloud se desengancha de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos. ### Servicios vivos, todos hijos de arje-zero `caddy` :80/:443 · `gitea` :3002 · `sshd` :2345 · `act_runner` (el CI de tawasuyu) · `qdrant` :6333/:6334 · `openclaw` · `zeroclaw` · `shuma-gateway` :7378 · `tejido` :4102 y :33097 · `puerta-f6e393ff` · `uvicorn` :8771 · `python3` :8770 · `squid` :1137 · `fail2ban` · `glances` · `php-fpm` · `metalog` · **`adb` :5037**. ### Lo que no está en ningún repo `/var/lib/gitea` (**2,2 G**: SQLite + los repos de git de todo, **incluido el `origin` de takana**) · `/var/www` (287 M) · `/home/sergio/gioser-web/` · `/etc/caddy/Caddyfile` **+ 20 ficheros `.bak`** · los certificados de letsencrypt · los crontabs · `/etc/arje/cards.d` · las 3 credenciales de `~/.config/hammer/*.env` · las llaves `~/.ssh/{github5,sergio}` · el token hcloud · y **la memoria del agente en `~/.claude/`**. ### 1.1 La basura, contada — porque una mudanza fiel copia también la basura De los 19 dominios del Caddyfile *(probados uno por uno desde la propia caja)*: | responden 200 | rotos | |---|---| | gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | **api.gioser.net → 502** (backend caído) | | | **aura · sigma · kosmofono · terapeuta.ec → sin DNS** | Y sus raíces **no existen en disco**: `/var/www` sólo tiene `terapeuta`, `goaccess`, `ogisoer`, `git-tawasuyu`, `letsencrypt` y un `.bak` — no hay `aura_frontend`, ni `sigma`, ni `summa/frontend`, ni `kosmofono`. **La mitad del Caddyfile describe un servidor que ya no existe.** ⇒ **Regla de la mudanza: no se muda lo que no responde.** Lo que no tiene DNS ni ficheros se anota y se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir *declarado / vivo / fósil*. --- ## 2. La caja: qué, cuánto y por qué Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) *(medido)*: | tipo | recursos | €/mes | |---|---|---:| | **`cx53`** | **16 c / 32 G / 320 G** | **29,49** | | `cpx41` | 8 c / 16 G / 240 G | 32,49 | | `ccx23` (dedicado) | 4 c / 16 G / 160 G | 85,99 | **Elegido `cx53`**: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana + rustup (~244 G) si algún día se muda todo. **Decisiones del usuario (2026-09-09):** 1. **La caja PUBLICA; el LXC prestado sigue moliendo.** `dev.gioser.net` cuesta €0 y es la máquina con más RAM (6 c / 16 G + 8 G swap). Separar *servir* de *compilar* mantiene la caja chica y barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker. 2. **«Producción» = operativo pero privado.** Sin descarga pública todavía ⇒ **no** se activan los bloqueantes legales del [SDD 19 §2](19-lanzamiento-publico.md) (espejo de fuentes público por HTTP, marca de terceros). Lo que sí hace falta igual es la **clave de release estable** (§5). 3. **gioser se BORRA** cuando la mudanza esté comprobada. Eso convierte esto en un experimento con fecha de caducidad, y cambia el diseño entero: ver §6. ⚠ **El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito.** `deadman.sh` y `farm-down.sh` lo protegen por lista negra de nombre **y** por ausencia del label `role=hammer-worker`. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a mano, con la comprobación del §6 en verde y no antes. --- ## 3. Instalar takana puro en una caja remota ### 3.1 Lo que ya está, y por qué está más cerca de lo que parece **El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI.** *(medido en gioser: no existe `/sys/firmware/efi`; GPT + GRUB; `/boot` es vfat de 1 G.)* Y `scripts/takana-install.sh` escribe **exactamente ese layout**: GPT + GRUB BIOS, `SeaBIOS → GRUB → kernel → arje-zero PID1`. El camino EFI —que fue el que más trabajo costó ([ADR 0010](adr/0010-arranque-grafo-mirada.md))— es el que Hetzner **no** necesita. | pieza | estado *(medido)* | |---|---| | kernel con virtio | `recipes/linux.toml` compila `VIRTIO`, `VIRTIO_PCI`, `VIRTIO_BLK`, `VIRTIO_NET`, `EXT4_FS` **`=y`** ⇒ sin initramfs | | red | `crates/netup` = cliente **DHCPv4 propio** en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por **DHCP en `eth0`** (`/32`, `net.ifnames=0`, DNS `185.12.64.2`) ⇒ es justo su caso | | acceso | `recipes/openssh.toml` sellado; el `product-rootfs` vigente **ya trae `sshd`**; `takana-install.sh` acepta `AUTHKEYS` | | servir | `recipes/caddy.toml` existe | | escritura a disco | `scripts/takana-install.sh` (no interactivo) y `scripts/takana-live-install.sh` (TUI), probado por `scripts/install-tui-test.sh` | ### 3.2 El camino remoto: rescue → `dd` → reboot Hetzner Cloud tiene un **sistema de rescate** (Debian por SSH) que se activa desde la API sin tocar la caja. Desde ahí se escribe la imagen al disco y se reinicia. **No hace falta ISO, ni consola, ni medio físico** — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso. ### 3.3 Los cuatro huecos, nombrados 1. **`perfil.servidor`** — **HECHO hoy** en `docs/state/targets.toml`. Ver §3.4. 2. **`console=ttyS0` en el cmdline** — el único salvavidas si el arranque falla y no hay teclado. Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil. 3. **IPv6** — `netup` hace DHCPv4 y Hetzner entrega un `/64` **estático**. La caja arrancaría con IPv4 sola. Aceptable para empezar, pero hay que saberlo **antes**, no descubrirlo. 4. **Un `.img` del perfil servidor** que el rescue pueda escribir. Es `hydrate-profile.py` + `install-image.sh`, los dos ya existentes — ver §7. ### 3.4 El perfil, y la deuda que declara *(hecho 2026-09-09)* `[perfil.servidor]` hereda `cli` (que hereda `base`, [SDD 27 §7.1](27-perfiles-instalables-y-el-instalador.md)) y añade **5 raíces que existen** (`openssh`, `caddy`, `curl`, `wget`, `tmux`) y **5 que NO**: | raíz que no resuelve | por qué está declarada igual | |---|---| | **`takana`** | **el propio takana no tiene receta.** El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como `cargo build` sobre un clon. Es EL bloqueante del §5 | | `chrony` | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» | | `nftables` | `dev.gioser.net` está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así | | `cronie` | el latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. **Decisión pendiente** | | `logrotate` | en una caja que sirve, el log de acceso crece hasta llenar el disco | Quedan **`wanted`** en el grafo, y **`wanted` no es `debt`** ⇒ `drenaje.json` seguirá diciendo `deuda=0`. Se declaran igual porque **una deuda escrita se ve y una omitida no**: sin ellas el perfil saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es la lección de `foot` en `escritorio-sway` (121/121 sellado y sin emulador de terminal), pagada por adelantado. **Cuenta esperada: 5.** Un sexto `wanted` es un error de tipeo, no una receta nueva. ### 3.5 La caja, creada *(2026-09-10)* | | | |---|---| | nombre / id | `takana` / `165447050` | | tipo | **`cx33`** — 4 c / 7,6 GiB / 76,3 G — **€8,49/mes** | | ubicación | **hel1** — la misma que los volúmenes `vvv` y `harkaq-cosecha` y que el Storage Box | | IPv4 / IPv6 | `2.29.29.217` · `2a01:4f9:c014:2f9c::/64` | | imagen inicial | `debian-13` — **desechable**: existe sólo para recibir ficheros hasta el `dd` del §3.2 | | label | `role=takana-server` | | protección de borrado | **NO**, a propósito: es la caja de sacrificio | ⚠ **El label importa**: `farm-down.sh` y `deadman.sh` borran **sólo** lo que lleva `role=hammer-worker`. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla. Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se protege por no haber nacido de `farm-up`. ⚠ **`cx53` no se pudo comprar: sale `Available: no` en las TRES ubicaciones.** Y la disponibilidad **cambia por día** — `cx33` salió `no` en hel1 el 2026-09-09 y `yes` el 2026-09-10, con la misma consulta. ⇒ **el precio de la tabla no dice que la máquina exista**: `hcloud server-type list` devuelve precios para tipos que no se pueden crear. Consultar `Available` por ubicación, y volver a consultarlo el día que se compra. #### Lo que la caja confirma, medido EN ELLA y no inferido de gioser 1. **Arranca por BIOS** — no existe `/sys/firmware/efi`… **y sin embargo la imagen trae un ESP** (`sda15`, 244 M vfat en `/boot/efi`). O sea: **la presencia de una partición EFI no indica arranque EFI**. Si el instalador decidiera la rama mirando particiones en vez de `/sys/firmware/efi`, elegiría mal en esta caja exacta. `takana-live-install.sh` mira lo correcto. 2. **`console=tty1 console=ttyS0` viene en el cmdline de Debian.** Confirma que el serial es lo que Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola web no muestra nada. 3. **`eth0` con `/32` dinámico por DHCP** — idéntico a gioser ⇒ es exactamente el caso de `netup`. #### Dos cosas pendientes en la caja, anotadas - **7,6 GiB de RAM y CERO swap** — la misma clase de máquina que ya provocó `global_oom` en gioser. Coherente con la decisión de §2.1 (la caja **publica**, el LXC muele), pero significa que **no se construye pesado acá** mientras siga así. - **`sshd` trae `PasswordAuthentication yes`** (con `PermitRootLogin without-password`, así que root por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian. ### 3.6 La imagen del `perfil.servidor`, armada y validada *(2026-09-10)* **Receta del ensamblado** (todo con scripts que ya existían, §7): ```sh # 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero) cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/ # 2. userland del perfil encima scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep # 3. ⚠ restaurar el init (ver abajo) y meter la clave ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys # 4. imagen ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \ CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \ ./scripts/install-image.sh ``` Resultado: **86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse)**. #### El kernel correcto es `linux-generic`, y `linux.toml` NO sirve para hcloud El disco de una caja hcloud lo maneja **`virtio_scsi`** (driver `sd`, disco `sda`) — no `virtio_blk`. `recipes/linux.toml` **apaga SCSI explícitamente** (`-d SCSI`) ⇒ su kernel arranca y **no ve el disco**. `linux-generic`, `linux-metal` y `linux-metal-dual` sí sirven. ⚠ **Y ninguna receta nombra `SCSI_VIRTIO`.** Está encendido igual, porque `make defconfig` lo trae y `-e SCSI -e BLK_DEV_SD` lo mantiene. Se supo mirando el `.config` que `linux-generic` **publica en su artefacto** (`/boot/config-7.1.2-generic`), no leyendo la receta. La receta dice lo que se cambió; sólo el `.config` sellado dice lo que quedó. #### ⚠ La hidratación PISA `/sbin/init`, y el resultado arranca igual `busybox` entra en la clausura del perfil y su `/sbin/init -> ../bin/busybox` **gana por llegar después**, borrando el `-> /usr/bin/arje-zero` del product-rootfs. La imagen habría arrancado perfecta **con el init de busybox**: no takana puro, y sin un solo error. Se tapa por las dos puntas — restaurando el symlink al ensamblar **y** con `init=/usr/bin/arje-zero` explícito en el cmdline, que es lo que hace gioser y es autoritativo pase lo que pase con el symlink. **Y la guarda que existía para esto estaba INVERTIDA** (arreglada en el mismo commit): `[ -e "$ROOTFS/sbin/init" ]` sigue el symlink y lo resuelve contra el *host*, así que el symlink ABSOLUTO a arje-zero daba falso y **el rootfs correcto era rechazado**, mientras que el relativo a busybox —el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si sirve; ésta fallaba al revés. #### EXDEV, dos veces en la misma tarde `store` y `work/out` son bind-mounts de `/dev/sdb`; `work/` está en `/dev/sdc`. Un hardlink no cruza un montaje **aunque sea el mismo disco**, así que reventaron (1) `hydrate-profile.py` con el destino fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de `install-image.sh`, que lo tenía cableado a `work/`. Ahora `STAGE` es env. Ver [[hardlink-cruza-mount-exdev]]. #### La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH ``` SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs → Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1" → netup (DHCP) → ssh-keygen -A → sshd ``` Y desde fuera: `ssh -p 2299 root@127.0.0.1` **entra**, `/proc/1/comm` dice `arje-zero`, `ip addr` da la IP que negoció `netup`. Es la puerta 1 del §6 ensayada en local antes de gastar la caja. El card `sshd` de `/ente/seed.card.json` ya hacía el trabajo entero sin que hubiera que tocar nada: `sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"` — red, claves de host y demonio en una línea. Lo único que hubo que inyectar fue `authorized_keys`. #### Un resto del renombre, encontrado por el arranque ``` arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2) ``` **`arje-zero` invoca el binario por el nombre VIEJO (`hammer`).** No es fatal —sigue arrancando— pero el menú de arranque por grafo del [ADR 0010](adr/0010-arranque-grafo-mirada.md) queda muerto. Y viene de tawasuyu, pineado por `commit` en `recipes/arje-zero.toml`, así que **no se arregla desde este repo**: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae `takana` (§3.4). ### 3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo La imagen validada en QEMU se escribió a la caja, arrancó… y **no se pudo entrar**. `hcloud` decía `running`, el puerto 22 cerrado, y desde fuera `Connection timed out` — el modo de fallo más caro de todos, porque no hay consola serie en hcloud para mirar. La causa, medida entrando al rescue de la propia caja: ``` 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 IPv4 viene con prefijo `/32` y la puerta (`172.31.1.1`) no pertenece a ninguna red conectada.** `netup` mandaba el default con `RTA_GATEWAY` y scope UNIVERSE y nada más, así que el kernel lo rechazaba con `ENETUNREACH`: la máquina se quedaba **con IP y sin salida**, contestando el DHCP y levantando sshd, sin nada visiblemente roto por dentro. **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 ``` Arreglado con `add_onlink_host_route` (dst `/32`, scope LINK) antes del default. **Por qué no había aparecido nunca**: `netup` se validaba contra el DHCP de QEMU slirp, que entrega un `/24` con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad. Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO entorno donde se probaba no contenía el caso. **Y destapó un segundo problema de fondo**: el binario de `netup` venía dentro del `product-rootfs`, o sea **congelado en un artefacto sellado del bootstrap** ⇒ arreglar netup no llegaba a la imagen. Por eso `netup` pasa a ser **raíz de `perfil.servidor`**: así la hidratación lo proyecta encima y la imagen lleva el vigente. Verificado por sha256 (imagen `a23df20e…`, product-rootfs `a84b0a0d…`). ### 3.8 ✅ Puerta 1, pasada *(2026-09-10)* ``` PID 1 : arje-zero kernel : 7.1.2 (linux-generic propio) IP : 2.29.29.217 rutas : default via 172.31.1.1 + 172.31.1.1 scope link montajes : /dev/sda3 -> /store /dev/sda4 -> /var/lib/hammer salida : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup ``` **Una caja Hetzner Cloud corriendo takana puro**: nuestro kernel, nuestro init, nuestro cliente DHCP, nuestro sshd — sin una línea de Debian debajo. El camino entero es `scripts/servidor-image.sh` + rescue + `dd`, y la escritura de los 7,1 G comprimidos tarda **~17 s** (gioser y la caja están las dos en hel1). Lo que la caja **todavía no** tiene, y es el paso 2: `takana` (sin receta, §3.4), el store real (el volumen no se engancha hasta acá) y las otras cuatro raíces `wanted`. --- ## 4. La utilidad de migración: absorber lo VIVO, no lo declarado `arje-absorb` ya existe, está sellado (`recipes/arje-absorb.toml`) y su `--help` dice: lee `sysvinit | runit | dinit | openrc | auto` y **emite una Tarjeta Semilla** con cada servicio del init ajeno como hija de arje-zero. Tiene parsers para los cinco (`systemd.rs`, `openrc.rs`, `runit.rs`, `dinit.rs`, `sysvinit.rs`). **Pero lee la declaración, y en gioser la declaración miente** (§0.2). Un `arje-absorb --from openrc` sobre esta caja produciría una Semilla que **no levanta el servidor**, y lo haría en silencio: la Semilla sería sintácticamente perfecta. ⇒ Lo que falta es un **segundo lector**: absorber del **proceso vivo** (`/proc`: `ppid==1`, `cmdline`, `cwd`, entorno, fds en LISTEN) y **reconciliar** ambas lecturas marcando dónde discrepan. Eso es lo que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito. La utilidad debe clasificar en tres, no en dos: | clase | criterio | qué se hace | |---|---|---| | **vivo** | proceso corriendo **y** declarado | se muda | | **declarado, muerto** | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | **fósil: se anota y se deja morir** | | **vivo, no declarado** | proceso con `ppid==1` que ningún init declara (caddy, gitea, sshd, act-runner) | **se escribe la tarjeta que faltaba** — es el trabajo real | **Se construye al final**, estrenándose con la migración gioser → caja nueva, con el caso del `rc-status` que miente como test de aceptación. Su especificación es el §1 de este documento. --- ## 5. Que el servidor sirva sus propios paquetes a su propio host Existe el camino entero: `scripts/build-repo.sh` (corpus → `.swm` firmados + índice), `takana install --repo` que **ya acepta lista HTTP multi-origen** y verifica cada paquete contra el `digest` del índice firmado ([ADR 0014](adr/0014-distribucion-multiorigen.md)), `takana upgrade` con rollback, y caddy para servir `dist/repo` con un `file_server`. Lo que falta **no es servir**, son dos cosas: 1. **La clave.** `build-repo.sh` firma hoy con una **clave efímera** por defecto (`keygen release` si no se le pasa `KEY=`) *(medido, línea 33)*. Sin clave estable, «firmado» no significa nada. Es el [SDD 19 §3.2](19-lanzamiento-publico.md) y es requisito aunque el repo sea privado. 2. **La receta de `takana`** (§3.4). El host necesita `takana` instalado para consumir los paquetes, y hoy no puede instalarlo desde el repo porque takana no está en el repo. ### 5.1 Hecho *(2026-09-10)*: clave estable, repo firmado, y la caja sirviéndolo - **`trust/`** — la clave PÚBLICA de release en el repo; la privada en `~/.config/takana/keys/` (0600, fuera de git). `scripts/repo-perfil.sh` **aborta si no la encuentra** en vez de inventar una efímera, y tras firmar **verifica** el índice contra `trust/`. - **88/88** paquetes del perfil `servidor` publicados con `expected_hash` anclado. - **La caja los sirve** con `caddy file-server` sobre `/srv/repo`. **El control de la firma, en los dos sentidos**, contra el repo servido por la caja: | `--trust` | resultado | |---|---| | `./trust` | `release: trusted (by release)` ⇒ instala | | un directorio vacío | `release: unknown-key … clave no confiada` ⇒ **ABORTA** | ### 5.2 ⚠ Lo que el lazo destapó: `strip_debug` no viajaba en el paquete La primera instalación real de `takana` desde su propio repo falló: ``` Error: expected_hash no coincide: declarado = b3:941d1857… obtenido = b3:2727050e… ``` `why-differs` lo nombró: los dos binarios pesaban **exactamente lo mismo** 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`… y **se olvidaba de `strip_debug`**, que ni siquiera existía en `SwmBuild`. Como **entra en `hash_inputs`**, el receptor reconstruía en otra dirección. **Alcance: 40 recetas del corpus, 18 de las 88 de este perfil.** Una quinta parte del repo no se podía instalar, y nadie lo sabía porque **nunca se había cerrado el lazo**. Arreglado, con test de regresión y su control. Tras el arreglo, `install takana` da cache-hit en el hash anclado. ### 5.3 ⚠ Y el loopback nunca se levantaba Al intentar que la caja instalara de sí misma por `http://127.0.0.1`: **timeout de 30 s**, con caddy escuchando en `0.0.0.0:80`. `lo` estaba **`DOWN` y sin dirección** — con systemd o OpenRC lo levanta el init; con arje-zero no lo hacía **nadie**. El síntoma no era «connection refused», que se diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en `netup` (es quien configura la red) y verificado desde la imagen, sin tocar la caja a mano. ### 5.4 🚧 Lo que NO se puede todavía, y por qué importa Con todo lo anterior en verde, la caja **sigue sin poder instalar de su propio repo**: ``` release: trusted (by release) ← la firma verifica repo: bajados 2 .swm de http://127.0.0.1 ← descarga de sí misma Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed. El toolchain entra en el ArtifactHash, así que sin rootfs no se puede calcular un hash comparable ``` O sea: **la mitad de servir está cerrada y probada; la de consumir, no** — y no por un detalle de instalación, sino porque `install` **reproduce desde fuente** y eso exige el LAB ENTERO en el cliente. No es «falta un compilador»: el lab entra en el `ArtifactHash`, así que sin él no se puede ni calcular el hash a comparar. Es exactamente el punto que [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md) dejó planteado y sin decidir —*reproducir o hidratar*— visto desde el otro lado: para un servidor, o para cualquiera que no sea un hub de build, **reproducir no es una opción**. El camino normal tiene que ser hidratar artefactos firmados, con `--reproduce` como camino auditable. Mientras esa decisión no se tome, el repo firmado sirve para distribuir **a hubs**, no a usuarios. ⚠ **Y una caja que se sirve a sí misma es un solo origen** — justo lo que el ADR 0014 existe para no tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también el Storage Box o gioser. Si no, el primer origen roto es el último. **Lo que esto vale**: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad» ([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el laptop de nadie**. ### 5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust *(2026-09-21)* Pedido del usuario: «instalar zsh con el comando takana». `zsh` es el primer paquete que se instala **con varios parches** y **con el binario fuera de `/usr/bin`**, y cada una de esas dos cosas era un fallo distinto. Los dos son de la familia del `strip_debug` del §5.2 —algo que el `.swm` no transportaba fiel— y los dos **se descubren sólo cerrando el lazo**, no leyendo el código. **1. Los parches viajaban CONCATENADOS, y eso cambia el hash.** `pack` unía los N parches de la receta en el `patch` inline único; `Recipe::hash_inputs` mete **una entrada por parche** y `of_inputs` va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al build, con una copia de `recipes/zsh.toml` con sus 6 parches concatenados en uno: ``` b3:0be3630d… ← expected_hash anclado (y lo que hay en /store) b3:d6329a62… ← lo que reconstruye el receptor (= la copia concatenada, exacto) ``` O sea: `install zsh` recompilaba zsh **entero** y recién entonces moría con `expected_hash no coincide`. **Alcance: las 16 recetas del corpus con ≥2 parches** — waterfox 12, firefox y gnupg 11, parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2… Arreglado con un campo `patches` (lista, en orden) en el `source_patch`; el `patch` único se sigue leyendo para no invalidar lo ya publicado. El receptor materializa **un fichero por parche**. Regresión con su **control negativo** en `swm_bridge`: si se concatenan, los `hash_inputs` TIENEN que diferir, o el test pasaría por la razón equivocada. ⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho peor: mueve el hash de las **50** recetas con parches e invalida sus artefactos sellados. **2. `target_bin` era una adivinanza que nadie comprobaba.** `pack` publica `/usr/bin/{name}`; `zsh` configura `--bindir=/bin`, así que su binario queda en `/bin/zsh`. El paquete se publicaba prometiendo un path que el artefacto no tiene, y el error salía **en la máquina que instala, después de hidratar los 1331 ficheros**: ``` Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en /usr/bin/zsh ``` Con `--build` el artefacto está delante: ahora `pack` comprueba el path contra él y lo **corrige** si el binario quedó en otro bindir, diciéndolo. ⚠ **Y el primer intento fue abortar, que era lo natural y estaba MAL.** Al publicar el perfil entero con esa versión, **65 de 175 paquetes se quedaron fuera del catálogo**: son las 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. Así que no aborta: avisa, y el paquete se publica. Lo mide el barrido de abajo, no el razonamiento — esa versión pasaba los tests igual. **Publicando el perfil `servidor` entero con el `pack` arreglado** *(medido, store del hub)*: | | | |---|---| | publicados y firmados | **162** (el índice verifica contra su `trust`) | | **`target_bin` corregidos** | **8: `bash` `sed` `tar` `parted` `dhcpcd` `squid` `wpa_supplicant` `zsh`** | | sin binario (librerías) | 64 — se publican, con aviso | | sin sellar en ESTE store | 12 | | fallaron | 1 (`os-release`, el mismo del §6.46) | Esos 8 son paquetes que **estaban publicados prometiendo un path que no existe**: `install bash` habría hidratado y muerto al final. Comprobado después del arreglo, contra el repo firmado servido por HTTP: `parted` (6 parches **y** binario en `/usr/sbin`) instala en 0,17 s y responde `parted (GNU parted) 3.7`; `bash` instala y responde `5.3.0(1)-release`. ### 5.7 El nombre ya existe: `repo.gioser.net` *(2026-09-21)* Dado de alta en la zona de Hetzner (la DNS de `gioser.net` está ahí: `helium`/`hydrogen`/`oxygen`), con la convención que ya usaban `takana`, `git`, `gitea`, `sergio` y `www`: **`repo` A → 2.29.29.217, ttl 60**. Resuelve *(comprobado contra Cloudflare; el resolutor de Google todavía servía el NXDOMAIN cacheado — negativo de 1 h por el `minimum` del SOA, no es un fallo del alta)*. ⚠ **La API vieja de DNS de Hetzner ya no es la buena.** `dns.hetzner.com/api/v1/zones` con `Auth-API-Token` devuelve **HTML de la consola web** (301 → 200 de una SPA), que es lo peor que puede devolver: no falla, contesta. Las zonas viven hoy en la **API de cloud** y las lee el MISMO token de `~/.config/hcloud/cli.toml`: ```sh curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones # gioser.net = 986350 curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones/986350/rrsets ``` Durante unas horas el nombre resolvió y **no sirvió nada** —`http://` daba el 308 global de Caddy y el HTTPS moría en el handshake (`tlsv1 alert internal error`) porque sin site block no hay certificado—, hasta que se pudo entrar como root a la caja. **Cerrado el 2026-09-21** con `scripts/servidor/publicar-repo.sh --install-vhost`, que publica y firma en `/srv/repo`, añade el bloque y lo aplica; por defecto **imprime el bloque sin tocar el Caddyfile**, porque en esta caja Caddy sirve 19 dominios y su config ya acumula 20 `.bak`. ⚠ **Publicar no es construir, y `repo-perfil.sh` no lo acotaba.** `pack --build` es instantáneo con cache-hit y una **compilación entera** si el artefacto no está sellado. Medido al ensayar el guion: la corrida se puso a compilar `libnftnl` —y después habría seguido con `rust`— en una caja de 4 cores que ya estaba a **load 46**. `build-repo.sh` llevaba `timeout` desde siempre; el que se corre en producción, no. Ahora `BUILD_TIMEOUT` (120 s) y `SKIP_UNSEALED=1`: un vencimiento **no es un error**, significa «ése no estaba sellado». Con eso, el perfil entero publica **173 anclados, 0 sin ancla, 2 saltados (`os-release`, `rust`)** sin compilar nada. **El `--install-vhost`, probado en sus dos caminos** *(con un Caddy de juguete, porque el de la caja no tiene el admin abierto)*: | camino | qué pasó | |---|---| | reload OK | `✓ la config valida` → `✓ recargado`; el admin muestra el `srv1` nuevo sirviendo el repo **y el sitio que ya estaba sigue en pie** | | reload FALLA | copia restaurada **byte a byte** (`b"repo.local" not in` el fichero final), y no recarga nada | El orden es lo único que lo hace seguro: copia → añadir → `caddy validate` → **sólo entonces** `reload`. Un reload con la config rota no tira el sitio nuevo: tira los 19. ⚠ **Y root no escribe en el árbol del usuario.** `work_root` sale de `dirname(store)/work` ⇒ con el store en `/store` es `/work`, donde `sources` y `out` son **enlaces a `/work/sergio/work/…`**, que es del usuario (CLAUDE.md §3 bis). Publicar como root dejaría ahí directorios de root y el siguiente build suyo moriría con un permiso denegado que no menciona la causa — es exactamente lo que ya pasó cuando root hizo `git pull` en el clon de tawasuyu y lo congeló para el dueño (`publicar-webs.sh`). El guion, si corre como root y nadie fijó `TAKANA_WORK`, lo manda a `/var/tmp/takana-publicar-work` y lo dice. Comprobado que el override no mueve el hash: `zsh` sella en `b3:0be3630d…` igual. ⚠ **Y trae su propia guarda contra el error más fácil de cometer:** si el `takana` de la caja es anterior al §5.5 (se detecta porque no conoce `outdated`), **aborta antes de publicar**. Con uno viejo el repo sale con las 16 recetas multi-parche irreproducibles y 8 paquetes prometiendo un binario que no existe — y eso no se nota hasta que alguien instala. ⚠ **Lo que queda vivo y no se tocó:** un `install ` DIRECTO sigue fallando tras hidratar, porque el `.swm` exige que el `target_bin` exista y para una librería no hay respuesta buena. Hacer `target_bin` opcional toca el formato ([SDD 06](06-swm-format.md)) y es su propia unidad de trabajo. Como dep funcionan, que es para lo que están en el catálogo. **El lazo, después:** `takana install zsh --repo http://… --require-signed` ⇒ cache-hit del hash anclado, **1331 ficheros hidratados en 0,14 s**, y el binario corre (`zsh 5.9`). Antes: recompilar zsh entero para morir al final. ### 5.6 `takana outdated` — el aviso que faltaba *(2026-09-21)* Ningún verbo comparaba lo instalado con el catálogo (`upgrade` es de **árboles/generaciones**, no de paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. `outdated` la da leyendo sólo el índice firmado y la DB de instalados — no construye, no baja `.swm`, no escribe nada. **Compara por hash, no por versión**, y eso es lo único que lo hace útil: un repo se re-publica sin que cambie la versión upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil. `scripts/servidor/avisar-actualizaciones.sh` lo corre por cron y **sólo grita cuando la lista de novedades cambia** (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se disfraza de «al día»: lo dice, sale ≠ 0 y **conserva el último estado bueno**. ⚠ **Lo que sigue sin poder hacerse, y no lo arregla ningún verbo:** actualizar en una máquina sin lab. El aviso llega a cualquiera; el `install` que lo resuelve sigue siendo reproducir desde fuente (§5.4). Por eso `outdated` **no actualiza**: instalar es verificar, y eso no se cuelga de un cron. ### 5.8 Puerta 5, de verdad: `repo.gioser.net` sirve el repo firmado *(2026-09-21)* ``` $ takana install zsh --repo https://repo.gioser.net --trust ./trust --require-signed release: trusted (by release) repo: bajados 6 .swm de https://repo.gioser.net caché: artefacto ya en el store hash=b3:0be3630d… name=zsh → hidratados 1331 archivo(s) real 0m0,286s ``` **173 paquetes anclados, 0 sin ancla**, índice firmado con la clave de release de la caja y certificado `CN=repo.gioser.net` de Let's Encrypt. Los 2 que faltan (`os-release`, `rust`) no estaban sellados y el `SKIP_UNSEALED` los saltó en vez de ponerse a compilar. **Lo que había que descubrir para poder aplicarlo, y no estaba escrito en ningún sitio:** - **`caddy reload` NO sirve en esta caja.** El Caddyfile lleva `admin off` (por eso el `:2019` siempre estuvo cerrado) y el `reload` de Caddy habla justamente por ese admin. La recarga real es **`arjectl restart caddy`**: caddy es un Ente de arje (`/etc/arje/cards.d/caddy.json`, `Restart{initial:1000,max:30000}`), no un proceso suelto. - **Un restart no es un reload: levanta los 19 dominios o ninguno.** Por eso el guion saca un **testigo** de la config PREVIA —el primer dominio con nombre propio, acá `gitea.gioser.net`— y espera a que vuelva a responder; si no vuelve, restaura la copia y revierte. Comprobado después: los 8 dominios vivos siguen en pie (`gitea` 301 a `git`, el resto 200). - **El certificado tarda más que la comprobación.** El guion dio `✗ todavía no responde` y la misma URL respondía **200 unos segundos después**: Caddy pide el certificado en la primera petición. El mensaje ahora dice «reintentá el curl antes de buscar nada» en vez de mandar a leer logs. **El espejo provisional se quitó.** Mientras no hubo root, el catálogo estuvo servido **sin firmar** bajo `takana.gioser.net/repo` (un árbol escribible sin root, con certificado válido). Funcionó —`install zsh` en 0,22 s— y sirvió para probar el mecanismo, pero un origen sin firma no es un origen: con `repo.gioser.net` firmado en pie, se borró. Lo que quedó de aquello vale la pena recordarlo: **firmar con una clave que no es la de release habría sido peor que no firmar**, porque una autoría inventada se lee igual que una real; se publicó sin firma y con `--require-signed` rechazándolo, que es lo único honesto que se podía hacer sin la clave. ⚠ **Sigue siendo un solo origen** (ADR 0014). La caja se sirve a sí misma; la lista de `--repo` de los clientes debería llevar también el Storage Box. El primer origen roto, si es el único, es el último. ### 5.9 El aviso, enchufado en la caja *(2026-09-21)* `41 * * * *` en el crontab de root → `/usr/local/bin/avisar-actualizaciones`, un envoltorio de tres líneas que **sólo fija las rutas de la caja** (`REPO`, `DB`, `TRUST`, `STATE`, `TAKANA`) y llama al guion del repo. La lógica no se copia a propósito: el día que el guion cambie, el cron no se queda con una versión vieja — y se comprobó en vivo, arreglando el guion en el clon y viendo cambiar la salida del envoltorio **sin volver a desplegar nada**. Tres piezas que faltaban en la caja y ahora están: | | | |---|---| | `/var/lib/hammer/trust/release.ed25519.pub` | el almacén de confianza **por defecto** de la CLI, que no existía ⇒ `install`/`outdated` verifican la firma **sin pasar `--trust`** | | `/usr/local/bin/takana` | build release con el arreglo del §5.5. ⚠ El `PATH` de la caja **no incluye `/usr/local/bin`**, así que un `takana` a secas sigue siendo el del store (que no conoce `outdated`). El cron usa rutas absolutas. Ponerlo canónico es reconstruir la receta `takana` e hidratarla — su propia unidad de trabajo | | `/var/log/takana-actualizaciones.log` | una línea por hora | ⚠ **Y la primera corrida real destapó un defecto del propio aviso**: con la DB de instalados vacía decía **«al día»**. Es verdad y no dice nada — peor, se lee como «comprobado y correcto» cuando lo cierto es que *no había nada que comprobar*. Un aviso que suena igual cuando vigila que cuando no vigila nada es exactamente cómo se deja de mirar un aviso. Ahora distingue: *«nada instalado por takana en esta máquina (DB …) — no hay qué comparar»*. Control en la caja con una DB de juguete (un `zsh` a otro hash): grita `1 con novedad · hash distinto`, y en la segunda corrida calla. Hoy la caja no instala de su propio repo —viene de imágenes—, así que el aviso dirá esa línea hasta que lo haga; se pone igual para que el día que instale algo ya esté, y porque una línea por hora no es ruido. Cada hora y no cada 30 minutos: el repo cambia cuando alguien publica. ### 5.10 `takana install zsh` en el sistema de verdad — cinco cosas que faltaban *(2026-09-21)* El usuario escribió `sudo takana install zsh` en la caja y le contestó **«paquete 'zsh' no está en el repo /var/lib/hammer/repo. Disponibles: (ninguno)»**. Reproducido tal cual. Debajo de ese mensaje había cinco cosas distintas, y ninguna se ve hasta que alguien instala de verdad. **1. El repo por defecto no existía.** `DEFAULT_REPO` está cableado a `/var/lib/hammer/repo` y el repo publicado vive en `/srv/repo`. Un enlace (`/var/lib/hammer/repo → /srv/repo`) hace que `install ` sin `--repo` funcione **y sin pasar por la red**: un directorio local sirve igual que la URL. **2. El lab no se encontraba desde `/root`.** `install` reproduce desde fuente y el toolchain entra en el `ArtifactHash`, así que sin `.dev-fs` ni siquiera puede calcular el hash a comparar (§5.4). La resolución es `TAKANA_LAB` → hermano del store → hacia arriba desde el CWD; con el store en `/store` y el CWD en `/root`, ninguna acertaba. Enlace `/.dev-fs → /work/dev-fs` y la regla del **hermano del store** acierta desde cualquier directorio y para cualquier usuario. *(Los dos labs de la caja tienen el mismo `apk db` byte a byte, así que el `LabFingerprint` es el mismo.)* **3. El `takana` del sistema no podía leer los paquetes nuevos.** Los `.tkn` de hoy llevan los parches como **lista** (§5.5) y un cliente anterior ignora ese campo —serde salta lo desconocido—, así que reconstruiría **sin parches** y moriría contra el `expected_hash`. Se reemplazó por el build release. ⚠ Y se comprobó ANTES de tocar que `/usr/bin/takana` tenía `enlaces=1` e inodo distinto al del store: es una copia, no un hardlink. **Un `cp` encima de un fichero hidratado que SÍ fuera hardlink reescribiría el artefacto del store por debajo** — se borra y se pone uno nuevo, nunca se sobrescribe. **4. ⚠ La instalación quedó PARTIDA, y eso no lo dice ningún mensaje.** Sin `--prefix`, `install` abre un overlay sobre el FHS… pero sólo sobre **siete** directorios clásicos (`/bin`, `/usr/bin`, `/sbin`, `/usr/sbin`, `/lib`, `/usr/lib`, `/etc`). **`/usr/share` no está entre ellos**, así que de los 1331 ficheros de zsh, **1296 fueron directos al FHS real** y sólo los 2 binarios quedaron en el upper esperando el `commit`. El mensaje final dice `apply OK` igual. Un `discard` en ese estado habría borrado el binario y dejado 1296 ficheros huérfanos: *el experimento no era reversible y parecía que sí*. **5. ⚠ `commit` no puede desmontar `/bin` en una máquina viva.** Los procesos tienen su ejecutable **mapeado** desde ahí —empezando por PID 1, `/usr/bin/arje-zero`— y un ejecutable mapeado pin-ea el montaje. Resultado: desmontó 5 de 7 targets, murió con `target is busy` en `/bin`, y dejó **dos overlays montados con el estado sin promocionar**, que es el peor de los finales. El comentario del propio `do_umount_lenient` prometía el desmontaje perezoso desde la Fase 2 y **el código nunca pasaba `-l`**. Arreglado: ante `busy` reintenta perezoso, que 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. **El arreglo, probado en la caja viva** con un segundo paquete instalado de cero: ``` $ takana install tree ⇒ overlay 1790022033-1575-000000 $ takana commit 1790022033-1575-000000 WARN takana_overlay: umount: el target estaba ocupado (procesos con ejecutables mapeados ahí, normal en un FHS vivo) ⇒ desmontaje perezoso target=/usr/bin commit OK — 1 archivo(s) promocionados, 0 eliminado(s) (anotado en el diario) ⇒ overlays montados: 0 · /usr/bin/tree real · `tree v2.3.2` corriendo ``` Mientras tanto la caja se dejó limpia a mano: los dos binarios copiados al `/bin` REAL —visto a través de un `mount --bind /` no recursivo, porque el overlay tapaba el destino—, los dos montajes sueltos en perezoso y el estado borrado. Ahora: `zsh 5.9` en `/bin/zsh`, 1296 ficheros en `/usr/share/zsh`, **cero overlays**, y `takana installed` lo lista. **Y la trampa del §5.7 se cobró su pieza**: root dejó un `/work/swm-recipes` **suyo** dentro del `/work` del usuario (el catálogo de recetas que `install` materializa; `work_root` sale de `dirname(store)/work`). Devuelto a `sergio`. El guion de publicar ya exporta `TAKANA_WORK` para esto; `install` a secas no tiene esa defensa, y es deuda anotada. ### 5.11 `chsh` no funcionaba, y las dos razones eran distintas *(2026-09-21)* Pedido del usuario después de instalar zsh. Dos capas, y sólo la primera es un arreglo. **1. `/etc/shells` no existía.** `chsh` de shadow rechaza cualquier shell que no esté en esa lista —para un usuario normal es un rechazo duro, no un aviso— y el fichero **no estaba en la caja**. Escrito con lo que EXISTE, comprobado `-x` uno por uno (`/bin/sh`, `/bin/ash`, `/bin/bash`, `/bin/zsh`) y no copiado de otra distro: *un shell listado que no está deja a quien lo elija sin poder entrar, y eso no se descubre hasta el siguiente login*. Con eso, `chsh -s /bin/zsh sergio` funciona y `su - sergio` entra con `zsh 5.9`. **2. ⚠ Pero `sergio` sigue sin poder cambiárselo él mismo, y eso no es un fichero que falte.** ``` $ su sergio -c "chsh -s /bin/bash" Cannot change ID to root. ``` Los cinco binarios de shadow que necesitan setuid root —`chsh`, `passwd`, `su`, `chfn`, `newgrp`— salen del store como **`r-xr-xr-x`**. No es que la hidratación pierda el bit: la receta lleva **`--disable-account-tools-setuid`**, heredado del APKBUILD de Alpine (donde esos binarios los hace setuid el *empaquetado*, no el configure — y acá no hay quien se lo reponga). Y hay una segunda capa debajo: **`ArtifactHash::of_tree` sólo hashea el bit de EJECUCIÓN** (`mode & 0o111`). O sea que hoy el modelo de artefactos **no puede ni expresar ni garantizar** un binario setuid: aunque la receta lo pusiera, el hash no lo cubriría y nadie notaría si se pierde o si aparece. ⇒ **Quitar la bandera no alcanza y no es un arreglo de receta: es una decisión de seguridad.** Un setuid root en una distro de artefactos sellados necesita contestar quién lo pone, quién lo verifica y qué significa que el hash no lo cubra. Anotado, sin decidir. ⚠⚠ **Y lo que de verdad hay que llevarse de esto: un overlay abierto se traga TODA la máquina.** Mientras el `/etc/shells` y el `chsh` se escribían, había un overlay abierto sobre el FHS —de un `takana install` sin `--prefix` que alguien había dejado sin commitear— y **las dos escrituras cayeron dentro de él**, aunque no tenían nada que ver con ese paquete ni pasaron por takana (fueron un `cat >` y el `chsh` de shadow). Medido con un `mount --bind /` no recursivo, que enseña el directorio real sin el overlay encima: | | `/etc` real | lo que se veía | |---|---|---| | `/etc/shells` | **no existe** | existe | | shell de `sergio` | **`/bin/bash`** | `/bin/zsh` | O sea: el trabajo estaba hecho «según la pantalla» y **un `discard` o un reinicio lo borraba**. Se resolvió con `commit` (5 ficheros promocionados: `passwd`, `passwd-`, `shells`, `zsh`, `zsh-5.9`) y ahí el desmontaje perezoso del §5.10 se ganó el sueldo: saltó en `/bin` **y** en `/usr/bin`. ⇒ **`install` sin `--prefix` no es «un experimento para ese paquete»: cambia la semántica de escritura de la máquina entera hasta que alguien commitea o descarta.** Cualquiera que toque `/etc` mientras tanto —otro agente, un servicio, un humano por ssh— escribe en el upper sin enterarse. El overlay no avisa: no hay nada en el prompt, ni en `mount` a simple vista, ni un aviso al entrar. Como mínimo `takana status` tendría que salir en el arranque de sesión, y `install` debería decir **qué queda pendiente** en vez de un `overlay listo` que se lee como «terminé». Anotado. Consecuencia práctica mientras tanto: **los cambios de shell y de contraseña en esta caja los hace root**. Y una observación que sale de mirar esto: no hay `/etc/shadow`; las contraseñas viven en `/etc/passwd`, que es legible por todos, con hash DES clásico. No lo toqué — es su propia unidad de trabajo y afecta al arranque de la caja. --- ## 6. La mudanza como experimento: el criterio de borrado El usuario decidió que **gioser se borra** al final. Eso no es un detalle de calendario: es lo que convierte la mudanza en un experimento con criterio de aceptación, y hay que escribirlo **antes**. **Regla base**: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien. Aplicado acá: *«lo copié»* no es prueba de nada. **La prueba es que el servicio responde en la caja nueva y que el dato se verifica por contenido.** ### 6.1 Las puertas, en orden. Ninguna se salta. | # | puerta | cómo se comprueba | |---|---|---| | 1 | la caja nueva arranca **takana puro** y se entra por SSH | `ssh` a la IP nueva tras el reboot del rescue | | 2 | el **store** está entero | `1327` artefactos **y ninguno vacío** — `respaldo-storagebox.sh --listar` separa los vacíos; un directorio vacío **no** es un artefacto | | 3 | los **grafos** salen idénticos | `build-state.py` en las dos máquinas ⇒ los cuatro grafos **byte a byte**. Es la prueba de que un hub está bien montado, no que «funcione» | | 4 | la **granja** late en la caja nueva | una cosecha completa con el LXC enchufado por `.fleet` y artefactos que vuelven | | 5 | el **repo** se sirve y el host se actualiza de sí mismo | §5 en verde, con la clave estable | | 6 | cada dominio **vivo** responde 200 desde fuera | la tabla del §1.1, repetida contra la IP nueva | | 7 | el **respaldo** al Storage Box corre desde la caja nueva | una corrida completa, no un `--seco` | | 8 | lo **fósil** está anotado y decidido | lista del §1.1 con «muere» o «revive» al lado de cada uno | ⚠ **Puerta 3 tiene una trampa conocida**: `sealed_remoto` dependía de la máquina (laptop 700, gioser 751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la diferencia es «cuánto de esto tengo en disco» y no «qué existe». ### 6.3 Estado de las puertas *(2026-09-10)* | # | puerta | estado | |---|---|---| | 1 | arranca takana puro y se entra por SSH | ✅ §3.8 | | 2 | el store está entero | ✅ **1362 artefactos reales, 0 vacíos** | | 3 | los cuatro grafos idénticos | ✅ **byte a byte** — §6.7 | | 4 | la granja late en la caja nueva | ✅ **cosecha completa: 1369→1575, 0 vacíos** — §6.8 | | 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) | | 6 | cada dominio vivo responde 200 | ⬖ sondeados: **6 vivos de 19** — §6.9 | | 7 | el respaldo corre desde la caja | ⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10 | | 8 | lo fósil, anotado y decidido | ✅ **decidido** — §6.9 | **Puerta 2, medida acotando al patrón `-`**: 1362 artefactos, **0 vacíos**. Los 3 sin `.hammer/recipe.toml` son `stage1-rootfs`, `product-rootfs` y `seed-zig` — productos de bootstrap, que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran `.mirror-tmp`, `.bootstrap-tmp` y `.divergen`: directorios de trabajo con punto inicial, no artefactos. El chequeo tiene que acotar por el patrón, no listar el directorio.) **De paso, el disco**: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran inalcanzables. Arreglado en `install-image.sh` — el store pasa a ser la ÚLTIMA partición (la única que puede crecer sin mover datos) y el wrapper de `/sbin/init` la extiende en el primer arranque. Medido en la caja: **`/store` = 68,7 G**, contra los 487 M de antes. Con eso el store de 55 G entra en el disco local y **la mudanza no depende de mover el volumen de gioser**. ### 6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER Al ir a computar los grafos en la caja: ``` $ python3 -c "print(1)" sh: python3: not found ← y `command -v python3` decía /usr/bin/python3 ``` No es que falte: **está y no arranca**. `/usr/bin/python3.12` son 23 MB y es un ELF **dinámico**: ``` Requesting program interpreter: /lib/ld-musl-x86_64.so.1 NEEDED libc.so ``` **Y en toda la imagen no hay ningún cargador dinámico.** Tampoco en el store: el artefacto `musl` publica sólo lo estático (`libc.a`, `crt*.o`, headers) — **no hay `libc.so` ni `ld-musl-x86_64.so.1` en ningún artefacto del corpus**. Lo que hace que estos binarios funcionen en gioser es el **rootfs Alpine del LAB**, que sí lo trae (`.dev-fs/alpine/lib/ld-musl-x86_64.so.1`). Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: *sella, reproduce y no corre*, porque pide algo que sólo existe en el laboratorio. **Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien).** Son la suite `binutils` entera (`ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof size ld.bfd`), más **`perl`**, **`python3`**, **`sqlite3`**, **`flex`** y **`nft`**. **Por qué frena la mudanza y no es un detalle**: *todo* el instrumental del proyecto es Python — `build-state.py`, `targets.py`, `yupana.py`, `hydrate-profile.py`, `drenar.py`, `triaje.py`. Un servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos) y la 4 (la granja) dependen de eso. **Y no se arregla de paso.** Las salidas son tres y ninguna es barata: 1. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del selfhost y hay que rehacerlo a propósito, no de rebote. 2. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio: Python estático con módulos de extensión es notoriamente arisco. 3. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo gioser, que es justo lo que se quiere apagar. Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md): las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**. ### 6.5 ✅ El muro, derribado: el corpus publica su propio cargador *(2026-09-11)* De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la **1**, que resultó ser más barata de lo que parecía y desbloquea la 2 en vez de competir con ella. **`recipes/musl-shared.toml`** — variante dinámica de musl que publica `/usr/lib/libc.so` (4,2 M) y `/lib/ld-musl-x86_64.so.1`. En musl el cargador Y la libc son el mismo objeto. **Por qué una variante y no `--enable-shared` en la canónica**, con su control: `musl` es componente de **Stage 1** y su `of_tree` es el baseline de `selfhost-verify`. Tocarla obliga a rehacer ese baseline a propósito. Con el patrón `*-shared` —que el corpus ya usa 23 veces— la canónica no se mueve: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y además **`musl` no es dep de nadie** (medido: sólo de sí misma; el enlace estático lo resuelve el musl que trae zig), así que esto **suma sin mover un solo ArtifactHash del corpus**. #### ⚠ Y hubo que cambiar de compilador, medido Con `zig-cc` el `libc.so` sale con **1586 símbolos dinámicos** contra los **1654** del musl de Alpine, y los **68 que faltan son exactamente** los que musl implementa en ensamblador x86_64 — `memset`, `memcpy`, `memmove`, `memcmp`, `strlen` y toda la familia matemática (`ceil floor sqrt fmod exp log sin cos tan fma round trunc`) — más los `__stack_chk_*`. No es que no se compilen: en el `libc.a` del musl canónico **están**. Lo que pasa es que **zig los resuelve con su propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0**, fuera de la tabla dinámica. El síntoma es un cargador que arranca, reloca y muere con `memset: symbol not found` — que se lee como «el binario está roto», no como «a la libc le faltan símbolos». Con `compiler = "gcc"`: **1651 símbolos**, `memset` y `ceil` presentes. #### El resultado, en la caja | antes | después | |---|---| | `sh: python3: not found` (con `command -v` contestando `/usr/bin/python3`) | `Python 3.12.10` | | 23 binarios inertes de 726 | **1** | `perl 5.040002`, `readelf`/`objdump`/`nm` (GNU Binutils 2.45.1) y el resto corren. El único que quedaba era **`sqlite3`**, con `Error loading shared library libz.so.1`: la zlib canónica también es `--disable-shared` y nadie publicaba ese soname. `zlib-shared` ya existía sellado en el corpus — sólo faltaba declararla en `base`. **La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente**: con el cargador publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza. ### 6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB Con `python3` vivo, `takana hash` en la caja **seguía fallando**: ``` Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed. El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable. ``` **El lab es parte de la IDENTIDAD, no del entorno.** Una máquina sin él no puede ni preguntar «¿cuál es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado el lab (tarball pineado + `tools/`, bajo `/store/dev-fs` porque la raíz son 6 G), el **hash testigo coincide byte a byte**: `takana hash recipes/zlib.toml` da `b3:dc363f26…` en las dos máquinas. ⚠ **Y la caja no podía desempacar su propio lab**: el `tar` del rootfs es el de busybox y contesta `tar: unrecognized option: zstd`. La distro comprime todo con zstd —la imagen del lab, el respaldo, el `dd` remoto— y la imagen no traía el binario. `zstd` ya tenía receta sellada; declarado en `base`. #### ⚠⚠ Copiar un store con `rsync -a` es una fábrica de vacíos, y ahora se sabe POR QUÉ La primera copia murió con `No space left on device` en un `.so` de Qt, dejando el destino al 100 % y **1367 de 1368 artefactos VACÍOS**. La puerta 2 corrida EN EL DESTINO lo cazó en el acto. La causa, medida: | medición sobre el store de gioser | | |---|---:| | `du -sh` (hardlinks contados UNA vez) | **60 G** | | `du -sh --count-links` (hardlinks EXPANDIDOS) | **85 G** | | `.dmerge` (la caché que hardlinkea al store) | **11 G** | **`rsync` sin `-H` no preserva hardlinks: los expande en copias enteras.** Así que «el store son 60 G» —que es lo que dice `du` y lo que uno planifica— se convierte en 85 G + lo que `.dmerge` expanda al otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes. La copia correcta es **`rsync -aH --exclude='.dmerge'`**: `-H` preserva los enlaces (60 G reales) y `.dmerge` no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker. **La lección de método**: `du -sh` sobre un árbol con hardlinks **no** dice cuánto ocupa copiarlo. Para dimensionar una copia hay que medir con `--count-links`, o preservar los enlaces. #### Y el instrumental apunta al binario de DESARROLLO, no al instalado La primera corrida de `build-state.py` en la caja devolvió **875 recetas `unhashable`** — todas — con el lab bien sembrado y `takana hash` funcionando a mano. La causa: ```python HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana")) ``` Los scripts asumen un **árbol de desarrollo**: `target/release/takana`. En una caja **instalada** el binario es `/usr/bin/takana` y `target/` ni existe (la siembra lo excluye a propósito). No falla ruidosamente: cada `hash` falla y el grafo sale entero como `unhashable`, que se lee como «este corpus no se puede hashear» y no como «no encontré el binario». Se destraba con `HAMMER=/usr/bin/takana STORE=/store`, que la propia cabecera del script documenta. Pero la deuda queda anotada: **el instrumental debería caer a `command -v takana`** antes de rendirse, porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista. ### 6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte ``` gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605 caja: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605 ``` `base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3` (`chrony`, `cronie`, `logrotate` — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas. **Costó tres intentos, y los dos fallos son la parte que vale.** **Intento 1 — `unhashable 875`**, todas. Ver arriba: los scripts apuntan a `target/release/takana`. **Intento 2 — dos nodos divergentes**: `aichat` y `zola`, `sealed` en gioser y `never` en la caja. No era deriva ni pérdida: **ninguno de los dos está en disco en gioser tampoco**. Son los «2 sellados que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime. `build-state.py` lee `work/farm-sellados.txt` y `work/respaldo-sellados.txt` — y **`work/` está gitignored**, así que un hub recién sembrado no los tiene y degrada esos nodos a `never` sin que nada falle. Es la misma familia que `scripts/farm/.fleet`: **un fichero fuera de git del que depende una medición compartida**. La regla que sale: *sembrar un hub no es clonar el repo — es repo + store + lab + los manifiestos*. Copiados los dos ficheros, los grafos coinciden exactamente. ### 6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco **Probado desde la caja** (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso): - llevada la clave de la granja (`~/.ssh/github5` → `/root/.ssh/github5`, 0600) y escrito `scripts/farm/.fleet` — los dos están fuera de git a propósito y hay que copiarlos a mano; - la caja **alcanza al worker**: `PruebasIA`, load 3, 775 artefactos; - `estado-granja.sh` corre allá y lee bien el worker y el avance KDE (198/199); - y el paso que cierra el lazo: se **cosechó un artefacto real del worker** (`speedtest-go`, 12 M) con el mismo transporte de la granja, llegó **con su `.hammer/recipe.toml`** y **su binario CORRE en la caja** (`speedtest-go v1.7.10`). Worker construye → hub cosecha → binario ejecuta. #### ✅ CERRADA (2026-09-11) con el volumen `takana-store` Resuelto el disco (§6.12), la cosecha completa corrió: **1369 → 1575 artefactos, 0 vacíos**, que son exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —`speedup 3.14`, el ahorro de `-H`— y `/store` queda en 73,7 G de 97,9 (79 %). ⚠ El enlace con el worker va a **11 MB/s**, no a los 90 MB/s de gioser↔caja: el LXC vive en el Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño. ⚠ **Lo que faltaba no era mecanismo: era DISCO.** | | | |---|---:| | artefactos en el worker que la caja no tiene | **206** | | a ~113 MB de media (el artefacto medio del corpus) | **~23 G** | | libre en `/store` de la caja | **3,7 G** (61,5 de 68,7 · 94 %) | Una cosecha completa **no entra**, y forzarla repetiría exactamente el llenado que dejó 1367 artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una de ingeniería: 1. **Enganchar `harkaq-cosecha` (250 G) a la caja.** Es el plan original y no cuesta nada extra — pero hay que DESENGANCHARLO de gioser, y ahí vive el store, `work/sources`, los tarballs y el `CARGO_HOME` de la granja. O sea: **es el cutover**. gioser deja de construir en ese mismo instante, y hoy está moliendo el árbol KDE. 2. **Un volumen nuevo para la caja** (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta. 3. **Podar el store de la caja** — deshace parte de la mudanza. No. ### 6.9 Puerta 8: los 19 dominios, sondeados uno por uno *(2026-09-11)* El Caddyfile de gioser declara 19 sitios. Sondeados por DNS **y** por HTTP desde fuera, sólo **6** los sirve gioser de verdad: | dominio | DNS | HTTP | qué es | |---|---|---|---| | `gioser.net` · `tawasuyu.net` | gioser | **200** | **VIVO** — las dos landings | | `git.gioser.net` · `git.tawasuyu.net` | gioser | **200** | **VIVO** — el gitea (aloja el `origin` de takana) | | `sergio.gioser.net` · `api.sergio.gioser.net` | gioser | **200** | **VIVO** | | `summa` · `api.summa` · `dev.summa` | **154.197.1.2** | 200 | **YA MUDADOS** a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva | | `api.gioser.net` | gioser | **502** | backend caído | | `mail.sigma.gioser.net` | gioser | **502** | backend caído | | `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **SIN DNS** | — | fósil: ni DNS ni ficheros en `/var/www` | | `terapeuta.ec` · `andino.ec` | **SIN DNS** | — | fósil con datos: 279 M en `/var/www/terapeuta` | | `gitea.gioser.net` | **SIN DNS** | — | era un `redir` a `git.gioser.net`; inofensivo | **Lo que esto cambia respecto del §1.1**: no son «4 fósiles» sino **13 de 19 entradas que no hay que mudar** — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a mudar son **6 sitios**, y de esos el que pesa es el **gitea**, porque aloja el `origin` de takana. #### ✅ DECIDIDO (usuario, 2026-09-11) | fósil | decisión | |---|---| | `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **mueren** — ni DNS ni ficheros | | `gitea.gioser.net` | **muere** — era un `redir`, y `git.gioser.net` lo cubre | | `api.gioser.net` · `mail.sigma.gioser.net` | **mueren** — backend caído, sin dueño | | `summa` · `api.summa` · `dev.summa` | **no se mudan**: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser | | **`terapeuta.ec` · `andino.ec`** | **MUEREN** — decisión explícita del usuario. Los 279 M de `/var/www/terapeuta` se van con la caja; **no se archivan** | ⇒ De las 19 entradas del Caddyfile, **13 no se mudan**. La superficie real es de **6 sitios**, y el que pesa es el gitea porque aloja el `origin` de takana. ⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no va a poder recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo. ### 6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo Llevadas las credenciales (`~/.config/hammer/*.env`, 0600) y la clave, **la caja alcanza el Storage Box** por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá: **3140 artefactos**, y de paso el script detecta **2 VACÍOS en el respaldo** (`gnome-desktop` ×2) que excluye del manifiesto — su propia guarda, funcionando. Para llegar ahí hubo que arreglar **cinco** cosas, y todas son de la misma familia: *el instrumental asume que el hub es gioser*. El `--seco` ahora completa los tres pasos desde la caja y lee la ocupación del box (111 G de 1 T). 1. **La raíz cableada.** `RAIZ="${RAIZ:-/mnt/vvv/takana}"` ⇒ desde cualquier otra máquina el script moría con `cd: /mnt/vvv/takana: No such file or directory`. Ahora se deriva de dónde está el script, como el resto de `scripts/`. 2. **El binario de desarrollo.** `HAMMER = ROOT/target/release/takana` (§6.7). 3. **El store no estaba donde el script creía.** Usaba `$RAIZ/store` cableado: en gioser es un bind-mount DENTRO del repo, pero en una caja instalada es una partición en `/store` ⇒ `change_dir "/opt/takana/store" failed`. Y rsync devuelve **23**, que está en la lista de reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta («un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»). Ahora `STORE` es env, con una guarda que aborta ANTES del bucle si la ruta no existe. 4. **🚨 Y una que habría costado el historial.** El paso [2/3] sube el repo **con `--delete`** — correcto, para eso está git. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`: es una COPIA, no un clon. **Una corrida real desde ahí habría borrado `.git` del Storage Box**, o sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una guarda que aborta si la raíz no tiene `.git` (escotilla `REPO_INCOMPLETO=1`), probada en los tres sentidos. **La regla que sale**: un `--delete` convierte «respaldar» en «sincronizar», y sincronizar desde un origen incompleto no sube menos — BORRA. Cualquier respaldo con `--delete` necesita una guarda de completitud del ORIGEN, no sólo del destino. 5. **El rsync del corpus no puede correr el respaldo del corpus.** El script exige `--compress-choice=zstd` y `recipes/rsync.toml` lo construía con `--disable-zstd` — porque cuando se escribió, `zstd` no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el script lo clasifica como «no de red» y **no reintenta**, así que el respaldo simplemente no se hacía. Arreglado por los dos lados: la receta enciende zstd (control: `zstd zlibx zlib none` vs `zlibx zlib none`; radio cero, `rsync` no es dep de nadie) y **el script degrada a zlib avisando** en vez de morir. ### 6.11 ⚠ La distro traía un `git` que NO PODÍA CLONAR Para cerrar la puerta 7 la caja necesita un **clon de verdad** (el `--delete` del respaldo exige un origen completo, §6.10). Llevada la clave del gitea —que es `~/.ssh/tawasuyu`, **no** la de la granja— `git ls-remote` contesta… y `git clone` muere: ``` fatal: fetch-pack: invalid index-pack output ``` El informe completo, que la traza corta escondía: ``` panic: load of misaligned address 0x… for type 'const uint32_t', which requires 4 byte alignment in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate → git_hash_update → unpack_entry_data → cmd_index_pack ``` **Dos causas, encadenadas:** 1. **`-fsanitize=undefined` estaba en `CFLAGS`**, no sólo en `LDFLAGS`. El motivo original era legítimo —las `libz.a` materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no se resuelven solas, y el flag *al enlazar* trae el runtime de zig— pero en `CFLAGS` **instrumenta el código de git**. Un arreglo de ENLACE convertido en una mina de RUNTIME, justo en la ruta de hash: por donde pasa todo lo que git recibe. 2. **`sha1collisiondetection` lee palabras de 32 bits SIN alinear.** Es el backend SHA1 por defecto de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte. `-DSHA1DC_FORCE_ALIGNED_ACCESS` lo hace leer byte a byte: quita el UB **en la fuente** en vez de esconderlo. Con las dos: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas**. Radio cero (`git` no es dep de nadie), pero **sí es raíz de `perfil.base`** ⇒ esto arregla la distro entera. **La lección**: `ls-remote` andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde. ### 6.12 ⚠ La caja dejó de ser desechable — y `hydrate` no cruza el store Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento: **1. Re-escribir la imagen con `dd` ya NO es una actualización, es una pérdida.** El `dd` sobrescribe el disco local, y ahí viven ahora `/opt/takana` (el clon), `/root/.ssh` (las dos claves), `/root/.config/hammer` (las credenciales del respaldo), `/srv/repo` y la configuración de caddy. El store se salva sólo porque está en el volumen. **Y hay una trampa peor**: la imagen etiqueta su partición local como `hammer-store`, así que tras un `dd` habría **dos filesystems con esa etiqueta** y el `findfs` del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición vacía. ⇒ el `dd` es para PROVISIONAR; actualizar es `takana upgrade` (que existe, con generaciones y rollback) y está sin probar en esta caja. **2. `takana hydrate` no puede proyectar al root vivo.** Hidratar enlaza en DURO, y en una caja instalada el store es **siempre** otra partición que `/`: ``` Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link ``` No es culpa del volumen: en el layout original `/store` es `sda4` y `/` es `sda2`, también distintos. **Hidratar al FHS vivo nunca fue posible en una caja instalada** — funciona en el hub porque ahí store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces (§6.6), ahora a nivel de sistema y no de herramienta. Para instalar el `git` arreglado se copió el artefacto a mano. Eso **funciona y no es el camino**: el camino es `takana upgrade`, y probarlo es su propia unidad de trabajo. ### 6.13 ✅ `takana upgrade`, probado de verdad — y el bug de durabilidad que destapó §6.12 dejó dicho que el camino de actualización de una caja instalada es `takana upgrade`, no `dd` ni `hydrate`. Probado con un paquete que la caja **realmente necesitaba**: `zstd`. | paso | resultado | |---|---| | `upgrade apply …-zstd-cli` | `✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados` | | la prueba de uso | `zstd -dc lab-image.tar.zst \| tar -xf -` ⇒ **la caja desempaca su propio lab** | | `upgrade rollback` | `✓ generación 2 revertida · 9 restaurados, 1 borrados` — `zstd` se va, `libzstd.a` vuelve | | re-`apply` + **corte duro sin `sync`** | sobrevive: generación viva 1, manifiesto íntegro | ✔ **Y cruza la frontera que `hydrate` no puede**: el store está en el volumen y `/` en el disco local, y `upgrade` copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada. #### ⚠ El bug: `write_atomic` era atómico pero NO DURABLE El primer intento pareció funcionar y **se perdió al reiniciar**. 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 ``` `write_atomic` hacía temporal + `rename`: atómico frente a otros PROCESOS, no frente a un CORTE. `rename` sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a bloques nunca escritos — cero bytes. Y `hcloud server reset` **es un corte duro**, no un apagado limpio: ahí está la mitad operativa de la lección. Lo grave no es perder un upgrade: es que **`recover` —que existe exactamente para «un apply interrumpido por un corte»— abortaba con la huella más probable de ese corte**. Arreglado: `fsync` del temporal antes del `rename` y `fsync` del directorio después (hacen falta los dos), y un `pending.json` vacío se reporta como `PendingCorrupt`, nombrando el corte y apuntando al árbol de respaldos. Con tests y su control. **Verificado en la máquina, en los dos sentidos**: con el binario viejo, apply + reset duro ⇒ estado en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio. #### ⚠⚠ `upgrade apply` NO es un gestor de paquetes: cada generación ES UN ÁRBOL Lo destapó usarlo dos veces seguidas. Tras aplicar `zstd-cli` y después `rsync`: ``` ✓ generación 2 aplicada + 0 añadidos, ~ 8 pisados, - 1 retirados ← el `zstd` de la generación 1 $ zstd --version AUSENTE ``` **Aplicar un árbol nuevo RETIRA los ficheros del anterior**, porque una generación es el estado completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/**producto** del store»— y con que exista `rollback`: lo que se revierte es un sistema, no un paquete. ⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente. **La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace `servidor-image.sh`— sellarlo, y aplicar ESO como una generación.** Eso además sería, de verdad, la «actualización en sitio probada» del [SDD 19 §5.2](19-lanzamiento-publico.md): una imagen entera, con rollback, sobre una caja viva. Mientras tanto la caja quedó con `zstd` y `rsync` puestos a mano — funciona y **no es el camino**, igual que el `git`. ⚠ Lo que NO se arregló: si 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. ### 6.2 Lo que la mudanza tiene que producir, además de la mudanza El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es una receta candidata con un caso de uso comprobado detrás: | lo que corre hoy en Artix | receta | |---|---| | `caddy` | ✅ existe (`recipes/caddy.toml`) | | `openssh` | ✅ existe | | `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo | | `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ | | `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` | > **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más > peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su > artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo. > > Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4 > ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete > ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea. > > Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar. **Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*. --- ### 6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE *(2026-09-14)* `servidor-image.sh` + QEMU: **PID 1 = `arje-zero`, gitea encarnado por él (`ppid=1`), corriendo como `uid=916`, escuchando en :3000 y devolviendo `200` con `takana git`**, con su `gitea.db` creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana: los que había venían horneados en el bootstrap. La cadena entera, eslabón por eslabón: `[[user]]` → `/etc/passwd` de la imagen · `[[service]]` → `takana service-cards` → `genesis` de la seed → arje lo encarna → `setuidgid` → sirve. #### Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN Los tres pasan el sellado, el `hash`, el resolutor de servicios y el armado. Ninguno se ve sin arrancar la imagen. 1. **⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store.** `takana users --merge` hacía `fs::write` sobre `/etc/passwd`, y ese fichero y el del artefacto `product-rootfs` son **el mismo inode** (medido: 1225824, `2 links`, modo `444`) — el rootfs se arma con hardlinks. Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro del producto. Se salvó porque el store es de sólo lectura y salió `Permission denied`: confiar en eso es confiar en un permiso. Ahora escribe por temporal + `rename`, con un control que comprueba que el fichero del store conserva su contenido y baja a 1 link. 2. **La imagen traía el binario, la cuenta… y nadie lo arrancaba.** El `genesis` sólo tenía lo que hornea `takana-bootstrap` (`sshd`, `console-getty`, `hammerd`): `servidor-image.sh` **no inyectaba las Cards** — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo dice: `targets.py --services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva esa declaración a la imagen. 3. **⚡ `trap invalid opcode`: el binario sólo corría en la CPU que lo compiló.** Con la card ya en el genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta `CC` apuntando a un wrapper con `-mcpu=baseline` (y su comentario 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 es `-mcpu=native`. No falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el `ArtifactHash` no puede cazarlo, 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`). #### Lo que NO está probado, y falta para la mudanza El `app.ini`, el usuario del sitio y los datos se pusieron **a mano** dentro de la VM para llegar al 200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — `/etc/gitea` ya está en `scripts/mudanza/rutas-fuera-de-git.txt`. Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para que arje volviera a intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en caliente. Para una caja de producción eso es una pieza que falta, no una comodidad. ### 6.12 🎛️ `arjectl` en la imagen: control en caliente *(2026-09-14)* El §6.11 cerró con «la imagen no trae `arjectl`, hubo que reiniciar la máquina para arrancar un servicio». Ya lo trae, y **no hubo que escribir nada**: el cliente existía en tawasuyu y se llama **`arje-ctl`** (el crate; el binario es `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí salió la conclusión falsa de que faltaba implementarlo — el protocolo (`arje-bus::BusRequest`) ya traía `ListEntes`, `SpawnCardFromDisk`, `StopCardFromDisk`, `KillEnte` y `EnteStatus`. `recipes/arjectl.toml` lo construye **del mismo commit que `arje-zero`** (`98db584f`), y eso no es comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no entenderse con el init. Publica **sólo `arjectl`**; el crate también produce un `systemctl` de camuflaje que acá no se instala — un `systemctl` en el PATH de una distro sin systemd invita a escribir runbooks con el verbo ajeno. #### El hueco que sólo se ve usándolo: el genesis no alcanza Con `arjectl` en la imagen, el primer intento falló con un mensaje que nombra la causa: $ arjectl start gitea Error: arje-zero rechazó: card gitea: No such file or directory (buscada en /etc/arje/cards.d/gitea.json) `start`/`restart` usan `SpawnCardFromDisk`, que lee **`/etc/arje/cards.d/