tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 70a76aedcba341d32d75a286328c1d131b6b2029 docs/28-servidor-de-produccion.md Markdown · 1346 siq'ikuna · 78.5 KB Ñawpa willay ch'uya

⚠ la comprobación que faltaba: DOS commits se habían quedado en el gitea viejo 70a76aedc · Sergio ·

1# SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella
2
3Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en
4Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo
5takana»). Todo número marcado *(medido)* salió de un comando corrido ese día sobre `gioser`.
6
7Continúa [SDD 19](19-lanzamiento-publico.md) (lanzamiento público) y
8[SDD 27](27-perfiles-instalables-y-el-instalador.md) (metapaquetes e instalador). **No repite el
9instalador**: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega.
10
11---
12
13## 0. La corrección de encuadre: ya estamos en Hetzner
14
15`gioser` **es** un servidor Hetzner (hcloud id `126324694`, hel1, 4 cores / 7,6 GiB) *(medido)*. La
16pregunta no era «cómo llegar a Hetzner» sino **cómo tener una caja de takana** en vez de ser un
17inquilino más de una máquina compartida que:
18
19- sirve **19 dominios** por Caddy,
20- tiene `/mnt/vvv` al **89 %** (27 G libres) *(medido)*,
21- es **fija y sin backup** por pedido explícito del usuario (blindaje anti-borrado en `deadman.sh` y
22  `farm-down.sh`, ver [ADR 0014](adr/0014-distribucion-multiorigen.md) y la memoria del proyecto),
23- y **no publica nada de takana**: cero entradas de takana en el Caddyfile *(medido)*.
24
25### 0.1 El hallazgo que cambia el tamaño del problema
26
27```
28$ ps -p 1 -o comm=,args=
29arje-zero  /usr/local/sbin/arje-zero
30$ cat /proc/cmdline
31BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
32  init=/usr/local/sbin/arje-zero loglevel=4
33```
34
35**El init de takana ya gobierna un servidor de producción real.** Caddy, gitea, sshd, qdrant y
36act-runner cuelgan directo de PID 1 *(medido: `ps -eo pid,ppid,comm | awk '$2==1'`)*.
37
38Pero **eso no es takana puro**: es Artix con nuestro init inyectado por `init=` en el cmdline. El
39kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.
40
41### 0.2 ⚠ La declaración del arranque no existe en ningún sitio legible
42
43`rc-status` de OpenRC dice `stopped` para **caddy, gitea, sshd, cronie y act-runner**, y los cinco
44están **vivos** *(medido, los dos comandos)*. `/etc/arje/cards.d` tiene **4 tarjetas** (`acpid`,
45`openrc-openclaw`, `squid`, `zram-swap`) y ninguna de ellas los levanta.
46
47⇒ **La secuencia de arranque de esta caja no está escrita.** Es lo menos reproducible de toda la
48mudanza y lo primero que hay que capturar — ver §4.
49
50---
51
52## 1. Inventario de lo que hay que mudar *(medido 2026-09-09)*
53
54### Datos en `/mnt/vvv` (217 G de 255)
55
56| | |
57|---|---|
58| `tawasuyu` | **124 G** |
59| `takana` | **91 G** — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios |
60| `rustup` | 29 G |
61| `act-runner` · `gioserv` · `_respaldo` | 6,5 · 5,4 · 4,1 G |
62| ~20 dirs `*-standalone` | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… |
63
64### El volumen `harkaq-cosecha` (250 G, 148 G usados)
65
66| | |
67|---|---|
68| `store` | **55 G**, **1327 artefactos** — lo irreemplazable |
69| `work-sources` + `cargo-home` + `gopath` | ~85 G — **caché regenerable**, no se muda |
70| `work-tarballs` · `devfs-cache` · `work-out` | 3,2 G · 4,5 G · 397 M |
71
72**El store no se copia por red.** Los dos volúmenes están en `hel1`; un volumen hcloud se desengancha
73de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.
74
75### Servicios vivos, todos hijos de arje-zero
76
77`caddy` :80/:443 · `gitea` :3002 · `sshd` :2345 · `act_runner` (el CI de tawasuyu) · `qdrant`
78:6333/:6334 · `openclaw` · `zeroclaw` · `shuma-gateway` :7378 · `tejido` :4102 y :33097 ·
79`puerta-f6e393ff` · `uvicorn` :8771 · `python3` :8770 · `squid` :1137 · `fail2ban` · `glances` ·
80`php-fpm` · `metalog` · **`adb` :5037**.
81
82### Lo que no está en ningún repo
83
84`/var/lib/gitea` (**2,2 G**: SQLite + los repos de git de todo, **incluido el `origin` de takana**) ·
85`/var/www` (287 M) · `/home/sergio/gioser-web/` · `/etc/caddy/Caddyfile` **+ 20 ficheros `.bak`** ·
86los certificados de letsencrypt · los crontabs · `/etc/arje/cards.d` · las 3 credenciales de
87`~/.config/hammer/*.env` · las llaves `~/.ssh/{github5,sergio}` · el token hcloud · y **la memoria
88del agente en `~/.claude/`**.
89
90### 1.1 La basura, contada — porque una mudanza fiel copia también la basura
91
92De los 19 dominios del Caddyfile *(probados uno por uno desde la propia caja)*:
93
94| responden 200 | rotos |
95|---|---|
96| gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | **api.gioser.net → 502** (backend caído) |
97| | **aura · sigma · kosmofono · terapeuta.ec → sin DNS** |
98
99Y sus raíces **no existen en disco**: `/var/www` sólo tiene `terapeuta`, `goaccess`, `ogisoer`,
100`git-tawasuyu`, `letsencrypt` y un `.bak` — no hay `aura_frontend`, ni `sigma`, ni `summa/frontend`,
101ni `kosmofono`. **La mitad del Caddyfile describe un servidor que ya no existe.**
102
103⇒ **Regla de la mudanza: no se muda lo que no responde.** Lo que no tiene DNS ni ficheros se anota y
104se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir *declarado / vivo / fósil*.
105
106---
107
108## 2. La caja: qué, cuánto y por qué
109
110Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) *(medido)*:
111
112| tipo | recursos | €/mes |
113|---|---|---:|
114| **`cx53`** | **16 c / 32 G / 320 G** | **29,49** |
115| `cpx41` | 8 c / 16 G / 240 G | 32,49 |
116| `ccx23` (dedicado) | 4 c / 16 G / 160 G | 85,99 |
117
118**Elegido `cx53`**: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana +
119rustup (~244 G) si algún día se muda todo.
120
121**Decisiones del usuario (2026-09-09):**
122
1231. **La caja PUBLICA; el LXC prestado sigue moliendo.** `dev.gioser.net` cuesta €0 y es la máquina
124   con más RAM (6 c / 16 G + 8 G swap). Separar *servir* de *compilar* mantiene la caja chica y
125   barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker.
1262. **«Producción» = operativo pero privado.** Sin descarga pública todavía ⇒ **no** se activan los
127   bloqueantes legales del [SDD 19 §2](19-lanzamiento-publico.md) (espejo de fuentes público por
128   HTTP, marca de terceros). Lo que sí hace falta igual es la **clave de release estable** (§5).
1293. **gioser se BORRA** cuando la mudanza esté comprobada. Eso convierte esto en un experimento con
130   fecha de caducidad, y cambia el diseño entero: ver §6.
131
132⚠ **El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito.**
133`deadman.sh` y `farm-down.sh` lo protegen por lista negra de nombre **y** por ausencia del label
134`role=hammer-worker`. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a
135mano, con la comprobación del §6 en verde y no antes.
136
137---
138
139## 3. Instalar takana puro en una caja remota
140
141### 3.1 Lo que ya está, y por qué está más cerca de lo que parece
142
143**El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI.** *(medido en gioser: no
144existe `/sys/firmware/efi`; GPT + GRUB; `/boot` es vfat de 1 G.)* Y `scripts/takana-install.sh`
145escribe **exactamente ese layout**: GPT + GRUB BIOS, `SeaBIOS → GRUB → kernel → arje-zero PID1`.
146El camino EFI —que fue el que más trabajo costó ([ADR 0010](adr/0010-arranque-grafo-mirada.md))— es
147el que Hetzner **no** necesita.
148
149| pieza | estado *(medido)* |
150|---|---|
151| kernel con virtio | `recipes/linux.toml` compila `VIRTIO`, `VIRTIO_PCI`, `VIRTIO_BLK`, `VIRTIO_NET`, `EXT4_FS` **`=y`** ⇒ sin initramfs |
152| 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 |
153| acceso | `recipes/openssh.toml` sellado; el `product-rootfs` vigente **ya trae `sshd`**; `takana-install.sh` acepta `AUTHKEYS` |
154| servir | `recipes/caddy.toml` existe |
155| escritura a disco | `scripts/takana-install.sh` (no interactivo) y `scripts/takana-live-install.sh` (TUI), probado por `scripts/install-tui-test.sh` |
156
157### 3.2 El camino remoto: rescue → `dd` → reboot
158
159Hetzner Cloud tiene un **sistema de rescate** (Debian por SSH) que se activa desde la API sin tocar
160la caja. Desde ahí se escribe la imagen al disco y se reinicia. **No hace falta ISO, ni consola, ni
161medio físico** — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.
162
163### 3.3 Los cuatro huecos, nombrados
164
1651. **`perfil.servidor`** — **HECHO hoy** en `docs/state/targets.toml`. Ver §3.4.
1662. **`console=ttyS0` en el cmdline** — el único salvavidas si el arranque falla y no hay teclado.
167   Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil.
1683. **IPv6** — `netup` hace DHCPv4 y Hetzner entrega un `/64` **estático**. La caja arrancaría con
169   IPv4 sola. Aceptable para empezar, pero hay que saberlo **antes**, no descubrirlo.
1704. **Un `.img` del perfil servidor** que el rescue pueda escribir. Es `hydrate-profile.py` +
171   `install-image.sh`, los dos ya existentes — ver §7.
172
173### 3.4 El perfil, y la deuda que declara *(hecho 2026-09-09)*
174
175`[perfil.servidor]` hereda `cli` (que hereda `base`, [SDD 27 §7.1](27-perfiles-instalables-y-el-instalador.md))
176y añade **5 raíces que existen** (`openssh`, `caddy`, `curl`, `wget`, `tmux`) y **5 que NO**:
177
178| raíz que no resuelve | por qué está declarada igual |
179|---|---|
180| **`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 |
181| `chrony` | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» |
182| `nftables` | `dev.gioser.net` está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así |
183| `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** |
184| `logrotate` | en una caja que sirve, el log de acceso crece hasta llenar el disco |
185
186Quedan **`wanted`** en el grafo, y **`wanted` no es `debt`** ⇒ `drenaje.json` seguirá diciendo
187`deuda=0`. Se declaran igual porque **una deuda escrita se ve y una omitida no**: sin ellas el perfil
188saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
189la lección de `foot` en `escritorio-sway` (121/121 sellado y sin emulador de terminal), pagada por
190adelantado. **Cuenta esperada: 5.** Un sexto `wanted` es un error de tipeo, no una receta nueva.
191
192### 3.5 La caja, creada *(2026-09-10)*
193
194| | |
195|---|---|
196| nombre / id | `takana` / `165447050` |
197| tipo | **`cx33`** — 4 c / 7,6 GiB / 76,3 G — **€8,49/mes** |
198| ubicación | **hel1** — la misma que los volúmenes `vvv` y `harkaq-cosecha` y que el Storage Box |
199| IPv4 / IPv6 | `2.29.29.217` · `2a01:4f9:c014:2f9c::/64` |
200| imagen inicial | `debian-13` — **desechable**: existe sólo para recibir ficheros hasta el `dd` del §3.2 |
201| label | `role=takana-server` |
202| protección de borrado | **NO**, a propósito: es la caja de sacrificio |
203
204⚠ **El label importa**: `farm-down.sh` y `deadman.sh` borran **sólo** lo que lleva
205`role=hammer-worker`. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla.
206Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se
207protege por no haber nacido de `farm-up`.
208
209⚠ **`cx53` no se pudo comprar: sale `Available: no` en las TRES ubicaciones.** Y la disponibilidad
210**cambia por día** — `cx33` salió `no` en hel1 el 2026-09-09 y `yes` el 2026-09-10, con la misma
211consulta. ⇒ **el precio de la tabla no dice que la máquina exista**: `hcloud server-type list`
212devuelve precios para tipos que no se pueden crear. Consultar `Available` por ubicación, y volver a
213consultarlo el día que se compra.
214
215#### Lo que la caja confirma, medido EN ELLA y no inferido de gioser
216
2171. **Arranca por BIOS** — no existe `/sys/firmware/efi`… **y sin embargo la imagen trae un ESP**
218   (`sda15`, 244 M vfat en `/boot/efi`). O sea: **la presencia de una partición EFI no indica
219   arranque EFI**. Si el instalador decidiera la rama mirando particiones en vez de
220   `/sys/firmware/efi`, elegiría mal en esta caja exacta. `takana-live-install.sh` mira lo correcto.
2212. **`console=tty1 console=ttyS0` viene en el cmdline de Debian.** Confirma que el serial es lo que
222   Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola
223   web no muestra nada.
2243. **`eth0` con `/32` dinámico por DHCP** — idéntico a gioser ⇒ es exactamente el caso de `netup`.
225
226#### Dos cosas pendientes en la caja, anotadas
227
228- **7,6 GiB de RAM y CERO swap** — la misma clase de máquina que ya provocó `global_oom` en gioser.
229  Coherente con la decisión de §2.1 (la caja **publica**, el LXC muele), pero significa que **no se
230  construye pesado acá** mientras siga así.
231- **`sshd` trae `PasswordAuthentication yes`** (con `PermitRootLogin without-password`, así que root
232  por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está
233  igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian.
234
235### 3.6 La imagen del `perfil.servidor`, armada y validada *(2026-09-10)*
236
237**Receta del ensamblado** (todo con scripts que ya existían, §7):
238
239```sh
240# 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero)
241cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/
242# 2. userland del perfil encima
243scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep
244# 3. ⚠ restaurar el init (ver abajo) y meter la clave
245ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys
246# 4. imagen
247ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \
248CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \
249  ./scripts/install-image.sh
250```
251
252Resultado: **86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse)**.
253
254#### El kernel correcto es `linux-generic`, y `linux.toml` NO sirve para hcloud
255
256El disco de una caja hcloud lo maneja **`virtio_scsi`** (driver `sd`, disco `sda`) — no `virtio_blk`.
257`recipes/linux.toml` **apaga SCSI explícitamente** (`-d SCSI`) ⇒ su kernel arranca y **no ve el
258disco**. `linux-generic`, `linux-metal` y `linux-metal-dual` sí sirven.
259
260⚠ **Y ninguna receta nombra `SCSI_VIRTIO`.** Está encendido igual, porque `make defconfig` lo trae y
261`-e SCSI -e BLK_DEV_SD` lo mantiene. Se supo mirando el `.config` que `linux-generic` **publica en su
262artefacto** (`/boot/config-7.1.2-generic`), no leyendo la receta. La receta dice lo que se cambió;
263sólo el `.config` sellado dice lo que quedó.
264
265#### ⚠ La hidratación PISA `/sbin/init`, y el resultado arranca igual
266
267`busybox` entra en la clausura del perfil y su `/sbin/init -> ../bin/busybox` **gana por llegar
268después**, borrando el `-> /usr/bin/arje-zero` del product-rootfs. La imagen habría arrancado
269perfecta **con el init de busybox**: no takana puro, y sin un solo error. Se tapa por las dos puntas
270— restaurando el symlink al ensamblar **y** con `init=/usr/bin/arje-zero` explícito en el cmdline,
271que es lo que hace gioser y es autoritativo pase lo que pase con el symlink.
272
273**Y la guarda que existía para esto estaba INVERTIDA** (arreglada en el mismo commit): `[ -e
274"$ROOTFS/sbin/init" ]` sigue el symlink y lo resuelve contra el *host*, así que el symlink ABSOLUTO
275a arje-zero daba falso y **el rootfs correcto era rechazado**, mientras que el relativo a busybox
276—el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si
277sirve; ésta fallaba al revés.
278
279#### EXDEV, dos veces en la misma tarde
280
281`store` y `work/out` son bind-mounts de `/dev/sdb`; `work/` está en `/dev/sdc`. Un hardlink no cruza
282un montaje **aunque sea el mismo disco**, así que reventaron (1) `hydrate-profile.py` con el destino
283fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de `install-image.sh`, que
284lo tenía cableado a `work/`. Ahora `STAGE` es env. Ver [[hardlink-cruza-mount-exdev]].
285
286#### La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH
287
288```
289SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs
290→ Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1"
291→ netup (DHCP) → ssh-keygen -A → sshd
292```
293
294Y desde fuera: `ssh -p 2299 root@127.0.0.1` **entra**, `/proc/1/comm` dice `arje-zero`, `ip addr` da
295la IP que negoció `netup`. Es la puerta 1 del §6 ensayada en local antes de gastar la caja.
296
297El card `sshd` de `/ente/seed.card.json` ya hacía el trabajo entero sin que hubiera que tocar nada:
298`sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"` — red, claves de host y
299demonio en una línea. Lo único que hubo que inyectar fue `authorized_keys`.
300
301#### Un resto del renombre, encontrado por el arranque
302
303```
304arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2)
305```
306
307**`arje-zero` invoca el binario por el nombre VIEJO (`hammer`).** No es fatal —sigue arrancando— pero
308el menú de arranque por grafo del [ADR 0010](adr/0010-arranque-grafo-mirada.md) queda muerto. Y viene
309de tawasuyu, pineado por `commit` en `recipes/arje-zero.toml`, así que **no se arregla desde este
310repo**: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae `takana` (§3.4).
311
312### 3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo
313
314La imagen validada en QEMU se escribió a la caja, arrancó… y **no se pudo entrar**. `hcloud` decía
315`running`, el puerto 22 cerrado, y desde fuera `Connection timed out` — el modo de fallo más caro de
316todos, porque no hay consola serie en hcloud para mirar.
317
318La causa, medida entrando al rescue de la propia caja:
319
320```
321inet 2.29.29.217/32 scope global eth0
322default via 172.31.1.1 dev eth0
323172.31.1.1 dev eth0 scope link      ← ESTA es la que faltaba
324```
325
326**La IPv4 viene con prefijo `/32` y la puerta (`172.31.1.1`) no pertenece a ninguna red conectada.**
327`netup` mandaba el default con `RTA_GATEWAY` y scope UNIVERSE y nada más, así que el kernel lo
328rechazaba con `ENETUNREACH`: la máquina se quedaba **con IP y sin salida**, contestando el DHCP y
329levantando sshd, sin nada visiblemente roto por dentro.
330
331**Control causal**, en un netns con una dummy y una `/32`:
332
333```
334ip route add default via 172.31.1.1 dev dummy0   ⇒ Error: Nexthop has invalid gateway.
335ip route add 172.31.1.1 dev dummy0 scope link    ⇒ ok
336ip route add default via 172.31.1.1 dev dummy0   ⇒ ACEPTADO
337```
338
339Arreglado con `add_onlink_host_route` (dst `/32`, scope LINK) antes del default.
340
341**Por qué no había aparecido nunca**: `netup` se validaba contra el DHCP de QEMU slirp, que entrega
342un `/24` con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad.
343Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO
344entorno donde se probaba no contenía el caso.
345
346**Y destapó un segundo problema de fondo**: el binario de `netup` venía dentro del `product-rootfs`,
347o sea **congelado en un artefacto sellado del bootstrap** ⇒ arreglar netup no llegaba a la imagen.
348Por eso `netup` pasa a ser **raíz de `perfil.servidor`**: así la hidratación lo proyecta encima y la
349imagen lleva el vigente. Verificado por sha256 (imagen `a23df20e…`, product-rootfs `a84b0a0d…`).
350
351### 3.8 ✅ Puerta 1, pasada *(2026-09-10)*
352
353```
354PID 1    : arje-zero          kernel : 7.1.2 (linux-generic propio)
355IP       : 2.29.29.217        rutas  : default via 172.31.1.1 + 172.31.1.1 scope link
356montajes : /dev/sda3 -> /store      /dev/sda4 -> /var/lib/hammer
357salida   : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup
358```
359
360**Una caja Hetzner Cloud corriendo takana puro**: nuestro kernel, nuestro init, nuestro cliente DHCP,
361nuestro sshd — sin una línea de Debian debajo. El camino entero es
362`scripts/servidor-image.sh` + rescue + `dd`, y la escritura de los 7,1 G comprimidos tarda **~17 s**
363(gioser y la caja están las dos en hel1).
364
365Lo que la caja **todavía no** tiene, y es el paso 2: `takana` (sin receta, §3.4), el store real (el
366volumen no se engancha hasta acá) y las otras cuatro raíces `wanted`.
367
368---
369
370## 4. La utilidad de migración: absorber lo VIVO, no lo declarado
371
372`arje-absorb` ya existe, está sellado (`recipes/arje-absorb.toml`) y su `--help` dice: lee
373`sysvinit | runit | dinit | openrc | auto` y **emite una Tarjeta Semilla** con cada servicio del init
374ajeno como hija de arje-zero. Tiene parsers para los cinco (`systemd.rs`, `openrc.rs`, `runit.rs`,
375`dinit.rs`, `sysvinit.rs`).
376
377**Pero lee la declaración, y en gioser la declaración miente** (§0.2). Un `arje-absorb --from openrc`
378sobre esta caja produciría una Semilla que **no levanta el servidor**, y lo haría en silencio: la
379Semilla sería sintácticamente perfecta.
380
381⇒ Lo que falta es un **segundo lector**: absorber del **proceso vivo** (`/proc`: `ppid==1`, `cmdline`,
382`cwd`, entorno, fds en LISTEN) y **reconciliar** ambas lecturas marcando dónde discrepan. Eso es lo
383que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito.
384
385La utilidad debe clasificar en tres, no en dos:
386
387| clase | criterio | qué se hace |
388|---|---|---|
389| **vivo** | proceso corriendo **y** declarado | se muda |
390| **declarado, muerto** | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | **fósil: se anota y se deja morir** |
391| **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 |
392
393**Se construye al final**, estrenándose con la migración gioser → caja nueva, con el caso del
394`rc-status` que miente como test de aceptación. Su especificación es el §1 de este documento.
395
396---
397
398## 5. Que el servidor sirva sus propios paquetes a su propio host
399
400Existe el camino entero: `scripts/build-repo.sh` (corpus → `.swm` firmados + índice),
401`takana install --repo` que **ya acepta lista HTTP multi-origen** y verifica cada paquete contra el
402`digest` del índice firmado ([ADR 0014](adr/0014-distribucion-multiorigen.md)), `takana upgrade` con
403rollback, y caddy para servir `dist/repo` con un `file_server`.
404
405Lo que falta **no es servir**, son dos cosas:
406
4071. **La clave.** `build-repo.sh` firma hoy con una **clave efímera** por defecto (`keygen release` si
408   no se le pasa `KEY=`) *(medido, línea 33)*. Sin clave estable, «firmado» no significa nada. Es el
409   [SDD 19 §3.2](19-lanzamiento-publico.md) y es requisito aunque el repo sea privado.
4102. **La receta de `takana`** (§3.4). El host necesita `takana` instalado para consumir los paquetes,
411   y hoy no puede instalarlo desde el repo porque takana no está en el repo.
412
413### 5.1 Hecho *(2026-09-10)*: clave estable, repo firmado, y la caja sirviéndolo
414
415- **`trust/`** — la clave PÚBLICA de release en el repo; la privada en `~/.config/takana/keys/`
416  (0600, fuera de git). `scripts/repo-perfil.sh` **aborta si no la encuentra** en vez de inventar una
417  efímera, y tras firmar **verifica** el índice contra `trust/`.
418- **88/88** paquetes del perfil `servidor` publicados con `expected_hash` anclado.
419- **La caja los sirve** con `caddy file-server` sobre `/srv/repo`.
420
421**El control de la firma, en los dos sentidos**, contra el repo servido por la caja:
422
423| `--trust` | resultado |
424|---|---|
425| `./trust` | `release: trusted (by release)` ⇒ instala |
426| un directorio vacío | `release: unknown-key … clave no confiada` ⇒ **ABORTA** |
427
428### 5.2 ⚠ Lo que el lazo destapó: `strip_debug` no viajaba en el paquete
429
430La primera instalación real de `takana` desde su propio repo falló:
431
432```
433Error: expected_hash no coincide:
434  declarado = b3:941d1857…   obtenido = b3:2727050e…
435```
436
437`why-differs` lo nombró: los dos binarios pesaban **exactamente lo mismo** y sólo divergían en
438`.shstrtab` — la firma de un `strip` que corrió una vez y la otra no. `swm_bridge` re-inyecta con
439cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`, `deps`… y **se olvidaba de
440`strip_debug`**, que ni siquiera existía en `SwmBuild`. Como **entra en `hash_inputs`**, el receptor
441reconstruía en otra dirección.
442
443**Alcance: 40 recetas del corpus, 18 de las 88 de este perfil.** Una quinta parte del repo no se
444podía instalar, y nadie lo sabía porque **nunca se había cerrado el lazo**. Arreglado, con test de
445regresión y su control. Tras el arreglo, `install takana` da cache-hit en el hash anclado.
446
447### 5.3 ⚠ Y el loopback nunca se levantaba
448
449Al intentar que la caja instalara de sí misma por `http://127.0.0.1`: **timeout de 30 s**, con caddy
450escuchando en `0.0.0.0:80`. `lo` estaba **`DOWN` y sin dirección** — con systemd o OpenRC lo levanta
451el init; con arje-zero no lo hacía **nadie**. El síntoma no era «connection refused», que se
452diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en `netup` (es quien
453configura la red) y verificado desde la imagen, sin tocar la caja a mano.
454
455### 5.4 🚧 Lo que NO se puede todavía, y por qué importa
456
457Con todo lo anterior en verde, la caja **sigue sin poder instalar de su propio repo**:
458
459```
460release: trusted (by release)              ← la firma verifica
461repo: bajados 2 .swm de http://127.0.0.1   ← descarga de sí misma
462Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
463       El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
464       calcular un hash comparable
465```
466
467O sea: **la mitad de servir está cerrada y probada; la de consumir, no** — y no por un detalle de
468instalación, sino porque `install` **reproduce desde fuente** y eso exige el LAB ENTERO en el
469cliente. No es «falta un compilador»: el lab entra en el `ArtifactHash`, así que sin él no se puede
470ni calcular el hash a comparar.
471
472Es exactamente el punto que [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md) dejó planteado
473y sin decidir —*reproducir o hidratar*— visto desde el otro lado: para un servidor, o para
474cualquiera que no sea un hub de build, **reproducir no es una opción**. El camino normal tiene que
475ser hidratar artefactos firmados, con `--reproduce` como camino auditable. Mientras esa decisión no
476se tome, el repo firmado sirve para distribuir **a hubs**, no a usuarios.
477
478⚠ **Y una caja que se sirve a sí misma es un solo origen** — justo lo que el ADR 0014 existe para no
479tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también
480el Storage Box o gioser. Si no, el primer origen roto es el último.
481
482**Lo que esto vale**: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad»
483([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
484laptop de nadie**.
485
486---
487
488## 6. La mudanza como experimento: el criterio de borrado
489
490El usuario decidió que **gioser se borra** al final. Eso no es un detalle de calendario: es lo que
491convierte la mudanza en un experimento con criterio de aceptación, y hay que escribirlo **antes**.
492
493**Regla base**: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue
494bien. Aplicado acá: *«lo copié»* no es prueba de nada. **La prueba es que el servicio responde en la
495caja nueva y que el dato se verifica por contenido.**
496
497### 6.1 Las puertas, en orden. Ninguna se salta.
498
499| # | puerta | cómo se comprueba |
500|---|---|---|
501| 1 | la caja nueva arranca **takana puro** y se entra por SSH | `ssh` a la IP nueva tras el reboot del rescue |
502| 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 |
503| 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» |
504| 4 | la **granja** late en la caja nueva | una cosecha completa con el LXC enchufado por `.fleet` y artefactos que vuelven |
505| 5 | el **repo** se sirve y el host se actualiza de sí mismo | §5 en verde, con la clave estable |
506| 6 | cada dominio **vivo** responde 200 desde fuera | la tabla del §1.1, repetida contra la IP nueva |
507| 7 | el **respaldo** al Storage Box corre desde la caja nueva | una corrida completa, no un `--seco` |
508| 8 | lo **fósil** está anotado y decidido | lista del §1.1 con «muere» o «revive» al lado de cada uno |
509
510⚠ **Puerta 3 tiene una trampa conocida**: `sealed_remoto` dependía de la máquina (laptop 700, gioser
511751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la
512diferencia es «cuánto de esto tengo en disco» y no «qué existe».
513
514### 6.3 Estado de las puertas *(2026-09-10)*
515
516| # | puerta | estado |
517|---|---|---|
518| 1 | arranca takana puro y se entra por SSH | ✅ §3.8 |
519| 2 | el store está entero | ✅ **1362 artefactos reales, 0 vacíos** |
520| 3 | los cuatro grafos idénticos | ✅ **byte a byte** — §6.7 |
521| 4 | la granja late en la caja nueva | ✅ **cosecha completa: 1369→1575, 0 vacíos** — §6.8 |
522| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
523| 6 | cada dominio vivo responde 200 | ⬖ sondeados: **6 vivos de 19** — §6.9 |
524| 7 | el respaldo corre desde la caja | ⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10 |
525| 8 | lo fósil, anotado y decidido | ✅ **decidido** — §6.9 |
526
527**Puerta 2, medida acotando al patrón `<hash>-<nombre>`**: 1362 artefactos, **0 vacíos**. Los 3 sin
528`.hammer/recipe.toml` son `stage1-rootfs`, `product-rootfs` y `seed-zig` — productos de bootstrap,
529que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran `.mirror-tmp`,
530`.bootstrap-tmp` y `.divergen`: directorios de trabajo con punto inicial, no artefactos. El chequeo
531tiene que acotar por el patrón, no listar el directorio.)
532
533**De paso, el disco**: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran
534inalcanzables. Arreglado en `install-image.sh` — el store pasa a ser la ÚLTIMA partición (la única
535que puede crecer sin mover datos) y el wrapper de `/sbin/init` la extiende en el primer arranque.
536Medido en la caja: **`/store` = 68,7 G**, contra los 487 M de antes. Con eso el store de 55 G entra
537en el disco local y **la mudanza no depende de mover el volumen de gioser**.
538
539### 6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER
540
541Al ir a computar los grafos en la caja:
542
543```
544$ python3 -c "print(1)"
545sh: python3: not found          ← y `command -v python3` decía /usr/bin/python3
546```
547
548No es que falte: **está y no arranca**. `/usr/bin/python3.12` son 23 MB y es un ELF **dinámico**:
549
550```
551Requesting program interpreter: /lib/ld-musl-x86_64.so.1
552NEEDED  libc.so
553```
554
555**Y en toda la imagen no hay ningún cargador dinámico.** Tampoco en el store: el artefacto `musl`
556publica sólo lo estático (`libc.a`, `crt*.o`, headers) — **no hay `libc.so` ni
557`ld-musl-x86_64.so.1` en ningún artefacto del corpus**. Lo que hace que estos binarios funcionen en
558gioser es el **rootfs Alpine del LAB**, que sí lo trae (`.dev-fs/alpine/lib/ld-musl-x86_64.so.1`).
559
560Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: *sella,
561reproduce y no corre*, porque pide algo que sólo existe en el laboratorio.
562
563**Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien).** Son la suite
564`binutils` entera (`ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof
565size ld.bfd`), más **`perl`**, **`python3`**, **`sqlite3`**, **`flex`** y **`nft`**.
566
567**Por qué frena la mudanza y no es un detalle**: *todo* el instrumental del proyecto es Python —
568`build-state.py`, `targets.py`, `yupana.py`, `hydrate-profile.py`, `drenar.py`, `triaje.py`. Un
569servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos)
570y la 4 (la granja) dependen de eso.
571
572**Y no se arregla de paso.** Las salidas son tres y ninguna es barata:
573
5741. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo
575   más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del
576   selfhost y hay que rehacerlo a propósito, no de rebote.
5772. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio:
578   Python estático con módulos de extensión es notoriamente arisco.
5793. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta
580   honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo
581   gioser, que es justo lo que se quiere apagar.
582
583Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md):
584las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le
585mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**.
586
587### 6.5 ✅ El muro, derribado: el corpus publica su propio cargador *(2026-09-11)*
588
589De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la **1**, que resultó ser más barata
590de lo que parecía y desbloquea la 2 en vez de competir con ella.
591
592**`recipes/musl-shared.toml`** — variante dinámica de musl que publica `/usr/lib/libc.so` (4,2 M) y
593`/lib/ld-musl-x86_64.so.1`. En musl el cargador Y la libc son el mismo objeto.
594
595**Por qué una variante y no `--enable-shared` en la canónica**, con su control: `musl` es componente
596de **Stage 1** y su `of_tree` es el baseline de `selfhost-verify`. Tocarla obliga a rehacer ese
597baseline a propósito. Con el patrón `*-shared` —que el corpus ya usa 23 veces— la canónica no se
598mueve: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
599además **`musl` no es dep de nadie** (medido: sólo de sí misma; el enlace estático lo resuelve el
600musl que trae zig), así que esto **suma sin mover un solo ArtifactHash del corpus**.
601
602#### ⚠ Y hubo que cambiar de compilador, medido
603
604Con `zig-cc` el `libc.so` sale con **1586 símbolos dinámicos** contra los **1654** del musl de Alpine,
605y los **68 que faltan son exactamente** los que musl implementa en ensamblador x86_64 — `memset`,
606`memcpy`, `memmove`, `memcmp`, `strlen` y toda la familia matemática (`ceil floor sqrt fmod exp log
607sin cos tan fma round trunc`) — más los `__stack_chk_*`.
608
609No es que no se compilen: en el `libc.a` del musl canónico **están**. Lo que pasa es que **zig los
610resuelve con su propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0**, fuera de la tabla dinámica.
611El síntoma es un cargador que arranca, reloca y muere con `memset: symbol not found` — que se lee
612como «el binario está roto», no como «a la libc le faltan símbolos».
613
614Con `compiler = "gcc"`: **1651 símbolos**, `memset` y `ceil` presentes.
615
616#### El resultado, en la caja
617
618| antes | después |
619|---|---|
620| `sh: python3: not found` (con `command -v` contestando `/usr/bin/python3`) | `Python 3.12.10` |
621| 23 binarios inertes de 726 | **1** |
622
623`perl 5.040002`, `readelf`/`objdump`/`nm` (GNU Binutils 2.45.1) y el resto corren. El único que
624quedaba era **`sqlite3`**, con `Error loading shared library libz.so.1`: la zlib canónica también es
625`--disable-shared` y nadie publicaba ese soname. `zlib-shared` ya existía sellado en el corpus —
626sólo faltaba declararla en `base`.
627
628**La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente**: con el cargador
629publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario
630menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.
631
632### 6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB
633
634Con `python3` vivo, `takana hash` en la caja **seguía fallando**:
635
636```
637Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
638       El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.
639```
640
641**El lab es parte de la IDENTIDAD, no del entorno.** Una máquina sin él no puede ni preguntar «¿cuál
642es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado
643el lab (tarball pineado + `tools/`, bajo `/store/dev-fs` porque la raíz son 6 G), el **hash testigo
644coincide byte a byte**: `takana hash recipes/zlib.toml` da `b3:dc363f26…` en las dos máquinas.
645
646⚠ **Y la caja no podía desempacar su propio lab**: el `tar` del rootfs es el de busybox y contesta
647`tar: unrecognized option: zstd`. La distro comprime todo con zstd —la imagen del lab, el respaldo,
648el `dd` remoto— y la imagen no traía el binario. `zstd` ya tenía receta sellada; declarado en `base`.
649
650#### ⚠⚠ Copiar un store con `rsync -a` es una fábrica de vacíos, y ahora se sabe POR QUÉ
651
652La primera copia murió con `No space left on device` en un `.so` de Qt, dejando el destino al 100 %
653y **1367 de 1368 artefactos VACÍOS**. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.
654
655La causa, medida:
656
657| medición sobre el store de gioser | |
658|---|---:|
659| `du -sh` (hardlinks contados UNA vez) | **60 G** |
660| `du -sh --count-links` (hardlinks EXPANDIDOS) | **85 G** |
661| `.dmerge` (la caché que hardlinkea al store) | **11 G** |
662
663**`rsync` sin `-H` no preserva hardlinks: los expande en copias enteras.** Así que «el store son 60 G»
664—que es lo que dice `du` y lo que uno planifica— se convierte en 85 G + lo que `.dmerge` expanda al
665otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se
666llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.
667
668La copia correcta es **`rsync -aH --exclude='.dmerge'`**: `-H` preserva los enlaces (60 G reales) y
669`.dmerge` no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.
670
671**La lección de método**: `du -sh` sobre un árbol con hardlinks **no** dice cuánto ocupa copiarlo.
672Para dimensionar una copia hay que medir con `--count-links`, o preservar los enlaces.
673
674#### Y el instrumental apunta al binario de DESARROLLO, no al instalado
675
676La primera corrida de `build-state.py` en la caja devolvió **875 recetas `unhashable`** — todas —
677con el lab bien sembrado y `takana hash` funcionando a mano. La causa:
678
679```python
680HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
681```
682
683Los scripts asumen un **árbol de desarrollo**: `target/release/takana`. En una caja **instalada** el
684binario es `/usr/bin/takana` y `target/` ni existe (la siembra lo excluye a propósito). No falla
685ruidosamente: cada `hash` falla y el grafo sale entero como `unhashable`, que se lee como «este
686corpus no se puede hashear» y no como «no encontré el binario».
687
688Se destraba con `HAMMER=/usr/bin/takana STORE=/store`, que la propia cabecera del script documenta.
689Pero la deuda queda anotada: **el instrumental debería caer a `command -v takana`** antes de rendirse,
690porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.
691
692### 6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte
693
694```
695gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
696caja:   7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
697```
698
699`base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3`
700(`chrony`, `cronie`, `logrotate` — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.
701
702**Costó tres intentos, y los dos fallos son la parte que vale.**
703
704**Intento 1 — `unhashable 875`**, todas. Ver arriba: los scripts apuntan a `target/release/takana`.
705
706**Intento 2 — dos nodos divergentes**: `aichat` y `zola`, `sealed` en gioser y `never` en la caja. No
707era deriva ni pérdida: **ninguno de los dos está en disco en gioser tampoco**. Son los «2 sellados
708que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime.
709`build-state.py` lee `work/farm-sellados.txt` y `work/respaldo-sellados.txt` — y **`work/` está
710gitignored**, así que un hub recién sembrado no los tiene y degrada esos nodos a `never` sin que nada
711falle.
712
713Es la misma familia que `scripts/farm/.fleet`: **un fichero fuera de git del que depende una medición
714compartida**. La regla que sale: *sembrar un hub no es clonar el repo — es repo + store + lab +
715los manifiestos*. Copiados los dos ficheros, los grafos coinciden exactamente.
716
717### 6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco
718
719**Probado desde la caja** (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):
720
721- llevada la clave de la granja (`~/.ssh/github5` → `/root/.ssh/github5`, 0600) y escrito
722  `scripts/farm/.fleet` — los dos están fuera de git a propósito y hay que copiarlos a mano;
723- la caja **alcanza al worker**: `PruebasIA`, load 3, 775 artefactos;
724- `estado-granja.sh` corre allá y lee bien el worker y el avance KDE (198/199);
725- y el paso que cierra el lazo: se **cosechó un artefacto real del worker** (`speedtest-go`, 12 M)
726  con el mismo transporte de la granja, llegó **con su `.hammer/recipe.toml`** y **su binario CORRE
727  en la caja** (`speedtest-go v1.7.10`). Worker construye → hub cosecha → binario ejecuta.
728
729#### ✅ CERRADA (2026-09-11) con el volumen `takana-store`
730
731Resuelto el disco (§6.12), la cosecha completa corrió: **1369 → 1575 artefactos, 0 vacíos**, que son
732exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —`speedup 3.14`, el ahorro de
733`-H`— y `/store` queda en 73,7 G de 97,9 (79 %).
734
735⚠ El enlace con el worker va a **11 MB/s**, no a los 90 MB/s de gioser↔caja: el LXC vive en el
736Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.
737
738⚠ **Lo que faltaba no era mecanismo: era DISCO.**
739
740| | |
741|---|---:|
742| artefactos en el worker que la caja no tiene | **206** |
743| a ~113 MB de media (el artefacto medio del corpus) | **~23 G** |
744| libre en `/store` de la caja | **3,7 G** (61,5 de 68,7 · 94 %) |
745
746Una cosecha completa **no entra**, y forzarla repetiría exactamente el llenado que dejó 1367
747artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una
748de ingeniería:
749
7501. **Enganchar `harkaq-cosecha` (250 G) a la caja.** Es el plan original y no cuesta nada extra —
751   pero hay que DESENGANCHARLO de gioser, y ahí vive el store, `work/sources`, los tarballs y el
752   `CARGO_HOME` de la granja. O sea: **es el cutover**. gioser deja de construir en ese mismo
753   instante, y hoy está moliendo el árbol KDE.
7542. **Un volumen nuevo para la caja** (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y
755   destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta.
7563. **Podar el store de la caja** — deshace parte de la mudanza. No.
757
758### 6.9 Puerta 8: los 19 dominios, sondeados uno por uno *(2026-09-11)*
759
760El Caddyfile de gioser declara 19 sitios. Sondeados por DNS **y** por HTTP desde fuera, sólo **6**
761los sirve gioser de verdad:
762
763| dominio | DNS | HTTP | qué es |
764|---|---|---|---|
765| `gioser.net` · `tawasuyu.net` | gioser | **200** | **VIVO** — las dos landings |
766| `git.gioser.net` · `git.tawasuyu.net` | gioser | **200** | **VIVO** — el gitea (aloja el `origin` de takana) |
767| `sergio.gioser.net` · `api.sergio.gioser.net` | gioser | **200** | **VIVO** |
768| `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 |
769| `api.gioser.net` | gioser | **502** | backend caído |
770| `mail.sigma.gioser.net` | gioser | **502** | backend caído |
771| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **SIN DNS** | — | fósil: ni DNS ni ficheros en `/var/www` |
772| `terapeuta.ec` · `andino.ec` | **SIN DNS** | — | fósil con datos: 279 M en `/var/www/terapeuta` |
773| `gitea.gioser.net` | **SIN DNS** | — | era un `redir` a `git.gioser.net`; inofensivo |
774
775**Lo que esto cambia respecto del §1.1**: no son «4 fósiles» sino **13 de 19 entradas que no hay que
776mudar** — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a
777mudar son **6 sitios**, y de esos el que pesa es el **gitea**, porque aloja el `origin` de takana.
778
779#### ✅ DECIDIDO (usuario, 2026-09-11)
780
781| fósil | decisión |
782|---|---|
783| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **mueren** — ni DNS ni ficheros |
784| `gitea.gioser.net` | **muere** — era un `redir`, y `git.gioser.net` lo cubre |
785| `api.gioser.net` · `mail.sigma.gioser.net` | **mueren** — backend caído, sin dueño |
786| `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 |
787| **`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** |
788
789⇒ De las 19 entradas del Caddyfile, **13 no se mudan**. La superficie real es de **6 sitios**, y el
790que pesa es el gitea porque aloja el `origin` de takana.
791
792⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no va a poder
793recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.
794
795### 6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo
796
797Llevadas las credenciales (`~/.config/hammer/*.env`, 0600) y la clave, **la caja alcanza el Storage
798Box** por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá:
799**3140 artefactos**, y de paso el script detecta **2 VACÍOS en el respaldo** (`gnome-desktop` ×2) que
800excluye del manifiesto — su propia guarda, funcionando.
801
802Para llegar ahí hubo que arreglar **cinco** cosas, y todas son de la misma familia: *el instrumental
803asume que el hub es gioser*. El `--seco` ahora completa los tres pasos desde la caja y lee la
804ocupación del box (111 G de 1 T).
805
8061. **La raíz cableada.** `RAIZ="${RAIZ:-/mnt/vvv/takana}"` ⇒ desde cualquier otra máquina el script
807   moría con `cd: /mnt/vvv/takana: No such file or directory`. Ahora se deriva de dónde está el
808   script, como el resto de `scripts/`.
8092. **El binario de desarrollo.** `HAMMER = ROOT/target/release/takana` (§6.7).
8103. **El store no estaba donde el script creía.** Usaba `$RAIZ/store` cableado: en gioser es un
811   bind-mount DENTRO del repo, pero en una caja instalada es una partición en `/store` ⇒
812   `change_dir "/opt/takana/store" failed`. Y rsync devuelve **23**, que está en la lista de
813   reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta
814   («un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»).
815   Ahora `STORE` es env, con una guarda que aborta ANTES del bucle si la ruta no existe.
8164. **🚨 Y una que habría costado el historial.** El paso [2/3] sube el repo **con `--delete`** —
817   correcto, para eso está git. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`:
818   es una COPIA, no un clon. **Una corrida real desde ahí habría borrado `.git` del Storage Box**, o
819   sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una
820   guarda que aborta si la raíz no tiene `.git` (escotilla `REPO_INCOMPLETO=1`), probada en los tres
821   sentidos.
822
823   **La regla que sale**: un `--delete` convierte «respaldar» en «sincronizar», y sincronizar desde
824   un origen incompleto no sube menos — BORRA. Cualquier respaldo con `--delete` necesita una guarda
825   de completitud del ORIGEN, no sólo del destino.
8265. **El rsync del corpus no puede correr el respaldo del corpus.** El script exige
827   `--compress-choice=zstd` y `recipes/rsync.toml` lo construía con `--disable-zstd` — porque cuando
828   se escribió, `zstd` no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el
829   script lo clasifica como «no de red» y **no reintenta**, así que el respaldo simplemente no se
830   hacía. Arreglado por los dos lados: la receta enciende zstd (control: `zstd zlibx zlib none` vs
831   `zlibx zlib none`; radio cero, `rsync` no es dep de nadie) y **el script degrada a zlib avisando**
832   en vez de morir.
833
834### 6.11 ⚠ La distro traía un `git` que NO PODÍA CLONAR
835
836Para cerrar la puerta 7 la caja necesita un **clon de verdad** (el `--delete` del respaldo exige un
837origen completo, §6.10). Llevada la clave del gitea —que es `~/.ssh/tawasuyu`, **no** la de la
838granja— `git ls-remote` contesta… y `git clone` muere:
839
840```
841fatal: fetch-pack: invalid index-pack output
842```
843
844El informe completo, que la traza corta escondía:
845
846```
847panic: load of misaligned address 0x… for type 'const uint32_t',
848       which requires 4 byte alignment
849  in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
850  → git_hash_update → unpack_entry_data → cmd_index_pack
851```
852
853**Dos causas, encadenadas:**
854
8551. **`-fsanitize=undefined` estaba en `CFLAGS`**, no sólo en `LDFLAGS`. El motivo original era
856   legítimo —las `libz.a` materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no
857   se resuelven solas, y el flag *al enlazar* trae el runtime de zig— pero en `CFLAGS` **instrumenta
858   el código de git**. Un arreglo de ENLACE convertido en una mina de RUNTIME, justo en la ruta de
859   hash: por donde pasa todo lo que git recibe.
8602. **`sha1collisiondetection` lee palabras de 32 bits SIN alinear.** Es el backend SHA1 por defecto
861   de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota
862   jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte.
863   `-DSHA1DC_FORCE_ALIGNED_ACCESS` lo hace leer byte a byte: quita el UB **en la fuente** en vez de
864   esconderlo.
865
866Con las dos: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas**. Radio cero
867(`git` no es dep de nadie), pero **sí es raíz de `perfil.base`** ⇒ esto arregla la distro entera.
868
869**La lección**: `ls-remote` andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el
870informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.
871
872### 6.12 ⚠ La caja dejó de ser desechable — y `hydrate` no cruza el store
873
874Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:
875
876**1. Re-escribir la imagen con `dd` ya NO es una actualización, es una pérdida.** El `dd` sobrescribe
877el disco local, y ahí viven ahora `/opt/takana` (el clon), `/root/.ssh` (las dos claves),
878`/root/.config/hammer` (las credenciales del respaldo), `/srv/repo` y la configuración de caddy. El
879store se salva sólo porque está en el volumen. **Y hay una trampa peor**: la imagen etiqueta su
880partición local como `hammer-store`, así que tras un `dd` habría **dos filesystems con esa etiqueta**
881y el `findfs` del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición
882vacía. ⇒ el `dd` es para PROVISIONAR; actualizar es `takana upgrade` (que existe, con generaciones y
883rollback) y está sin probar en esta caja.
884
885**2. `takana hydrate` no puede proyectar al root vivo.** Hidratar enlaza en DURO, y en una caja
886instalada el store es **siempre** otra partición que `/`:
887
888```
889Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link
890```
891
892No es culpa del volumen: en el layout original `/store` es `sda4` y `/` es `sda2`, también distintos.
893**Hidratar al FHS vivo nunca fue posible en una caja instalada** — funciona en el hub porque ahí
894store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces
895(§6.6), ahora a nivel de sistema y no de herramienta.
896
897Para instalar el `git` arreglado se copió el artefacto a mano. Eso **funciona y no es el camino**: el
898camino es `takana upgrade`, y probarlo es su propia unidad de trabajo.
899
900### 6.13 ✅ `takana upgrade`, probado de verdad — y el bug de durabilidad que destapó
901
902§6.12 dejó dicho que el camino de actualización de una caja instalada es `takana upgrade`, no `dd` ni
903`hydrate`. Probado con un paquete que la caja **realmente necesitaba**: `zstd`.
904
905| paso | resultado |
906|---|---|
907| `upgrade apply …-zstd-cli` | `✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados` |
908| la prueba de uso | `zstd -dc lab-image.tar.zst \| tar -xf -` ⇒ **la caja desempaca su propio lab** |
909| `upgrade rollback` | `✓ generación 2 revertida · 9 restaurados, 1 borrados` — `zstd` se va, `libzstd.a` vuelve |
910| re-`apply` + **corte duro sin `sync`** | sobrevive: generación viva 1, manifiesto íntegro |
911
912✔ **Y cruza la frontera que `hydrate` no puede**: el store está en el volumen y `/` en el disco local,
913y `upgrade` copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.
914
915#### ⚠ El bug: `write_atomic` era atómico pero NO DURABLE
916
917El primer intento pareció funcionar y **se perdió al reiniciar**. La caja volvió con:
918
919```
920pending.json                   0 bytes
921generations/2/manifest.json    0 bytes
922upgrade status  → Error: json: EOF while parsing a value
923upgrade recover → Error: json: EOF while parsing a value
924```
925
926`write_atomic` hacía temporal + `rename`: atómico frente a otros PROCESOS, no frente a un CORTE.
927`rename` sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a
928bloques nunca escritos — cero bytes. Y `hcloud server reset` **es un corte duro**, no un apagado
929limpio: ahí está la mitad operativa de la lección.
930
931Lo grave no es perder un upgrade: es que **`recover` —que existe exactamente para «un apply
932interrumpido por un corte»— abortaba con la huella más probable de ese corte**.
933
934Arreglado: `fsync` del temporal antes del `rename` y `fsync` del directorio después (hacen falta los
935dos), y un `pending.json` vacío se reporta como `PendingCorrupt`, nombrando el corte y apuntando al
936árbol de respaldos. Con tests y su control.
937
938**Verificado en la máquina, en los dos sentidos**: con el binario viejo, apply + reset duro ⇒ estado
939en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.
940
941#### ⚠⚠ `upgrade apply` NO es un gestor de paquetes: cada generación ES UN ÁRBOL
942
943Lo destapó usarlo dos veces seguidas. Tras aplicar `zstd-cli` y después `rsync`:
944
945```
946✓ generación 2 aplicada
947  + 0 añadidos, ~ 8 pisados, - 1 retirados      ← el `zstd` de la generación 1
948$ zstd --version
949AUSENTE
950```
951
952**Aplicar un árbol nuevo RETIRA los ficheros del anterior**, porque una generación es el estado
953completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/**producto**
954del store»— y con que exista `rollback`: lo que se revierte es un sistema, no un paquete.
955
956⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente.
957**La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace
958`servidor-image.sh`— sellarlo, y aplicar ESO como una generación.** Eso además sería, de verdad, la
959«actualización en sitio probada» del [SDD 19 §5.2](19-lanzamiento-publico.md): una imagen entera, con
960rollback, sobre una caja viva.
961
962Mientras tanto la caja quedó con `zstd` y `rsync` puestos a mano — funciona y **no es el camino**,
963igual que el `git`.
964
965⚠ Lo que NO se arregló: si el manifiesto se pierde, `recover` no puede deshacer solo — no sabe qué se
966tocó. El árbol de respaldos tiene la información; reconstruir desde ahí es su propia unidad.
967
968### 6.2 Lo que la mudanza tiene que producir, además de la mudanza
969
970El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es
971un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
972una receta candidata con un caso de uso comprobado detrás:
973
974| lo que corre hoy en Artix | receta |
975|---|---|
976| `caddy` | ✅ existe (`recipes/caddy.toml`) |
977| `openssh` | ✅ existe |
978| `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo |
979| `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ |
980| `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` |
981
982> **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más
983> peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su
984> artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.
985>
986> Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4
987> ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete
988> ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea.
989>
990> Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.
991
992**Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve
993el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*.
994
995---
996
997### 6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE *(2026-09-14)*
998
999`servidor-image.sh` + QEMU: **PID 1 = `arje-zero`, gitea encarnado por él (`ppid=1`), corriendo como
1000`uid=916`, escuchando en :3000 y devolviendo `200` con `<title>takana git</title>`**, con su
1001`gitea.db` creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana:
1002los que había venían horneados en el bootstrap.
1003
1004La cadena entera, eslabón por eslabón: `[[user]]` → `/etc/passwd` de la imagen · `[[service]]` →
1005`takana service-cards` → `genesis` de la seed → arje lo encarna → `setuidgid` → sirve.
1006
1007#### Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN
1008
1009Los tres pasan el sellado, el `hash`, el resolutor de servicios y el armado. Ninguno se ve sin
1010arrancar la imagen.
1011
10121. **⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store.** `takana users --merge` hacía
1013   `fs::write` sobre `<rootfs>/etc/passwd`, y ese fichero y el del artefacto `product-rootfs` son
1014   **el mismo inode** (medido: 1225824, `2 links`, modo `444`) — el rootfs se arma con hardlinks.
1015   Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro
1016   del producto. Se salvó porque el store es de sólo lectura y salió `Permission denied`: confiar en
1017   eso es confiar en un permiso. Ahora escribe por temporal + `rename`, con un control que comprueba
1018   que el fichero del store conserva su contenido y baja a 1 link.
1019
10202. **La imagen traía el binario, la cuenta… y nadie lo arrancaba.** El `genesis` sólo tenía lo que
1021   hornea `takana-bootstrap` (`sshd`, `console-getty`, `hammerd`): `servidor-image.sh` **no inyectaba
1022   las Cards** — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo
1023   dice: `targets.py --services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era
1024   el paso que lleva esa declaración a la imagen.
1025
10263. **⚡ `trap invalid opcode`: el binario sólo corría en la CPU que lo compiló.** Con la card ya en el
1027   genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta `CC` apuntando a un wrapper
1028   con `-mcpu=baseline` (y su comentario ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y
1029   **la fase Go del propio builder lo pisaba** con `CC="zig cc"` a secas, que es `-mcpu=native`. No
1030   falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el `ArtifactHash` no puede
1031   cazarlo, porque la CPU del builder no entra en `hash_inputs` — **dos workers distintos sellan bytes
1032   distintos bajo la misma dirección**. Arreglado en el builder y en la receta; re-hashea las cinco
1033   recetas `cgo = true` (`gitea`, `usql`, `sq`, `gocryptfs`, `naabu`).
1034
1035#### Lo que NO está probado, y falta para la mudanza
1036
1037El `app.ini`, el usuario del sitio y los datos se pusieron **a mano** dentro de la VM para llegar al
1038200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — `/etc/gitea` ya está en
1039`scripts/mudanza/rutas-fuera-de-git.txt`.
1040
1041Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para que arje volviera a
1042intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en
1043caliente. Para una caja de producción eso es una pieza que falta, no una comodidad.
1044
1045### 6.12 🎛️ `arjectl` en la imagen: control en caliente *(2026-09-14)*
1046
1047El §6.11 cerró con «la imagen no trae `arjectl`, hubo que reiniciar la máquina para arrancar un
1048servicio». Ya lo trae, y **no hubo que escribir nada**: el cliente existía en tawasuyu y se llama
1049**`arje-ctl`** (el crate; el binario es `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí
1050salió la conclusión falsa de que faltaba implementarlo — el protocolo (`arje-bus::BusRequest`) ya
1051traía `ListEntes`, `SpawnCardFromDisk`, `StopCardFromDisk`, `KillEnte` y `EnteStatus`.
1052
1053`recipes/arjectl.toml` lo construye **del mismo commit que `arje-zero`** (`98db584f`), y eso no es
1054comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no
1055entenderse con el init. Publica **sólo `arjectl`**; el crate también produce un `systemctl` de
1056camuflaje que acá no se instala — un `systemctl` en el PATH de una distro sin systemd invita a
1057escribir runbooks con el verbo ajeno.
1058
1059#### El hueco que sólo se ve usándolo: el genesis no alcanza
1060
1061Con `arjectl` en la imagen, el primer intento falló con un mensaje que nombra la causa:
1062
1063    $ arjectl start gitea
1064    Error: arje-zero rechazó: card gitea: No such file or directory
1065           (buscada en /etc/arje/cards.d/gitea.json)
1066
1067`start`/`restart` usan `SpawnCardFromDisk`, que lee **`/etc/arje/cards.d/<label>.json`** — y el
1068armado sólo escribía el `genesis` de la seed. Son dos preguntas distintas: *qué arranca solo* y *qué
1069se puede encarnar a pedido*. Con sólo la primera, un servicio que agota su backoff **no se puede
1070relanzar sin reiniciar la máquina**. `inyectar-cards.py` escribe ahora los dos árboles (y en
1071`cards.d` escribe TODAS, no sólo las nuevas: `sshd` viene del product-rootfs y tampoco era
1072relanzable).
1073
1074#### Medido, con la VM arrancada UNA sola vez
1075
1076| | |
1077|---|---|
1078| la imagen trae | `/etc/arje/cards.d/{gitea,sshd}.json` |
1079| `arjectl list-units` | tabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente |
1080| poner `/etc/gitea/app.ini` + `arjectl start gitea` | **`GET /` → 200**, sin reiniciar (`uptime` 6 min) |
1081| `arjectl restart gitea` | el PID cambia (118 → 192) y sigue sirviendo 200 |
1082
1083#### ⚠ `arjectl start` sobre un Ente YA VIVO lo DUPLICA
1084
1085`SpawnCardFromDisk` no deduplica por label: encarna otra instancia y arje le da un ULID nuevo. En
1086`gitea` el síntoma fue `unable to lock level db … resource temporarily unavailable` seguido de `[F]`
1087— dos servidores peleando por el mismo estado, con el HTTP intermitente mientras duraba. **Para
1088relanzar se usa `restart`**, o se mira `list-units` antes. Un `start` idempotente es trabajo de arje,
1089no de esta imagen; queda anotado río arriba.
1090
1091### 6.13 🧪 Ensayo con los DATOS REALES de gioser — el gitea de la caja vieja corriendo en takana
1092
1093Sin tocar gioser (todo lectura) y en la VM desechable. Es el ensayo que faltaba antes de cualquier
1094cutover, y trajo cuatro cosas que no se ven en el papel.
1095
1096**El snapshot de la DB se saca en caliente y sale consistente.** `sqlite3 gitea.db ".backup …"` con
1097el servidor VIVO: **1,5 s para 314 M**, `pragma integrity_check` → `ok`, 44 filas en `repository`.
1098Copiar el fichero a pelo con el servidor escribiendo es lo que NO hay que hacer; la API de backup de
1099sqlite existe justo para esto.
1100
1101**Resultado, medido dentro de la VM:** `<title>GioSer Gitea: Git with a cup of tea</title>`, 200, y
1102`/explore/repos` listando los repos de verdad (`sergio/takana`, `tawasuyu/agora`, `card`, `chasqui`,
1103`cosmos`, `khipu`, `llimphi`…). Y el clon de uno de los repos copiados: **478 commits**, HEAD
1104correcto, ficheros reales.
1105
1106#### Los tres detalles que sólo aparecen con los datos puestos
1107
11081. **El uid del origen no es el del destino.** Los ficheros llegan con su uid NUMÉRICO (`1001` en el
1109   rsync de prueba; el gitea de gioser es **969**) y el `[[user]]` declaraba 916. O se chownean 2 G
1110   —lento, y hay que acordarse— o gitea no puede leer sus datos, **y eso no falla al copiar: falla
1111   al arrancar**. La receta pasa a declarar **969**, alineado con el origen.
11122. **El `app.ini` de gioser escucha en `127.0.0.1:3002`**, porque allá caddy hace de proxy. Copiado
1113   tal cual, la caja sirve — pero sólo desde dentro. El `HTTP_ADDR`/`HTTP_PORT` y el `ROOT_URL` son
1114   parte de la adaptación, no del copiado.
11153. 🧨 **El `git` de la distro NO puede clonar por HTTP.** `git clone http://…` dentro de la imagen:
1116
1117       git: 'remote-http' is not a git command. See 'git --help'.
1118       fatal: remote helper 'http' aborted session
1119
1120   Medido sobre el ARTEFACTO sellado: `/usr/libexec/git-core/` trae `git-remote-ext`, `git-remote-fd`
1121   y `git-http-backend` (el lado SERVIDOR), y **no** `git-remote-http`/`-https`; `strings` del binario
1122   da **0** referencias a libcurl, pese a que `curl` está en `[deps] build`. Nadie lo había notado
1123   porque en el hub el `git` que se usa es el de Artix, no el sellado. Es
1124   [[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que
1125   hace falta. **Un hub nuevo no podría clonar el repo por HTTPS.** Arreglarlo re-sella `git`, que es
1126   raíz de `perfil.base` ⇒ unidad propia, no de paso.
1127
1128### 6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH *(2026-09-14)*
1129
1130**Hecho y verificado desde fuera:** `git.gioser.net` y `git.tawasuyu.net` responden **200 con TLS
1131válido desde `2.29.29.217`**, `git clone` funciona **por HTTPS y por SSH:2345**, y `gitea` y `caddy`
1132corren supervisados por `arje-zero` en la caja. El gitea de gioser está **parado**.
1133
1134**Mudanza incremental, no big-bang.** Los DNS de gioser son casi todos `CNAME → www`, así que mover
1135`www` habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo
1136`git` (en las dos zonas) de CNAME a **A propio con TTL 60**, que es reversible en un minuto.
1137
1138#### La secuencia que funcionó, en orden
1139
11401. Caja actualizada: `arjectl` + `gitea` + la cuenta `gitea:969` + las cards en `genesis` y `cards.d`.
11412. Datos en dos fases: **1,66 GB / 18 047 ficheros** con el origen VIVO (17 s), y en el corte el
1142   `rsync --delete` incremental + `sqlite3 ".backup"` (`integrity_check ok`).
11433. **Parar el origen de verdad.** `rc-service gitea stop` dice *«already stopped»* con el proceso
1144   vivo (el `rc-status` que miente, §Los tres hechos), y matarlo **no alcanza**: lo revive
1145   `arje-zero`, que lo tiene como card `openrc-gitea` con `Restart` — **9001 reinicios** acumulados
1146   en el contador. Se paró con **`arjectl stop openrc-gitea`**, y para eso hubo que extraer su card
1147   del `genesis` y escribirla en `/etc/arje/cards.d/` (§6.12: `SpawnCardFromDisk`/`StopCardFromDisk`
1148   leen de ahí, no del genesis).
11494. DNS por la **API de Hetzner Cloud** (`/v1/zones`): el token de `hcloud` **también gestiona el
1150   DNS** desde la unificación — la API vieja `dns.hetzner.com/api/v1` redirige a la consola. Y un
1151   `PUT` sobre el rrset **no puede cambiar el tipo**: hay que `DELETE` del CNAME y `POST` del A.
11525. TLS: el primer intento de ACME **falló contra gioser** (`502`) porque el challenge salió antes de
1153   que propagara; con el DNS ya al día, `arjectl restart caddy` y *certificate obtained successfully*.
1154
1155#### La identidad SSH se muda con el servicio
1156
1157`git clone ssh://…:2345` daba **`REMOTE HOST IDENTIFICATION HAS CHANGED`**: es otra máquina. Se
1158copiaron las **claves de host** de gioser (`/etc/ssh/ssh_host_*`) a la caja ⇒ los clones existentes
1159no notan nada. El precio es que cambia también la identidad del `:22` de administración, y hay que
1160limpiar el `known_hosts` propio — a los clientes del servicio no les afecta, que es lo que importa.
1161
1162Y el `sshd` del producto escucha sólo en `:22`: el git por SSH necesitó `Port 2345` y que el usuario
1163`gitea` tenga **shell real** (su `authorized_keys` fuerza `command="gitea serv …"`, y con
1164`/bin/false` no se ejecuta nada).
1165
1166#### Lo que esto cambia para la granja
1167
1168Las 23 recetas que ahora clonan por `https://git.tawasuyu.net/…` **apuntan a la caja**. Comprobado
1169tras el cambio: el worker resuelve `2.29.29.217` y `git ls-remote` responde. La granja depende ya
1170del servidor nuevo, que es el sentido de la mudanza.
1171
1172#### Cómo se revierte, si hiciera falta
1173
1174`arjectl start openrc-gitea` en gioser y devolver los dos `git` a `CNAME → www`. Con TTL 60, minutos.
1175
1176### 6.15 Cerrar el intermedio: el viejo apagado de verdad, el `:22` cerrado y los datos respaldados
1177
1178#### ⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor
1179
1180Media hora después del corte, gioser volvía a servir en `:3002`. `arjectl list-units` **ya no lo
1181mostraba** (el `stop` de arje seguía en pie) y el proceso tenía **`ppid ≠ 1`**: lo había levantado
1182**OpenRC**, que lo tenía en el runlevel `default`. O sea **dos supervisores para el mismo servicio**
1183—la card `openrc-gitea` de arje y el runlevel de OpenRC— y parar uno no para el otro.
1184
1185    rc-update del gitea default   # que no vuelva
1186    rc-service gitea stop         # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo
1187
1188Antes del corte pasaba lo contrario (`rc-service … stop` decía *«already stopped»* con el proceso
1189vivo, porque quien lo tenía era arje). **La pregunta no es «¿está parado?» sino «¿quién lo tiene?»**,
1190y hay que responderla dos veces.
1191
1192#### El origen deja de servir git, y se comprueba que no se rompió lo demás
1193
1194Los tres bloques (`git.gioser.net`, `git.tawasuyu.net`, `gitea.gioser.net`) salen del Caddyfile de
1195gioser —con respaldo previo, `caddy validate` y `reload`—, y quedan 16. Control inmediato:
1196`sergio.gioser.net` y `hifas.gioser.net` siguen en **200**. `gitea.gioser.net` pasa a `A` → la caja,
1197donde ya está su `redir`.
1198
1199#### El `:22` cerrado, sin perder el acceso
1200
1201Administración en **22022**, git por SSH en **2345**, y el **22 cerrado** (`Connection refused`) para
1202sacarse de encima el barrido de bots. La secuencia importa y es la única segura: **añadir** el puerto
1203nuevo → reiniciar → **entrar por él** → sólo entonces quitar el 22. Comprobado en ese orden, y el
1204`git clone ssh://…:2345` sigue funcionando después.
1205
1206#### 🧨 Los datos del gitea NUNCA estuvieron respaldados
1207
1208`respaldo-storagebox.sh` respalda el **store de artefactos**, no `/var/lib/gitea`. O sea que los 44
1209repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí
1210lo hace urgente: **gioser se borra**. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo
1211+ `rsync` a `u647150:gitea/` — **1,7 G** (`datos/` + `gitea.db` de 314 M).
1212
1213⚠ **Al Storage Box se entra por el puerto 23**, no el 22: el 22 da SFTP/SCP restringido y contesta
1214`Permission denied (publickey,password)`, que se lee como *«no tengo la clave»* cuando la clave está
1215perfecta. Pasó en las dos máquinas antes de mirar el script (`SB_PORT=23`).
1216
1217**Pendiente**: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.
1218
1219### 6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó
1220
1221La pregunta correcta después de un cutover no es «¿arrancó?» sino **«¿entró algo en el viejo
1222mientras tanto?»**. Se contesta comparando **todos los refs de los dos lados**:
1223
1224```sh
1225for r in <repos>/*/*.git; do git -C "$r" for-each-ref --format="$r %(refname) %(objectname)"; done | sort
1226```
1227
1228845 refs a cada lado, y **dos diferencias**. Una era esperada (`sergio/takana` main, el nuevo por
1229delante con los commits posteriores al corte — `merge-base --is-ancestor` confirma fast-forward). La
1230otra **no**: `tawasuyu/tawasuyu` tenía en el VIEJO dos commits que el nuevo no tenía —
1231`b69008eb` (freebsd) y `02a9535f` (shuma, **19:48**)—, o sea **quince minutos después del snapshot
1232final**.
1233
1234**Por qué entraron**: el gitea viejo había resucitado (§6.15, OpenRC) y un cliente con el DNS
1235**cacheado** —el CNAME anterior tenía TTL 600— lo empujó a gioser aunque el autoritativo ya dijera
1236la caja. Las dos condiciones a la vez: un origen que revive y una caché que todavía apunta.
1237
1238**Recuperados** con un `git push` del ref exacto desde el clon local al gitea nuevo; la UI del nuevo
1239ya muestra `02a9535f`. Segunda pasada de comparación: **845/845 y una sola diferencia, la esperada**.
1240Y del lado de los metadatos, `action` tenía tres filas posteriores al snapshot (los mismos dos
1241repos) y **cero** issues, comentarios o releases: no se perdió nada que no fueran esos commits.
1242
1243**La lección para el resto de la mudanza**, en orden: (1) bajar el TTL **antes** del corte, no
1244durante; (2) apagar el origen de verdad —los DOS supervisores— **antes** de tocar el DNS; (3)
1245comparar refs después, siempre, porque es la única prueba de que no se perdió nada; y (4) dejar el
1246origen apagado un rato **antes** de borrarlo, precisamente para poder hacer esta comparación.
1247
1248## 7. Reusar los scripts que ya existen, y no escribir de nuevo
1249
1250Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
1251
1252| para | script existente | qué le falta |
1253|---|---|---|
1254| armar el rootfs de un perfil | `scripts/hydrate-profile.py` | nada — es el camino bueno (los `hydrate-*.sh` por escritorio **divergen** de `targets.toml`) |
1255| producir el `.img` | `scripts/install-image.sh` / `scripts/product-image.sh` | apuntar al `perfil.servidor` |
1256| escribir a disco | `scripts/takana-install.sh` | el envoltorio de rescue del §3.2 |
1257| instalador con TUI | `scripts/takana-live-install.sh` | ser el **mismo motor** que el remoto, con las respuestas por fichero |
1258| provisionar un worker | `scripts/farm/vps-setup.sh` | ya soporta apt **y** dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
1259| enchufar a la granja | `scripts/farm/.fleet` | **está en `.gitignore`** ⇒ un hub recién clonado nace con la flota vacía **en silencio** |
1260| latido | `scripts/farm/cosecha-cron.sh` | decidir cron vs tarjeta de arje (§3.4) |
1261| respaldo | `scripts/respaldo-storagebox.sh` | nada — es interrumpible y continuable |
1262| absorber el init ajeno | `arje-absorb` | el lector de procesos vivos (§4) |
1263
1264⚠ **Un motor, tres frentes.** La tentación es escribir el instalador remoto aparte del TUI. Ahí es
1265donde divergen: ya pasó con los `hydrate-*.sh` contra `targets.toml`, y con la unidad systemd del
1266worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser **la misma
1267que hace la persona**, grabada en un fichero de respuestas.
1268
1269---
1270
1271## 8. `churay` como instalador centralizado — la dirección, no la decisión
1272
1273El usuario lo planteó el 2026-09-09: **un instalador único, con CLI y TUI**, que sirva para
1274
1275- las **apps de tawasuyu**,
1276- **paquetes sueltos en cualquier distro**, al estilo nix (instalar sin ser root, sin tocar el
1277  sistema anfitrión),
1278- **takana** mismo,
1279- y **`llimphi`**.
1280
1281Encaja con lo que ya hay y por eso vale escribirlo: `takana install` ya resuelve por nombre contra un
1282índice firmado y **ya acepta múltiples orígenes**; `qorpa` ([ADR 0015](adr/0015-imagenes-ajenas.md))
1283ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre
1284en cron desde gioser.
1285
1286**No se decide acá.** Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir
1287una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la
1288misma pregunta del [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md), y la respuesta puede no
1289ser la misma fuera de takana; (2) quién es el dueño del verbo, si `takana install` crece o si
1290`churay` lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta
1291del store a propósito. Queda anotado para que se **decida**, no para que se descubra a mitad.
1292
1293---
1294
1295## 9. Estado y orden
1296
1297**Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros.** Nada de espejar a GitHub
1298para «tener copia fuera»: la copia fuera **la da la propia mudanza** — en cuanto el gitea vive en la
1299caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de
1300pagar dos máquinas, no montar una dependencia más. `scripts/mudanza/espejar-repos.sh` queda como
1301herramienta disponible, no como paso del plan.
1302
1303**Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público.**
1304Las 23 recetas que apuntaban a `gitea@git.tawasuyu.net` (SSH) pasaron a `https://git.tawasuyu.net/…`
1305— la URL es locator y no entra en `hash_inputs` (ADR 0013), así que se cambió **con control y sin
1306mover un solo ArtifactHash**. Medido en el worker: clona y `git archive` extrae el árbol (155 M) sin
1307ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna
1308receta usa uno.
1309
1310### Lo que YA está, y no hay que volver a tocar
1311
1312| | |
1313|---|---|
1314| la caja `takana` (`2.29.29.217`) | arranca takana puro, PID 1 `arje-zero` — §3.8 |
1315| store, grafos, granja, respaldo | puertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10 |
1316| repo firmado servido por la caja | §5.1 |
1317| **la imagen del perfil `servidor` con gitea** | **arranca y sirve 200, supervisado por arje** — §6.11 |
1318| **`arjectl` para operar sin reiniciar** | §6.12 |
1319| **el gitea de gioser corriendo sobre takana, con sus datos** | ensayo completo — §6.13 |
1320
1321### Lo que falta para el cutover, en este orden
1322
13231. **Actualizar la caja a la imagen nueva** (gitea + `arjectl` + las cuentas de `[[user]]`).
1324   `takana upgrade` está probado (§6.13 del SDD 27 y §5.4 acá); el `dd` es para provisionar, no para
1325   actualizar — sobrescribe el disco local y re-etiqueta la partición.
13262. **Mudar los datos del gitea**: `sqlite3 … ".backup"` (1,5 s, consistente con el servidor vivo) +
1327   `rsync -aH` de `/var/lib/gitea` y `/etc/gitea`. El corte final se hace con el gitea del origen
1328   **parado**, para que el último snapshot no pierda escrituras.
13293. **Caddy en la caja**: los **6 dominios vivos** del §6.9, con su TLS. Los otros 13 no se mudan.
13304. **DNS**: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
1331   resolviendo al mismo nombre.
13325. **Verificar desde fuera**: cada dominio vivo responde 200 contra la IP nueva **y** un `git clone`
1333   real contra el gitea mudado.
13346. **El resto de servicios vivos** del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
1335   `planear.py` ya emite.
13367. **Borrar gioser** — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
1337
1338### Bloqueantes conocidos, con su tamaño
1339
1340- **`git` sellado sin `remote-http`** (§6.13): afecta a clonar POR HTTPS *desde* una caja takana, no
1341  al gitea que sirve. Re-sella `git`, raíz de `perfil.base` ⇒ unidad propia.
1342- **Puerta 5** sigue a medias: la caja sirve su repo pero no se instala de él (`install` reproduce y
1343  eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
1344
1345**El volumen del store NO se engancha a la caja hasta que pase la puerta 1** — ya pasó, y el store
1346local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.

28-servidor-de-produccion.md

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 (lanzamiento público) y SDD 27 (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:

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)

tawasuyu124 G
takana91 G — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios
rustup29 G
act-runner · gioserv · _respaldo6,5 · 5,4 · 4,1 G
~20 dirs *-standaloneagora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi…

El volumen harkaq-cosecha (250 G, 148 G usados)

store55 G, 1327 artefactos — lo irreemplazable
work-sources + cargo-home + gopath~85 G — caché regenerable, no se muda
work-tarballs · devfs-cache · work-out3,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 200rotos
gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.netapi.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):

tiporecursos€/mes
cx5316 c / 32 G / 320 G29,49
cpx418 c / 16 G / 240 G32,49
ccx23 (dedicado)4 c / 16 G / 160 G85,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 (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)— es el que Hetzner no necesita.

piezaestado (medido)
kernel con virtiorecipes/linux.toml compila VIRTIO, VIRTIO_PCI, VIRTIO_BLK, VIRTIO_NET, EXT4_FS =y ⇒ sin initramfs
redcrates/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
accesorecipes/openssh.toml sellado; el product-rootfs vigente ya trae sshd; takana-install.sh acepta AUTHKEYS
servirrecipes/caddy.toml existe
escritura a discoscripts/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) y añade 5 raíces que existen (openssh, caddy, curl, wget, tmux) y 5 que NO:

raíz que no resuelvepor qué está declarada igual
takanael 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
chronysin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora»
nftablesdev.gioser.net está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así
cronieel 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
logrotateen 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 / idtakana / 165447050
tipocx33 — 4 c / 7,6 GiB / 76,3 G — €8,49/mes
ubicaciónhel1 — la misma que los volúmenes vvv y harkaq-cosecha y que el Storage Box
IPv4 / IPv62.29.29.217 · 2a01:4f9:c014:2f9c::/64
imagen inicialdebian-13 — desechable: existe sólo para recibir ficheros hasta el dd del §3.2
labelrole=takana-server
protección de borradoNO, 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

3.6 La imagen del perfil.servidor, armada y validada (2026-09-10)

Receta del ensamblado (todo con scripts que ya existían, §7):

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

clasecriterioqué se hace
vivoproceso corriendo y declaradose muda
declarado, muertoen la config, sin proceso ni ficheros (los 4 dominios sin DNS)fósil: se anota y se deja morir
vivo, no declaradoproceso 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), 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 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

