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 | 🚧 **bloqueada** — ver §6.4 |
521| 4 | la granja late en la caja nueva | 🚧 bloqueada por lo mismo |
522| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
523| 6–8 | dominios vivos · respaldo · fósiles decididos | pendientes |
524
525**Puerta 2, medida acotando al patrón `<hash>-<nombre>`**: 1362 artefactos, **0 vacíos**. Los 3 sin
526`.hammer/recipe.toml` son `stage1-rootfs`, `product-rootfs` y `seed-zig` — productos de bootstrap,
527que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran `.mirror-tmp`,
528`.bootstrap-tmp` y `.divergen`: directorios de trabajo con punto inicial, no artefactos. El chequeo
529tiene que acotar por el patrón, no listar el directorio.)
530
531**De paso, el disco**: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran
532inalcanzables. Arreglado en `install-image.sh` — el store pasa a ser la ÚLTIMA partición (la única
533que puede crecer sin mover datos) y el wrapper de `/sbin/init` la extiende en el primer arranque.
534Medido en la caja: **`/store` = 68,7 G**, contra los 487 M de antes. Con eso el store de 55 G entra
535en el disco local y **la mudanza no depende de mover el volumen de gioser**.
536
537### 6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER
538
539Al ir a computar los grafos en la caja:
540
541```
542$ python3 -c "print(1)"
543sh: python3: not found ← y `command -v python3` decía /usr/bin/python3
544```
545
546No es que falte: **está y no arranca**. `/usr/bin/python3.12` son 23 MB y es un ELF **dinámico**:
547
548```
549Requesting program interpreter: /lib/ld-musl-x86_64.so.1
550NEEDED libc.so
551```
552
553**Y en toda la imagen no hay ningún cargador dinámico.** Tampoco en el store: el artefacto `musl`
554publica sólo lo estático (`libc.a`, `crt*.o`, headers) — **no hay `libc.so` ni
555`ld-musl-x86_64.so.1` en ningún artefacto del corpus**. Lo que hace que estos binarios funcionen en
556gioser es el **rootfs Alpine del LAB**, que sí lo trae (`.dev-fs/alpine/lib/ld-musl-x86_64.so.1`).
557
558Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: *sella,
559reproduce y no corre*, porque pide algo que sólo existe en el laboratorio.
560
561**Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien).** Son la suite
562`binutils` entera (`ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof
563size ld.bfd`), más **`perl`**, **`python3`**, **`sqlite3`**, **`flex`** y **`nft`**.
564
565**Por qué frena la mudanza y no es un detalle**: *todo* el instrumental del proyecto es Python —
566`build-state.py`, `targets.py`, `yupana.py`, `hydrate-profile.py`, `drenar.py`, `triaje.py`. Un
567servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos)
568y la 4 (la granja) dependen de eso.
569
570**Y no se arregla de paso.** Las salidas son tres y ninguna es barata:
571
5721. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo
573 más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del
574 selfhost y hay que rehacerlo a propósito, no de rebote.
5752. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio:
576 Python estático con módulos de extensión es notoriamente arisco.
5773. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta
578 honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo
579 gioser, que es justo lo que se quiere apagar.
580
581Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md):
582las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le
583mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**.
584
585### 6.2 Lo que la mudanza tiene que producir, además de la mudanza
586
587El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es
588un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
589una receta candidata con un caso de uso comprobado detrás:
590
591| lo que corre hoy en Artix | receta |
592|---|---|
593| `caddy` | ✅ existe (`recipes/caddy.toml`) |
594| `openssh` | ✅ existe |
595| `gitea` | ✗ — y es la que más peso tiene: aloja el `origin` de takana |
596| `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ |
597| `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` |
598
599**Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve
600el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*.
601
602---
603
604## 7. Reusar los scripts que ya existen, y no escribir de nuevo
605
606Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
607
608| para | script existente | qué le falta |
609|---|---|---|
610| armar el rootfs de un perfil | `scripts/hydrate-profile.py` | nada — es el camino bueno (los `hydrate-*.sh` por escritorio **divergen** de `targets.toml`) |
611| producir el `.img` | `scripts/install-image.sh` / `scripts/product-image.sh` | apuntar al `perfil.servidor` |
612| escribir a disco | `scripts/takana-install.sh` | el envoltorio de rescue del §3.2 |
613| instalador con TUI | `scripts/takana-live-install.sh` | ser el **mismo motor** que el remoto, con las respuestas por fichero |
614| provisionar un worker | `scripts/farm/vps-setup.sh` | ya soporta apt **y** dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
615| enchufar a la granja | `scripts/farm/.fleet` | **está en `.gitignore`** ⇒ un hub recién clonado nace con la flota vacía **en silencio** |
616| latido | `scripts/farm/cosecha-cron.sh` | decidir cron vs tarjeta de arje (§3.4) |
617| respaldo | `scripts/respaldo-storagebox.sh` | nada — es interrumpible y continuable |
618| absorber el init ajeno | `arje-absorb` | el lector de procesos vivos (§4) |
619
620⚠ **Un motor, tres frentes.** La tentación es escribir el instalador remoto aparte del TUI. Ahí es
621donde divergen: ya pasó con los `hydrate-*.sh` contra `targets.toml`, y con la unidad systemd del
622worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser **la misma
623que hace la persona**, grabada en un fichero de respuestas.
624
625---
626
627## 8. `churay` como instalador centralizado — la dirección, no la decisión
628
629El usuario lo planteó el 2026-09-09: **un instalador único, con CLI y TUI**, que sirva para
630
631- las **apps de tawasuyu**,
632- **paquetes sueltos en cualquier distro**, al estilo nix (instalar sin ser root, sin tocar el
633 sistema anfitrión),
634- **takana** mismo,
635- y **`llimphi`**.
636
637Encaja con lo que ya hay y por eso vale escribirlo: `takana install` ya resuelve por nombre contra un
638índice firmado y **ya acepta múltiples orígenes**; `qorpa` ([ADR 0015](adr/0015-imagenes-ajenas.md))
639ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre
640en cron desde gioser.
641
642**No se decide acá.** Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir
643una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la
644misma pregunta del [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md), y la respuesta puede no
645ser la misma fuera de takana; (2) quién es el dueño del verbo, si `takana install` crece o si
646`churay` lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta
647del store a propósito. Queda anotado para que se **decida**, no para que se descubra a mitad.
648
649---
650
651## 9. Estado y orden
652
653| paso | estado |
654|---|---|
655| **0.** `perfil.servidor` + este documento + el renombre `hammer-*` de los instaladores | ✅ **2026-09-09** |
656| **1a.** caja de sacrificio creada — `cx33` (no había `cx53`), hel1, §3.5 | ✅ **2026-09-10** |
657| **1b.** imagen del `perfil.servidor` + rescue → `dd` → arranca takana puro (§3) | ✅ **2026-09-10** — puerta 1 pasada, §3.8 |
658| **2a.** clave estable + receta de `takana` + repo firmado servido por la caja (§5.1) | ✅ **2026-09-10** |
659| **2b.** que el host se actualice DESDE su propio repo (§5.4) | 🚧 bloqueado por la decisión de [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md): `install` reproduce, y eso exige el lab entero |
660| **3.** mudanza con las 8 puertas del §6, y recetas nuevas del §6.2 | pendiente |
661| **4.** la utilidad de absorción (§4), estrenándose con esta migración | pendiente |
662| **5.** `churay` como instalador centralizado (§8) | ADR propio, sin decidir |
663
664**El volumen del store NO se engancha a la caja nueva hasta que pase la puerta 1.** Una caja de
665sacrificio con el store dentro deja de ser de sacrificio.
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:
- sirve 19 dominios por Caddy,
- tiene
/mnt/vvv al 89 % (27 G libres) (medido),
- es fija y sin backup por pedido explícito del usuario (blindaje anti-borrado en
deadman.sh y
farm-down.sh, ver ADR 0014 y la memoria del proyecto),
- y no publica nada de takana: cero entradas de takana en el Caddyfile (medido).
0.1 El hallazgo que cambia el tamaño del problema
$ ps -p 1 -o comm=,args=
arje-zero /usr/local/sbin/arje-zero
$ cat /proc/cmdline
BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
init=/usr/local/sbin/arje-zero loglevel=4
El init de takana ya gobierna un servidor de producción real. Caddy, gitea, sshd, qdrant y
act-runner cuelgan directo de PID 1 (medido: ps -eo pid,ppid,comm | awk '$2==1').
Pero eso no es takana puro: es Artix con nuestro init inyectado por init= en el cmdline. El
kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.
0.2 ⚠ La declaración del arranque no existe en ningún sitio legible
rc-status de OpenRC dice stopped para caddy, gitea, sshd, cronie y act-runner, y los cinco
están vivos (medido, los dos comandos). /etc/arje/cards.d tiene 4 tarjetas (acpid,
openrc-openclaw, squid, zram-swap) y ninguna de ellas los levanta.
⇒ La secuencia de arranque de esta caja no está escrita. Es lo menos reproducible de toda la
mudanza y lo primero que hay que capturar — ver §4.
1. Inventario de lo que hay que mudar (medido 2026-09-09)
Datos en /mnt/vvv (217 G de 255)
| |
tawasuyu | 124 G |
takana | 91 G — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios |
rustup | 29 G |
act-runner · gioserv · _respaldo | 6,5 · 5,4 · 4,1 G |
~20 dirs *-standalone | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… |
El volumen harkaq-cosecha (250 G, 148 G usados)
| |
store | 55 G, 1327 artefactos — lo irreemplazable |
work-sources + cargo-home + gopath | ~85 G — caché regenerable, no se muda |
work-tarballs · devfs-cache · work-out | 3,2 G · 4,5 G · 397 M |
El store no se copia por red. Los dos volúmenes están en hel1; un volumen hcloud se desengancha
de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.
Servicios vivos, todos hijos de arje-zero
caddy :80/:443 · gitea :3002 · sshd :2345 · act_runner (el CI de tawasuyu) · qdrant
:6333/:6334 · openclaw · zeroclaw · shuma-gateway :7378 · tejido :4102 y :33097 ·
puerta-f6e393ff · uvicorn :8771 · python3 :8770 · squid :1137 · fail2ban · glances ·
php-fpm · metalog · adb :5037.
Lo que no está en ningún repo
/var/lib/gitea (2,2 G: SQLite + los repos de git de todo, incluido el origin de takana) ·
/var/www (287 M) · /home/sergio/gioser-web/ · /etc/caddy/Caddyfile + 20 ficheros .bak ·
los certificados de letsencrypt · los crontabs · /etc/arje/cards.d · las 3 credenciales de
~/.config/hammer/*.env · las llaves ~/.ssh/{github5,sergio} · el token hcloud · y la memoria
del agente en ~/.claude/.
1.1 La basura, contada — porque una mudanza fiel copia también la basura
De los 19 dominios del Caddyfile (probados uno por uno desde la propia caja):
| responden 200 | rotos |
| gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | api.gioser.net → 502 (backend caído) |
| aura · sigma · kosmofono · terapeuta.ec → sin DNS |
Y sus raíces no existen en disco: /var/www sólo tiene terapeuta, goaccess, ogisoer,
git-tawasuyu, letsencrypt y un .bak — no hay aura_frontend, ni sigma, ni summa/frontend,
ni kosmofono. La mitad del Caddyfile describe un servidor que ya no existe.
⇒ Regla de la mudanza: no se muda lo que no responde. Lo que no tiene DNS ni ficheros se anota y
se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir declarado / vivo / fósil.
2. La caja: qué, cuánto y por qué
Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) (medido):
| tipo | recursos | €/mes |
cx53 | 16 c / 32 G / 320 G | 29,49 |
cpx41 | 8 c / 16 G / 240 G | 32,49 |
ccx23 (dedicado) | 4 c / 16 G / 160 G | 85,99 |
Elegido cx53: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana +
rustup (~244 G) si algún día se muda todo.
Decisiones del usuario (2026-09-09):
- 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.
- «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).
- 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.
| pieza | estado (medido) |
| kernel con virtio | recipes/linux.toml compila VIRTIO, VIRTIO_PCI, VIRTIO_BLK, VIRTIO_NET, EXT4_FS =y ⇒ sin initramfs |
| red | crates/netup = cliente DHCPv4 propio en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por DHCP en eth0 (/32, net.ifnames=0, DNS 185.12.64.2) ⇒ es justo su caso |
| acceso | recipes/openssh.toml sellado; el product-rootfs vigente ya trae sshd; takana-install.sh acepta AUTHKEYS |
| servir | recipes/caddy.toml existe |
| escritura a disco | scripts/takana-install.sh (no interactivo) y scripts/takana-live-install.sh (TUI), probado por scripts/install-tui-test.sh |
3.2 El camino remoto: rescue → dd → reboot
Hetzner Cloud tiene un sistema de rescate (Debian por SSH) que se activa desde la API sin tocar
la caja. Desde ahí se escribe la imagen al disco y se reinicia. No hace falta ISO, ni consola, ni
medio físico — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.
3.3 Los cuatro huecos, nombrados
perfil.servidor — HECHO hoy en docs/state/targets.toml. Ver §3.4.
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.
- 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.
- 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 resuelve | por qué está declarada igual |
takana | el propio takana no tiene receta. El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como cargo build sobre un clon. Es EL bloqueante del §5 |
chrony | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» |
nftables | dev.gioser.net está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así |
cronie | el latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. Decisión pendiente |
logrotate | en una caja que sirve, el log de acceso crece hasta llenar el disco |
Quedan wanted en el grafo, y wanted no es debt ⇒ drenaje.json seguirá diciendo
deuda=0. Se declaran igual porque una deuda escrita se ve y una omitida no: sin ellas el perfil
saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
la lección de foot en escritorio-sway (121/121 sellado y sin emulador de terminal), pagada por
adelantado. Cuenta esperada: 5. Un sexto wanted es un error de tipeo, no una receta nueva.
3.5 La caja, creada (2026-09-10)
| |
| nombre / id | takana / 165447050 |
| tipo | cx33 — 4 c / 7,6 GiB / 76,3 G — €8,49/mes |
| ubicación | hel1 — la misma que los volúmenes vvv y harkaq-cosecha y que el Storage Box |
| IPv4 / IPv6 | 2.29.29.217 · 2a01:4f9:c014:2f9c::/64 |
| imagen inicial | debian-13 — desechable: existe sólo para recibir ficheros hasta el dd del §3.2 |
| label | role=takana-server |
| protección de borrado | NO, a propósito: es la caja de sacrificio |
⚠ El label importa: farm-down.sh y deadman.sh borran sólo lo que lleva
role=hammer-worker. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla.
Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se
protege por no haber nacido de farm-up.
⚠ cx53 no se pudo comprar: sale Available: no en las TRES ubicaciones. Y la disponibilidad
cambia por día — cx33 salió no en hel1 el 2026-09-09 y yes el 2026-09-10, con la misma
consulta. ⇒ el precio de la tabla no dice que la máquina exista: hcloud server-type list
devuelve precios para tipos que no se pueden crear. Consultar Available por ubicación, y volver a
consultarlo el día que se compra.
Lo que la caja confirma, medido EN ELLA y no inferido de gioser
- 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.
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.
eth0 con /32 dinámico por DHCP — idéntico a gioser ⇒ es exactamente el caso de netup.
Dos cosas pendientes en la caja, anotadas
- 7,6 GiB de RAM y CERO swap — la misma clase de máquina que ya provocó
global_oom en gioser.
Coherente con la decisión de §2.1 (la caja publica, el LXC muele), pero significa que no se
construye pesado acá mientras siga así.
sshd trae PasswordAuthentication yes (con PermitRootLogin without-password, así que root
por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está
igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian.
3.6 La imagen del perfil.servidor, armada y validada (2026-09-10)
Receta del ensamblado (todo con scripts que ya existían, §7):
# 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:
| clase | criterio | qué se hace |
| vivo | proceso corriendo y declarado | se muda |
| declarado, muerto | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | fósil: se anota y se deja morir |
| vivo, no declarado | proceso con ppid==1 que ningún init declara (caddy, gitea, sshd, act-runner) | se escribe la tarjeta que faltaba — es el trabajo real |
Se construye al final, estrenándose con la migración gioser → caja nueva, con el caso del
rc-status que miente como test de aceptación. Su especificación es el §1 de este documento.
5. Que el servidor sirva sus propios paquetes a su propio host
Existe el camino entero: scripts/build-repo.sh (corpus → .swm firmados + índice),
takana install --repo que ya acepta lista HTTP multi-origen y verifica cada paquete contra el
digest del índice firmado (ADR 0014), takana upgrade con
rollback, y caddy para servir dist/repo con un file_server.
Lo que falta no es servir, son dos cosas:
- 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.
- La receta de
takana (§3.4). El host necesita takana instalado para consumir los paquetes,
y hoy no puede instalarlo desde el repo porque takana no está en el repo.
5.1 Hecho (2026-09-10): clave estable, repo firmado, y la caja sirviéndolo
trust/ — la clave PÚBLICA de release en el repo; la privada en ~/.config/takana/keys/
(0600, fuera de git). scripts/repo-perfil.sh aborta si no la encuentra en vez de inventar una
efímera, y tras firmar verifica el índice contra trust/.
- 88/88 paquetes del perfil
servidor publicados con expected_hash anclado.
- La caja los sirve con
caddy file-server sobre /srv/repo.
El control de la firma, en los dos sentidos, contra el repo servido por la caja:
--trust | resultado |
./trust | release: trusted (by release) ⇒ instala |
| un directorio vacío | release: unknown-key … clave no confiada ⇒ ABORTA |
5.2 ⚠ Lo que el lazo destapó: strip_debug no viajaba en el paquete
La primera instalación real de takana desde su propio repo falló:
Error: expected_hash no coincide:
declarado = b3:941d1857… obtenido = b3:2727050e…
why-differs lo nombró: los dos binarios pesaban exactamente lo mismo y sólo divergían en
.shstrtab — la firma de un strip que corrió una vez y la otra no. swm_bridge re-inyecta con
cuidado flags, phases, zig_version, strip_components, patches, deps… y se olvidaba de
strip_debug, que ni siquiera existía en SwmBuild. Como entra en hash_inputs, el receptor
reconstruía en otra dirección.
Alcance: 40 recetas del corpus, 18 de las 88 de este perfil. Una quinta parte del repo no se
podía instalar, y nadie lo sabía porque nunca se había cerrado el lazo. Arreglado, con test de
regresión y su control. Tras el arreglo, install takana da cache-hit en el hash anclado.
5.3 ⚠ Y el loopback nunca se levantaba
Al intentar que la caja instalara de sí misma por http://127.0.0.1: timeout de 30 s, con caddy
escuchando en 0.0.0.0:80. lo estaba DOWN y sin dirección — con systemd o OpenRC lo levanta
el init; con arje-zero no lo hacía nadie. El síntoma no era «connection refused», que se
diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en netup (es quien
configura la red) y verificado desde la imagen, sin tocar la caja a mano.
5.4 🚧 Lo que NO se puede todavía, y por qué importa
Con todo lo anterior en verde, la caja sigue sin poder instalar de su propio repo:
release: trusted (by release) ← la firma verifica
repo: bajados 2 .swm de http://127.0.0.1 ← descarga de sí misma
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
calcular un hash comparable
O sea: la mitad de servir está cerrada y probada; la de consumir, no — y no por un detalle de
instalación, sino porque install reproduce desde fuente y eso exige el LAB ENTERO en el
cliente. No es «falta un compilador»: el lab entra en el ArtifactHash, así que sin él no se puede
ni calcular el hash a comparar.
Es exactamente el punto que SDD 27 §4 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.
| # | puerta | cómo se comprueba |
| 1 | la caja nueva arranca takana puro y se entra por SSH | ssh a la IP nueva tras el reboot del rescue |
| 2 | el store está entero | 1327 artefactos y ninguno vacío — respaldo-storagebox.sh --listar separa los vacíos; un directorio vacío no es un artefacto |
| 3 | los grafos salen idénticos | build-state.py en las dos máquinas ⇒ los cuatro grafos byte a byte. Es la prueba de que un hub está bien montado, no que «funcione» |
| 4 | la granja late en la caja nueva | una cosecha completa con el LXC enchufado por .fleet y artefactos que vuelven |
| 5 | el repo se sirve y el host se actualiza de sí mismo | §5 en verde, con la clave estable |
| 6 | cada dominio vivo responde 200 desde fuera | la tabla del §1.1, repetida contra la IP nueva |
| 7 | el respaldo al Storage Box corre desde la caja nueva | una corrida completa, no un --seco |
| 8 | lo fósil está anotado y decidido | lista del §1.1 con «muere» o «revive» al lado de cada uno |
⚠ Puerta 3 tiene una trampa conocida: sealed_remoto dependía de la máquina (laptop 700, gioser
751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la
diferencia es «cuánto de esto tengo en disco» y no «qué existe».
6.3 Estado de las puertas (2026-09-10)
| # | puerta | estado |
| 1 | arranca takana puro y se entra por SSH | ✅ §3.8 |
| 2 | el store está entero | ✅ 1362 artefactos reales, 0 vacíos |
| 3 | los cuatro grafos idénticos | 🚧 bloqueada — ver §6.4 |
| 4 | la granja late en la caja nueva | 🚧 bloqueada por lo mismo |
| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
| 6–8 | dominios vivos · respaldo · fósiles decididos | pendientes |
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:
- 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.
- 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.
- 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.2 Lo que la mudanza tiene que producir, además de la mudanza
El usuario lo pidió explícito: que este experimento saque recetas y las pruebe. La caja vieja es
un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
una receta candidata con un caso de uso comprobado detrás:
| lo que corre hoy en Artix | receta |
caddy | ✅ existe (recipes/caddy.toml) |
openssh | ✅ existe |
gitea | ✗ — y es la que más peso tiene: aloja el origin de takana |
qdrant, squid, fail2ban, php-fpm, metalog, glances | ✗ |
chrony, nftables, cronie, logrotate | ✗ — ya declaradas wanted en perfil.servidor |
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.
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:
| para | script existente | qué le falta |
| armar el rootfs de un perfil | scripts/hydrate-profile.py | nada — es el camino bueno (los hydrate-*.sh por escritorio divergen de targets.toml) |
producir el .img | scripts/install-image.sh / scripts/product-image.sh | apuntar al perfil.servidor |
| escribir a disco | scripts/takana-install.sh | el envoltorio de rescue del §3.2 |
| instalador con TUI | scripts/takana-live-install.sh | ser el mismo motor que el remoto, con las respuestas por fichero |
| provisionar un worker | scripts/farm/vps-setup.sh | ya soporta apt y dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
| enchufar a la granja | scripts/farm/.fleet | está en .gitignore ⇒ un hub recién clonado nace con la flota vacía en silencio |
| latido | scripts/farm/cosecha-cron.sh | decidir cron vs tarjeta de arje (§3.4) |
| respaldo | scripts/respaldo-storagebox.sh | nada — es interrumpible y continuable |
| absorber el init ajeno | arje-absorb | el 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
- las apps de tawasuyu,
- paquetes sueltos en cualquier distro, al estilo nix (instalar sin ser root, sin tocar el
sistema anfitrión),
- takana mismo,
- y
llimphi.
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
| paso | estado |
0. perfil.servidor + este documento + el renombre hammer-* de los instaladores | ✅ 2026-09-09 |
1a. caja de sacrificio creada — cx33 (no había cx53), hel1, §3.5 | ✅ 2026-09-10 |
1b. imagen del perfil.servidor + rescue → dd → arranca takana puro (§3) | ✅ 2026-09-10 — puerta 1 pasada, §3.8 |
2a. clave estable + receta de takana + repo firmado servido por la caja (§5.1) | ✅ 2026-09-10 |
| 2b. que el host se actualice DESDE su propio repo (§5.4) | 🚧 bloqueado por la decisión de SDD 27 §4: install reproduce, y eso exige el lab entero |
| 3. mudanza con las 8 puertas del §6, y recetas nuevas del §6.2 | pendiente |
| 4. la utilidad de absorción (§4), estrenándose con esta migración | pendiente |
5. churay como instalador centralizado (§8) | ADR propio, sin decidir |
El volumen del store NO se engancha a la caja nueva hasta que pase la puerta 1. Una caja de
sacrificio con el store dentro deja de ser de sacrificio.