El control de la firma, en los dos sentidos, contra el repo servido por la caja:

--trustresultado
./trustrelease: trusted (by release) ⇒ instala
un directorio vacíorelease: 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 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), hecho sobre una máquina que importa pero que no es el laptop de nadie.


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.

#puertacómo se comprueba
1la caja nueva arranca takana puro y se entra por SSHssh a la IP nueva tras el reboot del rescue
2el store está entero1327 artefactos y ninguno vacío — respaldo-storagebox.sh --listar separa los vacíos; un directorio vacío no es un artefacto
3los grafos salen idénticosbuild-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»
4la granja late en la caja nuevauna cosecha completa con el LXC enchufado por .fleet y artefactos que vuelven
5el repo se sirve y el host se actualiza de sí mismo§5 en verde, con la clave estable
6cada dominio vivo responde 200 desde fuerala tabla del §1.1, repetida contra la IP nueva
7el respaldo al Storage Box corre desde la caja nuevauna corrida completa, no un --seco
8lo fósil está anotado y decididolista 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)

#puertaestado
1arranca takana puro y se entra por SSH✅ §3.8
2el store está entero✅ 1362 artefactos reales, 0 vacíos
3los cuatro grafos idénticos✅ byte a byte — §6.7
4la granja late en la caja nueva✅ cosecha completa: 1369→1575, 0 vacíos — §6.8
5el repo se sirve y el host se actualiza de sí mismo⬖ mitad: sirve (§5.1), no consume (§5.4)
6cada dominio vivo responde 200⬖ sondeados: 6 vivos de 19 — §6.9
7el respaldo corre desde la caja⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10
8lo fósil, anotado y decidido✅ decidido — §6.9

Puerta 2, medida acotando al patrón <hash>-<nombre>: 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: 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

antesdespués
sh: python3: not found (con command -v contestando /usr/bin/python3)Python 3.12.10
23 binarios inertes de 7261

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:

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

✅ 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 tiene206
a ~113 MB de media (el artefacto medio del corpus)~23 G
libre en /store de la caja3,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:

dominioDNSHTTPqué es
gioser.net · tawasuyu.netgioser200VIVO — las dos landings
git.gioser.net · git.tawasuyu.netgioser200VIVO — el gitea (aloja el origin de takana)
sergio.gioser.net · api.sergio.gioser.netgioser200VIVO
summa · api.summa · dev.summa154.197.1.2200YA MUDADOS a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva
api.gioser.netgioser502backend caído
mail.sigma.gioser.netgioser502backend caído
aura · api.aura · sigma · kosmofono · api.kosmofonoSIN DNS—fósil: ni DNS ni ficheros en /var/www
terapeuta.ec · andino.ecSIN DNS—fósil con datos: 279 M en /var/www/terapeuta
gitea.gioser.netSIN 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ósildecisión
aura · api.aura · sigma · kosmofono · api.kosmofonomueren — ni DNS ni ficheros
gitea.gioser.netmuere — era un redir, y git.gioser.net lo cubre
api.gioser.net · mail.sigma.gioser.netmueren — backend caído, sin dueño
summa · api.summa · dev.summano se mudan: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser
terapeuta.ec · andino.ecMUEREN — 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.

pasoresultado
upgrade apply …-zstd-cli✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados
la prueba de usozstd -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 syncsobrevive: 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: 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 Artixreceta
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 <title>takana git</title>, 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 <rootfs>/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/<label>.json — y el armado sólo escribía el genesis de la seed. Son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido. Con sólo la primera, un servicio que agota su backoff no se puede relanzar sin reiniciar la máquina. inyectar-cards.py escribe ahora los dos árboles (y en cards.d escribe TODAS, no sólo las nuevas: sshd viene del product-rootfs y tampoco era relanzable).

Medido, con la VM arrancada UNA sola vez

la imagen trae/etc/arje/cards.d/{gitea,sshd}.json
arjectl list-unitstabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente
poner /etc/gitea/app.ini + arjectl start giteaGET / → 200, sin reiniciar (uptime 6 min)
arjectl restart giteael PID cambia (118 → 192) y sigue sirviendo 200

⚠ arjectl start sobre un Ente YA VIVO lo DUPLICA

SpawnCardFromDisk no deduplica por label: encarna otra instancia y arje le da un ULID nuevo. En gitea el síntoma fue unable to lock level db … resource temporarily unavailable seguido de [F] — dos servidores peleando por el mismo estado, con el HTTP intermitente mientras duraba. Para relanzar se usa restart, o se mira list-units antes. Un start idempotente es trabajo de arje, no de esta imagen; queda anotado río arriba.

6.13 🧪 Ensayo con los DATOS REALES de gioser — el gitea de la caja vieja corriendo en takana

Sin tocar gioser (todo lectura) y en la VM desechable. Es el ensayo que faltaba antes de cualquier cutover, y trajo cuatro cosas que no se ven en el papel.

El snapshot de la DB se saca en caliente y sale consistente. sqlite3 gitea.db ".backup …" con el servidor VIVO: 1,5 s para 314 M, pragma integrity_check → ok, 44 filas en repository. Copiar el fichero a pelo con el servidor escribiendo es lo que NO hay que hacer; la API de backup de sqlite existe justo para esto.

Resultado, medido dentro de la VM: <title>GioSer Gitea: Git with a cup of tea</title>, 200, y /explore/repos listando los repos de verdad (sergio/takana, tawasuyu/agora, card, chasqui, cosmos, khipu, llimphi…). Y el clon de uno de los repos copiados: 478 commits, HEAD correcto, ficheros reales.

Los tres detalles que sólo aparecen con los datos puestos

  1. El uid del origen no es el del destino. Los ficheros llegan con su uid NUMÉRICO (1001 en el rsync de prueba; el gitea de gioser es 969) y el [[user]] declaraba 916. O se chownean 2 G —lento, y hay que acordarse— o gitea no puede leer sus datos, y eso no falla al copiar: falla al arrancar. La receta pasa a declarar 969, alineado con el origen.

  2. El app.ini de gioser escucha en 127.0.0.1:3002, porque allá caddy hace de proxy. Copiado tal cual, la caja sirve — pero sólo desde dentro. El HTTP_ADDR/HTTP_PORT y el ROOT_URL son parte de la adaptación, no del copiado.

  3. 🧨 El git de la distro NO puede clonar por HTTP. git clone http://… dentro de la imagen:

    git: 'remote-http' is not a git command. See 'git --help'.
    fatal: remote helper 'http' aborted session
    

    Medido sobre el ARTEFACTO sellado: /usr/libexec/git-core/ trae git-remote-ext, git-remote-fd y git-http-backend (el lado SERVIDOR), y no git-remote-http/-https; strings del binario da 0 referencias a libcurl, pese a que curl está en [deps] build. Nadie lo había notado porque en el hub el git que se usa es el de Artix, no el sellado. Es [[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que hace falta. Un hub nuevo no podría clonar el repo por HTTPS. Arreglarlo re-sella git, que es raíz de perfil.base ⇒ unidad propia, no de paso.

6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH (2026-09-14)

Hecho y verificado desde fuera: git.gioser.net y git.tawasuyu.net responden 200 con TLS válido desde 2.29.29.217, git clone funciona por HTTPS y por SSH:2345, y gitea y caddy corren supervisados por arje-zero en la caja. El gitea de gioser está parado.

Mudanza incremental, no big-bang. Los DNS de gioser son casi todos CNAME → www, así que mover www habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo git (en las dos zonas) de CNAME a A propio con TTL 60, que es reversible en un minuto.

La secuencia que funcionó, en orden

  1. Caja actualizada: arjectl + gitea + la cuenta gitea:969 + las cards en genesis y cards.d.
  2. Datos en dos fases: 1,66 GB / 18 047 ficheros con el origen VIVO (17 s), y en el corte el rsync --delete incremental + sqlite3 ".backup" (integrity_check ok).
  3. Parar el origen de verdad. rc-service gitea stop dice «already stopped» con el proceso vivo (el rc-status que miente, §Los tres hechos), y matarlo no alcanza: lo revive arje-zero, que lo tiene como card openrc-gitea con Restart — 9001 reinicios acumulados en el contador. Se paró con arjectl stop openrc-gitea, y para eso hubo que extraer su card del genesis y escribirla en /etc/arje/cards.d/ (§6.12: SpawnCardFromDisk/StopCardFromDisk leen de ahí, no del genesis).
  4. DNS por la API de Hetzner Cloud (/v1/zones): el token de hcloud también gestiona el DNS desde la unificación — la API vieja dns.hetzner.com/api/v1 redirige a la consola. Y un PUT sobre el rrset no puede cambiar el tipo: hay que DELETE del CNAME y POST del A.
  5. TLS: el primer intento de ACME falló contra gioser (502) porque el challenge salió antes de que propagara; con el DNS ya al día, arjectl restart caddy y certificate obtained successfully.

La identidad SSH se muda con el servicio

git clone ssh://…:2345 daba REMOTE HOST IDENTIFICATION HAS CHANGED: es otra máquina. Se copiaron las claves de host de gioser (/etc/ssh/ssh_host_*) a la caja ⇒ los clones existentes no notan nada. El precio es que cambia también la identidad del :22 de administración, y hay que limpiar el known_hosts propio — a los clientes del servicio no les afecta, que es lo que importa.

Y el sshd del producto escucha sólo en :22: el git por SSH necesitó Port 2345 y que el usuario gitea tenga shell real (su authorized_keys fuerza command="gitea serv …", y con /bin/false no se ejecuta nada).

Lo que esto cambia para la granja

Las 23 recetas que ahora clonan por https://git.tawasuyu.net/… apuntan a la caja. Comprobado tras el cambio: el worker resuelve 2.29.29.217 y git ls-remote responde. La granja depende ya del servidor nuevo, que es el sentido de la mudanza.

Cómo se revierte, si hiciera falta

arjectl start openrc-gitea en gioser y devolver los dos git a CNAME → www. Con TTL 60, minutos.

6.15 Cerrar el intermedio: el viejo apagado de verdad, el :22 cerrado y los datos respaldados

⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor

Media hora después del corte, gioser volvía a servir en :3002. arjectl list-units ya no lo mostraba (el stop de arje seguía en pie) y el proceso tenía ppid ≠ 1: lo había levantado OpenRC, que lo tenía en el runlevel default. O sea dos supervisores para el mismo servicio —la card openrc-gitea de arje y el runlevel de OpenRC— y parar uno no para el otro.

rc-update del gitea default   # que no vuelva
rc-service gitea stop         # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo

Antes del corte pasaba lo contrario (rc-service … stop decía «already stopped» con el proceso vivo, porque quien lo tenía era arje). La pregunta no es «¿está parado?» sino «¿quién lo tiene?», y hay que responderla dos veces.

El origen deja de servir git, y se comprueba que no se rompió lo demás

Los tres bloques (git.gioser.net, git.tawasuyu.net, gitea.gioser.net) salen del Caddyfile de gioser —con respaldo previo, caddy validate y reload—, y quedan 16. Control inmediato: sergio.gioser.net y hifas.gioser.net siguen en 200. gitea.gioser.net pasa a A → la caja, donde ya está su redir.

El :22 cerrado, sin perder el acceso

Administración en 22022, git por SSH en 2345, y el 22 cerrado (Connection refused) para sacarse de encima el barrido de bots. La secuencia importa y es la única segura: añadir el puerto nuevo → reiniciar → entrar por él → sólo entonces quitar el 22. Comprobado en ese orden, y el git clone ssh://…:2345 sigue funcionando después.

🧨 Los datos del gitea NUNCA estuvieron respaldados

respaldo-storagebox.sh respalda el store de artefactos, no /var/lib/gitea. O sea que los 44 repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí lo hace urgente: gioser se borra. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo

⚠ Al Storage Box se entra por el puerto 23, no el 22: el 22 da SFTP/SCP restringido y contesta Permission denied (publickey,password), que se lee como «no tengo la clave» cuando la clave está perfecta. Pasó en las dos máquinas antes de mirar el script (SB_PORT=23).

Pendiente: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.

6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó

La pregunta correcta después de un cutover no es «¿arrancó?» sino «¿entró algo en el viejo mientras tanto?». Se contesta comparando todos los refs de los dos lados:

for r in <repos>/*/*.git; do git -C "$r" for-each-ref --format="$r %(refname) %(objectname)"; done | sort

845 refs a cada lado, y dos diferencias. Una era esperada (sergio/takana main, el nuevo por delante con los commits posteriores al corte — merge-base --is-ancestor confirma fast-forward). La otra no: tawasuyu/tawasuyu tenía en el VIEJO dos commits que el nuevo no tenía — b69008eb (freebsd) y 02a9535f (shuma, 19:48)—, o sea quince minutos después del snapshot final.

Por qué entraron: el gitea viejo había resucitado (§6.15, OpenRC) y un cliente con el DNS cacheado —el CNAME anterior tenía TTL 600— lo empujó a gioser aunque el autoritativo ya dijera la caja. Las dos condiciones a la vez: un origen que revive y una caché que todavía apunta.

Recuperados con un git push del ref exacto desde el clon local al gitea nuevo; la UI del nuevo ya muestra 02a9535f. Segunda pasada de comparación: 845/845 y una sola diferencia, la esperada. Y del lado de los metadatos, action tenía tres filas posteriores al snapshot (los mismos dos repos) y cero issues, comentarios o releases: no se perdió nada que no fueran esos commits.

La lección para el resto de la mudanza, en orden: (1) bajar el TTL antes del corte, no durante; (2) apagar el origen de verdad —los DOS supervisores— antes de tocar el DNS; (3) comparar refs después, siempre, porque es la única prueba de que no se perdió nada; y (4) dejar el origen apagado un rato antes de borrarlo, precisamente para poder hacer esta comparación.

7. Reusar los scripts que ya existen, y no escribir de nuevo

Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:

parascript existentequé le falta
armar el rootfs de un perfilscripts/hydrate-profile.pynada — es el camino bueno (los hydrate-*.sh por escritorio divergen de targets.toml)
producir el .imgscripts/install-image.sh / scripts/product-image.shapuntar al perfil.servidor
escribir a discoscripts/takana-install.shel envoltorio de rescue del §3.2
instalador con TUIscripts/takana-live-install.shser el mismo motor que el remoto, con las respuestas por fichero
provisionar un workerscripts/farm/vps-setup.shya soporta apt y dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno)
enchufar a la granjascripts/farm/.fleetestá en .gitignore ⇒ un hub recién clonado nace con la flota vacía en silencio
latidoscripts/farm/cosecha-cron.shdecidir cron vs tarjeta de arje (§3.4)
respaldoscripts/respaldo-storagebox.shnada — es interrumpible y continuable
absorber el init ajenoarje-absorbel lector de procesos vivos (§4)

⚠ Un motor, tres frentes. La tentación es escribir el instalador remoto aparte del TUI. Ahí es donde divergen: ya pasó con los hydrate-*.sh contra targets.toml, y con la unidad systemd del worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser la misma que hace la persona, grabada en un fichero de respuestas.


8. churay como instalador centralizado — la dirección, no la decisión

El usuario lo planteó el 2026-09-09: un instalador único, con CLI y TUI, que sirva para

Encaja con lo que ya hay y por eso vale escribirlo: takana install ya resuelve por nombre contra un índice firmado y ya acepta múltiples orígenes; qorpa (ADR 0015) ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre en cron desde gioser.

No se decide acá. Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la misma pregunta del SDD 27 §4, y la respuesta puede no ser la misma fuera de takana; (2) quién es el dueño del verbo, si takana install crece o si churay lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta del store a propósito. Queda anotado para que se decida, no para que se descubra a mitad.


9. Estado y orden

Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros. Nada de espejar a GitHub para «tener copia fuera»: la copia fuera la da la propia mudanza — en cuanto el gitea vive en la caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de pagar dos máquinas, no montar una dependencia más. scripts/mudanza/espejar-repos.sh queda como herramienta disponible, no como paso del plan.

Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público. Las 23 recetas que apuntaban a gitea@git.tawasuyu.net (SSH) pasaron a https://git.tawasuyu.net/… — la URL es locator y no entra en hash_inputs (ADR 0013), así que se cambió con control y sin mover un solo ArtifactHash. Medido en el worker: clona y git archive extrae el árbol (155 M) sin ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna receta usa uno.

Lo que YA está, y no hay que volver a tocar

la caja takana (2.29.29.217)arranca takana puro, PID 1 arje-zero — §3.8
store, grafos, granja, respaldopuertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10
repo firmado servido por la caja§5.1
la imagen del perfil servidor con giteaarranca y sirve 200, supervisado por arje — §6.11
arjectl para operar sin reiniciar§6.12
el gitea de gioser corriendo sobre takana, con sus datosensayo completo — §6.13

Lo que falta para el cutover, en este orden

  1. Actualizar la caja a la imagen nueva (gitea + arjectl + las cuentas de [[user]]). takana upgrade está probado (§6.13 del SDD 27 y §5.4 acá); el dd es para provisionar, no para actualizar — sobrescribe el disco local y re-etiqueta la partición.
  2. Mudar los datos del gitea: sqlite3 … ".backup" (1,5 s, consistente con el servidor vivo) + rsync -aH de /var/lib/gitea y /etc/gitea. El corte final se hace con el gitea del origen parado, para que el último snapshot no pierda escrituras.
  3. Caddy en la caja: los 6 dominios vivos del §6.9, con su TLS. Los otros 13 no se mudan.
  4. DNS: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen resolviendo al mismo nombre.
  5. Verificar desde fuera: cada dominio vivo responde 200 contra la IP nueva y un git clone real contra el gitea mudado.
  6. El resto de servicios vivos del censo (qdrant, squid, act-runner, php-fpm…), por el plan que planear.py ya emite.
  7. Borrar gioser — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).

Bloqueantes conocidos, con su tamaño

El volumen del store NO se engancha a la caja hasta que pase la puerta 1 — ya pasó, y el store local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.