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### 6.17 ⚠ En gioser conviven arje Y OpenRC — y el prefijo `openrc-` de las cards ENGAÑA
1249
1250El usuario avisó: *«esa máquina la tengo con arje desde hace meses»*. Tiene razón, **y las dos cosas
1251son ciertas a la vez**:
1252
1253| medido | |
1254|---|---|
1255| PID 1 | **`arje-zero`** ✓ |
1256| la card `openrc-gitea` | ejecuta `sh -c "cd /var/lib/gitea && exec /usr/bin/gitea web …"` — **no llama a OpenRC** |
1257| `/run/openrc/started/` | **existe y tiene servicios**: NetworkManager, dbus, dhcpcd, fsck, hostname, hwclock, local, localmount… |
1258| `rc-update show default` | listaba `gitea` |
1259
1260⇒ **El prefijo `openrc-` es herencia del NOMBRE**, de cuando `arje-absorb` tradujo los servicios de
1261OpenRC a cards: la card no pasa por `rc-service`, encarna el binario directo. Pero **OpenRC quedó
1262como residuo ACTIVO** y puede arrancar servicios por su cuenta — que es lo que revivió al gitea con
1263`ppid ≠ 1` después de que arje lo hubiera detenido.
1264
1265**La regla para el resto de la mudanza**: antes de dar un servicio de gioser por apagado, preguntar a
1266los **dos**:
1267
1268```sh
1269arjectl list-units | grep <svc> # la capa de arje (PID 1)
1270rc-update show default | grep <svc> # el residuo de OpenRC, que también arranca
1271```
1272
1273Es [[la-etiqueta-no-es-el-hecho]] en su forma más cara: un nombre que dice `openrc-` y no es OpenRC,
1274junto a un OpenRC real que nadie esperaba que siguiera operando.
1275
1276### 6.18 Mudados `takana.gioser.net` y `hifas.gioser.net` *(estáticos puros)*
1277
1278Los dos son ficheros y nada más —sin backend—, así que mudarlos fue copiar 52 K, añadir su vhost al
1279Caddyfile de la caja, mover el DNS de CNAME a **A** y quitarlos del origen. **200 con TLS válido
1280desde `2.29.29.217`**, y control de que no se rompió lo demás: `sergio.gioser.net` sigue en 200 y
1281gioser baja de 16 a **14 vhosts**.
1282
1283Se repitió el patrón de ACME: el primer intento de certificado sale ANTES de que propague el DNS,
1284falla contra el origen y hay que forzar el reintento con `arjectl restart caddy`. **Conviene mover
1285el DNS y recién entonces añadir el vhost** — o asumir un `restart` de más.
1286
1287**Quedan en gioser 14 vhosts**, todos con backend propio (uvicorn, php-fpm, `tejido`, `shuma`…) o
1288fósiles sin DNS. Ésos no se mudan copiando un directorio: necesitan su servicio del otro lado.
1289
1290### 6.19 Los fósiles, barridos: de 19 vhosts a **2**
1291
1292Se midió cada dominio que quedaba —DNS por DoH **y** HTTP contra la IP del origen— antes de tocar
1293nada. El resultado deja la mudanza mucho más chica de lo que parecía:
1294
1295| clase | cuántos | qué son |
1296|---|---|---|
1297| **fósiles sin DNS** | 5 | `aura`, `api.aura`, `sigma`, `kosmofono`, `api.kosmofono` |
1298| **ya viven en OTRA máquina** | 3 | `summa`, `dev.summa`, `api.dev.summa` → `154.197.1.2` |
1299| **502 permanente** | 2 | `mail.sigma` (:9000 caído), `api.gioser.net` (:8000 caído) |
1300| **mudados hoy** | 5 | los tres de git + `takana` + `hifas` |
1301| **VIVOS y por mudar** | **2** | `sergio.gioser.net` y `api.sergio.gioser.net` |
1302
1303**Y ninguna de las raíces estáticas de los fósiles existe en disco**: `/var/www/{aura_frontend,
1304sigma,kosmofono,summa,summa-dev}` no están. El Caddyfile servía directorios ausentes — la
1305configuración sobrevivió a sus datos. De los backends, sólo `:8770` sigue escuchando, y su dominio
1306ya apunta a otra máquina; `:8766 :8765 :9000 :8000 :8768` están caídos.
1307
1308Los 10 bloques muertos salen del Caddyfile (con respaldo previo, `validate` y `reload`), y el control
1309confirma que los vivos siguen: `sergio` y `api.sergio` en **200**, y los mudados respondiendo desde
1310la caja. **gioser pasa de 19 vhosts a 2.**
1311
1312⚠ **El único fósil CON datos es `terapeuta.ec`: 279 M en `/var/www/terapeuta`**, sin DNS y sin vhost.
1313No se borra acá — se anota para la decisión de borrado de la máquina, que es del §6.
1314
1315**Lo que esto cambia**: mudar los dos que quedan es un frente acotado —un backend Python en `:7378`
1316y una API en `:8771`— y no las quince cosas que la lista sugería.
1317
1318### 6.20 🛟 Cinco binarios rescatados de la nada — el paso que CADUCABA
1319
1320Antes de tocar `sergio.gioser.net` apareció lo urgente: **cinco procesos vivos cuyo binario YA NO
1321EXISTE en disco** (`/proc/<pid>/exe` → `… (deleted)`). Sólo existían como inode huérfano: si el
1322proceso muere o la máquina se reinicia, se pierden. Es el paso que el SDD 29 §4.0 undecies llamó *«el
1323que caduca»*, y caducaba de verdad.
1324
1325 tejido · sandokan-mcp · pacha-secretos · puerta-f6e393ffbef3999e · shuma-gateway (240 M)
1326
1327Se recuperaron leyendo `/proc/<pid>/exe` con el proceso vivo, y ya están **fuera de gioser**: en
1328`/work/rescate-binarios/` de la caja **y** en `rescate-binarios/` del Storage Box. Cinco de los trece
1329binarios que nadie provee dejaron de depender de que nadie reinicie nada.
1330
1331### 6.21 🧱 `sergio.gioser.net`: el muro no es el sitio, es **glibc**
1332
1333El sitio son tres piezas, y sólo una se puede mudar hoy:
1334
1335| pieza | qué es | mudable a takana |
1336|---|---|---|
1337| el frontend | **117 M** de estático (`dist/`) | ✅ copiado ya a `/work/www/sergioh` |
1338| `/shuma/*` | `shuma-gateway` en `:7378` — **ELF dinámico, y su binario estaba BORRADO** | ⚠ hay que construirlo desde tawasuyu (es Rust del monorepo) |
1339| `api.sergio` | **uvicorn** con un venv de **405 M** | 🧱 **no, hoy no** |
1340
1341El backend es `fastapi + uvicorn + pydantic + google-generativeai + **langchain**`, con extensiones
1342compiladas `…cpython-314-x86_64-linux-**gnu**.so` — o sea **glibc**. Reempaquetar ese árbol de PyPI
1343como recetas no es realista, y correrlo en musl no es posible. **Es el caso de uso canónico del
1344[ADR 0015](adr/0015-imagenes-ajenas.md) (`qorpa`: imágenes ajenas glibc enjauladas)**, que está
1345PROPUESTO y sin implementar del todo.
1346
1347⇒ **El DNS de `sergio` NO se movió**: mover el nombre sin el backend deja el sitio servido y la
1348consola rota. El frontend queda copiado y esperando; el corte se hace cuando exista el camino para
1349las otras dos piezas.
1350
1351**Esto es lo que decide la fecha de borrado de gioser**: no es «mudar dominios», es que **un
1352servicio vivo no tiene hoy camino a takana**. Las salidas son tres y hay que elegir: (1) `qorpa`, que
1353es para exactamente esto; (2) que ese servicio viva en otra máquina —`summa` ya lo hace, y su DNS lo
1354demuestra—; o (3) que muera. Ninguna es técnica: es una decisión.
1355
1356### 6.22 🧨 El `curl` de la distro NO PUEDE VALIDAR TLS — y por eso nada se baja por HTTPS
1357
1358Al ir a traer la primera imagen ajena (`qorpa pull`), la caja falló con `curl failed to verify the
1359legitimacy of the server`. El certificado del servidor estaba bien; lo que falta es de este lado:
1360
1361```
1362* CApath: /etc/ssl/certs
1363* SSL certificate ... unable to get local issuer certificate (20)
1364$ curl --cacert /etc/ssl/certs/ca-certificates.crt ... → 200
1365```
1366
1367El `curl` del corpus está compilado **con `--with-ca-path=/etc/ssl/certs` y sin CAfile**, y ese
1368directorio contiene **un solo fichero**: el bundle. Un *capath* necesita **hash-links**
1369(`c_rehash`) — el índice — y no los hay. Los certificados están, el índice no.
1370
1371**Por qué faltan**: `recipes/ca-certificates.toml` deja escrito el hook
1372`/etc/ca-certificates/update.d/certhash`, que en Alpine ejecuta `apk` al instalar. **En takana no lo
1373ejecuta nadie**: la distro proyecta artefactos, no corre post-install. Es la misma familia que
1374[[subcomando-sin-driver]] — todo presente, y el paso que lo conecta no existe.
1375
1376**Alcance**: no es sólo `qorpa pull`. Es `takana install --repo https://…`, el mirror, y cualquier
1377`curl`/`wget` de un script en una caja instalada. **No falla al construir ni al instalar: falla la
1378primera vez que la máquina intenta bajar algo.** Nadie lo había visto porque el hub usa el `curl` de
1379Artix.
1380
1381**Arreglado en la receta**: los hash-links se generan **en el build** —donde `c_rehash` y perl ya
1382existen— y viajan dentro del artefacto, con una guarda que **falla ruidosamente si el rehash no
1383produce ninguno**. Re-sella `ca-certificates`, que es raíz de `perfil.base`.
1384
1385**Apaño mientras tanto**, para invocaciones puntuales: `CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`
1386(comprobado: 200).
1387
1388### 6.23 `qorpa` en la caja: preflight en verde, y el segundo muro es `tar`
1389
1390Con el backend de `sergio` sin camino a musl (§6.21), la salida elegida es **qorpa**. Estado medido
1391en la caja:
1392
1393| paso | resultado |
1394|---|---|
1395| userns anidado · overlayfs sin privilegios | ✅ ya estaban |
1396| **disco** | ❌→✅ `/var/lib/hammer` vive en la raíz (3 G) ⇒ `qorpa` **se movió a `/work`** (62 G) por symlink |
1397| `subuid`/`subgid` | ❌→✅ rango escrito para root |
1398| `newuidmap`/`newgidmap` sin capability | ❌→✅ `setcap` aplicado — **y el artefacto del store NO se tocó** (inodes distintos: en una caja instalada esos binarios son copias, no hardlinks) |
1399| veredicto del preflight | **de «BLOQUEA 1 · LIMITA 4» a «LIMITA 1»** |
1400| `qorpa pull` de Arch bootstrap | baja y **verifica el sha256**, y falla al desempacar |
1401
1402El segundo muro: el rootfs de Arch es `.tar.zst` y **el `tar` de la imagen es el de busybox**, que no
1403lo entiende — el mismo tropiezo que ya costó el lab (`tar: unrecognized option: zstd`). `zstd` SÍ
1404está instalado. ⇒ `qorpa pull` tiene que descomprimir con `zstd -dc | tar -x` cuando el archivo es
1405`.zst`, en vez de dárselo entero a `tar`. Es un arreglo pequeño en el CLI, y hasta entonces la
1406alternativa es una imagen `.tar.gz` (Ubuntu base) — que además obliga a reinstalar las deps del venv,
1407porque el de gioser trae extensiones de **CPython 3.14** y Ubuntu 24.04 lleva 3.12.
1408
1409### 6.24 El TLS arreglado, la distro con nombre, y **por qué `upgrade` se quedaba sin espacio**
1410
1411**El `curl` ya valida.** Con `ca-certificates` re-sellado —**119 hash-links en el capath**, generados
1412en el build— la caja baja por HTTPS **sin ningún apaño**: `curl -I https://…` → **200**. El arreglo
1413tardó tres intentos y los dos primeros los cazó la guarda que puse en la propia receta: primero
1414`c_rehash` no estaba en el `PATH` (lo instala esa misma receta en `/out/usr/bin`), y después los
1415certificados **no estaban sueltos** en `usr/share/ca-certificates/` sino en su subdirectorio
1416`mozilla/`, así que `c_rehash` sólo veía el bundle y lo saltaba —correctamente— con «does not
1417contain exactly one certificate». Sin la guarda, las tres veces habría sellado un capath vacío.
1418
1419**`fastfetch` (2.68.1) corre en la caja** — y al correrlo apareció otra ausencia:
1420
1421```
1422OS: takana x86_64 ← ahora
1423Host: vServer (20171111)
1424Kernel: Linux 7.1.2 · CPU: AMD EPYC-Rome (4) · Memory: 393 MiB / 7.58 GiB
1425```
1426
1427…porque **`/etc/os-release` NO EXISTÍA**. La distro era anónima para cualquier programa que no fuera
1428suyo. Se resolvió con una receta propia (`os-release`, `source.dir`) en vez de una constante en
1429`takana-bootstrap`: meterlo ahí re-sellaría el `product-rootfs` —el baseline del selfhost— por cinco
1430líneas. Sin `VERSION_ID` ni fecha a propósito: un sello con la fecha del build cambiaría el hash del
1431artefacto, y con él la clausura de toda imagen que lo lleve, cada día y sin motivo.
1432
1433#### 🧨 `/var/lib/hammer` es una partición de 487 MB — y ahí viven las generaciones
1434
1435`upgrade apply` falló dos veces con `No space left on device` teniendo **3 G libres en la raíz**. Ni
1436el espacio de `/` ni los inodos (9% usados): el estado de generaciones vive en
1437**`/var/lib/hammer`, que es `sda3` y mide 487 MB** (la partición «estado» del layout). Cada
1438generación guarda una copia del árbol aplicado — el nuestro son **272 MB** — así que **dos no caben**.
1439
1440⇒ El estado se movió a `/work` por symlink, igual que `qorpa`. Tras eso: `✓ generación 6 aplicada`.
1441**El layout de la imagen necesita revisión**: 512 MB de «estado» no alcanzan para un mecanismo que
1442guarda un árbol entero por generación, y el síntoma (`ENOSPC` con la raíz medio vacía) apunta al
1443sitio equivocado.
1444
1445### 6.25 ✅ CUTOVER DE `api.sergio.gioser.net` — el primer servicio AJENO que sirve takana *(2026-09-15)*
1446
1447**`https://api.sergio.gioser.net` responde 200 con certificado público válido desde `2.29.29.217`**,
1448y su `/api/chat/` contesta con Gemini de verdad. El backend corre **dentro de una jaula `qorpa`**
1449(rootfs de Arch pineado por sha256), supervisado por `arje-zero` como el ente `sergioh-api`.
1450
1451Es el servicio que §6.21 declaró sin camino a musl —su venv trae extensiones
1452`cpython-314-x86_64-linux-**gnu**.so`— y por eso «lo que decide la fecha de borrado de gioser». El
1453camino elegido era `qorpa` (ADR 0015) y ahora está recorrido de punta a punta.
1454
1455**La coincidencia que lo hizo barato:** el Arch bootstrap pineado trae **python 3.14.7** y el venv de
1456gioser es **3.14.6**. Misma serie ⇒ las extensiones compiladas no son un problema: el venv se rehace
1457adentro con `pip`, no se copia.
1458
1459#### 🧨 EL MURO: la jaula no viajaba en NINGUNA imagen
1460
1461`takana qorpa provision` abortó con un mensaje correcto y una instrucción imposible:
1462
1463 Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
1464 construilo: gcc -O1 -Wall -static -o … scripts/harkaq/harkaq-exec.c
1465
1466**En una caja instalada no hay `gcc`, ni `scripts/`, ni árbol de desarrollo.** El binario sólo existía
1467como un `gcc` a mano en la caché de `$HOME` del hub — o sea que la jaula del ADR 0015 funcionaba
1468**únicamente en la máquina donde alguien la había compilado**. Y su compañero estaba igual, medido en
1469`build-state.json`: **`bwrap` sellado desde hace meses con `"perfiles": []`**, CERO perfiles. Los dos
1470se invocan por PATH, y no sólo desde `qorpa`: `takana-build/src/sandbox.rs` llama a `bwrap` en cada
1471build. ⇒ **ninguna imagen de takana podía enjaular nada, ni construir**. Es
1472[[subcomando-sin-driver]] un piso más abajo: el CLI que los llama viaja en todas las imágenes y sus
1473herramientas en ninguna.
1474
1475Arreglado en la misma unidad de trabajo: `recipes/harkaq-exec.toml` (nueva, estática musl,
1476`b3:cd34954f…`, 269 K), `bwrap` + `harkaq-exec` declarados en **`perfil.base`**, y `qorpa.rs`
1477buscando el binario también en `/usr/bin` —el orden es `HARKAQ_BIN` → árbol de desarrollo → paquete,
1478para que un cambio en la jaula se pruebe sin instalar nada—. ⚠ El pin de la receta va **al commit que
1479tocó la fuente** (`1d9ddcee`, del 2026-09-03) y no a HEAD: el repo commitea cada media hora por el
1480cron de la cosecha, y pinear HEAD re-hashearía la receta cada media hora sin que su fuente se moviera.
1481
1482#### Los tres tropiezos del camino, todos de la misma familia
1483
14841. **`chown`**: el `rsync` preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del
1485 rango mapeado ⇒ ni siendo root adentro se puede escribir. `python -m venv` fallaba con
1486 `Permission denied` en el directorio concedido mientras andaba perfecto en `/tmp`. El árbol del
1487 sitio va a `root:root` del anfitrión, que es lo que el mapa de la jaula sí alcanza.
14882. **Una instancia, un `run`**: el segundo `qorpa run` sobre la misma instancia es rechazado por el
1489 kernel («dos overlays con el mismo `upper` corromperían la capa mutable»). Correcto y hay que
1490 saberlo: con el servicio arriba **no se entra a la instancia a hacer mantenimiento**.
14913. **`ps` de la caja no ve los procesos y `dig` no existe en el hub**. Dos herramientas ausentes que
1492 se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un `until` de
1493 espera de DNS girando cinco minutos contra una condición que **nunca podía cumplirse**. Un
1494 guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.
1495
1496#### Lo que quedó declarado, y lo que no
1497
1498- `instance.toml` es la verdad: `packages = ["python","python-pip"]`, `network = true` (pacman, pip y
1499 la propia API de Gemini la necesitan) y **un solo dir concedido** (`/work/sergioh` → `/srv/sergioh`,
1500 rw). Ni sockets, ni dispositivos, ni nada más.
1501- El código y el venv viven **fuera** de la instancia, en el anfitrión: el `upper` es caché (D3) y se
1502 tira. Y las 84 dependencias quedaron pineadas en `requirements.lock` — el `requirements.txt` del
1503 sitio no pinea nada, así que sin el lock un `recreate` traería otras versiones.
1504- La tarjeta `sergioh-api` va en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
1505 (son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido, §6.12), con dos
1506 guardas que salen 78 nombrando el arreglo si falta la instancia o el venv.
1507- ⚠ **Todavía invoca con `HARKAQ_BIN=/usr/bin`** en la tarjeta: el `takana` de la caja es el del
1508 `commit` pineado en `recipes/takana.toml` y no trae aún la búsqueda en `/usr/bin`. Sale solo cuando
1509 se suba ese pin.
1510
1511#### El estado de la mudanza tras esto
1512
1513| | |
1514|---|---|
1515| **mudado hoy** | `api.sergio.gioser.net` (DNS: CNAME→`www` **borrado**, A→`2.29.29.217` TTL 60) |
1516| **pendiente inmediato** | apagar el `sergioh_backend` de gioser **cuando propague** (OpenRC *y* arje: `rc-update del` + `rc-service stop` + `arjectl stop openrc-…`, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado |
1517| **lo que falta de `sergio.gioser.net`** | el frontend ya está copiado en `/work/www/sergioh`; falta `/shuma/*` → `shuma-gateway` (:7378), que es glibc dinámico y **cabe en esta misma jaula** (el binario está rescatado en `/work/rescate-binarios/`) pero cuelga de `shuma-daemon`, que es un sistema entero, no un binario |
1518| **y los otros dos vivos** | `gioser.net`/`www` (estáticos + `/hooks/*` en :8770 + `/reencuentro` con php-fpm) y `tawasuyu.net` (estático, raíz en `/mnt/vvv/tawasuyu`) |
1519
1520### 6.26 ✅ `tawasuyu.net` y `gioser.net` MUDADOS — y el PHP que no existe en el corpus *(2026-09-15)*
1521
1522**Los dos sitios que quedaban con tráfico real sirven desde la caja `takana`**, con TLS público y
1523verificados desde fuera. Los servicios viejos **siguen corriendo en gioser a propósito** (decisión del
1524usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).
1525
1526| dominio | qué es | estado |
1527|---|---|---|
1528| `tawasuyu.net`, `www.` | landing + `/pkg` wasm + `/descargas` (3,8 G) | **200 desde 2.29.29.217** |
1529| `gioser.net`, `www.` | 5 raíces estáticas (428 M) + `/reencuentro` con PHP | **200 desde 2.29.29.217** |
1530| `sergio.gioser.net` | frontend + `/shuma/*` | **se queda en gioser**: su consola no tiene camino todavía |
1531
1532**`sergio` dejó de ser un `CNAME → www` y tiene su A propio** (apuntando a gioser, sin cambio
1533visible). Sin eso, mover `www` se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo:
1534en esta zona **casi todo cuelga de `www`**. Lo que sí quedó colgando son tres fósiles (`api`,
1535`mail.sigma`, `monitor`): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están
1536muertos hace meses; se anotan, no se mudan.
1537
1538**Dos cosas que el log de accesos decidió mejor que cualquier intuición:**
1539- La raíz de `tawasuyu.net` en gioser era **el monorepo entero (145 G) servido por HTTP**. El log
1540 dice que las únicas rutas con tráfico son `/`, `/descargas` y `/web` ⇒ se replicaron **sólo** los
1541 dos subárboles que el sitio usa (1,2 M + 3,8 G), con la MISMA estructura para que cada `rewrite`
1542 siga siendo el mismo.
1543- **`/hooks/*` NO se muda**: lo atiende `webhook-deploy.py`, que redespliega `aura_*`, `sigma_*`,
1544 `summa_*` y `brahman` — **los cuatro son fósiles del §6.19**, y tiene cero peticiones. Muere con la
1545 caja vieja. Un servicio vivo que sólo sirve a muertos es basura, no deuda.
1546
1547#### PHP: la segunda jaula, y por qué no entra al corpus
1548
1549`/reencuentro/` es una página con **un** `guardar.php`. PHP no está en el corpus y no tiene por qué
1550estar: lo sirve **`php-fpm 8.5.10` dentro de la instancia `gioser-php`** (ADR 0015), por
1551`127.0.0.1:9000`, supervisado por arje. Control contra el original, en los dos sentidos: `POST` con la
1552trampa anti-robots → `{"ok":true}` 200 igual que en gioser; `GET` → **405** igual que en gioser.
1553
1554⚠ **El webroot entra a la jaula EN LA MISMA RUTA** (`/work/www/gioser-web`): el `SCRIPT_FILENAME` de
1555FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta
1556«File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN
1557(`/work/etc/gioser-php/php-fpm.d/`) y entra por concesión: el `upper` de la instancia es caché y
1558`recreate` se la llevaría.
1559
1560#### 🧨 Los padres que inventa bwrap son **0700** — y eso deja la concesión fuera de alcance
1561
1562El muro real del día, y es general, no de PHP:
1563
1564 $ curl -X POST https://gioser.net/reencuentro/guardar.php
1565 File not found. ← y el fichero ESTÁ, montado, con sus modos correctos
1566
1567Para montar en `/work/www/gioser-web`, bwrap tiene que crear `/work` y `/work/www` —que la imagen de
1568Arch no trae— y **los crea `drwx------ root`**. Adentro somos root, así que todo a mano funciona; pero
1569**un servicio que baja de privilegio** (php-fpm a `http`, o cualquier instancia con `run_as`) no puede
1570ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como
1571`http`, leer un fichero **de la imagen** funciona y leer el **directorio concedido** da
1572`Permission denied`.
1573
1574⇒ `grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y **sólo los que la
1575imagen no trae**: hacerlo sobre `/etc` o `/home` le cambiaría los modos a la imagen. Con test que
1576falla a propósito si la condición se rompe (`left: ["/etc","/etc/php"]` contra `right: ["/etc/php"]`).
1577Misma familia que el `--perms 1777` que ya hacía falta antes del `--tmpfs /tmp`.
1578
1579⚠ La caja corre el `takana` del `commit` pineado en `recipes/takana.toml`, así que **allá el arreglo
1580todavía no está**: el camino se abrió a mano en el `upper` de la instancia (`chmod 755 /work
1581/work/www`). Es exactamente lo que el arreglo vuelve innecesario en el próximo `takana upgrade`.
1582
1583#### Y el estado de `rust` y de `claude`, que no es el que parece
1584
1585- **`rust` está en la caja pero no instalado**: `rust-toolchain-bin` está **sellado en el store** y
1586 declarado en `perfil.servidor`, y `rustc 1.97.0` corre invocándolo por su ruta del store — pero
1587 **no está en el `PATH`** porque la caja se instaló antes de esa declaración. Proyectarlo son 798 M
1588 sobre una raíz de **5,8 G con 2,9 G libres**: entra, pero el layout de la imagen (§6.24) merece la
1589 revisión primero.
1590- **`claude` no está mudado**, y es otro caso de jaula: el binario de gioser es **glibc dinámico**
1591 (`/lib64/ld-linux-x86-64.so.2`), o sea el mismo montón que el backend de `sergio`. Lo que pesa no
1592 es el binario sino su estado: **6,8 G de `~/.claude`**, de los cuales 5,3 G son `jobs` y 976 M
1593 `projects` — y ahí vive la memoria, **indexada POR RUTA del repo**. Mudarlo es una decisión sobre
1594 qué se lleva, no una copia.
1595
1596### 6.27 🧦 `squid` mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init *(2026-09-15)*
1597
1598**El proxy de salida sirve desde la caja**: `squid 7.7` en `2.29.29.217:1137`, con la config, el
1599`passwd` de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia `squid` y
1600supervisado por arje. **El de gioser sigue vivo e intacto** (7.6), así que los clientes que apuntan
1601a la IP vieja no se enteraron.
1602
1603Es un servicio con gente encima: el `access.log` del origen mostraba `CONNECT api.anthropic.com` y
1604`CONNECT claude.ai` **en el minuto anterior a mudarlo**, desde IPs de Venezuela y con tres usuarios
1605autenticados. Por eso se mudó entero y sin reescribir nada.
1606
1607Control en los dos sentidos: desde una IP no autorizada, **407 Proxy Authentication Required**
1608—igual que el original—; desde `localhost`, que su propia config permite, **TCP_TUNNEL/200 a
1609claude.ai y a api.anthropic.com**.
1610
1611#### Los cuatro muros, y el cuarto es el que vale
1612
16131. **`provision` con la config ya montada aborta**: pacman dice «squid.conf.default exists in
1614 filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar
1615 **primero**, montar **después**. Queda escrito en el manifiesto.
16162. **`/dev/shm` lo crea bwrap 0755 root**, y squid —que baja a `proxy`— muere con
1617 `FATAL: shm_open(...): (13) Permission denied`. Un `chmod 1777` en el arranque.
16183. **El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco**: cada `run` tiene su propio
1619 namespace de PIDs, así que el `/run/squid.pid` de la corrida anterior dice `PID 2`… y en la nueva
1620 hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ `rm -f` en el arranque.
1621 Es una trampa general de cualquier servicio con pidfile dentro de una jaula con `--unshare-pid`.
16224. 🧨 **El rango de subuid se buscaba por `$USER` — y un init no lo pone.** Bajo arje la tarjeta
1623 arranca con `PATH` y `HOME` y poco más; el nombre salía **vacío**, no había rango, y la jaula caía
1624 al **userns de un solo id**. Squid moría en `setgid(15): (22) Invalid argument` y el supervisor lo
1625 reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para `""` en
1626 /etc/subuid»— pero **lo que se ve en el bucle es el error de squid, no el nuestro: la degradación
1627 silenciosa la paga el de más abajo**. Y el mismo comando a mano, desde una shell con `$USER`,
1628 funcionaba perfecto: *el síntoma dependía de quién lo arrancaba*.
1629 ⇒ `nombre_de_usuario()` deriva el nombre del **uid real** leyendo `/etc/passwd`, y el entorno pasa
1630 a ser el último recurso. El uid es el hecho; `$USER` es una etiqueta ([[la-etiqueta-no-es-el-hecho]]).
1631 Las tarjetas llevan además `USER`/`LOGNAME` explícitos, que era el arreglo del día.
1632
1633⚠ **`arjectl start` sobre un Ente ya vivo lo DUPLICA** (se vio con `gioser-php`: dos entes, uno
1634peleando por el mismo `upper`). Y matar el proceso de adentro **no libera la instancia**: el
1635`takana qorpa run` de afuera sigue vivo y el siguiente `run` choca. Para relanzar, `stop` y después
1636`start`, mirando `status` en el medio.
1637
1638#### Y la pregunta que corresponde: ¿no va en una receta?
1639
1640**Sí, y sigue pendiente.** El propio §6.2 pone a `squid` en la lista de recetas que esta mudanza
1641tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en
1642minutos, sin compilar nada—, no la respuesta final. La diferencia con `php-fpm` importa:
1643**PHP no queremos que entre al corpus** (es un intérprete ajeno al proyecto, y la jaula es su lugar
1644definitivo), mientras que **`squid` es C, se compila con el lab y su sitio natural es una receta**
1645como la de `gitea`. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.
1646
1647### 6.28 📦 `squid` YA NO ESTÁ ENJAULADO: corre desde el corpus *(2026-09-15)*
1648
1649La jaula del §6.27 duró unas horas, que es lo que tenía que durar. **Hoy el proxy de producción es
1650el artefacto sellado `b3:932ba096…`**, proyectado en la caja y arrancado por arje sin bwrap de por
1651medio: `pid 11548 · uid=968 · exe=/usr/sbin/squid`. La instancia `qorpa/squid` se borró — el
1652manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.
1653
1654`recipes/squid.toml`: 7.7, estático de verdad (binario **y** los cuatro helpers), `[[user]] proxy`
1655con uid 968 y `[[service]]` del SDD 30. Declarado en `perfil.servidor` como paquete y en `servicios`.
1656
1657**La config NO viaja en el paquete y es a propósito**: `squid.conf`, el `passwd` de los tres usuarios
1658y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar
1659y sale **78 nombrando cuál falta**, en vez de dejar un bucle de reinicios.
1660
1661#### Los tres muros de la receta, en orden
1662
16631. **La URL de upstream devolvía una página HTML de 8985 bytes con un 200.**
1664 `squid-cache.org/Versions/v7/squid-7.7.tar.xz` no es el tarball. Pinear su sha256 habría anclado
1665 la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de
1666 GitHub, que además trae `configure` generado.
16672. 🧨 **`--export-dynamic` desactivaba el enlace estático, y el binario sellaba igual.** El primer
1668 artefacto tenía `usr/sbin/squid` **dinámico con `NEEDED libc.so`** mientras sus helpers salían
1669 estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a
1670 `link = "static"`. La cadena tiene tres eslabones y ninguno es obvio: `squid_LDFLAGS` trae
1671 `-export-dynamic` (para módulos eCAP, apagados) → **el wrapper del lab quita `-static` de
1672 cualquier enlace que mencione `--export-dynamic`**, a propósito, para poder enlazar las `.so`
1673 dlopen-ables de Python → y **libtool vuelve a añadirlo solo** en cuanto hay un `-dlopen`, así que
1674 sacarlo de la variable no alcanza. Se vacía `export_dynamic_flag_spec` en el `libtool` GENERADO,
1675 con guarda que aborta si el campo no aparece: *un `sed` que no acierta deja pasar el problema y
1676 el binario sale dinámico sin que nada falle*.
1677 ⚠ Y `-dlopen force` **no sobra**: vaciar `squid_LDFLAGS` entero rompe el enlace con
1678 `undefined symbol: lt__PROGRAM__LTX_preloaded_symbols`, que emite el propio libtool porque hay un
1679 `-dlopen`. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
16803. **`--disable-log-daemon-helpers` compilaba perfecto y no arrancaba.** El default de `access_log`
1681 es `daemon:`, así que sin `log_file_daemon` squid muere con
1682 `FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory`. **Sólo se
1683 ve levantando el servicio con la config real; el sellado, el hash y el `-k parse` pasan los tres.**
1684 Es la familia de [[subcomando-sin-driver]] otra vez.
1685
1686#### El control que hay que correr antes de tocar producción
1687
1688Con la config REAL del origen, en el hub y en un puerto aparte: `-k parse` sin FATAL, arranque
1689completo, **`CONNECT claude.ai` y `CONNECT api.anthropic.com` con TCP_TUNNEL/200**, el `access.log`
1690escrito por el log daemon, y el `basic_ncsa_auth` leyendo el `passwd` de verdad (control negativo:
1691con una clave mala contesta `ERR Wrong password`). Los dos primeros intentos de esta receta habrían
1692llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
1693
1694### 6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano *(2026-09-15)*
1695
1696`planear.py --revisar` sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
1697los servicios del usuario sino **tres capacidades que ninguna métrica reclama**: el disparador
1698periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
1699`perfil.servidor` desde que ese perfil nació — **lo que faltaba era el eslabón que los ARRANCA**.
1700
1701| | antes | ahora |
1702|---|---|---|
1703| `chronyd` | la caja iba **15 s atrasada** y nadie la corregía | stratum 3 contra `ntp{1,2,3}.hetzner.de`, **0,000005 s** de NTP |
1704| `crond` | no existía el latido | ejecuta el crontab real, verificado con una entrada `* * * * *` |
1705| `logrotate` | `/var/log/squid` sin rotar | diario con `squid -k rotate`, 14 copias |
1706| respaldo del gitea | **a mano, una vez** (§6.15) | `17 3 * * *`, con el snapshot consistente de la sqlite |
1707
1708**El respaldo, medido:** `scripts/respaldo-gitea.sh` sube **44 repos (23 `sergio` + 21 `tawasuyu`,
17091,5 G)** y un `gitea.db` de **332 M** tomado con `.backup` —consistente con el servidor vivo— al
1710Storage Box. El control no es que el script salga 0: es **contar los repos de los dos lados**, que
1711es lo que se hizo.
1712
1713⚠ **`chrony` y `cronie` NO declaraban `[[service]]`**, como `squid` antes: el paquete llegaba a la
1714imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron)
1715y `perfil.servidor` los habilita en `servicios`.
1716
1717⚠ **La config va aparte y el servicio sale 78 si falta**, igual que gitea: a qué NTP se pregunta y
1718qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
1719eligió es peor que una sin hora, porque nadie lo mira.
1720
1721#### Tres cosas medidas que valen más que el resultado
1722
17231. **La deriva era de gioser, no de la caja.** Antes de chrony la caja iba 15 s atrás; después, la
1724 caja quedó en hora de NTP y **el hub pasó a estar 3 s adelantado**. La máquina de referencia era
1725 la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
17262. **El spool de cronie es `/var/spool/cron/<usuario>`, no `…/crontabs/<usuario>`.** `crontab -l`
1727 mostraba las entradas y `crontabs/` estaba vacío. El día que alguien restaure un crontab a mano
1728 en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
1729 intuición: meter un `* * * * *` en el crontab REAL y ver si dispara.
17303. 🧨 **`sqlite3` seguía inerte en la caja** (`Error relocating: compressBound: symbol not found`):
1731 es el último de los 23 binarios del §6.4, y le faltaba `zlib-shared` — **sellado y declarado en
1732 `perfil.base` desde el 2026-09-11**, pero la caja se instaló antes. Sin él no había snapshot
1733 consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. *Un paquete declarado no
1734 es un paquete instalado: en una caja viva, lo que vale es lo que el `upgrade` proyectó.*
1735
1736### 6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía *(2026-09-15)*
1737
1738Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:
1739
1740| servicio | por qué muere |
1741|---|---|
1742| **`qdrant`** (`:6333`, `:6334`) | es la base vectorial de `gioser_api`, y **ese backend es un fósil**: `api.gioser.net` da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta `recipes/qdrant.toml` queda en el catálogo —**catálogo ≠ imagen**, y eso no es deuda—, pero no entra en ningún perfil. |
1743| **`act_runner`** (`:41027`) | el runner de CI del gitea, un binario suelto en `~/.local/bin` que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado. |
1744| **`fail2ban-server`** | **tawasuyu ya tiene el sustituto**, y es mejor: `shared/cortafuegos` (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa **dos sets dinámicos del kernel** (`ban4`/`ban6`, `flags dynamic`, `timeout`) con `limit rate over N/minute` **por IP de origen**. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor. |
1745
1746#### 🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae `nf_tables`
1747
1748 $ nft list ruleset
1749 netlink: Error: cache initialization failed: Invalid argument
1750
1751`nft 1.1.6` **está instalado** (`nftables` es raíz de `perfil.servidor` y está sellado). Lo que falta
1752está un piso más abajo, en el `.config` que el artefacto publica:
1753
1754 CONFIG_NETFILTER=y ← sí
1755 CONFIG_NF_CONNTRACK=y ← sí
1756 # CONFIG_NF_TABLES is not set ← AQUÍ
1757 # CONFIG_NETFILTER_ADVANCED is not set ← y ésta es la causa: NF_TABLES cuelga de ella
1758
1759Es otra vez la lección del §6.4 muro 1: **la receta dice lo que se CAMBIÓ; sólo el `.config` sellado
1760dice lo que QUEDÓ**. `linux-generic` sale de un `make defconfig`, y defconfig deja `NETFILTER_ADVANCED`
1761apagado, que arrastra a `NF_TABLES` con él.
1762
1763⇒ **Hoy la caja no tiene cortafuegos de ninguna clase**: ni fail2ban (que no se muda), ni el
1764sustituto (que no arranca), ni una regla. Con `:22022`, `:2345`, `:1137`, `:80` y `:443` públicos,
1765eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con
1766`CONFIG_NETFILTER_ADVANCED=y` + `NF_TABLES` (y `NFT_CT`, `NFT_LIMIT`, `NFT_COUNTER`, `NFT_LOG`),
1767que es trabajo del SDD 22 y termina en un **reinicio de la caja de producción**.
1768
1769#### La mitigación que NO depende del kernel, hecha hoy
1770
1771El `sshd` de la caja ofrecía `password` y `keyboard-interactive` — su config eran **cuatro líneas**
1772y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin
1773fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es **sólo claves**, con los
1774cuatro controles corridos: la clave entra ✅, `:22022` contesta `Permission denied (publickey)` sin
1775ofrecer contraseña ✅, `:2345` igual ✅, y el `git ls-remote` por SSH sigue funcionando ✅.
1776
1777**Lo que enseña:** *una defensa que se muda tiene que mudarse con su capacidad, no con su nombre*.
1778Decidir «fail2ban muere porque tenemos algo mejor» es correcto **y** deja un hueco abierto hasta que
1779ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.
1780
1781### 6.31 ✅ La caja arrancó con `nf_tables` — y el init la levantó ENTERA sola *(2026-09-15)*
1782
1783 $ nft list ruleset
1784 $ ← sin error: vacío, que es lo correcto en una caja sin reglas
1785
1786El kernel `b3:63fdd57c…` bootea, y con él **`nft` funciona por primera vez en una máquina takana**.
1787Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un
1788`set ... flags dynamic; timeout 10s` con `add @ban4 { ip saddr timeout 10s limit rate over 5/minute }
1789drop` — aceptado por el kernel (`rc=0`) y releído tal cual. Eso es el ban por IP **dentro del
1790kernel**, sin daemon.
1791
1792#### Lo que el reinicio probó de paso, y vale más que el kernel
1793
1794**La Semilla levantó los OCHO entes sola, todos con `↻ 0`:** `sshd`, `gitea`, `caddy`, `squid`,
1795`crond`, `chronyd`, `sergioh-api` y `gioser-php` — o sea los dos servicios enjaulados en `qorpa`
1796(con su `newuidmap`, su overlay y su `harkaq`) **arrancan desde el genesis igual que un binario
1797nativo**. Todo lo que se fue editando en `/ente/seed.card.json` a lo largo del día era, hasta este
1798momento, una declaración sin probar: un reinicio es el único examen que toma.
1799
1800Desde fuera, al minuto: los siete dominios en **200**, el proxy en **407** y `git ls-remote` por
1801SSH:2345 devolviendo el commit. Cero intervención.
1802
1803#### ⚠ `reboot` NO reinicia una caja con arje-zero
1804
1805`reboot` de busybox le pide a PID 1 que lo haga, y **arje-zero no implementa ese protocolo**: el
1806comando vuelve, no dice nada, y la máquina sigue arriba (`up 4 days` después de «reiniciar»). Lo que
1807sí funciona es **`reboot -f`**, que llama a `reboot(2)` directo saltándose al init. Con `sync` antes,
1808claro: el corte es duro por definición.
1809
1810⇒ **Y falta la pieza**: un init que gobierna un servidor de producción tiene que atender un apagado
1811ordenado. Hoy la única vía es el corte duro, que ya costó el `write_atomic` no durable del §6.13.
1812
1813#### 🧨 Casi reinicio el HUB por un backtick en un `echo`
1814
1815Escribiendo el aviso del reinicio, el mensaje decía ``… no atiende el `reboot` normal …`` dentro de
1816comillas DOBLES. La shell ejecutó lo que había entre backticks: **`reboot`, en gioser**. No pasó nada
1817por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»—
1818y el hub lleva 11 días arriba, con la granja y el cron encima.
1819
1820Es la **tercera vez en el día** que un backtick dentro de un texto se ejecuta
1821([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario.
1822La regla deja de ser de estilo: **nunca un backtick dentro de comillas dobles en una shell** —
1823comillas simples, o `$(printf '%s' …)`, o directamente no poner el acento grave en el texto.
1824
1825#### Lo que queda para cerrar el frente del cortafuegos
1826
1827El kernel ya no estorba; falta **la política**: `PoliticaEntrada` (.ron) → `generate-input` →
1828`apply-input` del cortafuegos de tawasuyu, con los puertos reales de la caja (`22022`, `2345`, `80`,
1829`443`, `1137`). ⚠ Y hay que aplicarla con **interruptor de hombre muerto** (aplicar, esperar, y
1830`flush` automático si nadie confirma): una regla mal puesta en una caja **sin consola serie** deja
1831la única salida en `hcloud server enable-rescue`.
1832
1833### 6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo *(2026-09-15)*
1834
1835La caja filtra: `table inet tawasuyu_input`, **`policy drop`**, 15 reglas de puerto, y el set `ban4`
1836llenándose solo con lo que golpea desde internet. A los pocos minutos: **27 IPs baneadas y paquetes
1837tirados por el contador**. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del
1838kernel y sin daemon.
1839
1840La política vive en `scripts/servidor/cortafuegos-entrada.ron` (formato `PoliticaEntrada` del
1841cortafuegos de tawasuyu) y declara los cinco puertos públicos: `22022` admin, `2345` git, `80`, `443`
1842y `1137` el proxy. El reglaset se genera con `cortafuegos generate-input` **en el hub** —el binario es
1843glibc y la caja es musl— y se aplica allá con `nft -f`.
1844
1845#### 🧨 El primer baneado fui yo, en segundos
1846
1847Aplicada la primera versión, desde fuera: `ssh` ✅, `https` ✅… y **`http`, el proxy y el git por SSH
1848muertos**. Al minuto, la caja entera dejó de contestarme.
1849
1850La causa no es una regla mal escrita: **el set `@ban4` es UNO SOLO para todos los servicios**, y la
1851regla `ip saddr @ban4 drop` está antes que todo. O sea que pasarse de tasa en *un* puerto te tira en
1852*todos*. Y el `nuevas_por_min: 5` del puerto de administración —correcto contra internet— es
1853exactamente lo que **me** pasa a mí: cada comando remoto de esta sesión es una conexión nueva.
1854
1855Lo que devolvió la máquina fue el **interruptor de hombre muerto**: antes de aplicar se deja
1856programado un `nft flush ruleset` a 180 s, y sólo se desarma si la verificación desde fuera sale
1857bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.
1858
1859⚠ **Y el tipo ya lo avisaba.** `PoliticaEntrada` tiene un campo `confiables` cuyo comentario dice,
1860palabra por palabra, lo que me acababa de pasar: *«un `nuevas_por_min` chico en el puerto de SSH es
1861exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre
1862seis sesiones en un minuto sos vos»*. Copié el `.ron` de ejemplo en vez de leer el tipo. Es
1863[[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea:
1864`confiables: ["204.168.193.248", "154.197.1.13"]` — el hub y el worker, que se aceptan **antes** de
1865la lógica de tasa y por lo tanto no pueden entrar al set.
1866
1867#### Lo que este kernel todavía no puede, dicho en voz alta
1868
1869`nft -c` rechazó **cinco reglas** antes de aplicar nada:
1870
1871 tcp dport 22022 ct count over 10 drop
1872 ^^^^^^^^ Error: Could not process rule: No such file or directory
1873
1874`ct count` es `CONFIG_NFT_CONNLIMIT`, que el kernel de hoy no trae (el `.config` lo dice:
1875`# CONFIG_NFT_CONNLIMIT is not set`). Es el tope de conexiones **concurrentes** por servicio — el ban
1876por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan
1877**comentadas, no borradas**, en el fichero que se aplica: *lo que la política declara y el kernel no
1878puede tiene que verse*. El símbolo ya entró a `recipes/linux-generic.toml` y vuelve con el próximo
1879kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.
1880
1881#### Que sobreviva al reinicio: un servicio `oneshot`
1882
1883`recipes/nftables.toml` declara ahora su `[[service]]` con `lifecycle = "oneshot"`: **cargar un
1884reglaset no es un daemon, es un acto**. Va **primero en el genesis**, antes que sshd y los demás:
1885entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más
1886corto posible. Probado de verdad — `nft flush ruleset` y después `arjectl start cortafuegos`: las 15
1887reglas vuelven.
1888
1889Su guarda sale **78 nombrando el fichero** si el reglaset no está, en vez de dejar la caja sin
1890filtrar creyendo que está protegida: *un cortafuegos ausente y uno vacío se ven igual desde afuera
1891hasta que alguien prueba*.
1892
1893### 6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó *(2026-09-16)*
1894
1895El usuario preguntó si el proxy estaba caído. **No lo estaba**: el ente `squid` llevaba horas
1896`corriendo` con `↻ 0`, escuchando en `:1137` y contestando `407` a quien preguntara. Lo que estaba
1897caído era **el acceso**: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.
1898
1899La prueba, en una línea: **`154.197.1.2` —una IP que está en la ACL de squid, o sea gente con
1900contraseña— apareció en `@ban4`**. Y el contador del ban iba en **38.246 paquetes tirados**.
1901
1902#### La causa no es la tasa: es la RÁFAGA
1903
1904 tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop
1905
1906`burst 5` significa «tolero cinco conexiones por encima del promedio». **Un navegador o un agente
1907detrás de un proxy abre decenas de CONNECT en el mismo instante**, aunque su promedio sea bajísimo:
1908seis de golpe y ya estás en el set. Y como **`@ban4` es UN SOLO set consultado antes que todo**,
1909caer por el puerto del proxy te tira también el web y el git.
1910
1911⚠ El tipo del cortafuegos ya traía el campo (`rafaga`, default 5) con un comentario que decía
1912«para un servicio web va alto (100-200)». Otra vez: **leí el `.ron` de ejemplo y no el tipo**.
1913
1914#### Lo que se hizo, en orden de urgencia
1915
19161. **`nft flush set … ban4/ban6`** — servicio restaurado en el acto.
19172. **Los usuarios del proxy a `confiables`**: la misma lista que la ACL de squid (21 IPs + 5 redes).
1918 Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido;
1919 lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
19203. **`rafaga` por servicio, según su clientela**: ssh 5 · git 20 · web 100 · **proxy 200**. Y el
1921 proxy pasa a `1200/min` con ban de 60 s: red de contención contra un flood, no control de uso.
19224. Control después: **cero autorizados en el set**, 24 baneados (escáneres, que es lo que se quiere),
1923 y tráfico real pasando — `154.194.14.37 … CONNECT api.deepseek.com:443 sigma`, 298 K tunelados.
1924
1925⚠⚠ **Y un fallo de método que casi lo tapa: `nft -f` SUMA a la tabla existente.** Cargué el reglaset
1926corregido encima del viejo y quedaron **30 reglas**: las viejas ADELANTE, así que los `confiables`
1927nuevos no llegaban a evaluarse nunca. Se vio contando (`grep -c dport` daba 30 y no 15) y mirando
1928qué `saddr` salía primero. Por eso `cortafuegos apply-input` **borra la tabla antes de cargar** —
1929usar `nft -f` a pelo se saltea esa mitad.
1930
1931#### La hora exacta, reconstruida del `access.log`
1932
1933El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —`45.234.61.160`,
1934autenticada como `sergio`, **2709 peticiones**— tuvo estos silencios:
1935
1936| silencio | desde | hasta |
1937|---:|---|---|
1938| **93,2 min** | 16-09 **11:59:37** UTC | **13:32:49** UTC ← cuando vacié los sets |
1939| 50,1 min | 10:44:58 | 11:35:06 |
1940| 10,0 min | 11:45:06 | 11:55:05 |
1941
1942Los tres son el **`timeout` de 10 minutos del ban disparándose una y otra vez**: cada vez que volvía,
1943su primera ráfaga lo baneaba de nuevo. `154.197.1.2`, que usa el proxy de forma sostenida pero sin
1944ráfagas, sólo perdió huecos de 5 minutos.
1945
1946🧨 **Y la exención que tenía no servía: su IP no estaba en la red eximida.** La ACL histórica de squid
1947exime `45.234.60.0/24` y él entra desde `45.234.61.160` — **una red de al lado**. Parecía cubierto y
1948no lo estaba: la pertenencia se MIDE (`ip in red`), no se deduce del parecido del prefijo. Con el
1949`/23` que nft dedujo al unir los dos `/24`, ahora sí.
1950
1951#### Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa
1952
1953El uso real de esta caja es **intensivo** —varias sesiones de agente en paralelo, cada una abriendo
1954túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el
1955atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y
1956contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado
1957(60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope
1958GLOBAL `syn_nuevas_por_seg`, que no distingue usuarios ni banea a nadie por ser rápido.
1959
1960#### Lo que esto enseña del cortafuegos como herramienta
1961
1962Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia **no se ve al
1963aplicarlo**: las reglas cargan, el `nft -c` pasa, los sitios responden, y el daño aparece sólo cuando
1964un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es
1965el que no corrí: **una ráfaga de conexiones desde una IP que NO esté en `confiables`**.
1966
1967### 6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M
1968
1969Censo fresco de gioser (`censar.py --local --probe-dns`, sólo lee), para saber **qué falta de
1970verdad** en vez de arrastrar la lista del 11.
1971
1972```
1973 servicios vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
1974 dominios apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
1975 datos /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M
1976```
1977
1978**De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: `sergio.gioser.net`.**
1979Comprobado desde fuera, resolución + HTTP, no por el fichero de config:
1980
1981| dominio | resuelve a | HTTP |
1982|---|---|---|
1983| `tawasuyu.net` · `gioser.net` · `git.tawasuyu.net` · `takana.gioser.net` · `hifas.gioser.net` · `api.sergio.gioser.net` | `2.29.29.217` | 200 |
1984| `gitea.gioser.net` | `2.29.29.217` | 301 |
1985| **`sergio.gioser.net`** | **`204.168.193.248` (gioser)** | 200 |
1986| `api.summa.gioser.net` | `154.197.1.2` (otra máquina) | — |
1987| `terapeuta.ec`, `andino.ec`, `api.sigma…` y 4 más | **sin DNS** | — |
1988
1989⇒ la lista de «tres vhosts» del §9 quedó vieja: `gioser.net` y `tawasuyu.net` ya están mudados
1990(§6.26) con su origen vivo a propósito. **Lo único que ata el DNS a gioser es la consola de `sergio`.**
1991
1992#### El rescate del §6.20 sigue vigente — y caduca de verdad
1993
1994Seis procesos corren hoy desde un **inodo borrado**. Cuatro tienen su copia en
1995`work/mudanza/rescate/` y el sha256 del `/proc/<pid>/exe` vivo **coincide bit a bit** con lo
1996rescatado el 12. Los otros dos (`/usr/local/bin/tejido` y `/usr/local/bin/shuma-daemon`) **no están
1997en el rescate**, pero en disco hay una versión **más nueva** de cada uno (hoy 11:13 y 14:20): no se
1998perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: *ese
1999paso caduca, y quién caducó se ve mirando `/proc`, no el directorio*.
2000
2001#### 🕳️ Y el hallazgo del día: `-e` contesta «no existe» cuando lo que pasa es «no puedo mirar»
2002
2003El censo tiene tres estados a propósito (`ausente` / `sin_permiso` / `ok`) porque un `ls` sin
2004permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba **sobre la
2005propia ruta**: cuando el que no deja pasar es un **ANCESTRO**, `[ -e "$p" ]` contesta que no, y la
2006ruta caía en `ausente` — que el censo **descarta sin decir nada**.
2007
2008```
2009 /root/.local drwx------ root root ← 0700
2010 /root/.local/bin 572 M de binarios a mano que NINGÚN paquete provee
2011```
2012
2013Corrido como `sergio`, el censo decía que `/root/.local/bin` no existe. Corrido como root, son
2014**572 M** de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano.
2015Arreglado —si un ancestro existe y no es atravesable, el estado es `sin_permiso`— y probado en los
2016dos sentidos, que es lo que distingue un guardián de un adorno:
2017
2018```
2019 ancestro 0700 de otro dueño → sin_permiso (y sale en el ⚠ del resumen)
2020 ruta que NO existe de verdad → ausente (y NO se reporta) ← el control que tiene que pasar
2021```
2022
2023Con el arreglo, el censo como `sergio` pasa de **2 rutas ciegas a 13** — entre ellas el home entero
2024de `artix`, que antes no figuraba de ninguna manera. **El censo se corre como root**; la lista de
2025ciegas es el recibo de lo que una corrida sin privilegio no puede ver.
2026
2027#### Lo que queda, y por qué no lo decide el programa
2028
2029Descontados los que ya se decidieron (§6.30: `qdrant`, `act_runner`, `fail2ban` mueren) y los que
2030provee el init del destino, lo que sigue vivo en gioser es **un montón de daemons de tawasuyu que
2031ningún paquete provee** — binarios a mano en `/usr/local/bin` (1,2 G) y `~/.local/bin` (765 M + 572 M
2032en el home de root):
2033
2034```
2035 shuma-daemon · shuma-gateway (:7378) la consola de sergio.gioser.net — el único vhost que queda
2036 willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
2037 matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
2038 puerta-f6e393ffbef3999e (:34221,:44961) binario de test en target/debug, BORRADO del disco
2039 webhook-deploy.py
2040```
2041
2042`planear.py --revisar` los saca con su motivo, pero **la decisión es del usuario y el programa no la
2043va a inventar**: son las herramientas propias del usuario, no servicios de un catálogo. Y los
2044**datos** tampoco se recomiendan solos (§3): `/home` son 25 G que incluyen 6,8 G de `.claude` y
20451,3 G de binarios sueltos.
2046
2047### 6.35 🧳 Los binarios sueltos, desglosados — y qué de `.claude` viaja *(2026-09-16)*
2048
2049Decisión del usuario sobre el §6.34: **shuma se construye desde tawasuyu** (ya está encolado:
2050`recipes/incoming/shuma-{gateway,daemon}.toml`), **los daemons de tawasuyu viven todos**, y de
2051`.claude` decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.
2052
2053#### Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable
2054
2055| clase | cuánto | qué es | se reconstruye |
2056|---|---|---|---|
2057| **tawasuyu** | 57 ficheros · **1003 M** | `pata-llimphi` 105 M, `nahual-shell-llimphi` 98 M, `shuma-shell-llimphi` 76 M, los 14 `mirada-*`, `pata-*`, `shuma-*`, `sandokan-*`, `willay-*`, `pacha`, `tejido`, `tupu`, `matilda`, `thasnuna`… | **sí**: son salida del monorepo, que ya vive en el gitea de la caja |
2058| **terceros** | 10 ficheros · **841 M** | `kiro-cli*` 571 M (tres binarios), `kcl` 95 M, `qdrant` 91 M (muere, §6.30), `gitea-runner` 21 M, `act_runner` 21 M (muere), `listmonk` 19 M, `hcloud` 18 M, `goaccess` 3,4 M | no, pero **se vuelven a bajar de su origen** |
2059| **respaldo / duplicado** | 21 ficheros · **876 M** | `/root/.local/bin` es **una copia entera de `kiro-cli*`** (571 M) más el CLI de `claude` (219 M); el resto son `.respaldo-FECHA`, `.previo` y `.bak` | no hace falta |
2060| **guiones** | 27 ficheros · **< 1 M** | `git-deploy`, `webhook-deploy.py`, `update-squid-allowed-ips.sh`, `mirada-session*`, `huellita-aviso`… | **NO** ← lo único de verdad frágil |
2061
2062⇒ **lo caro no es lo valioso**. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no
2063está en ningún repo ni en ningún paquete son **27 guiones que suman menos de un megabyte** — el
2064pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.
2065
2066Y los **572 M de `/root/.local/bin`** que el censo no veía (§6.34) resultaron ser exactamente eso:
2067la copia de `kiro-cli*` que ya está en el home de `sergio`. La lección no cambia —un censo que dice
2068«no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era
2069duplicado.
2070
2071#### `.claude`: 6,8 G en disco, **15 M es lo que hace falta para seguir funcionando igual**
2072
2073Criterio, porque es lo que me deja trabajar allá como acá:
2074
2075| viaja | qué | tamaño |
2076|---|---|---|
2077| ✅ | `projects/*/memory/` de los 18 proyectos — la memoria destilada | **6,2 M** |
2078| ✅ | `settings.json`, `plugins/`, `history.jsonl` | ~8,6 M |
2079| ⛔ | `jobs/` (5,3 G), `downloads/` (228 M), `archivo-sesiones/` (286 M), `file-history/`, `shell-snapshots/`, `cache/`, `paste-cache/` | 6,1 G |
2080| ⚠ | `projects/*/*.jsonl` — los transcripts crudos (973 M) | ver abajo |
2081
2082Los **transcripts no hacen falta para funcionar**: la memoria es su destilado y es lo que se lee al
2083arrancar una sesión. Lo que se pierde sin ellos es `--resume` de una sesión vieja y la posibilidad
2084de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en
2085`/work` y los quiere para el store— sino al **Storage Box del respaldo**, que es donde vive lo que
2086se guarda por si acaso. La memoria, en cambio, viaja con la máquina.
2087
2088#### ⚠ Y una trampa de ruta que haría todo esto inútil
2089
2090**La memoria se indexa POR RUTA**: el directorio se llama `-mnt-vvv-takana` porque el repo vive en
2091`/mnt/vvv/takana`. En la caja el repo está en **`/opt/takana`** ⇒ copiar `memory/` tal cual deja los
2092163 ficheros en disco y **ninguno se encuentra**, sin que nada falle. Las dos salidas son renombrar
2093el directorio a la ruta de destino (`-opt-takana`) o darle al repo la misma ruta que acá. Se elige
2094al copiar, no después.
2095
2096#### 🕳️ La caja no tiene usuario `sergio`
2097
2098`/etc/passwd` de `2.29.29.217`: `root`, `sshd`, `gitea`, `proxy`. Nada más. En gioser,
2099`shuma-daemon` corre como `sergio` —y `shuma-gateway` también—, así que la tarjeta de arje que los
2100declare tiene que **crear la cuenta primero**; y el payload `Native` de arje **no tiene campo de
2101usuario** (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la
2102misma advertencia que ya lleva `recipes/incoming/shuma-daemon.toml` en su cabecera, escrita donde se
2103va a leer.
2104
2105### 6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO *(2026-09-16)*
2106
2107Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. **Cada copia se
2108verificó del lado del destino**, que es la regla que el `rsync` con exit 0 y 1367 artefactos vacíos
2109dejó escrita (§6.13, y la regla 2 de `aplicar.py`).
2110
2111| qué | a dónde | verificación en destino |
2112|---|---|---|
2113| **los 27 guiones** (75 KB) | `takana:/work/rescate-guiones/` | `sha256sum -c SHA256SUMS` ⇒ **27 OK** |
2114| **la memoria de Claude** (733 ficheros, 6,3 M) + `settings.json`, `plugins`, `history.jsonl` | `takana:/root/.claude/` | 733 ficheros en origen y **733 en destino** |
2115| **los transcripts crudos** (1 966 ficheros, 1,3 G) | Storage Box `claude-gioser/` | `rsync -an` de vuelta: **1 solo fichero pendiente**, y es el transcript de la sesión que estaba escribiéndolo |
2116| **la clave PRIVADA de release** | `takana:/root/.config/takana/keys/` 0600 | sha256 idéntico en los dos lados **y** su pública == `trust/release.ed25519.pub` |
2117| credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile) | `takana:/work/mudanza-secretos/` 0700 | 27 ficheros en origen y **27 en destino** |
2118| estado de los daemons que viven (`sigma`, `tupu`, `willay`, `sandokan-watch.json`) + `/var/www/para-respaldar` | `takana:/work/mudanza-estado/` | 351 ficheros y 68 M en los dos lados |
2119
2120#### ⚠ Las credenciales NO se instalaron en su sitio, a propósito
2121
2122La caja **ya tiene** su `/root/.ssh` (con `github5`), su crontab (el latido de la caja, §6.29) y su
2123`/etc/caddy/Caddyfile` con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de
2124gioser rompe lo que hoy funciona. Quedan en `/work/mudanza-secretos/` con su `LEEME.txt`, y se
2125instalan **cuando se mude el hub**, que es otra unidad de trabajo. El `.gitconfig` es el caso más
2126claro: trae los `insteadOf` que **reescriben remotos**, así que instalarlo cambia a dónde empuja
2127`git push` sin que nadie lo pida.
2128
2129La **única** excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede
2130volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que
2131alguien publica.
2132
2133#### 🔍 Un «576 M contra 1,3 G» que parecía una copia a medias
2134
2135`du -sh` en el Storage Box decía **576 M** de los 1,3 G subidos. Con el antecedente del rsync que
2136llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box **comprime**, y su shell
2137restringida no tiene `find` ni acepta tuberías, así que el conteo de ficheros —la verificación
2138obvia— ahí no se puede hacer. Lo que sí se puede es **preguntarle a rsync**: una corrida `-an`
2139compara tamaño y fecha contra el destino real y dijo **1 fichero pendiente**, el que estaba
2140creciendo mientras se copiaba.
2141
2142⇒ *la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina*. Y un
2143número que no cuadra merece una segunda medición antes de un diagnóstico: `du` medía otra cosa.
2144
2145#### Lo que NO se movió, y no es olvido
2146
2147**~10 G de árboles personales en `/home/sergio`** —`android-sdk` 1 G, `maps`, `imm`, `humanoid`,
2148`hifas`, `fdroid`, `valens-conversion`, `pyswisseph`, `accordion`…— y `/opt` (1,2 G, que es
2149`android-sdk` otra vez y `google`). **Qué datos valen no se deduce de la máquina** (§3), y éstos son
2150proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha
2151por criterio ajeno.
2152
2153De `/var/lib` (8,1 G) se movió sólo el estado de los daemons que viven: **6,1 G son swap** y 1,9 G
2154el gitea, que ya está mudado desde el §6.14.
2155
2156### 6.37 ✅ CUTOVER DE `sergio.gioser.net` — el último vhost, y gioser deja de servir *(2026-09-16)*
2157
2158**Ya no queda ningún dominio sirviéndose desde gioser.** El último era éste, y lo que lo ataba no
2159era el sitio sino su consola: `/shuma/*`. Verificado desde fuera, contra la IP nueva:
2160
2161```
2162 https://sergio.gioser.net/ → 200 408 bytes, la SPA
2163 https://sergio.gioser.net/shuma/ → 200 «shuma-gateway ok» ← el gateway, no la SPA
2164 https://sergio.gioser.net/ruta-inventada → 200 la SPA (el fallback, que es lo correcto)
2165```
2166
2167⚠ **El `200` de `/shuma/` había que mirarlo dos veces.** El bloque termina en
2168`try_files {path} {path}/ /index.html`, así que **cualquier ruta inexistente devuelve 200 con la
2169SPA**: un `curl -o /dev/null` que sólo mira el código habría dado el mismo verde con el proxy mal
2170puesto. Lo que decide es el CUERPO — 17 bytes que dicen `shuma-gateway ok` — y el control es
2171compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con `/shuma/rpc`: las
2172dos máquinas contestan el MISMO `400 {"error":"bad json: …"}`.
2173
2174#### Lo que hubo que construir, en orden
2175
21761. **Las dos recetas** (§6.34) sellaron en el worker: `b3:107dfb54…-shuma-gateway` (6,3 M) y
2177 `b3:f9b92acc…-shuma-daemon` (5,6 M) — los hashes que `takana hash` había anticipado antes de
2178 encolarlas. `file` dice **`statically linked`**: nada de cargador, que era exactamente lo que le
2179 faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
21802. **La cuenta, declarada y sin privilegio.** `[[user]] shuma` (uid 967) en la receta del daemon, y
2181 el descenso con `setuidgid` dentro del `argv` de la Card, igual que gitea — porque el payload
2182 `Native` de arje **no tiene campo de usuario**. Declararlo «como estaba» lo habría puesto de
2183 root, y esto es un demonio que abre PTYs y lanza shells.
21843. **⚠ Y la shell de la cuenta NO es un detalle.** El default de `[[user]]` es `/bin/false`, que es
2185 lo correcto para gitea o squid y **exactamente lo contrario** para éste: con la shell inerte el
2186 servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante.
2187 Un fallo que no se ve al desplegar: se ve al usarlo. Va `shell = "/bin/sh"`.
21884. **La config que no se regenera.** El `gateway-token` (32 B) y `keys/identity.x25519` viajaron a
2189 `/var/lib/shuma/.config/shuma/`: generar unos nuevos deja fuera a los clientes ya emparejados.
2190 Por eso la guarda de la Card **exige** el token y no lo crea, y dice de dónde sale.
21915. **Las dos Cards** entran en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
2192 (son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla
2193 quedó con 13 entradas y vuelve a parsear.
21946. **Declarado en `perfil.servidor`**: los dos paquetes y los dos labels en `servicios`. Entran como
2195 PAR — el gateway sin el daemon es un puente a ninguna parte.
2196
2197`[[user]]` y `[[service]]` están **fuera de `hash_inputs`**, así que declarar todo eso no movió
2198ningún ArtifactHash: comprobado en los dos, antes y después.
2199
2200#### 🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado
2201
2202Aquel cutover dejó escrito «un `PUT` sobre el rrset no puede cambiar el tipo: hay que `DELETE` del
2203CNAME y `POST` del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el `PUT` tampoco
2204sirve —`can't update records with this endpoint`— pero existe una acción que lo hace **atómica**:
2205
2206```
2207POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records {"records":[{"value":"…"}]}
2208```
2209
2210Sin ventana sin registro, que es lo que sí tiene el `DELETE`+`POST`. Con TTL 60 la resolución
2211pública cambió **en menos de 8 segundos**, y volver atrás es la misma llamada con la IP vieja.
2212
2213#### El origen sigue vivo, a propósito
2214
2215Como en §6.26: en gioser `shuma-daemon`, `shuma-gateway` y su bloque de caddy **siguen corriendo**.
2216Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras
2217la máquina exista. Lo que ya no existe es la dependencia: **ningún dominio resuelve a gioser**.
2218
2219⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra
2220`shuma-askpass` («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es
2221otra receta — no bloquea la consola.
2222
2223### 6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja *(2026-09-16)*
2224
2225De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:
2226
2227| | cuenta | qué hace, comprobado |
2228|---|---|---|
2229| `matilda` | **root** | lee el access log de caddy y archivó **78 ficheros** en `/var/lib/tupu/takana/` |
2230| `pacha` | `pacha` (965) | vivo, 0 reinicios, con SUS reglas (`reglas.ron`), no las de fábrica |
2231| `pacha-secretos` | `pacha` | vivo, 0 reinicios — y su binario en gioser era un **inodo borrado** |
2232| `sandokan-watch` | root | **frenado a propósito**: no puede trabajar acá (ver abajo) |
2233
2234#### ⚠ Tres corren como ROOT, y está declarado en vez de heredado
2235
2236Lo natural sería una cuenta sin privilegio por servicio, como `shuma` o `gitea`. Con el trío de la
2237telemetría **no se puede**, y las dos razones son medidas:
2238
22391. `matilda` lee `/var/log/caddy/requests.json`, que caddy crea **`-rw------- root root`** y recrea
2240 igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
22412. `tupu`, `matilda` y `sandokan-watch` escriben en el MISMO `/var/lib/tupu`, y en gioser sus 322
2242 ficheros son `root:root`. Bajar uno solo deja **mezcla de dueños en un árbol compartido**: escribe
2243 el primero y los otros fallan al modificar.
2244
2245⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se
2246quiera: que caddy escriba con `mode 640` y que el árbol sea de una cuenta propia. `pacha` y
2247`pacha-secretos` sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.
2248
2249#### 🕳️ La caja se llamaba «(none)» — y eso se archivaba
2250
2251`matilda` archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a `/var/lib/tupu/**none**/`:
2252
2253```
2254 $ hostname → (none)
2255 $ cat /etc/hostname → no existe
2256```
2257
2258**Ninguna caja takana fija su hostname**: no lo hace `netup`, no está en el product-rootfs, no hay
2259`/etc/hostname`. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no
2260falla: archiva bajo `none`, y dos máquinas distintas archivarían en la misma carpeta.
2261
2262Puesto `/etc/hostname` + una Card **OneShot** `hostname` en el genesis, para que sobreviva al
2263reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en
2264`/var/lib/tupu/takana/`. Quedan 72 ficheros huérfanos bajo `none/`: son los 20 minutos anteriores.
2265
2266⇒ **lo correcto es que lo haga `netup`**, que es quien configura la identidad de red de la máquina;
2267la Card es la mitigación de hoy, y está dicho en su propia guarda.
2268
2269#### 🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve
2270
2271`sandokan-watch` arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo
2272si uno lo corre a mano:
2273
2274```
2275sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada
2276```
2277
2278En gioser lee el `auth.log` de un syslog que **en takana no existe** — no hay systemd ni syslog. Su
2279Card ahora lleva una guarda que sale **78** nombrando el problema, así que el servicio no queda
2280verde mintiendo; y **no entra al `genesis`**, sólo a `cards.d`, porque no tiene sentido que arranque
2281al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.
2282
2283#### Dos herramientas que no existen donde se prueba
2284
2285Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:
2286
2287- **`/dev/tcp/host/puerto` no existe en busybox `ash`** ⇒ un barrido de puertos contra gioser dio
2288 «cerrado» para los cinco, incluidos `:80` y `:443`, que sirven. Con `curl telnet://` el resultado
2289 es el contrario: `:2345` y `:443` **abiertos**, `:22` cerrado.
2290- **La expansión de llaves (`{a,b}`) tampoco** ⇒ un `ls /store/*-{matilda,pacha}` dijo que los
2291 artefactos no habían llegado. Habían llegado.
2292
2293Es la misma lección del §6.23 (`ps` y `dig` ausentes) y merece repetirse: *una prueba que no puede
2294pasar se ve igual que una que todavía no pasó*.
2295
2296### 6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» *(2026-09-16)*
2297
2298Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y **frenadas a propósito**,
2299cada una por un motivo distinto y con su guarda diciéndolo.
2300
2301| corriendo | | frenado | por qué |
2302|---|---|---|---|
2303| `matilda` · `tupu` | root, escriben `/var/lib/tupu/takana/` | `sandokan-watch` | en takana no hay `auth.log` ni journalctl |
2304| `pacha` · `pacha-secretos` | cuenta `pacha` (965) | `tejido` | **misma identidad libp2p que el de gioser** |
2305| `willay-daemon` | root, con su índice mudado | `willay-crosscheck` | necesita el roster de tejido |
2306| `shuma-daemon` · `shuma-gateway` | cuenta `shuma` (§6.37) | `thasnuna` | pide `claude` autenticado y `sandokan-mcp` |
2307
2308#### ⚠ La regla que sirvió para los vhosts NO sirve para una identidad
2309
2310Con los sitios web la pauta fue: **mudá el DNS y dejá el origen encendido**, así volver cuesta un
2311minuto (§6.26). Con `tejido` eso es exactamente lo que NO hay que hacer, y el README del propio
2312tejido lo dice: *«la clave que el roster atesta ES la identidad de transporte libp2p»*
2313(`~/.tejido/device.seed`). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio:
2314son **el mismo PeerId en dos sitios**, o sea dos impostores mutuos para el resto de la flota.
2315
2316⇒ `tejido` se **intercambia**: se apaga allá, se enciende acá, en ese orden. Está todo listo para el
2317intercambio —binario, cuenta `tejido` (964), identidad instalada 700 en `/var/lib/tejido/.tejido/`—
2318y su Card vive en `cards.d` pero **no en el `genesis`**, para que un reinicio no lo encienda solo.
2319
2320Y arrastra a otro: `willay-crosscheck` **no es independiente**. Corriendo su línea a mano:
2321
2322```
2323Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)
2324```
2325
2326Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.
2327
2328#### Lo que cada guarda evita
2329
2330- `thasnuna`: su `INSTALAR.md` —escrito hoy por el frente tawasuyu para esta misma mudanza— pide
2331 tres cosas, y dos no están: `sandokan-mcp` en el PATH («el agente contesta pero no tiene
2332 herramientas») y **`claude` instalado y AUTENTICADO** («no hay anfitrión»). El CLI de claude es un
2333 binario **glibc de ~300 M**: en una caja musl pide jaula qorpa, como `sergioh-api`.
2334- Y de ahí sale un hallazgo que vale para el respaldo entero: ese documento explica por qué el token
2335 no va en la Card, y el motivo está medido en la máquina vieja — **la Card `openrc-openclaw` de
2336 gioser lleva la API key de su proveedor EN CLARO dentro del JSON de `/etc/arje/cards.d/`**, que es
2337 un directorio que se respalda y se copia. Un secreto dentro de una Card viaja a todas partes.
2338- `willay-crosscheck`: `--label` **nombra a la máquina**. En gioser decía `momento`; copiarlo tal
2339 cual habría hecho que la caja nueva se anunciara con el nombre de la que se borra. Ahora sale de
2340 `hostname`, y si no hay, la guarda lo dice.
2341
2342#### 🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene
2343
2344Van con el §6.38 y ya son patrón, no anécdota:
2345
2346| la prueba | lo que dijo | lo que pasaba |
2347|---|---|---|
2348| `/dev/tcp/host/puerto` | los 5 puertos de gioser «cerrados» | busybox `ash` no tiene `/dev/tcp` |
2349| `ls /store/*-{a,b}` | «los artefactos no llegaron» | busybox no expande llaves |
2350| `find … -newermt "-2 minutes"` | «tupu no escribe hace 2 min» | tupu **sí** escribe: su `mtime` avanza cada 10 s |
2351
2352El de `tupu` es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene
2353**tamaño constante** (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba
2354nada. Lo que decide es el `mtime`, medido dos veces con 40 s de por medio.
2355
2356### 6.40 🚪 Las ocho puertas, medidas de nuevo *(2026-09-16)* — y la que falta es UNA
2357
2358La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:
2359
2360| # | puerta | estado | medido |
2361|---|---|---|---|
2362| 1 | arranca takana puro, se entra por SSH | ✅ | sigue en pie |
2363| 2 | el store está entero | ✅ | **1632 artefactos, 0 vacíos** (eran 1362) · ⚠ `/store` al 88 %, 11,3 G libres |
2364| 3 | los cuatro grafos idénticos | ✅ | §6.7 |
2365| 4 | **la granja late en la caja nueva** | ❌ | **el latido sigue en gioser**: la caja no tiene el cron |
2366| 5 | el repo se sirve y el host se actualiza solo | ⬖ | sirve; consumir exige el lab entero (SDD 27 §4) |
2367| 6 | cada dominio vivo responde 200 desde fuera | ✅ | **los 8 contra `2.29.29.217`** — y `sergio` ya entre ellos |
2368| 7 | el respaldo corre desde la caja | ⬖ | el script **corre y lista** el Storage Box desde la caja; falta una corrida completa |
2369| 8 | lo fósil, anotado y decidido | ✅ | §6.9 + `docs/state/mudanza-decisiones.txt` |
2370
2371#### La puerta 4 es ahora una línea de crontab — y por qué no la puse
2372
2373La caja ya tiene **todo** lo que el latido necesita, comprobado uno por uno: `git`, `python3`,
2374`rsync`, `ssh`, `takana`, `flock`, la llave del worker en `/root/.ssh/github5` y el `.fleet` con
2375`dev.gioser.net`. Le faltaban dos costuras y están hechas:
2376
2377```
2378 /opt/takana/store -> /store (el script espera `./store`; acá es partición)
2379 /opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)
2380```
2381
2382Y el repo de `/opt/takana`, que estaba **5 días atrasado**, quedó en `origin/main` exacto (0
2383diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes.
2384⚠ Esos 92 no eran trabajo local: eran **contenido que ya estaba río arriba** —el cambio de SSH a
2385HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio»
2386que en realidad está *atrasado*, que es el mismo espejismo que el §5 del informe de tawasuyu
2387describe para el monorepo.
2388
2389Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:
2390
2391```
2392 ▶ dev.gioser.net (154.197.1.13) load: 2.72 · store: 895 artefactos
2393 ══ AVANCE KDE ══ 197/198 sellado
2394 ══ CRON DE COSECHA ══ ⚠ NO instalado en el crontab
2395```
2396
2397**No lo instalé, y el motivo es el mismo que el de `tejido`**: con el cron puesto en las dos
2398máquinas, las dos harían `rsync --delete` del repo al worker y las dos cosecharían y commitearían.
2399El latido **se intercambia, no se duplica** — se apaga en gioser y se enciende acá, en ese orden, y
2400es el último movimiento antes del borrado.
2401
2402⇒ **de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.**
2403
2404### 6.41 🔀 INTERCAMBIO de `tejido` — la identidad cambió de máquina, no de dueño *(2026-09-17)*
2405
2406Hecho, y en este orden, que es el único posible: **apagar allá → encender acá**.
2407
2408```
2409 gioser tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334) → detenidos
2410 takana tejido 4102 · willay-crosscheck 4103 → corriendo
2411```
2412
2413**La prueba de que la identidad VIAJÓ y no se generó otra** no es que el servicio arranque —eso
2414pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que
2415la semilla sea la misma:
2416
2417```
2418 device.seed gioser cad6f35b633e8637… caja cad6f35b633e8637… ✓ idéntica
2419 roster.postcard gioser 590f2713075ac934… caja 590f2713075ac934… ✓ idéntica
2420```
2421
2422El `PeerId` sale de esa semilla, así que sigue siendo `12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L`.
2423
2424#### ⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr
2425
2426Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso
2427que corría en gioser:
2428
2429```
2430 antes: /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
2431 ahora: /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u… ← mismo PeerId, otra IP
2432```
2433
2434Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. **El PeerId
2435no hay que tocarlo; la IP sí, en cada equipo del roster.**
2436
2437#### 🧱 Sin el cortafuegos, el intercambio habría sido un verde falso
2438
2439El reglaset de la caja abría `80, 443, 1137, 2345, 22022` **y nada más**: `4102` estaba cerrado. Con
2440el relay encendido y el puerto filtrado, `arjectl status` diría «corriendo · 0 reinicios» y la flota
2441no llegaría — el peor de los verdes. Se abrieron `4102` (relay) y `4103` (crosscheck), con su ban
2442por tasa como el resto. **No se abrió `33097`**: ése era el puerto efímero de un `tejido serve`
2443(cliente), no un servicio que alguien busque.
2444
2445Y el reglaset quedó **idempotente**, que era deuda del §6.33: sus dos primeras líneas crean y borran
2446la tabla antes de definirla, así que `nft -f` **reemplaza** en vez de acumular. Comprobado contando:
244715 reglas `dport` antes, 21 después (no 36).
2448
2449⚠ **La política que genera ese fichero NO EXISTE EN NINGÚN DISCO.** Su cabecera dice «GENERADO, no
2450editar a mano — regenerar con `cortafuegos generate-input --policy <ruta>`», pero esa ruta no está
2451ni en la caja, ni en gioser, ni en el repo (sólo dos `politica_*_ejemplo.ron` en tawasuyu), y el
2452binario `cortafuegos` tampoco está instalado en ninguna de las dos. **El generado sobrevivió a su
2453fuente**: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su
2454primera línea pide no hacer.
2455
2456#### 🆔 `takana` acepta ULIDs que `arje-zero` RECHAZA
2457
2458La Card de tejido no encarnaba: `arje-zero rechazó: card tejido JSON: invalid character at line 7`.
2459La línea 7 es el `id`, y el id era `01M2EKDA00TEJ1D0RELAY00001` — que tiene una **`L`**, carácter
2460**excluido del alfabeto Crockford** de ULID (junto con `I`, `O`, `U`). `takana service-cards` lo
2461validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.
2462
2463Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre
2464termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los
2465`id` del repo con el alfabeto correcto había marcado justo ése. Era el único con `L` (venía de
2466«RELAY»); ahora es `01M2EKDA00TEJ1D0RE1AY00001`.
2467
2468#### Estabilidad, medida y no supuesta
2469
2470`willay-crosscheck` marcaba **4296 reinicios**, que asusta hasta mirar de dónde vienen: son las
2471horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y
24721 m 53 s de vida, está estable. `tejido`: 0 reinicios. Los dos puertos aceptan conexión desde fuera.
2473
2474⚠ De paso, un detalle feo: `arjectl status | head -2` hace que **arjectl paniquee** con
2475`failed printing to stdout: Broken pipe`. No rompe nada, pero un `EPIPE` sin manejar en una
2476herramienta de operación aparece justo cuando alguien la encadena con `head`/`grep -q`.
2477
2478#### Cómo volver, si hiciera falta
2479
2480Está escrito en `work/mudanza/volver-atras-tejido.txt`: apagar los dos de la caja y relanzar los tres
2481de gioser con las líneas exactas que se leyeron de `/proc/<pid>/environ` y `cmdline`. **Nunca las dos
2482a la vez**: es la misma identidad.
2483
2484### 6.42 ❌ El latido NO se puede mudar todavía — y el motivo redefine qué es un «hub» *(2026-09-17)*
2485
2486Se intentó el último intercambio: apagar el cron de la cosecha en gioser y encenderlo en la caja.
2487**Salió mal, se vio en el primer ciclo, y se volvió atrás en minutos.** Queda escrito porque el
2488motivo no era ninguno de los que se habían previsto.
2489
2490#### Lo que sí funcionó
2491
2492La caja tiene todo lo del latido y el ciclo corrió de punta a punta: sembró el repo al worker, leyó
2493su manifiesto, regeneró los ocho ficheros de estado, pasó los vigías, **se puso al día por
2494fast-forward y commiteó+empujó**. El `git config` de identidad y el `pull --ff-only` nuevo hicieron
2495su parte.
2496
2497#### 🕳️ Lo que publicó, que es el problema
2498
2499```
2500 totals que publicó la CAJA : {recipes: 1112, nodes: 1115, unhashable: 1112, ajeno: 3}
2501 totals de gioser : {recipes: 1112, nodes: 1115, sealed: 1105, never: 5, debt: 2}
2502```
2503
2504**`unhashable: 1112`** — no es «no está sellado»: es que **no se pudo CALCULAR el hash de ninguna
2505receta**. Y la causa es la misma que el §5.2 documenta desde el otro lado: **el lab entra en el
2506ArtifactHash**, así que una máquina sin el rootfs del lab no puede hashear nada. La caja de
2507producción no lo tiene — a propósito, no es un olvido.
2508
2509El daño era real y silencioso: el commit `45b5685a` dejó en `main` un `build-state*.json` que dice
2510que **el corpus entero no existe**. Cualquiera que lo lea —o cualquier vigía que lo compare— vería
25111112 recetas en cero.
2512
2513#### La lección, que vale más que el intento
2514
2515**Un hub no es «la máquina que tiene el repo y la llave»: es la que tiene el LAB.** El latido parece
2516tarea de cron —siembra, cosecha, commitea— pero su tercer paso es *pensar sobre el corpus*, y eso
2517exige el toolchain. Por eso la puerta 4 **no** era una línea de crontab, aunque todas las piezas que
2518se miraron (git, python3, rsync, ssh, takana, flock, `.fleet`, la llave) estuvieran.
2519
2520⇒ Las salidas son tres, y ninguna es «poner el cron»:
25211. **darle el lab a la caja** (`/.dev-fs/…`) — deja de ser sólo servidor y pasa a ser también hub;
25222. **partir el latido**: siembra+cosecha en la caja, regeneración del grafo donde haya lab;
25233. **que el hub sea otra máquina** (el LXC prestado ya tiene lab y muele 24/7).
2524
2525Es decisión del usuario, y es **lo único que queda entre esto y borrar gioser**.
2526
2527#### Vuelta atrás, en minutos
2528
2529Cron de la caja borrado, cron de gioser reactivado (estaba comentado, no borrado). El propio latido
2530de gioser **repara el daño solo** en su siguiente ciclo: regenera el grafo desde SU store —que sí
2531tiene el lab— y vuelve a publicar los números buenos.
2532
2533#### 🧰 Y de paso, tres cosas que el ciclo destapó en la caja
2534
2535- **`poda-fuentes.sh` no corre en takana**: `/dev/fd/63: No such file or directory` y
2536 `viejos: unbound variable`. Es **sustitución de procesos** (`<(...)`), que busybox `ash` no tiene —
2537 el cuarto tropiezo del día con la misma forma. El podador de `work/sources` es justo lo que un hub
2538 necesita para no llenarse.
2539- Los vigías corrieron y hablaron: **1 ofensor nuevo de raíces (`bzip2`)**, **3 herramientas
2540 selladas que no se pueden invocar**, y `servicios.txt` con 7 errores y 30 avisos. Son hallazgos del
2541 corpus, no de la mudanza, y quedan anotados donde el humano los mira.
2542- `arjectl status | head -2` **hace paniquear a arjectl** (`failed printing to stdout: Broken pipe`).
2543 No rompe nada, pero un `EPIPE` sin manejar en una herramienta de operación aparece justo cuando
2544 alguien la encadena.
2545
2546### 6.43 🧪 La caja ya es un HUB: el lab viajó, y con él la puerta 4 *(2026-09-17)*
2547
2548El §6.42 dejó dicho qué faltaba y el usuario eligió: **darle el lab a la caja**. Hecho, y el latido
2549ya late desde allá.
2550
2551#### El lab no se copia: se PINEA y se trae
2552
2553`scripts/lab-image.sh` empaqueta `.dev-fs/alpine` de forma **determinista** (`--sort=name
2554--mtime=@1 --owner=0 --group=0 --numeric-owner`, excluyendo `var/log/apk.log`, que es lo único que
2555difiere entre dos labs por lo demás iguales), lo publica al Storage Box con el sha en el nombre, y
2556del otro lado `--traer` **verifica el sha antes de extraer**. Eso es lo que hace que las dos máquinas
2557hasheen igual, y es un contrato, no una copia.
2558
2559**Y el empaquetado de hoy devolvió el sha que el repo ya pineaba:**
2560
2561```
2562 empaquetado hoy en gioser : d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
2563 pineado en bootstrap-devfs.sh: d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
2564```
2565
2566O sea dos cosas de una: **el lab de gioser nunca derivó** desde que se publicó esa imagen, y **el
2567empaquetado es reproducible de verdad** — no era una promesa del comentario.
2568
2569#### La prueba que decide, y no es que arranque
2570
2571Con el lab instalado, la caja calcula los MISMOS `ArtifactHash` que gioser:
2572
2573```
2574 zlib b3:dc363f26… busybox b3:2a2b1280…
2575 caddy b3:c915987d… shuma-daemon b3:f9b92acc…
2576```
2577
2578Cuatro de cuatro. Si el lab fuera otro, estos números serían otros y el store no lo notaría — es el
2579agujero que `hash_inputs` cerró (§lab en el hash) y la razón por la que esto se verifica con hashes
2580y no con un «arrancó bien».
2581
2582#### Los stores, convergidos — y una trampa que me tendí solo
2583
2584El grafo seguía discrepando porque el store de la caja no tenía 24 artefactos vigentes de gioser
2585(3,0 G). Al copiarlos con `rsync -a --files-from=<lista de DIRECTORIOS>` pasó lo peor posible:
2586
2587```
2588 sent 2,281 bytes … total size is 0 ← no copió NADA
2589 artefactos en la caja: 1631 → 1655 ← pero creó los 24 DIRECTORIOS
2590```
2591
2592**`--files-from` no recursa sin `-r`**: creó 24 directorios vacíos en el store. Y un directorio vacío
2593en el store **es un cache-hit** (§3 de CLAUDE.md, [[artefacto-vacio-envenena-cache]]): `build` lo
2594habría dado por sellado y habría salido OK sin construir. Se detectó al contar, se borraron los 24 y
2595se repitió con `-r`: 3,18 GB transferidos, **0 vacíos de 1655**.
2596
2597Después faltaban dos más (`obs-studio` y `spectacle`, justo los que el worker no logra construir),
2598copiados también. Resultado:
2599
2600| | gioser | caja |
2601|---|---|---|
2602| corpus | sealed 929 · debt 2 | **sealed 930 · debt 1** |
2603| KDE | sealed 1105 · debt 2 | **sealed 1106 · debt 1** |
2604
2605La caja no empata: **gana por uno** en los dos grafos.
2606
2607#### El latido, mudado
2608
2609Cron apagado en gioser, encendido en la caja, y un ciclo completo corrido a mano para mirar lo que
2610publica: `==> repo al día (ff)` → `==> estado commiteado+pusheado`, con los números buenos. El
2611`build-state` que hay en `main` ahora lo escribió la caja.
2612
2613⚠ **Lo que queda apretado es el disco**: `/store` de la caja está al **91 %, 8,2 G libres**. Un hub
2614sella y cosecha, así que ése es el próximo muro — `scripts/store-gc.sh` es la herramienta y todavía
2615no se corrió allá.
2616
2617⇒ **puerta 4 en verde.** Con eso, de las ocho quedan sólo las dos a medias (5 y 7) y ninguna en rojo.
2618
2619### 6.44 🧹 Primer `store-gc` en la caja: 26,8 G — y dos bugs que sólo salen fuera de GNU *(2026-09-17)*
2620
2621El store estaba al **91 %** (8,2 G libres) justo cuando la caja pasó a ser hub, que es la peor
2622combinación: un hub sella y cosecha.
2623
2624```
2625 antes: 84,7 G usados · 8,2 G libres · 91 % · 1657 artefactos
2626 después: 57,9 G usados · 35,0 G libres · 62 % · 1320 artefactos
2627```
2628
2629**337 artefactos superados borrados y verificados (0 sobrevivientes), 26,8 G.** El default del gc no
2630toca los **150 huérfanos** —`11,4 G`, los que son el único ejemplar de su nombre— y eso está bien:
2631borrarlos es la diferencia entre «se reconstruye en dos minutos» y «hay que rehacer la torre».
2632
2633#### El control que hay que correr DESPUÉS de un gc en esta caja, y que no es obvio
2634
2635**11 binarios de `/usr/bin` son symlinks que apuntan DENTRO del store** — los daemons que se
2636instalaron estos dos días. Un gc que borre el artefacto equivocado no rompe el store: rompe el
2637`/usr/bin` de una máquina en producción. Antes de `--aplicar` se comprobó que los 11 enlazan al hash
2638**vigente** (y por lo tanto están en el conjunto protegido); después, que los 11 siguen resolviendo y
2639que los 21 entes siguen corriendo. Los dos controles pasaron.
2640
2641⚠ Y la advertencia que el propio gc imprime y que en esa caja **no se puede satisfacer**: «sin
2642`.config` legible (ni `/proc/config.gz` ni `/boot/config-…`) ⇒ NO se protege ningún kernel por esta
2643vía». Con el default da igual; con `--huerfanos` en una máquina que arranca de su store, no.
2644
2645#### Los dos números que el gc no sabía decir
2646
2647Funcionó, pero sus dos cifras salieron vacías — y son las que uno mira para decidir si vale la pena:
2648
2649```
2650 ==> espacio: superados · huérfanos
2651 ==> 337 artefactos borrados y VERIFICADOS · libres: Available → Available
2652```
2653
26541. **`du -sch --files0-from=-` es de GNU y busybox no lo tiene** ⇒ `espacio()` devolvía vacío. *Un
2655 número que falta se lee como un número chico.*
26562. **`df -h /home` estaba cableado** y el store no vive ahí: en gioser es un bind-mount del volumen,
2657 en la caja es `/store`. Sin `/home`, `tail -1` se quedó con la CABECERA — de ahí
2658 «Available → Available».
2659
2660Arreglados los dos… y el primer arreglo **también estaba mal**: `xargs -d '\n'` es otra extensión
2661GNU. Eso imprimió `huérfanos ?`, y ese `?` es deliberado: cuando el total no se puede calcular, la
2662función lo DICE en vez de escribir 0 — un cero inventado habría dicho «no hay nada que ganar» con
266311,4 G en huérfanos. Con `tr '\n' '\0' | xargs -0`, que busybox sí tiene, la caja ya contesta
2664**`espacio: superados 0 · huérfanos 11.4G`**.
2665
2666⇒ Van **cinco** tropiezos del mismo día con la misma forma (`/dev/tcp`, expansión de llaves,
2667`find -newermt`, sustitución de procesos, y estos dos). El patrón ya no es anécdota: **un script del
2668hub escrito en una máquina GNU no corre en la caja hasta que se prueba ahí.**
2669
2670## 7. Reusar los scripts que ya existen, y no escribir de nuevo
2671
2672Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
2673
2674| para | script existente | qué le falta |
2675|---|---|---|
2676| armar el rootfs de un perfil | `scripts/hydrate-profile.py` | nada — es el camino bueno (los `hydrate-*.sh` por escritorio **divergen** de `targets.toml`) |
2677| producir el `.img` | `scripts/install-image.sh` / `scripts/product-image.sh` | apuntar al `perfil.servidor` |
2678| escribir a disco | `scripts/takana-install.sh` | el envoltorio de rescue del §3.2 |
2679| instalador con TUI | `scripts/takana-live-install.sh` | ser el **mismo motor** que el remoto, con las respuestas por fichero |
2680| provisionar un worker | `scripts/farm/vps-setup.sh` | ya soporta apt **y** dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
2681| enchufar a la granja | `scripts/farm/.fleet` | **está en `.gitignore`** ⇒ un hub recién clonado nace con la flota vacía **en silencio** |
2682| latido | `scripts/farm/cosecha-cron.sh` | decidir cron vs tarjeta de arje (§3.4) |
2683| respaldo | `scripts/respaldo-storagebox.sh` | nada — es interrumpible y continuable |
2684| absorber el init ajeno | `arje-absorb` | el lector de procesos vivos (§4) |
2685
2686⚠ **Un motor, tres frentes.** La tentación es escribir el instalador remoto aparte del TUI. Ahí es
2687donde divergen: ya pasó con los `hydrate-*.sh` contra `targets.toml`, y con la unidad systemd del
2688worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser **la misma
2689que hace la persona**, grabada en un fichero de respuestas.
2690
2691---
2692
2693## 8. `churay` como instalador centralizado — la dirección, no la decisión
2694
2695El usuario lo planteó el 2026-09-09: **un instalador único, con CLI y TUI**, que sirva para
2696
2697- las **apps de tawasuyu**,
2698- **paquetes sueltos en cualquier distro**, al estilo nix (instalar sin ser root, sin tocar el
2699 sistema anfitrión),
2700- **takana** mismo,
2701- y **`llimphi`**.
2702
2703Encaja con lo que ya hay y por eso vale escribirlo: `takana install` ya resuelve por nombre contra un
2704índice firmado y **ya acepta múltiples orígenes**; `qorpa` ([ADR 0015](adr/0015-imagenes-ajenas.md))
2705ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre
2706en cron desde gioser.
2707
2708**No se decide acá.** Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir
2709una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la
2710misma pregunta del [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md), y la respuesta puede no
2711ser la misma fuera de takana; (2) quién es el dueño del verbo, si `takana install` crece o si
2712`churay` lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta
2713del store a propósito. Queda anotado para que se **decida**, no para que se descubra a mitad.
2714
2715---
2716
2717## 9. Estado y orden
2718
2719**Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros.** Nada de espejar a GitHub
2720para «tener copia fuera»: la copia fuera **la da la propia mudanza** — en cuanto el gitea vive en la
2721caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de
2722pagar dos máquinas, no montar una dependencia más. `scripts/mudanza/espejar-repos.sh` queda como
2723herramienta disponible, no como paso del plan.
2724
2725**Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público.**
2726Las 23 recetas que apuntaban a `gitea@git.tawasuyu.net` (SSH) pasaron a `https://git.tawasuyu.net/…`
2727— la URL es locator y no entra en `hash_inputs` (ADR 0013), así que se cambió **con control y sin
2728mover un solo ArtifactHash**. Medido en el worker: clona y `git archive` extrae el árbol (155 M) sin
2729ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna
2730receta usa uno.
2731
2732### Lo que YA está, y no hay que volver a tocar
2733
2734| | |
2735|---|---|
2736| la caja `takana` (`2.29.29.217`) | arranca takana puro, PID 1 `arje-zero` — §3.8 |
2737| store, grafos, granja, respaldo | puertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10 |
2738| repo firmado servido por la caja | §5.1 |
2739| **la imagen del perfil `servidor` con gitea** | **arranca y sirve 200, supervisado por arje** — §6.11 |
2740| **`arjectl` para operar sin reiniciar** | §6.12 |
2741| **el gitea de gioser corriendo sobre takana, con sus datos** | ensayo completo — §6.13 |
2742
2743### Lo que falta para el cutover, en este orden
2744
2745⚠ **La lista de abajo es del 2026-09-11 y los pasos 1-5 YA ESTÁN** (gitea mudado y sirviendo, §6.14;
2746estáticos mudados, §6.18; fósiles barridos, §6.19; `api.sergio` mudado, §6.25; `tawasuyu.net` y
2747`gioser.net` mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).
2748
2749**El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista:
2750queda UN solo vhost sirviéndose desde gioser — `sergio.gioser.net` — y el montón de daemons propios
2751del usuario que ningún paquete provee.** Los pasos 6 y 7 son lo único que sigue abierto.
2752
2753
27541. **Actualizar la caja a la imagen nueva** (gitea + `arjectl` + las cuentas de `[[user]]`).
2755 `takana upgrade` está probado (§6.13 del SDD 27 y §5.4 acá); el `dd` es para provisionar, no para
2756 actualizar — sobrescribe el disco local y re-etiqueta la partición.
27572. **Mudar los datos del gitea**: `sqlite3 … ".backup"` (1,5 s, consistente con el servidor vivo) +
2758 `rsync -aH` de `/var/lib/gitea` y `/etc/gitea`. El corte final se hace con el gitea del origen
2759 **parado**, para que el último snapshot no pierda escrituras.
27603. **Caddy en la caja**: los **6 dominios vivos** del §6.9, con su TLS. Los otros 13 no se mudan.
27614. **DNS**: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
2762 resolviendo al mismo nombre.
27635. **Verificar desde fuera**: cada dominio vivo responde 200 contra la IP nueva **y** un `git clone`
2764 real contra el gitea mudado.
27656. **El resto de servicios vivos** del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
2766 `planear.py` ya emite.
27677. **Borrar gioser** — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
2768
2769### Bloqueantes conocidos, con su tamaño
2770
2771- **`git` sellado sin `remote-http`** (§6.13): afecta a clonar POR HTTPS *desde* una caja takana, no
2772 al gitea que sirve. Re-sella `git`, raíz de `perfil.base` ⇒ unidad propia.
2773- **Puerta 5** sigue a medias: la caja sirve su repo pero no se instala de él (`install` reproduce y
2774 eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
2775
2776**El volumen del store NO se engancha a la caja hasta que pase la puerta 1** — ya pasó, y el store
2777local 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:
- 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 | ✅ byte a byte — §6.7 |
| 4 | la granja late en la caja nueva | ✅ cosecha completa: 1369→1575, 0 vacíos — §6.8 |
| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
| 6 | cada dominio vivo responde 200 | ⬖ sondeados: 6 vivos de 19 — §6.9 |
| 7 | el respaldo corre desde la caja | ⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10 |
| 8 | lo fósil, anotado y decidido | ✅ decidido — §6.9 |
Puerta 2, medida acotando al patrón <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.5 ✅ El muro, derribado: el corpus publica su propio cargador (2026-09-11)
De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la 1, que resultó ser más barata
de lo que parecía y desbloquea la 2 en vez de competir con ella.
recipes/musl-shared.toml — variante dinámica de musl que publica /usr/lib/libc.so (4,2 M) y
/lib/ld-musl-x86_64.so.1. En musl el cargador Y la libc son el mismo objeto.
Por qué una variante y no --enable-shared en la canónica, con su control: musl es componente
de Stage 1 y su of_tree es el baseline de selfhost-verify. Tocarla obliga a rehacer ese
baseline a propósito. Con el patrón *-shared —que el corpus ya usa 23 veces— la canónica no se
mueve: takana hash recipes/musl.toml sigue dando b3:ce952f72…, el mismo sellado de Stage 1. Y
además musl no es dep de nadie (medido: sólo de sí misma; el enlace estático lo resuelve el
musl que trae zig), así que esto suma sin mover un solo ArtifactHash del corpus.
⚠ Y hubo que cambiar de compilador, medido
Con zig-cc el libc.so sale con 1586 símbolos dinámicos contra los 1654 del musl de Alpine,
y los 68 que faltan son exactamente los que musl implementa en ensamblador x86_64 — memset,
memcpy, memmove, memcmp, strlen y toda la familia matemática (ceil floor sqrt fmod exp log sin cos tan fma round trunc) — más los __stack_chk_*.
No es que no se compilen: en el libc.a del musl canónico están. Lo que pasa es que zig los
resuelve con su propio musl y los deja FUNC LOCAL HIDDEN de tamaño 0, fuera de la tabla dinámica.
El síntoma es un cargador que arranca, reloca y muere con memset: symbol not found — que se lee
como «el binario está roto», no como «a la libc le faltan símbolos».
Con compiler = "gcc": 1651 símbolos, memset y ceil presentes.
El resultado, en la caja
| antes | después |
sh: python3: not found (con command -v contestando /usr/bin/python3) | Python 3.12.10 |
| 23 binarios inertes de 726 | 1 |
perl 5.040002, readelf/objdump/nm (GNU Binutils 2.45.1) y el resto corren. El único que
quedaba era sqlite3, con Error loading shared library libz.so.1: la zlib canónica también es
--disable-shared y nadie publicaba ese soname. zlib-shared ya existía sellado en el corpus —
sólo faltaba declararla en base.
La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente: con el cargador
publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario
menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.
6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB
Con python3 vivo, takana hash en la caja seguía fallando:
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.
El lab es parte de la IDENTIDAD, no del entorno. Una máquina sin él no puede ni preguntar «¿cuál
es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado
el lab (tarball pineado + tools/, bajo /store/dev-fs porque la raíz son 6 G), el hash testigo
coincide byte a byte: takana hash recipes/zlib.toml da b3:dc363f26… en las dos máquinas.
⚠ Y la caja no podía desempacar su propio lab: el tar del rootfs es el de busybox y contesta
tar: unrecognized option: zstd. La distro comprime todo con zstd —la imagen del lab, el respaldo,
el dd remoto— y la imagen no traía el binario. zstd ya tenía receta sellada; declarado en base.
⚠⚠ Copiar un store con rsync -a es una fábrica de vacíos, y ahora se sabe POR QUÉ
La primera copia murió con No space left on device en un .so de Qt, dejando el destino al 100 %
y 1367 de 1368 artefactos VACÍOS. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.
La causa, medida:
| medición sobre el store de gioser | |
du -sh (hardlinks contados UNA vez) | 60 G |
du -sh --count-links (hardlinks EXPANDIDOS) | 85 G |
.dmerge (la caché que hardlinkea al store) | 11 G |
rsync sin -H no preserva hardlinks: los expande en copias enteras. Así que «el store son 60 G»
—que es lo que dice du y lo que uno planifica— se convierte en 85 G + lo que .dmerge expanda al
otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se
llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.
La copia correcta es rsync -aH --exclude='.dmerge': -H preserva los enlaces (60 G reales) y
.dmerge no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.
La lección de método: du -sh sobre un árbol con hardlinks no dice cuánto ocupa copiarlo.
Para dimensionar una copia hay que medir con --count-links, o preservar los enlaces.
Y el instrumental apunta al binario de DESARROLLO, no al instalado
La primera corrida de build-state.py en la caja devolvió 875 recetas unhashable — todas —
con el lab bien sembrado y takana hash funcionando a mano. La causa:
HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
Los scripts asumen un árbol de desarrollo: target/release/takana. En una caja instalada el
binario es /usr/bin/takana y target/ ni existe (la siembra lo excluye a propósito). No falla
ruidosamente: cada hash falla y el grafo sale entero como unhashable, que se lee como «este
corpus no se puede hashear» y no como «no encontré el binario».
Se destraba con HAMMER=/usr/bin/takana STORE=/store, que la propia cabecera del script documenta.
Pero la deuda queda anotada: el instrumental debería caer a command -v takana antes de rendirse,
porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.
6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte
gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
caja: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0, wanted 3
(chrony, cronie, logrotate — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.
Costó tres intentos, y los dos fallos son la parte que vale.
Intento 1 — unhashable 875, todas. Ver arriba: los scripts apuntan a target/release/takana.
Intento 2 — dos nodos divergentes: aichat y zola, sealed en gioser y never en la caja. No
era deriva ni pérdida: ninguno de los dos está en disco en gioser tampoco. Son los «2 sellados
que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime.
build-state.py lee work/farm-sellados.txt y work/respaldo-sellados.txt — y work/ está
gitignored, así que un hub recién sembrado no los tiene y degrada esos nodos a never sin que nada
falle.
Es la misma familia que scripts/farm/.fleet: un fichero fuera de git del que depende una medición
compartida. La regla que sale: sembrar un hub no es clonar el repo — es repo + store + lab +
los manifiestos. Copiados los dos ficheros, los grafos coinciden exactamente.
6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco
Probado desde la caja (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):
- llevada la clave de la granja (
~/.ssh/github5 → /root/.ssh/github5, 0600) y escrito
scripts/farm/.fleet — los dos están fuera de git a propósito y hay que copiarlos a mano;
- la caja alcanza al worker:
PruebasIA, load 3, 775 artefactos;
estado-granja.sh corre allá y lee bien el worker y el avance KDE (198/199);
- y el paso que cierra el lazo: se cosechó un artefacto real del worker (
speedtest-go, 12 M)
con el mismo transporte de la granja, llegó con su .hammer/recipe.toml y su binario CORRE
en la caja (speedtest-go v1.7.10). Worker construye → hub cosecha → binario ejecuta.
✅ CERRADA (2026-09-11) con el volumen takana-store
Resuelto el disco (§6.12), la cosecha completa corrió: 1369 → 1575 artefactos, 0 vacíos, que son
exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —speedup 3.14, el ahorro de
-H— y /store queda en 73,7 G de 97,9 (79 %).
⚠ El enlace con el worker va a 11 MB/s, no a los 90 MB/s de gioser↔caja: el LXC vive en el
Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.
⚠ Lo que faltaba no era mecanismo: era DISCO.
| |
| artefactos en el worker que la caja no tiene | 206 |
| a ~113 MB de media (el artefacto medio del corpus) | ~23 G |
libre en /store de la caja | 3,7 G (61,5 de 68,7 · 94 %) |
Una cosecha completa no entra, y forzarla repetiría exactamente el llenado que dejó 1367
artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una
de ingeniería:
- 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.
- 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.
- Podar el store de la caja — deshace parte de la mudanza. No.
6.9 Puerta 8: los 19 dominios, sondeados uno por uno (2026-09-11)
El Caddyfile de gioser declara 19 sitios. Sondeados por DNS y por HTTP desde fuera, sólo 6
los sirve gioser de verdad:
| dominio | DNS | HTTP | qué es |
gioser.net · tawasuyu.net | gioser | 200 | VIVO — las dos landings |
git.gioser.net · git.tawasuyu.net | gioser | 200 | VIVO — el gitea (aloja el origin de takana) |
sergio.gioser.net · api.sergio.gioser.net | gioser | 200 | VIVO |
summa · api.summa · dev.summa | 154.197.1.2 | 200 | YA MUDADOS a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva |
api.gioser.net | gioser | 502 | backend caído |
mail.sigma.gioser.net | gioser | 502 | backend caído |
aura · api.aura · sigma · kosmofono · api.kosmofono | SIN DNS | — | fósil: ni DNS ni ficheros en /var/www |
terapeuta.ec · andino.ec | SIN DNS | — | fósil con datos: 279 M en /var/www/terapeuta |
gitea.gioser.net | SIN DNS | — | era un redir a git.gioser.net; inofensivo |
Lo que esto cambia respecto del §1.1: no son «4 fósiles» sino 13 de 19 entradas que no hay que
mudar — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a
mudar son 6 sitios, y de esos el que pesa es el gitea, porque aloja el origin de takana.
✅ DECIDIDO (usuario, 2026-09-11)
| fósil | decisión |
aura · api.aura · sigma · kosmofono · api.kosmofono | mueren — ni DNS ni ficheros |
gitea.gioser.net | muere — era un redir, y git.gioser.net lo cubre |
api.gioser.net · mail.sigma.gioser.net | mueren — backend caído, sin dueño |
summa · api.summa · dev.summa | no se mudan: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser |
terapeuta.ec · andino.ec | MUEREN — decisión explícita del usuario. Los 279 M de /var/www/terapeuta se van con la caja; no se archivan |
⇒ De las 19 entradas del Caddyfile, 13 no se mudan. La superficie real es de 6 sitios, y el
que pesa es el gitea porque aloja el origin de takana.
⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, terapeuta.ec no va a poder
recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.
6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo
Llevadas las credenciales (~/.config/hammer/*.env, 0600) y la clave, la caja alcanza el Storage
Box por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá:
3140 artefactos, y de paso el script detecta 2 VACÍOS en el respaldo (gnome-desktop ×2) que
excluye del manifiesto — su propia guarda, funcionando.
Para llegar ahí hubo que arreglar cinco cosas, y todas son de la misma familia: el instrumental
asume que el hub es gioser. El --seco ahora completa los tres pasos desde la caja y lee la
ocupación del box (111 G de 1 T).
-
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/.
-
El binario de desarrollo. HAMMER = ROOT/target/release/takana (§6.7).
-
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.
-
🚨 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.
-
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:
-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.
sha1collisiondetection lee palabras de 32 bits SIN alinear. Es el backend SHA1 por defecto
de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota
jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte.
-DSHA1DC_FORCE_ALIGNED_ACCESS lo hace leer byte a byte: quita el UB en la fuente en vez de
esconderlo.
Con las dos: git clone --depth 1 del propio repo desde la caja ⇒ 877 recetas. Radio cero
(git no es dep de nadie), pero sí es raíz de perfil.base ⇒ esto arregla la distro entera.
La lección: ls-remote andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el
informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.
6.12 ⚠ La caja dejó de ser desechable — y hydrate no cruza el store
Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:
1. Re-escribir la imagen con dd ya NO es una actualización, es una pérdida. El dd sobrescribe
el disco local, y ahí viven ahora /opt/takana (el clon), /root/.ssh (las dos claves),
/root/.config/hammer (las credenciales del respaldo), /srv/repo y la configuración de caddy. El
store se salva sólo porque está en el volumen. Y hay una trampa peor: la imagen etiqueta su
partición local como hammer-store, así que tras un dd habría dos filesystems con esa etiqueta
y el findfs del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición
vacía. ⇒ el dd es para PROVISIONAR; actualizar es takana upgrade (que existe, con generaciones y
rollback) y está sin probar en esta caja.
2. takana hydrate no puede proyectar al root vivo. Hidratar enlaza en DURO, y en una caja
instalada el store es siempre otra partición que /:
Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link
No es culpa del volumen: en el layout original /store es sda4 y / es sda2, también distintos.
Hidratar al FHS vivo nunca fue posible en una caja instalada — funciona en el hub porque ahí
store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces
(§6.6), ahora a nivel de sistema y no de herramienta.
Para instalar el git arreglado se copió el artefacto a mano. Eso funciona y no es el camino: el
camino es takana upgrade, y probarlo es su propia unidad de trabajo.
6.13 ✅ takana upgrade, probado de verdad — y el bug de durabilidad que destapó
§6.12 dejó dicho que el camino de actualización de una caja instalada es takana upgrade, no dd ni
hydrate. Probado con un paquete que la caja realmente necesitaba: zstd.
| paso | resultado |
upgrade apply …-zstd-cli | ✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados |
| la prueba de uso | zstd -dc lab-image.tar.zst | tar -xf - ⇒ la caja desempaca su propio lab |
upgrade rollback | ✓ generación 2 revertida · 9 restaurados, 1 borrados — zstd se va, libzstd.a vuelve |
re-apply + corte duro sin sync | sobrevive: generación viva 1, manifiesto íntegro |
✔ Y cruza la frontera que hydrate no puede: el store está en el volumen y / en el disco local,
y upgrade copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.
⚠ El bug: write_atomic era atómico pero NO DURABLE
El primer intento pareció funcionar y se perdió al reiniciar. La caja volvió con:
pending.json 0 bytes
generations/2/manifest.json 0 bytes
upgrade status → Error: json: EOF while parsing a value
upgrade recover → Error: json: EOF while parsing a value
write_atomic hacía temporal + rename: atómico frente a otros PROCESOS, no frente a un CORTE.
rename sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a
bloques nunca escritos — cero bytes. Y hcloud server reset es un corte duro, no un apagado
limpio: ahí está la mitad operativa de la lección.
Lo grave no es perder un upgrade: es que recover —que existe exactamente para «un apply
interrumpido por un corte»— abortaba con la huella más probable de ese corte.
Arreglado: fsync del temporal antes del rename y fsync del directorio después (hacen falta los
dos), y un pending.json vacío se reporta como PendingCorrupt, nombrando el corte y apuntando al
árbol de respaldos. Con tests y su control.
Verificado en la máquina, en los dos sentidos: con el binario viejo, apply + reset duro ⇒ estado
en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.
⚠⚠ upgrade apply NO es un gestor de paquetes: cada generación ES UN ÁRBOL
Lo destapó usarlo dos veces seguidas. Tras aplicar zstd-cli y después rsync:
✓ generación 2 aplicada
+ 0 añadidos, ~ 8 pisados, - 1 retirados ← el `zstd` de la generación 1
$ zstd --version
AUSENTE
Aplicar un árbol nuevo RETIRA los ficheros del anterior, porque una generación es el estado
completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/producto
del store»— y con que exista rollback: lo que se revierte es un sistema, no un paquete.
⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente.
La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace
servidor-image.sh— sellarlo, y aplicar ESO como una generación. Eso además sería, de verdad, la
«actualización en sitio probada» del SDD 19 §5.2: una imagen entera, con
rollback, sobre una caja viva.
Mientras tanto la caja quedó con zstd y rsync puestos a mano — funciona y no es el camino,
igual que el git.
⚠ Lo que NO se arregló: si el manifiesto se pierde, recover no puede deshacer solo — no sabe qué se
tocó. El árbol de respaldos tiene la información; reconstruir desde ahí es su propia unidad.
6.2 Lo que la mudanza tiene que producir, además de la mudanza
El usuario lo pidió explícito: que este experimento saque recetas y las pruebe. La caja vieja es
un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
una receta candidata con un caso de uso comprobado detrás:
| lo que corre hoy en Artix | receta |
caddy | ✅ existe (recipes/caddy.toml) |
openssh | ✅ existe |
gitea | ✅ SÍ existe (recipes/gitea.toml) y está sellada (35bb4f04…) — ver la corrección abajo |
qdrant, squid, fail2ban, php-fpm, metalog, glances | ✗ |
chrony, nftables, cronie, logrotate | ✗ — ya declaradas wanted en perfil.servidor |
CORRECCIÓN (2026-09-11). Esta tabla decía que gitea NO tenía receta y que «es la que más
peso tiene». Las dos cosas eran falsas: recipes/gitea.toml existe desde el 2026-09-09 y su
artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.
Lo destapó planear.py al cruzar cada binario contra recipes/: de los servicios de gioser, 4
ya tienen receta takana y están sellados (caddy, gitea, python3…), 4 vienen de un paquete
ajeno y 11 son binarios sueltos sin dueño. Ésos últimos son el trabajo real, no gitea.
Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.
Cada una que se cierre se prueba sola: el servicio o levanta en la caja nueva o no, y eso se ve
el mismo día. Es la diferencia entre una receta «sellada» y una receta usada.
6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE (2026-09-14)
servidor-image.sh + QEMU: PID 1 = arje-zero, gitea encarnado por él (ppid=1), corriendo como
uid=916, escuchando en :3000 y devolviendo 200 con <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.
-
⚠⚠ 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.
-
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.
-
⚡ 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-units | tabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente |
poner /etc/gitea/app.ini + arjectl start gitea | GET / → 200, sin reiniciar (uptime 6 min) |
arjectl restart gitea | el 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
-
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.
-
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.
-
🧨 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
- Caja actualizada:
arjectl + gitea + la cuenta gitea:969 + las cards en genesis y cards.d.
- 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).
- 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).
- 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.
- 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
rsync a u647150:gitea/ — 1,7 G (datos/ + gitea.db de 314 M).
⚠ 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.
6.17 ⚠ En gioser conviven arje Y OpenRC — y el prefijo openrc- de las cards ENGAÑA
El usuario avisó: «esa máquina la tengo con arje desde hace meses». Tiene razón, y las dos cosas
son ciertas a la vez:
| medido | |
| PID 1 | arje-zero ✓ |
la card openrc-gitea | ejecuta sh -c "cd /var/lib/gitea && exec /usr/bin/gitea web …" — no llama a OpenRC |
/run/openrc/started/ | existe y tiene servicios: NetworkManager, dbus, dhcpcd, fsck, hostname, hwclock, local, localmount… |
rc-update show default | listaba gitea |
⇒ El prefijo openrc- es herencia del NOMBRE, de cuando arje-absorb tradujo los servicios de
OpenRC a cards: la card no pasa por rc-service, encarna el binario directo. Pero OpenRC quedó
como residuo ACTIVO y puede arrancar servicios por su cuenta — que es lo que revivió al gitea con
ppid ≠ 1 después de que arje lo hubiera detenido.
La regla para el resto de la mudanza: antes de dar un servicio de gioser por apagado, preguntar a
los dos:
arjectl list-units | grep <svc> # la capa de arje (PID 1)
rc-update show default | grep <svc> # el residuo de OpenRC, que también arranca
Es [[la-etiqueta-no-es-el-hecho]] en su forma más cara: un nombre que dice openrc- y no es OpenRC,
junto a un OpenRC real que nadie esperaba que siguiera operando.
6.18 Mudados takana.gioser.net y hifas.gioser.net (estáticos puros)
Los dos son ficheros y nada más —sin backend—, así que mudarlos fue copiar 52 K, añadir su vhost al
Caddyfile de la caja, mover el DNS de CNAME a A y quitarlos del origen. 200 con TLS válido
desde 2.29.29.217, y control de que no se rompió lo demás: sergio.gioser.net sigue en 200 y
gioser baja de 16 a 14 vhosts.
Se repitió el patrón de ACME: el primer intento de certificado sale ANTES de que propague el DNS,
falla contra el origen y hay que forzar el reintento con arjectl restart caddy. Conviene mover
el DNS y recién entonces añadir el vhost — o asumir un restart de más.
Quedan en gioser 14 vhosts, todos con backend propio (uvicorn, php-fpm, tejido, shuma…) o
fósiles sin DNS. Ésos no se mudan copiando un directorio: necesitan su servicio del otro lado.
6.19 Los fósiles, barridos: de 19 vhosts a 2
Se midió cada dominio que quedaba —DNS por DoH y HTTP contra la IP del origen— antes de tocar
nada. El resultado deja la mudanza mucho más chica de lo que parecía:
| clase | cuántos | qué son |
| fósiles sin DNS | 5 | aura, api.aura, sigma, kosmofono, api.kosmofono |
| ya viven en OTRA máquina | 3 | summa, dev.summa, api.dev.summa → 154.197.1.2 |
| 502 permanente | 2 | mail.sigma (:9000 caído), api.gioser.net (:8000 caído) |
| mudados hoy | 5 | los tres de git + takana + hifas |
| VIVOS y por mudar | 2 | sergio.gioser.net y api.sergio.gioser.net |
Y ninguna de las raíces estáticas de los fósiles existe en disco: /var/www/{aura_frontend, sigma,kosmofono,summa,summa-dev} no están. El Caddyfile servía directorios ausentes — la
configuración sobrevivió a sus datos. De los backends, sólo :8770 sigue escuchando, y su dominio
ya apunta a otra máquina; :8766 :8765 :9000 :8000 :8768 están caídos.
Los 10 bloques muertos salen del Caddyfile (con respaldo previo, validate y reload), y el control
confirma que los vivos siguen: sergio y api.sergio en 200, y los mudados respondiendo desde
la caja. gioser pasa de 19 vhosts a 2.
⚠ El único fósil CON datos es terapeuta.ec: 279 M en /var/www/terapeuta, sin DNS y sin vhost.
No se borra acá — se anota para la decisión de borrado de la máquina, que es del §6.
Lo que esto cambia: mudar los dos que quedan es un frente acotado —un backend Python en :7378
y una API en :8771— y no las quince cosas que la lista sugería.
6.20 🛟 Cinco binarios rescatados de la nada — el paso que CADUCABA
Antes de tocar sergio.gioser.net apareció lo urgente: cinco procesos vivos cuyo binario YA NO
EXISTE en disco (/proc/<pid>/exe → … (deleted)). Sólo existían como inode huérfano: si el
proceso muere o la máquina se reinicia, se pierden. Es el paso que el SDD 29 §4.0 undecies llamó «el
que caduca», y caducaba de verdad.
tejido · sandokan-mcp · pacha-secretos · puerta-f6e393ffbef3999e · shuma-gateway (240 M)
Se recuperaron leyendo /proc/<pid>/exe con el proceso vivo, y ya están fuera de gioser: en
/work/rescate-binarios/ de la caja y en rescate-binarios/ del Storage Box. Cinco de los trece
binarios que nadie provee dejaron de depender de que nadie reinicie nada.
6.21 🧱 sergio.gioser.net: el muro no es el sitio, es glibc
El sitio son tres piezas, y sólo una se puede mudar hoy:
| pieza | qué es | mudable a takana |
| el frontend | 117 M de estático (dist/) | ✅ copiado ya a /work/www/sergioh |
/shuma/* | shuma-gateway en :7378 — ELF dinámico, y su binario estaba BORRADO | ⚠ hay que construirlo desde tawasuyu (es Rust del monorepo) |
api.sergio | uvicorn con un venv de 405 M | 🧱 no, hoy no |
El backend es fastapi + uvicorn + pydantic + google-generativeai + **langchain**, con extensiones
compiladas …cpython-314-x86_64-linux-**gnu**.so — o sea glibc. Reempaquetar ese árbol de PyPI
como recetas no es realista, y correrlo en musl no es posible. Es el caso de uso canónico del
ADR 0015 (qorpa: imágenes ajenas glibc enjauladas), que está
PROPUESTO y sin implementar del todo.
⇒ El DNS de sergio NO se movió: mover el nombre sin el backend deja el sitio servido y la
consola rota. El frontend queda copiado y esperando; el corte se hace cuando exista el camino para
las otras dos piezas.
Esto es lo que decide la fecha de borrado de gioser: no es «mudar dominios», es que un
servicio vivo no tiene hoy camino a takana. Las salidas son tres y hay que elegir: (1) qorpa, que
es para exactamente esto; (2) que ese servicio viva en otra máquina —summa ya lo hace, y su DNS lo
demuestra—; o (3) que muera. Ninguna es técnica: es una decisión.
6.22 🧨 El curl de la distro NO PUEDE VALIDAR TLS — y por eso nada se baja por HTTPS
Al ir a traer la primera imagen ajena (qorpa pull), la caja falló con curl failed to verify the legitimacy of the server. El certificado del servidor estaba bien; lo que falta es de este lado:
* CApath: /etc/ssl/certs
* SSL certificate ... unable to get local issuer certificate (20)
$ curl --cacert /etc/ssl/certs/ca-certificates.crt ... → 200
El curl del corpus está compilado con --with-ca-path=/etc/ssl/certs y sin CAfile, y ese
directorio contiene un solo fichero: el bundle. Un capath necesita hash-links
(c_rehash) — el índice — y no los hay. Los certificados están, el índice no.
Por qué faltan: recipes/ca-certificates.toml deja escrito el hook
/etc/ca-certificates/update.d/certhash, que en Alpine ejecuta apk al instalar. En takana no lo
ejecuta nadie: la distro proyecta artefactos, no corre post-install. Es la misma familia que
[[subcomando-sin-driver]] — todo presente, y el paso que lo conecta no existe.
Alcance: no es sólo qorpa pull. Es takana install --repo https://…, el mirror, y cualquier
curl/wget de un script en una caja instalada. No falla al construir ni al instalar: falla la
primera vez que la máquina intenta bajar algo. Nadie lo había visto porque el hub usa el curl de
Artix.
Arreglado en la receta: los hash-links se generan en el build —donde c_rehash y perl ya
existen— y viajan dentro del artefacto, con una guarda que falla ruidosamente si el rehash no
produce ninguno. Re-sella ca-certificates, que es raíz de perfil.base.
Apaño mientras tanto, para invocaciones puntuales: CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
(comprobado: 200).
6.23 qorpa en la caja: preflight en verde, y el segundo muro es tar
Con el backend de sergio sin camino a musl (§6.21), la salida elegida es qorpa. Estado medido
en la caja:
| paso | resultado |
| userns anidado · overlayfs sin privilegios | ✅ ya estaban |
| disco | ❌→✅ /var/lib/hammer vive en la raíz (3 G) ⇒ qorpa se movió a /work (62 G) por symlink |
subuid/subgid | ❌→✅ rango escrito para root |
newuidmap/newgidmap sin capability | ❌→✅ setcap aplicado — y el artefacto del store NO se tocó (inodes distintos: en una caja instalada esos binarios son copias, no hardlinks) |
| veredicto del preflight | de «BLOQUEA 1 · LIMITA 4» a «LIMITA 1» |
qorpa pull de Arch bootstrap | baja y verifica el sha256, y falla al desempacar |
El segundo muro: el rootfs de Arch es .tar.zst y el tar de la imagen es el de busybox, que no
lo entiende — el mismo tropiezo que ya costó el lab (tar: unrecognized option: zstd). zstd SÍ
está instalado. ⇒ qorpa pull tiene que descomprimir con zstd -dc | tar -x cuando el archivo es
.zst, en vez de dárselo entero a tar. Es un arreglo pequeño en el CLI, y hasta entonces la
alternativa es una imagen .tar.gz (Ubuntu base) — que además obliga a reinstalar las deps del venv,
porque el de gioser trae extensiones de CPython 3.14 y Ubuntu 24.04 lleva 3.12.
6.24 El TLS arreglado, la distro con nombre, y por qué upgrade se quedaba sin espacio
El curl ya valida. Con ca-certificates re-sellado —119 hash-links en el capath, generados
en el build— la caja baja por HTTPS sin ningún apaño: curl -I https://… → 200. El arreglo
tardó tres intentos y los dos primeros los cazó la guarda que puse en la propia receta: primero
c_rehash no estaba en el PATH (lo instala esa misma receta en /out/usr/bin), y después los
certificados no estaban sueltos en usr/share/ca-certificates/ sino en su subdirectorio
mozilla/, así que c_rehash sólo veía el bundle y lo saltaba —correctamente— con «does not
contain exactly one certificate». Sin la guarda, las tres veces habría sellado un capath vacío.
fastfetch (2.68.1) corre en la caja — y al correrlo apareció otra ausencia:
OS: takana x86_64 ← ahora
Host: vServer (20171111)
Kernel: Linux 7.1.2 · CPU: AMD EPYC-Rome (4) · Memory: 393 MiB / 7.58 GiB
…porque /etc/os-release NO EXISTÍA. La distro era anónima para cualquier programa que no fuera
suyo. Se resolvió con una receta propia (os-release, source.dir) en vez de una constante en
takana-bootstrap: meterlo ahí re-sellaría el product-rootfs —el baseline del selfhost— por cinco
líneas. Sin VERSION_ID ni fecha a propósito: un sello con la fecha del build cambiaría el hash del
artefacto, y con él la clausura de toda imagen que lo lleve, cada día y sin motivo.
🧨 /var/lib/hammer es una partición de 487 MB — y ahí viven las generaciones
upgrade apply falló dos veces con No space left on device teniendo 3 G libres en la raíz. Ni
el espacio de / ni los inodos (9% usados): el estado de generaciones vive en
/var/lib/hammer, que es sda3 y mide 487 MB (la partición «estado» del layout). Cada
generación guarda una copia del árbol aplicado — el nuestro son 272 MB — así que dos no caben.
⇒ El estado se movió a /work por symlink, igual que qorpa. Tras eso: ✓ generación 6 aplicada.
El layout de la imagen necesita revisión: 512 MB de «estado» no alcanzan para un mecanismo que
guarda un árbol entero por generación, y el síntoma (ENOSPC con la raíz medio vacía) apunta al
sitio equivocado.
6.25 ✅ CUTOVER DE api.sergio.gioser.net — el primer servicio AJENO que sirve takana (2026-09-15)
https://api.sergio.gioser.net responde 200 con certificado público válido desde 2.29.29.217,
y su /api/chat/ contesta con Gemini de verdad. El backend corre dentro de una jaula qorpa
(rootfs de Arch pineado por sha256), supervisado por arje-zero como el ente sergioh-api.
Es el servicio que §6.21 declaró sin camino a musl —su venv trae extensiones
cpython-314-x86_64-linux-**gnu**.so— y por eso «lo que decide la fecha de borrado de gioser». El
camino elegido era qorpa (ADR 0015) y ahora está recorrido de punta a punta.
La coincidencia que lo hizo barato: el Arch bootstrap pineado trae python 3.14.7 y el venv de
gioser es 3.14.6. Misma serie ⇒ las extensiones compiladas no son un problema: el venv se rehace
adentro con pip, no se copia.
🧨 EL MURO: la jaula no viajaba en NINGUNA imagen
takana qorpa provision abortó con un mensaje correcto y una instrucción imposible:
Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
construilo: gcc -O1 -Wall -static -o … scripts/harkaq/harkaq-exec.c
En una caja instalada no hay gcc, ni scripts/, ni árbol de desarrollo. El binario sólo existía
como un gcc a mano en la caché de $HOME del hub — o sea que la jaula del ADR 0015 funcionaba
únicamente en la máquina donde alguien la había compilado. Y su compañero estaba igual, medido en
build-state.json: bwrap sellado desde hace meses con "perfiles": [], CERO perfiles. Los dos
se invocan por PATH, y no sólo desde qorpa: takana-build/src/sandbox.rs llama a bwrap en cada
build. ⇒ ninguna imagen de takana podía enjaular nada, ni construir. Es
[[subcomando-sin-driver]] un piso más abajo: el CLI que los llama viaja en todas las imágenes y sus
herramientas en ninguna.
Arreglado en la misma unidad de trabajo: recipes/harkaq-exec.toml (nueva, estática musl,
b3:cd34954f…, 269 K), bwrap + harkaq-exec declarados en perfil.base, y qorpa.rs
buscando el binario también en /usr/bin —el orden es HARKAQ_BIN → árbol de desarrollo → paquete,
para que un cambio en la jaula se pruebe sin instalar nada—. ⚠ El pin de la receta va al commit que
tocó la fuente (1d9ddcee, del 2026-09-03) y no a HEAD: el repo commitea cada media hora por el
cron de la cosecha, y pinear HEAD re-hashearía la receta cada media hora sin que su fuente se moviera.
Los tres tropiezos del camino, todos de la misma familia
chown: el rsync preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del
rango mapeado ⇒ ni siendo root adentro se puede escribir. python -m venv fallaba con
Permission denied en el directorio concedido mientras andaba perfecto en /tmp. El árbol del
sitio va a root:root del anfitrión, que es lo que el mapa de la jaula sí alcanza.
- Una instancia, un
run: el segundo qorpa run sobre la misma instancia es rechazado por el
kernel («dos overlays con el mismo upper corromperían la capa mutable»). Correcto y hay que
saberlo: con el servicio arriba no se entra a la instancia a hacer mantenimiento.
ps de la caja no ve los procesos y dig no existe en el hub. Dos herramientas ausentes que
se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un until de
espera de DNS girando cinco minutos contra una condición que nunca podía cumplirse. Un
guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.
Lo que quedó declarado, y lo que no
instance.toml es la verdad: packages = ["python","python-pip"], network = true (pacman, pip y
la propia API de Gemini la necesitan) y un solo dir concedido (/work/sergioh → /srv/sergioh,
rw). Ni sockets, ni dispositivos, ni nada más.
- El código y el venv viven fuera de la instancia, en el anfitrión: el
upper es caché (D3) y se
tira. Y las 84 dependencias quedaron pineadas en requirements.lock — el requirements.txt del
sitio no pinea nada, así que sin el lock un recreate traería otras versiones.
- La tarjeta
sergioh-api va en /etc/arje/cards.d/ y en el genesis de /ente/seed.card.json
(son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido, §6.12), con dos
guardas que salen 78 nombrando el arreglo si falta la instancia o el venv.
- ⚠ Todavía invoca con
HARKAQ_BIN=/usr/bin en la tarjeta: el takana de la caja es el del
commit pineado en recipes/takana.toml y no trae aún la búsqueda en /usr/bin. Sale solo cuando
se suba ese pin.
El estado de la mudanza tras esto
| |
| mudado hoy | api.sergio.gioser.net (DNS: CNAME→www borrado, A→2.29.29.217 TTL 60) |
| pendiente inmediato | apagar el sergioh_backend de gioser cuando propague (OpenRC y arje: rc-update del + rc-service stop + arjectl stop openrc-…, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado |
lo que falta de sergio.gioser.net | el frontend ya está copiado en /work/www/sergioh; falta /shuma/* → shuma-gateway (:7378), que es glibc dinámico y cabe en esta misma jaula (el binario está rescatado en /work/rescate-binarios/) pero cuelga de shuma-daemon, que es un sistema entero, no un binario |
| y los otros dos vivos | gioser.net/www (estáticos + /hooks/* en :8770 + /reencuentro con php-fpm) y tawasuyu.net (estático, raíz en /mnt/vvv/tawasuyu) |
6.26 ✅ tawasuyu.net y gioser.net MUDADOS — y el PHP que no existe en el corpus (2026-09-15)
Los dos sitios que quedaban con tráfico real sirven desde la caja takana, con TLS público y
verificados desde fuera. Los servicios viejos siguen corriendo en gioser a propósito (decisión del
usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).
| dominio | qué es | estado |
tawasuyu.net, www. | landing + /pkg wasm + /descargas (3,8 G) | 200 desde 2.29.29.217 |
gioser.net, www. | 5 raíces estáticas (428 M) + /reencuentro con PHP | 200 desde 2.29.29.217 |
sergio.gioser.net | frontend + /shuma/* | se queda en gioser: su consola no tiene camino todavía |
sergio dejó de ser un CNAME → www y tiene su A propio (apuntando a gioser, sin cambio
visible). Sin eso, mover www se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo:
en esta zona casi todo cuelga de www. Lo que sí quedó colgando son tres fósiles (api,
mail.sigma, monitor): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están
muertos hace meses; se anotan, no se mudan.
Dos cosas que el log de accesos decidió mejor que cualquier intuición:
- La raíz de
tawasuyu.net en gioser era el monorepo entero (145 G) servido por HTTP. El log
dice que las únicas rutas con tráfico son /, /descargas y /web ⇒ se replicaron sólo los
dos subárboles que el sitio usa (1,2 M + 3,8 G), con la MISMA estructura para que cada rewrite
siga siendo el mismo.
/hooks/* NO se muda: lo atiende webhook-deploy.py, que redespliega aura_*, sigma_*,
summa_* y brahman — los cuatro son fósiles del §6.19, y tiene cero peticiones. Muere con la
caja vieja. Un servicio vivo que sólo sirve a muertos es basura, no deuda.
PHP: la segunda jaula, y por qué no entra al corpus
/reencuentro/ es una página con un guardar.php. PHP no está en el corpus y no tiene por qué
estar: lo sirve php-fpm 8.5.10 dentro de la instancia gioser-php (ADR 0015), por
127.0.0.1:9000, supervisado por arje. Control contra el original, en los dos sentidos: POST con la
trampa anti-robots → {"ok":true} 200 igual que en gioser; GET → 405 igual que en gioser.
⚠ El webroot entra a la jaula EN LA MISMA RUTA (/work/www/gioser-web): el SCRIPT_FILENAME de
FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta
«File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN
(/work/etc/gioser-php/php-fpm.d/) y entra por concesión: el upper de la instancia es caché y
recreate se la llevaría.
🧨 Los padres que inventa bwrap son 0700 — y eso deja la concesión fuera de alcance
El muro real del día, y es general, no de PHP:
$ curl -X POST https://gioser.net/reencuentro/guardar.php
File not found. ← y el fichero ESTÁ, montado, con sus modos correctos
Para montar en /work/www/gioser-web, bwrap tiene que crear /work y /work/www —que la imagen de
Arch no trae— y los crea drwx------ root. Adentro somos root, así que todo a mano funciona; pero
un servicio que baja de privilegio (php-fpm a http, o cualquier instancia con run_as) no puede
ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como
http, leer un fichero de la imagen funciona y leer el directorio concedido da
Permission denied.
⇒ grants_to_args crea ahora los ancestros que faltan con --perms 0755 --dir, y sólo los que la
imagen no trae: hacerlo sobre /etc o /home le cambiaría los modos a la imagen. Con test que
falla a propósito si la condición se rompe (left: ["/etc","/etc/php"] contra right: ["/etc/php"]).
Misma familia que el --perms 1777 que ya hacía falta antes del --tmpfs /tmp.
⚠ La caja corre el takana del commit pineado en recipes/takana.toml, así que allá el arreglo
todavía no está: el camino se abrió a mano en el upper de la instancia (chmod 755 /work /work/www). Es exactamente lo que el arreglo vuelve innecesario en el próximo takana upgrade.
Y el estado de rust y de claude, que no es el que parece
rust está en la caja pero no instalado: rust-toolchain-bin está sellado en el store y
declarado en perfil.servidor, y rustc 1.97.0 corre invocándolo por su ruta del store — pero
no está en el PATH porque la caja se instaló antes de esa declaración. Proyectarlo son 798 M
sobre una raíz de 5,8 G con 2,9 G libres: entra, pero el layout de la imagen (§6.24) merece la
revisión primero.
claude no está mudado, y es otro caso de jaula: el binario de gioser es glibc dinámico
(/lib64/ld-linux-x86-64.so.2), o sea el mismo montón que el backend de sergio. Lo que pesa no
es el binario sino su estado: 6,8 G de ~/.claude, de los cuales 5,3 G son jobs y 976 M
projects — y ahí vive la memoria, indexada POR RUTA del repo. Mudarlo es una decisión sobre
qué se lleva, no una copia.
6.27 🧦 squid mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init (2026-09-15)
El proxy de salida sirve desde la caja: squid 7.7 en 2.29.29.217:1137, con la config, el
passwd de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia squid y
supervisado por arje. El de gioser sigue vivo e intacto (7.6), así que los clientes que apuntan
a la IP vieja no se enteraron.
Es un servicio con gente encima: el access.log del origen mostraba CONNECT api.anthropic.com y
CONNECT claude.ai en el minuto anterior a mudarlo, desde IPs de Venezuela y con tres usuarios
autenticados. Por eso se mudó entero y sin reescribir nada.
Control en los dos sentidos: desde una IP no autorizada, 407 Proxy Authentication Required
—igual que el original—; desde localhost, que su propia config permite, TCP_TUNNEL/200 a
claude.ai y a api.anthropic.com.
Los cuatro muros, y el cuarto es el que vale
provision con la config ya montada aborta: pacman dice «squid.conf.default exists in
filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar
primero, montar después. Queda escrito en el manifiesto.
/dev/shm lo crea bwrap 0755 root, y squid —que baja a proxy— muere con
FATAL: shm_open(...): (13) Permission denied. Un chmod 1777 en el arranque.
- El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco: cada
run tiene su propio
namespace de PIDs, así que el /run/squid.pid de la corrida anterior dice PID 2… y en la nueva
hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ rm -f en el arranque.
Es una trampa general de cualquier servicio con pidfile dentro de una jaula con --unshare-pid.
- 🧨 El rango de subuid se buscaba por
$USER — y un init no lo pone. Bajo arje la tarjeta
arranca con PATH y HOME y poco más; el nombre salía vacío, no había rango, y la jaula caía
al userns de un solo id. Squid moría en setgid(15): (22) Invalid argument y el supervisor lo
reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para "" en
/etc/subuid»— pero lo que se ve en el bucle es el error de squid, no el nuestro: la degradación
silenciosa la paga el de más abajo. Y el mismo comando a mano, desde una shell con $USER,
funcionaba perfecto: el síntoma dependía de quién lo arrancaba.
⇒ nombre_de_usuario() deriva el nombre del uid real leyendo /etc/passwd, y el entorno pasa
a ser el último recurso. El uid es el hecho; $USER es una etiqueta ([[la-etiqueta-no-es-el-hecho]]).
Las tarjetas llevan además USER/LOGNAME explícitos, que era el arreglo del día.
⚠ arjectl start sobre un Ente ya vivo lo DUPLICA (se vio con gioser-php: dos entes, uno
peleando por el mismo upper). Y matar el proceso de adentro no libera la instancia: el
takana qorpa run de afuera sigue vivo y el siguiente run choca. Para relanzar, stop y después
start, mirando status en el medio.
Y la pregunta que corresponde: ¿no va en una receta?
Sí, y sigue pendiente. El propio §6.2 pone a squid en la lista de recetas que esta mudanza
tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en
minutos, sin compilar nada—, no la respuesta final. La diferencia con php-fpm importa:
PHP no queremos que entre al corpus (es un intérprete ajeno al proyecto, y la jaula es su lugar
definitivo), mientras que squid es C, se compila con el lab y su sitio natural es una receta
como la de gitea. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.
6.28 📦 squid YA NO ESTÁ ENJAULADO: corre desde el corpus (2026-09-15)
La jaula del §6.27 duró unas horas, que es lo que tenía que durar. Hoy el proxy de producción es
el artefacto sellado b3:932ba096…, proyectado en la caja y arrancado por arje sin bwrap de por
medio: pid 11548 · uid=968 · exe=/usr/sbin/squid. La instancia qorpa/squid se borró — el
manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.
recipes/squid.toml: 7.7, estático de verdad (binario y los cuatro helpers), [[user]] proxy
con uid 968 y [[service]] del SDD 30. Declarado en perfil.servidor como paquete y en servicios.
La config NO viaja en el paquete y es a propósito: squid.conf, el passwd de los tres usuarios
y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar
y sale 78 nombrando cuál falta, en vez de dejar un bucle de reinicios.
Los tres muros de la receta, en orden
- La URL de upstream devolvía una página HTML de 8985 bytes con un 200.
squid-cache.org/Versions/v7/squid-7.7.tar.xz no es el tarball. Pinear su sha256 habría anclado
la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de
GitHub, que además trae configure generado.
- 🧨
--export-dynamic desactivaba el enlace estático, y el binario sellaba igual. El primer
artefacto tenía usr/sbin/squid dinámico con NEEDED libc.so mientras sus helpers salían
estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a
link = "static". La cadena tiene tres eslabones y ninguno es obvio: squid_LDFLAGS trae
-export-dynamic (para módulos eCAP, apagados) → el wrapper del lab quita -static de
cualquier enlace que mencione --export-dynamic, a propósito, para poder enlazar las .so
dlopen-ables de Python → y libtool vuelve a añadirlo solo en cuanto hay un -dlopen, así que
sacarlo de la variable no alcanza. Se vacía export_dynamic_flag_spec en el libtool GENERADO,
con guarda que aborta si el campo no aparece: un sed que no acierta deja pasar el problema y
el binario sale dinámico sin que nada falle.
⚠ Y -dlopen force no sobra: vaciar squid_LDFLAGS entero rompe el enlace con
undefined symbol: lt__PROGRAM__LTX_preloaded_symbols, que emite el propio libtool porque hay un
-dlopen. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
--disable-log-daemon-helpers compilaba perfecto y no arrancaba. El default de access_log
es daemon:, así que sin log_file_daemon squid muere con
FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory. Sólo se
ve levantando el servicio con la config real; el sellado, el hash y el -k parse pasan los tres.
Es la familia de [[subcomando-sin-driver]] otra vez.
El control que hay que correr antes de tocar producción
Con la config REAL del origen, en el hub y en un puerto aparte: -k parse sin FATAL, arranque
completo, CONNECT claude.ai y CONNECT api.anthropic.com con TCP_TUNNEL/200, el access.log
escrito por el log daemon, y el basic_ncsa_auth leyendo el passwd de verdad (control negativo:
con una clave mala contesta ERR Wrong password). Los dos primeros intentos de esta receta habrían
llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano (2026-09-15)
planear.py --revisar sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
los servicios del usuario sino tres capacidades que ninguna métrica reclama: el disparador
periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
perfil.servidor desde que ese perfil nació — lo que faltaba era el eslabón que los ARRANCA.
| antes | ahora |
chronyd | la caja iba 15 s atrasada y nadie la corregía | stratum 3 contra ntp{1,2,3}.hetzner.de, 0,000005 s de NTP |
crond | no existía el latido | ejecuta el crontab real, verificado con una entrada * * * * * |
logrotate | /var/log/squid sin rotar | diario con squid -k rotate, 14 copias |
| respaldo del gitea | a mano, una vez (§6.15) | 17 3 * * *, con el snapshot consistente de la sqlite |
El respaldo, medido: scripts/respaldo-gitea.sh sube 44 repos (23 sergio + 21 tawasuyu,
1,5 G) y un gitea.db de 332 M tomado con .backup —consistente con el servidor vivo— al
Storage Box. El control no es que el script salga 0: es contar los repos de los dos lados, que
es lo que se hizo.
⚠ chrony y cronie NO declaraban [[service]], como squid antes: el paquete llegaba a la
imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de hash_inputs: los hashes no se movieron)
y perfil.servidor los habilita en servicios.
⚠ La config va aparte y el servicio sale 78 si falta, igual que gitea: a qué NTP se pregunta y
qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
eligió es peor que una sin hora, porque nadie lo mira.
Tres cosas medidas que valen más que el resultado
- La deriva era de gioser, no de la caja. Antes de chrony la caja iba 15 s atrás; después, la
caja quedó en hora de NTP y el hub pasó a estar 3 s adelantado. La máquina de referencia era
la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
- El spool de cronie es
/var/spool/cron/<usuario>, no …/crontabs/<usuario>. crontab -l
mostraba las entradas y crontabs/ estaba vacío. El día que alguien restaure un crontab a mano
en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
intuición: meter un * * * * * en el crontab REAL y ver si dispara.
- 🧨
sqlite3 seguía inerte en la caja (Error relocating: compressBound: symbol not found):
es el último de los 23 binarios del §6.4, y le faltaba zlib-shared — sellado y declarado en
perfil.base desde el 2026-09-11, pero la caja se instaló antes. Sin él no había snapshot
consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. Un paquete declarado no
es un paquete instalado: en una caja viva, lo que vale es lo que el upgrade proyectó.
6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía (2026-09-15)
Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:
| servicio | por qué muere |
qdrant (:6333, :6334) | es la base vectorial de gioser_api, y ese backend es un fósil: api.gioser.net da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta recipes/qdrant.toml queda en el catálogo —catálogo ≠ imagen, y eso no es deuda—, pero no entra en ningún perfil. |
act_runner (:41027) | el runner de CI del gitea, un binario suelto en ~/.local/bin que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado. |
fail2ban-server | tawasuyu ya tiene el sustituto, y es mejor: shared/cortafuegos (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa dos sets dinámicos del kernel (ban4/ban6, flags dynamic, timeout) con limit rate over N/minute por IP de origen. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor. |
🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae nf_tables
$ nft list ruleset
netlink: Error: cache initialization failed: Invalid argument
nft 1.1.6 está instalado (nftables es raíz de perfil.servidor y está sellado). Lo que falta
está un piso más abajo, en el .config que el artefacto publica:
CONFIG_NETFILTER=y ← sí
CONFIG_NF_CONNTRACK=y ← sí
# CONFIG_NF_TABLES is not set ← AQUÍ
# CONFIG_NETFILTER_ADVANCED is not set ← y ésta es la causa: NF_TABLES cuelga de ella
Es otra vez la lección del §6.4 muro 1: la receta dice lo que se CAMBIÓ; sólo el .config sellado
dice lo que QUEDÓ. linux-generic sale de un make defconfig, y defconfig deja NETFILTER_ADVANCED
apagado, que arrastra a NF_TABLES con él.
⇒ Hoy la caja no tiene cortafuegos de ninguna clase: ni fail2ban (que no se muda), ni el
sustituto (que no arranca), ni una regla. Con :22022, :2345, :1137, :80 y :443 públicos,
eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con
CONFIG_NETFILTER_ADVANCED=y + NF_TABLES (y NFT_CT, NFT_LIMIT, NFT_COUNTER, NFT_LOG),
que es trabajo del SDD 22 y termina en un reinicio de la caja de producción.
La mitigación que NO depende del kernel, hecha hoy
El sshd de la caja ofrecía password y keyboard-interactive — su config eran cuatro líneas
y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin
fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es sólo claves, con los
cuatro controles corridos: la clave entra ✅, :22022 contesta Permission denied (publickey) sin
ofrecer contraseña ✅, :2345 igual ✅, y el git ls-remote por SSH sigue funcionando ✅.
Lo que enseña: una defensa que se muda tiene que mudarse con su capacidad, no con su nombre.
Decidir «fail2ban muere porque tenemos algo mejor» es correcto y deja un hueco abierto hasta que
ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.
6.31 ✅ La caja arrancó con nf_tables — y el init la levantó ENTERA sola (2026-09-15)
$ nft list ruleset
$ ← sin error: vacío, que es lo correcto en una caja sin reglas
El kernel b3:63fdd57c… bootea, y con él nft funciona por primera vez en una máquina takana.
Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un
set ... flags dynamic; timeout 10s con add @ban4 { ip saddr timeout 10s limit rate over 5/minute } drop — aceptado por el kernel (rc=0) y releído tal cual. Eso es el ban por IP dentro del
kernel, sin daemon.
Lo que el reinicio probó de paso, y vale más que el kernel
La Semilla levantó los OCHO entes sola, todos con ↻ 0: sshd, gitea, caddy, squid,
crond, chronyd, sergioh-api y gioser-php — o sea los dos servicios enjaulados en qorpa
(con su newuidmap, su overlay y su harkaq) arrancan desde el genesis igual que un binario
nativo. Todo lo que se fue editando en /ente/seed.card.json a lo largo del día era, hasta este
momento, una declaración sin probar: un reinicio es el único examen que toma.
Desde fuera, al minuto: los siete dominios en 200, el proxy en 407 y git ls-remote por
SSH:2345 devolviendo el commit. Cero intervención.
⚠ reboot NO reinicia una caja con arje-zero
reboot de busybox le pide a PID 1 que lo haga, y arje-zero no implementa ese protocolo: el
comando vuelve, no dice nada, y la máquina sigue arriba (up 4 days después de «reiniciar»). Lo que
sí funciona es reboot -f, que llama a reboot(2) directo saltándose al init. Con sync antes,
claro: el corte es duro por definición.
⇒ Y falta la pieza: un init que gobierna un servidor de producción tiene que atender un apagado
ordenado. Hoy la única vía es el corte duro, que ya costó el write_atomic no durable del §6.13.
🧨 Casi reinicio el HUB por un backtick en un echo
Escribiendo el aviso del reinicio, el mensaje decía … no atiende el `reboot` normal … dentro de
comillas DOBLES. La shell ejecutó lo que había entre backticks: reboot, en gioser. No pasó nada
por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»—
y el hub lleva 11 días arriba, con la granja y el cron encima.
Es la tercera vez en el día que un backtick dentro de un texto se ejecuta
([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario.
La regla deja de ser de estilo: nunca un backtick dentro de comillas dobles en una shell —
comillas simples, o $(printf '%s' …), o directamente no poner el acento grave en el texto.
Lo que queda para cerrar el frente del cortafuegos
El kernel ya no estorba; falta la política: PoliticaEntrada (.ron) → generate-input →
apply-input del cortafuegos de tawasuyu, con los puertos reales de la caja (22022, 2345, 80,
443, 1137). ⚠ Y hay que aplicarla con interruptor de hombre muerto (aplicar, esperar, y
flush automático si nadie confirma): una regla mal puesta en una caja sin consola serie deja
la única salida en hcloud server enable-rescue.
6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo (2026-09-15)
La caja filtra: table inet tawasuyu_input, policy drop, 15 reglas de puerto, y el set ban4
llenándose solo con lo que golpea desde internet. A los pocos minutos: 27 IPs baneadas y paquetes
tirados por el contador. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del
kernel y sin daemon.
La política vive en scripts/servidor/cortafuegos-entrada.ron (formato PoliticaEntrada del
cortafuegos de tawasuyu) y declara los cinco puertos públicos: 22022 admin, 2345 git, 80, 443
y 1137 el proxy. El reglaset se genera con cortafuegos generate-input en el hub —el binario es
glibc y la caja es musl— y se aplica allá con nft -f.
🧨 El primer baneado fui yo, en segundos
Aplicada la primera versión, desde fuera: ssh ✅, https ✅… y http, el proxy y el git por SSH
muertos. Al minuto, la caja entera dejó de contestarme.
La causa no es una regla mal escrita: el set @ban4 es UNO SOLO para todos los servicios, y la
regla ip saddr @ban4 drop está antes que todo. O sea que pasarse de tasa en un puerto te tira en
todos. Y el nuevas_por_min: 5 del puerto de administración —correcto contra internet— es
exactamente lo que me pasa a mí: cada comando remoto de esta sesión es una conexión nueva.
Lo que devolvió la máquina fue el interruptor de hombre muerto: antes de aplicar se deja
programado un nft flush ruleset a 180 s, y sólo se desarma si la verificación desde fuera sale
bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.
⚠ Y el tipo ya lo avisaba. PoliticaEntrada tiene un campo confiables cuyo comentario dice,
palabra por palabra, lo que me acababa de pasar: «un nuevas_por_min chico en el puerto de SSH es
exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre
seis sesiones en un minuto sos vos». Copié el .ron de ejemplo en vez de leer el tipo. Es
[[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea:
confiables: ["204.168.193.248", "154.197.1.13"] — el hub y el worker, que se aceptan antes de
la lógica de tasa y por lo tanto no pueden entrar al set.
Lo que este kernel todavía no puede, dicho en voz alta
nft -c rechazó cinco reglas antes de aplicar nada:
tcp dport 22022 ct count over 10 drop
^^^^^^^^ Error: Could not process rule: No such file or directory
ct count es CONFIG_NFT_CONNLIMIT, que el kernel de hoy no trae (el .config lo dice:
# CONFIG_NFT_CONNLIMIT is not set). Es el tope de conexiones concurrentes por servicio — el ban
por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan
comentadas, no borradas, en el fichero que se aplica: lo que la política declara y el kernel no
puede tiene que verse. El símbolo ya entró a recipes/linux-generic.toml y vuelve con el próximo
kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.
Que sobreviva al reinicio: un servicio oneshot
recipes/nftables.toml declara ahora su [[service]] con lifecycle = "oneshot": cargar un
reglaset no es un daemon, es un acto. Va primero en el genesis, antes que sshd y los demás:
entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más
corto posible. Probado de verdad — nft flush ruleset y después arjectl start cortafuegos: las 15
reglas vuelven.
Su guarda sale 78 nombrando el fichero si el reglaset no está, en vez de dejar la caja sin
filtrar creyendo que está protegida: un cortafuegos ausente y uno vacío se ven igual desde afuera
hasta que alguien prueba.
6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó (2026-09-16)
El usuario preguntó si el proxy estaba caído. No lo estaba: el ente squid llevaba horas
corriendo con ↻ 0, escuchando en :1137 y contestando 407 a quien preguntara. Lo que estaba
caído era el acceso: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.
La prueba, en una línea: 154.197.1.2 —una IP que está en la ACL de squid, o sea gente con
contraseña— apareció en @ban4. Y el contador del ban iba en 38.246 paquetes tirados.
La causa no es la tasa: es la RÁFAGA
tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop
burst 5 significa «tolero cinco conexiones por encima del promedio». Un navegador o un agente
detrás de un proxy abre decenas de CONNECT en el mismo instante, aunque su promedio sea bajísimo:
seis de golpe y ya estás en el set. Y como @ban4 es UN SOLO set consultado antes que todo,
caer por el puerto del proxy te tira también el web y el git.
⚠ El tipo del cortafuegos ya traía el campo (rafaga, default 5) con un comentario que decía
«para un servicio web va alto (100-200)». Otra vez: leí el .ron de ejemplo y no el tipo.
Lo que se hizo, en orden de urgencia
nft flush set … ban4/ban6 — servicio restaurado en el acto.
- Los usuarios del proxy a
confiables: la misma lista que la ACL de squid (21 IPs + 5 redes).
Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido;
lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
rafaga por servicio, según su clientela: ssh 5 · git 20 · web 100 · proxy 200. Y el
proxy pasa a 1200/min con ban de 60 s: red de contención contra un flood, no control de uso.
- Control después: cero autorizados en el set, 24 baneados (escáneres, que es lo que se quiere),
y tráfico real pasando —
154.194.14.37 … CONNECT api.deepseek.com:443 sigma, 298 K tunelados.
⚠⚠ Y un fallo de método que casi lo tapa: nft -f SUMA a la tabla existente. Cargué el reglaset
corregido encima del viejo y quedaron 30 reglas: las viejas ADELANTE, así que los confiables
nuevos no llegaban a evaluarse nunca. Se vio contando (grep -c dport daba 30 y no 15) y mirando
qué saddr salía primero. Por eso cortafuegos apply-input borra la tabla antes de cargar —
usar nft -f a pelo se saltea esa mitad.
La hora exacta, reconstruida del access.log
El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —45.234.61.160,
autenticada como sergio, 2709 peticiones— tuvo estos silencios:
| silencio | desde | hasta |
| 93,2 min | 16-09 11:59:37 UTC | 13:32:49 UTC ← cuando vacié los sets |
| 50,1 min | 10:44:58 | 11:35:06 |
| 10,0 min | 11:45:06 | 11:55:05 |
Los tres son el timeout de 10 minutos del ban disparándose una y otra vez: cada vez que volvía,
su primera ráfaga lo baneaba de nuevo. 154.197.1.2, que usa el proxy de forma sostenida pero sin
ráfagas, sólo perdió huecos de 5 minutos.
🧨 Y la exención que tenía no servía: su IP no estaba en la red eximida. La ACL histórica de squid
exime 45.234.60.0/24 y él entra desde 45.234.61.160 — una red de al lado. Parecía cubierto y
no lo estaba: la pertenencia se MIDE (ip in red), no se deduce del parecido del prefijo. Con el
/23 que nft dedujo al unir los dos /24, ahora sí.
Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa
El uso real de esta caja es intensivo —varias sesiones de agente en paralelo, cada una abriendo
túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el
atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y
contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado
(60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope
GLOBAL syn_nuevas_por_seg, que no distingue usuarios ni banea a nadie por ser rápido.
Lo que esto enseña del cortafuegos como herramienta
Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia no se ve al
aplicarlo: las reglas cargan, el nft -c pasa, los sitios responden, y el daño aparece sólo cuando
un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es
el que no corrí: una ráfaga de conexiones desde una IP que NO esté en confiables.
6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M
Censo fresco de gioser (censar.py --local --probe-dns, sólo lee), para saber qué falta de
verdad en vez de arrastrar la lista del 11.
servicios vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
dominios apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
datos /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M
De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: sergio.gioser.net.
Comprobado desde fuera, resolución + HTTP, no por el fichero de config:
| dominio | resuelve a | HTTP |
tawasuyu.net · gioser.net · git.tawasuyu.net · takana.gioser.net · hifas.gioser.net · api.sergio.gioser.net | 2.29.29.217 | 200 |
gitea.gioser.net | 2.29.29.217 | 301 |
sergio.gioser.net | 204.168.193.248 (gioser) | 200 |
api.summa.gioser.net | 154.197.1.2 (otra máquina) | — |
terapeuta.ec, andino.ec, api.sigma… y 4 más | sin DNS | — |
⇒ la lista de «tres vhosts» del §9 quedó vieja: gioser.net y tawasuyu.net ya están mudados
(§6.26) con su origen vivo a propósito. Lo único que ata el DNS a gioser es la consola de sergio.
El rescate del §6.20 sigue vigente — y caduca de verdad
Seis procesos corren hoy desde un inodo borrado. Cuatro tienen su copia en
work/mudanza/rescate/ y el sha256 del /proc/<pid>/exe vivo coincide bit a bit con lo
rescatado el 12. Los otros dos (/usr/local/bin/tejido y /usr/local/bin/shuma-daemon) no están
en el rescate, pero en disco hay una versión más nueva de cada uno (hoy 11:13 y 14:20): no se
perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: ese
paso caduca, y quién caducó se ve mirando /proc, no el directorio.
🕳️ Y el hallazgo del día: -e contesta «no existe» cuando lo que pasa es «no puedo mirar»
El censo tiene tres estados a propósito (ausente / sin_permiso / ok) porque un ls sin
permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba sobre la
propia ruta: cuando el que no deja pasar es un ANCESTRO, [ -e "$p" ] contesta que no, y la
ruta caía en ausente — que el censo descarta sin decir nada.
/root/.local drwx------ root root ← 0700
/root/.local/bin 572 M de binarios a mano que NINGÚN paquete provee
Corrido como sergio, el censo decía que /root/.local/bin no existe. Corrido como root, son
572 M de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano.
Arreglado —si un ancestro existe y no es atravesable, el estado es sin_permiso— y probado en los
dos sentidos, que es lo que distingue un guardián de un adorno:
ancestro 0700 de otro dueño → sin_permiso (y sale en el ⚠ del resumen)
ruta que NO existe de verdad → ausente (y NO se reporta) ← el control que tiene que pasar
Con el arreglo, el censo como sergio pasa de 2 rutas ciegas a 13 — entre ellas el home entero
de artix, que antes no figuraba de ninguna manera. El censo se corre como root; la lista de
ciegas es el recibo de lo que una corrida sin privilegio no puede ver.
Lo que queda, y por qué no lo decide el programa
Descontados los que ya se decidieron (§6.30: qdrant, act_runner, fail2ban mueren) y los que
provee el init del destino, lo que sigue vivo en gioser es un montón de daemons de tawasuyu que
ningún paquete provee — binarios a mano en /usr/local/bin (1,2 G) y ~/.local/bin (765 M + 572 M
en el home de root):
shuma-daemon · shuma-gateway (:7378) la consola de sergio.gioser.net — el único vhost que queda
willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
puerta-f6e393ffbef3999e (:34221,:44961) binario de test en target/debug, BORRADO del disco
webhook-deploy.py
planear.py --revisar los saca con su motivo, pero la decisión es del usuario y el programa no la
va a inventar: son las herramientas propias del usuario, no servicios de un catálogo. Y los
datos tampoco se recomiendan solos (§3): /home son 25 G que incluyen 6,8 G de .claude y
1,3 G de binarios sueltos.
6.35 🧳 Los binarios sueltos, desglosados — y qué de .claude viaja (2026-09-16)
Decisión del usuario sobre el §6.34: shuma se construye desde tawasuyu (ya está encolado:
recipes/incoming/shuma-{gateway,daemon}.toml), los daemons de tawasuyu viven todos, y de
.claude decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.
Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable
| clase | cuánto | qué es | se reconstruye |
| tawasuyu | 57 ficheros · 1003 M | pata-llimphi 105 M, nahual-shell-llimphi 98 M, shuma-shell-llimphi 76 M, los 14 mirada-*, pata-*, shuma-*, sandokan-*, willay-*, pacha, tejido, tupu, matilda, thasnuna… | sí: son salida del monorepo, que ya vive en el gitea de la caja |
| terceros | 10 ficheros · 841 M | kiro-cli* 571 M (tres binarios), kcl 95 M, qdrant 91 M (muere, §6.30), gitea-runner 21 M, act_runner 21 M (muere), listmonk 19 M, hcloud 18 M, goaccess 3,4 M | no, pero se vuelven a bajar de su origen |
| respaldo / duplicado | 21 ficheros · 876 M | /root/.local/bin es una copia entera de kiro-cli* (571 M) más el CLI de claude (219 M); el resto son .respaldo-FECHA, .previo y .bak | no hace falta |
| guiones | 27 ficheros · < 1 M | git-deploy, webhook-deploy.py, update-squid-allowed-ips.sh, mirada-session*, huellita-aviso… | NO ← lo único de verdad frágil |
⇒ lo caro no es lo valioso. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no
está en ningún repo ni en ningún paquete son 27 guiones que suman menos de un megabyte — el
pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.
Y los 572 M de /root/.local/bin que el censo no veía (§6.34) resultaron ser exactamente eso:
la copia de kiro-cli* que ya está en el home de sergio. La lección no cambia —un censo que dice
«no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era
duplicado.
.claude: 6,8 G en disco, 15 M es lo que hace falta para seguir funcionando igual
Criterio, porque es lo que me deja trabajar allá como acá:
| viaja | qué | tamaño |
| ✅ | projects/*/memory/ de los 18 proyectos — la memoria destilada | 6,2 M |
| ✅ | settings.json, plugins/, history.jsonl | ~8,6 M |
| ⛔ | jobs/ (5,3 G), downloads/ (228 M), archivo-sesiones/ (286 M), file-history/, shell-snapshots/, cache/, paste-cache/ | 6,1 G |
| ⚠ | projects/*/*.jsonl — los transcripts crudos (973 M) | ver abajo |
Los transcripts no hacen falta para funcionar: la memoria es su destilado y es lo que se lee al
arrancar una sesión. Lo que se pierde sin ellos es --resume de una sesión vieja y la posibilidad
de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en
/work y los quiere para el store— sino al Storage Box del respaldo, que es donde vive lo que
se guarda por si acaso. La memoria, en cambio, viaja con la máquina.
⚠ Y una trampa de ruta que haría todo esto inútil
La memoria se indexa POR RUTA: el directorio se llama -mnt-vvv-takana porque el repo vive en
/mnt/vvv/takana. En la caja el repo está en /opt/takana ⇒ copiar memory/ tal cual deja los
163 ficheros en disco y ninguno se encuentra, sin que nada falle. Las dos salidas son renombrar
el directorio a la ruta de destino (-opt-takana) o darle al repo la misma ruta que acá. Se elige
al copiar, no después.
🕳️ La caja no tiene usuario sergio
/etc/passwd de 2.29.29.217: root, sshd, gitea, proxy. Nada más. En gioser,
shuma-daemon corre como sergio —y shuma-gateway también—, así que la tarjeta de arje que los
declare tiene que crear la cuenta primero; y el payload Native de arje no tiene campo de
usuario (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la
misma advertencia que ya lleva recipes/incoming/shuma-daemon.toml en su cabecera, escrita donde se
va a leer.
6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO (2026-09-16)
Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. Cada copia se
verificó del lado del destino, que es la regla que el rsync con exit 0 y 1367 artefactos vacíos
dejó escrita (§6.13, y la regla 2 de aplicar.py).
| qué | a dónde | verificación en destino |
| los 27 guiones (75 KB) | takana:/work/rescate-guiones/ | sha256sum -c SHA256SUMS ⇒ 27 OK |
la memoria de Claude (733 ficheros, 6,3 M) + settings.json, plugins, history.jsonl | takana:/root/.claude/ | 733 ficheros en origen y 733 en destino |
| los transcripts crudos (1 966 ficheros, 1,3 G) | Storage Box claude-gioser/ | rsync -an de vuelta: 1 solo fichero pendiente, y es el transcript de la sesión que estaba escribiéndolo |
| la clave PRIVADA de release | takana:/root/.config/takana/keys/ 0600 | sha256 idéntico en los dos lados y su pública == trust/release.ed25519.pub |
| credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile) | takana:/work/mudanza-secretos/ 0700 | 27 ficheros en origen y 27 en destino |
estado de los daemons que viven (sigma, tupu, willay, sandokan-watch.json) + /var/www/para-respaldar | takana:/work/mudanza-estado/ | 351 ficheros y 68 M en los dos lados |
⚠ Las credenciales NO se instalaron en su sitio, a propósito
La caja ya tiene su /root/.ssh (con github5), su crontab (el latido de la caja, §6.29) y su
/etc/caddy/Caddyfile con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de
gioser rompe lo que hoy funciona. Quedan en /work/mudanza-secretos/ con su LEEME.txt, y se
instalan cuando se mude el hub, que es otra unidad de trabajo. El .gitconfig es el caso más
claro: trae los insteadOf que reescriben remotos, así que instalarlo cambia a dónde empuja
git push sin que nadie lo pida.
La única excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede
volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que
alguien publica.
🔍 Un «576 M contra 1,3 G» que parecía una copia a medias
du -sh en el Storage Box decía 576 M de los 1,3 G subidos. Con el antecedente del rsync que
llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box comprime, y su shell
restringida no tiene find ni acepta tuberías, así que el conteo de ficheros —la verificación
obvia— ahí no se puede hacer. Lo que sí se puede es preguntarle a rsync: una corrida -an
compara tamaño y fecha contra el destino real y dijo 1 fichero pendiente, el que estaba
creciendo mientras se copiaba.
⇒ la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina. Y un
número que no cuadra merece una segunda medición antes de un diagnóstico: du medía otra cosa.
Lo que NO se movió, y no es olvido
~10 G de árboles personales en /home/sergio —android-sdk 1 G, maps, imm, humanoid,
hifas, fdroid, valens-conversion, pyswisseph, accordion…— y /opt (1,2 G, que es
android-sdk otra vez y google). Qué datos valen no se deduce de la máquina (§3), y éstos son
proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha
por criterio ajeno.
De /var/lib (8,1 G) se movió sólo el estado de los daemons que viven: 6,1 G son swap y 1,9 G
el gitea, que ya está mudado desde el §6.14.
6.37 ✅ CUTOVER DE sergio.gioser.net — el último vhost, y gioser deja de servir (2026-09-16)
Ya no queda ningún dominio sirviéndose desde gioser. El último era éste, y lo que lo ataba no
era el sitio sino su consola: /shuma/*. Verificado desde fuera, contra la IP nueva:
https://sergio.gioser.net/ → 200 408 bytes, la SPA
https://sergio.gioser.net/shuma/ → 200 «shuma-gateway ok» ← el gateway, no la SPA
https://sergio.gioser.net/ruta-inventada → 200 la SPA (el fallback, que es lo correcto)
⚠ El 200 de /shuma/ había que mirarlo dos veces. El bloque termina en
try_files {path} {path}/ /index.html, así que cualquier ruta inexistente devuelve 200 con la
SPA: un curl -o /dev/null que sólo mira el código habría dado el mismo verde con el proxy mal
puesto. Lo que decide es el CUERPO — 17 bytes que dicen shuma-gateway ok — y el control es
compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con /shuma/rpc: las
dos máquinas contestan el MISMO 400 {"error":"bad json: …"}.
Lo que hubo que construir, en orden
- Las dos recetas (§6.34) sellaron en el worker:
b3:107dfb54…-shuma-gateway (6,3 M) y
b3:f9b92acc…-shuma-daemon (5,6 M) — los hashes que takana hash había anticipado antes de
encolarlas. file dice statically linked: nada de cargador, que era exactamente lo que le
faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
- La cuenta, declarada y sin privilegio.
[[user]] shuma (uid 967) en la receta del daemon, y
el descenso con setuidgid dentro del argv de la Card, igual que gitea — porque el payload
Native de arje no tiene campo de usuario. Declararlo «como estaba» lo habría puesto de
root, y esto es un demonio que abre PTYs y lanza shells.
- ⚠ Y la shell de la cuenta NO es un detalle. El default de
[[user]] es /bin/false, que es
lo correcto para gitea o squid y exactamente lo contrario para éste: con la shell inerte el
servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante.
Un fallo que no se ve al desplegar: se ve al usarlo. Va shell = "/bin/sh".
- La config que no se regenera. El
gateway-token (32 B) y keys/identity.x25519 viajaron a
/var/lib/shuma/.config/shuma/: generar unos nuevos deja fuera a los clientes ya emparejados.
Por eso la guarda de la Card exige el token y no lo crea, y dice de dónde sale.
- Las dos Cards entran en
/etc/arje/cards.d/ y en el genesis de /ente/seed.card.json
(son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla
quedó con 13 entradas y vuelve a parsear.
- Declarado en
perfil.servidor: los dos paquetes y los dos labels en servicios. Entran como
PAR — el gateway sin el daemon es un puente a ninguna parte.
[[user]] y [[service]] están fuera de hash_inputs, así que declarar todo eso no movió
ningún ArtifactHash: comprobado en los dos, antes y después.
🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado
Aquel cutover dejó escrito «un PUT sobre el rrset no puede cambiar el tipo: hay que DELETE del
CNAME y POST del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el PUT tampoco
sirve —can't update records with this endpoint— pero existe una acción que lo hace atómica:
POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records {"records":[{"value":"…"}]}
Sin ventana sin registro, que es lo que sí tiene el DELETE+POST. Con TTL 60 la resolución
pública cambió en menos de 8 segundos, y volver atrás es la misma llamada con la IP vieja.
El origen sigue vivo, a propósito
Como en §6.26: en gioser shuma-daemon, shuma-gateway y su bloque de caddy siguen corriendo.
Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras
la máquina exista. Lo que ya no existe es la dependencia: ningún dominio resuelve a gioser.
⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra
shuma-askpass («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es
otra receta — no bloquea la consola.
6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja (2026-09-16)
De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:
| cuenta | qué hace, comprobado |
matilda | root | lee el access log de caddy y archivó 78 ficheros en /var/lib/tupu/takana/ |
pacha | pacha (965) | vivo, 0 reinicios, con SUS reglas (reglas.ron), no las de fábrica |
pacha-secretos | pacha | vivo, 0 reinicios — y su binario en gioser era un inodo borrado |
sandokan-watch | root | frenado a propósito: no puede trabajar acá (ver abajo) |
⚠ Tres corren como ROOT, y está declarado en vez de heredado
Lo natural sería una cuenta sin privilegio por servicio, como shuma o gitea. Con el trío de la
telemetría no se puede, y las dos razones son medidas:
matilda lee /var/log/caddy/requests.json, que caddy crea -rw------- root root y recrea
igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
tupu, matilda y sandokan-watch escriben en el MISMO /var/lib/tupu, y en gioser sus 322
ficheros son root:root. Bajar uno solo deja mezcla de dueños en un árbol compartido: escribe
el primero y los otros fallan al modificar.
⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se
quiera: que caddy escriba con mode 640 y que el árbol sea de una cuenta propia. pacha y
pacha-secretos sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.
🕳️ La caja se llamaba «(none)» — y eso se archivaba
matilda archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a /var/lib/tupu/**none**/:
$ hostname → (none)
$ cat /etc/hostname → no existe
Ninguna caja takana fija su hostname: no lo hace netup, no está en el product-rootfs, no hay
/etc/hostname. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no
falla: archiva bajo none, y dos máquinas distintas archivarían en la misma carpeta.
Puesto /etc/hostname + una Card OneShot hostname en el genesis, para que sobreviva al
reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en
/var/lib/tupu/takana/. Quedan 72 ficheros huérfanos bajo none/: son los 20 minutos anteriores.
⇒ lo correcto es que lo haga netup, que es quien configura la identidad de red de la máquina;
la Card es la mitigación de hoy, y está dicho en su propia guarda.
🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve
sandokan-watch arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo
si uno lo corre a mano:
sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada
En gioser lee el auth.log de un syslog que en takana no existe — no hay systemd ni syslog. Su
Card ahora lleva una guarda que sale 78 nombrando el problema, así que el servicio no queda
verde mintiendo; y no entra al genesis, sólo a cards.d, porque no tiene sentido que arranque
al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.
Dos herramientas que no existen donde se prueba
Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:
/dev/tcp/host/puerto no existe en busybox ash ⇒ un barrido de puertos contra gioser dio
«cerrado» para los cinco, incluidos :80 y :443, que sirven. Con curl telnet:// el resultado
es el contrario: :2345 y :443 abiertos, :22 cerrado.
- La expansión de llaves (
{a,b}) tampoco ⇒ un ls /store/*-{matilda,pacha} dijo que los
artefactos no habían llegado. Habían llegado.
Es la misma lección del §6.23 (ps y dig ausentes) y merece repetirse: una prueba que no puede
pasar se ve igual que una que todavía no pasó.
6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» (2026-09-16)
Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y frenadas a propósito,
cada una por un motivo distinto y con su guarda diciéndolo.
| corriendo | | frenado | por qué |
matilda · tupu | root, escriben /var/lib/tupu/takana/ | sandokan-watch | en takana no hay auth.log ni journalctl |
pacha · pacha-secretos | cuenta pacha (965) | tejido | misma identidad libp2p que el de gioser |
willay-daemon | root, con su índice mudado | willay-crosscheck | necesita el roster de tejido |
shuma-daemon · shuma-gateway | cuenta shuma (§6.37) | thasnuna | pide claude autenticado y sandokan-mcp |
⚠ La regla que sirvió para los vhosts NO sirve para una identidad
Con los sitios web la pauta fue: mudá el DNS y dejá el origen encendido, así volver cuesta un
minuto (§6.26). Con tejido eso es exactamente lo que NO hay que hacer, y el README del propio
tejido lo dice: «la clave que el roster atesta ES la identidad de transporte libp2p»
(~/.tejido/device.seed). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio:
son el mismo PeerId en dos sitios, o sea dos impostores mutuos para el resto de la flota.
⇒ tejido se intercambia: se apaga allá, se enciende acá, en ese orden. Está todo listo para el
intercambio —binario, cuenta tejido (964), identidad instalada 700 en /var/lib/tejido/.tejido/—
y su Card vive en cards.d pero no en el genesis, para que un reinicio no lo encienda solo.
Y arrastra a otro: willay-crosscheck no es independiente. Corriendo su línea a mano:
Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)
Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.
Lo que cada guarda evita
thasnuna: su INSTALAR.md —escrito hoy por el frente tawasuyu para esta misma mudanza— pide
tres cosas, y dos no están: sandokan-mcp en el PATH («el agente contesta pero no tiene
herramientas») y claude instalado y AUTENTICADO («no hay anfitrión»). El CLI de claude es un
binario glibc de ~300 M: en una caja musl pide jaula qorpa, como sergioh-api.
- Y de ahí sale un hallazgo que vale para el respaldo entero: ese documento explica por qué el token
no va en la Card, y el motivo está medido en la máquina vieja — la Card
openrc-openclaw de
gioser lleva la API key de su proveedor EN CLARO dentro del JSON de /etc/arje/cards.d/, que es
un directorio que se respalda y se copia. Un secreto dentro de una Card viaja a todas partes.
willay-crosscheck: --label nombra a la máquina. En gioser decía momento; copiarlo tal
cual habría hecho que la caja nueva se anunciara con el nombre de la que se borra. Ahora sale de
hostname, y si no hay, la guarda lo dice.
🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene
Van con el §6.38 y ya son patrón, no anécdota:
| la prueba | lo que dijo | lo que pasaba |
/dev/tcp/host/puerto | los 5 puertos de gioser «cerrados» | busybox ash no tiene /dev/tcp |
ls /store/*-{a,b} | «los artefactos no llegaron» | busybox no expande llaves |
find … -newermt "-2 minutes" | «tupu no escribe hace 2 min» | tupu sí escribe: su mtime avanza cada 10 s |
El de tupu es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene
tamaño constante (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba
nada. Lo que decide es el mtime, medido dos veces con 40 s de por medio.
6.40 🚪 Las ocho puertas, medidas de nuevo (2026-09-16) — y la que falta es UNA
La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:
| # | puerta | estado | medido |
| 1 | arranca takana puro, se entra por SSH | ✅ | sigue en pie |
| 2 | el store está entero | ✅ | 1632 artefactos, 0 vacíos (eran 1362) · ⚠ /store al 88 %, 11,3 G libres |
| 3 | los cuatro grafos idénticos | ✅ | §6.7 |
| 4 | la granja late en la caja nueva | ❌ | el latido sigue en gioser: la caja no tiene el cron |
| 5 | el repo se sirve y el host se actualiza solo | ⬖ | sirve; consumir exige el lab entero (SDD 27 §4) |
| 6 | cada dominio vivo responde 200 desde fuera | ✅ | los 8 contra 2.29.29.217 — y sergio ya entre ellos |
| 7 | el respaldo corre desde la caja | ⬖ | el script corre y lista el Storage Box desde la caja; falta una corrida completa |
| 8 | lo fósil, anotado y decidido | ✅ | §6.9 + docs/state/mudanza-decisiones.txt |
La puerta 4 es ahora una línea de crontab — y por qué no la puse
La caja ya tiene todo lo que el latido necesita, comprobado uno por uno: git, python3,
rsync, ssh, takana, flock, la llave del worker en /root/.ssh/github5 y el .fleet con
dev.gioser.net. Le faltaban dos costuras y están hechas:
/opt/takana/store -> /store (el script espera `./store`; acá es partición)
/opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)
Y el repo de /opt/takana, que estaba 5 días atrasado, quedó en origin/main exacto (0
diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes.
⚠ Esos 92 no eran trabajo local: eran contenido que ya estaba río arriba —el cambio de SSH a
HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio»
que en realidad está atrasado, que es el mismo espejismo que el §5 del informe de tawasuyu
describe para el monorepo.
Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:
▶ dev.gioser.net (154.197.1.13) load: 2.72 · store: 895 artefactos
══ AVANCE KDE ══ 197/198 sellado
══ CRON DE COSECHA ══ ⚠ NO instalado en el crontab
No lo instalé, y el motivo es el mismo que el de tejido: con el cron puesto en las dos
máquinas, las dos harían rsync --delete del repo al worker y las dos cosecharían y commitearían.
El latido se intercambia, no se duplica — se apaga en gioser y se enciende acá, en ese orden, y
es el último movimiento antes del borrado.
⇒ de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.
6.41 🔀 INTERCAMBIO de tejido — la identidad cambió de máquina, no de dueño (2026-09-17)
Hecho, y en este orden, que es el único posible: apagar allá → encender acá.
gioser tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334) → detenidos
takana tejido 4102 · willay-crosscheck 4103 → corriendo
La prueba de que la identidad VIAJÓ y no se generó otra no es que el servicio arranque —eso
pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que
la semilla sea la misma:
device.seed gioser cad6f35b633e8637… caja cad6f35b633e8637… ✓ idéntica
roster.postcard gioser 590f2713075ac934… caja 590f2713075ac934… ✓ idéntica
El PeerId sale de esa semilla, así que sigue siendo 12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L.
⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr
Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso
que corría en gioser:
antes: /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
ahora: /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u… ← mismo PeerId, otra IP
Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. El PeerId
no hay que tocarlo; la IP sí, en cada equipo del roster.
🧱 Sin el cortafuegos, el intercambio habría sido un verde falso
El reglaset de la caja abría 80, 443, 1137, 2345, 22022 y nada más: 4102 estaba cerrado. Con
el relay encendido y el puerto filtrado, arjectl status diría «corriendo · 0 reinicios» y la flota
no llegaría — el peor de los verdes. Se abrieron 4102 (relay) y 4103 (crosscheck), con su ban
por tasa como el resto. No se abrió 33097: ése era el puerto efímero de un tejido serve
(cliente), no un servicio que alguien busque.
Y el reglaset quedó idempotente, que era deuda del §6.33: sus dos primeras líneas crean y borran
la tabla antes de definirla, así que nft -f reemplaza en vez de acumular. Comprobado contando:
15 reglas dport antes, 21 después (no 36).
⚠ La política que genera ese fichero NO EXISTE EN NINGÚN DISCO. Su cabecera dice «GENERADO, no
editar a mano — regenerar con cortafuegos generate-input --policy <ruta>», pero esa ruta no está
ni en la caja, ni en gioser, ni en el repo (sólo dos politica_*_ejemplo.ron en tawasuyu), y el
binario cortafuegos tampoco está instalado en ninguna de las dos. El generado sobrevivió a su
fuente: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su
primera línea pide no hacer.
🆔 takana acepta ULIDs que arje-zero RECHAZA
La Card de tejido no encarnaba: arje-zero rechazó: card tejido JSON: invalid character at line 7.
La línea 7 es el id, y el id era 01M2EKDA00TEJ1D0RELAY00001 — que tiene una L, carácter
excluido del alfabeto Crockford de ULID (junto con I, O, U). takana service-cards lo
validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.
Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre
termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los
id del repo con el alfabeto correcto había marcado justo ése. Era el único con L (venía de
«RELAY»); ahora es 01M2EKDA00TEJ1D0RE1AY00001.
Estabilidad, medida y no supuesta
willay-crosscheck marcaba 4296 reinicios, que asusta hasta mirar de dónde vienen: son las
horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y
1 m 53 s de vida, está estable. tejido: 0 reinicios. Los dos puertos aceptan conexión desde fuera.
⚠ De paso, un detalle feo: arjectl status | head -2 hace que arjectl paniquee con
failed printing to stdout: Broken pipe. No rompe nada, pero un EPIPE sin manejar en una
herramienta de operación aparece justo cuando alguien la encadena con head/grep -q.
Cómo volver, si hiciera falta
Está escrito en work/mudanza/volver-atras-tejido.txt: apagar los dos de la caja y relanzar los tres
de gioser con las líneas exactas que se leyeron de /proc/<pid>/environ y cmdline. Nunca las dos
a la vez: es la misma identidad.
6.42 ❌ El latido NO se puede mudar todavía — y el motivo redefine qué es un «hub» (2026-09-17)
Se intentó el último intercambio: apagar el cron de la cosecha en gioser y encenderlo en la caja.
Salió mal, se vio en el primer ciclo, y se volvió atrás en minutos. Queda escrito porque el
motivo no era ninguno de los que se habían previsto.
Lo que sí funcionó
La caja tiene todo lo del latido y el ciclo corrió de punta a punta: sembró el repo al worker, leyó
su manifiesto, regeneró los ocho ficheros de estado, pasó los vigías, se puso al día por
fast-forward y commiteó+empujó. El git config de identidad y el pull --ff-only nuevo hicieron
su parte.
🕳️ Lo que publicó, que es el problema
totals que publicó la CAJA : {recipes: 1112, nodes: 1115, unhashable: 1112, ajeno: 3}
totals de gioser : {recipes: 1112, nodes: 1115, sealed: 1105, never: 5, debt: 2}
unhashable: 1112 — no es «no está sellado»: es que no se pudo CALCULAR el hash de ninguna
receta. Y la causa es la misma que el §5.2 documenta desde el otro lado: el lab entra en el
ArtifactHash, así que una máquina sin el rootfs del lab no puede hashear nada. La caja de
producción no lo tiene — a propósito, no es un olvido.
El daño era real y silencioso: el commit 45b5685a dejó en main un build-state*.json que dice
que el corpus entero no existe. Cualquiera que lo lea —o cualquier vigía que lo compare— vería
1112 recetas en cero.
La lección, que vale más que el intento
Un hub no es «la máquina que tiene el repo y la llave»: es la que tiene el LAB. El latido parece
tarea de cron —siembra, cosecha, commitea— pero su tercer paso es pensar sobre el corpus, y eso
exige el toolchain. Por eso la puerta 4 no era una línea de crontab, aunque todas las piezas que
se miraron (git, python3, rsync, ssh, takana, flock, .fleet, la llave) estuvieran.
⇒ Las salidas son tres, y ninguna es «poner el cron»:
- darle el lab a la caja (
/.dev-fs/…) — deja de ser sólo servidor y pasa a ser también hub;
- partir el latido: siembra+cosecha en la caja, regeneración del grafo donde haya lab;
- que el hub sea otra máquina (el LXC prestado ya tiene lab y muele 24/7).
Es decisión del usuario, y es lo único que queda entre esto y borrar gioser.
Vuelta atrás, en minutos
Cron de la caja borrado, cron de gioser reactivado (estaba comentado, no borrado). El propio latido
de gioser repara el daño solo en su siguiente ciclo: regenera el grafo desde SU store —que sí
tiene el lab— y vuelve a publicar los números buenos.
🧰 Y de paso, tres cosas que el ciclo destapó en la caja
poda-fuentes.sh no corre en takana: /dev/fd/63: No such file or directory y
viejos: unbound variable. Es sustitución de procesos (<(...)), que busybox ash no tiene —
el cuarto tropiezo del día con la misma forma. El podador de work/sources es justo lo que un hub
necesita para no llenarse.
- Los vigías corrieron y hablaron: 1 ofensor nuevo de raíces (
bzip2), 3 herramientas
selladas que no se pueden invocar, y servicios.txt con 7 errores y 30 avisos. Son hallazgos del
corpus, no de la mudanza, y quedan anotados donde el humano los mira.
arjectl status | head -2 hace paniquear a arjectl (failed printing to stdout: Broken pipe).
No rompe nada, pero un EPIPE sin manejar en una herramienta de operación aparece justo cuando
alguien la encadena.
6.43 🧪 La caja ya es un HUB: el lab viajó, y con él la puerta 4 (2026-09-17)
El §6.42 dejó dicho qué faltaba y el usuario eligió: darle el lab a la caja. Hecho, y el latido
ya late desde allá.
El lab no se copia: se PINEA y se trae
scripts/lab-image.sh empaqueta .dev-fs/alpine de forma determinista (--sort=name --mtime=@1 --owner=0 --group=0 --numeric-owner, excluyendo var/log/apk.log, que es lo único que
difiere entre dos labs por lo demás iguales), lo publica al Storage Box con el sha en el nombre, y
del otro lado --traer verifica el sha antes de extraer. Eso es lo que hace que las dos máquinas
hasheen igual, y es un contrato, no una copia.
Y el empaquetado de hoy devolvió el sha que el repo ya pineaba:
empaquetado hoy en gioser : d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
pineado en bootstrap-devfs.sh: d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
O sea dos cosas de una: el lab de gioser nunca derivó desde que se publicó esa imagen, y el
empaquetado es reproducible de verdad — no era una promesa del comentario.
La prueba que decide, y no es que arranque
Con el lab instalado, la caja calcula los MISMOS ArtifactHash que gioser:
zlib b3:dc363f26… busybox b3:2a2b1280…
caddy b3:c915987d… shuma-daemon b3:f9b92acc…
Cuatro de cuatro. Si el lab fuera otro, estos números serían otros y el store no lo notaría — es el
agujero que hash_inputs cerró (§lab en el hash) y la razón por la que esto se verifica con hashes
y no con un «arrancó bien».
Los stores, convergidos — y una trampa que me tendí solo
El grafo seguía discrepando porque el store de la caja no tenía 24 artefactos vigentes de gioser
(3,0 G). Al copiarlos con rsync -a --files-from=<lista de DIRECTORIOS> pasó lo peor posible:
sent 2,281 bytes … total size is 0 ← no copió NADA
artefactos en la caja: 1631 → 1655 ← pero creó los 24 DIRECTORIOS
--files-from no recursa sin -r: creó 24 directorios vacíos en el store. Y un directorio vacío
en el store es un cache-hit (§3 de CLAUDE.md, [[artefacto-vacio-envenena-cache]]): build lo
habría dado por sellado y habría salido OK sin construir. Se detectó al contar, se borraron los 24 y
se repitió con -r: 3,18 GB transferidos, 0 vacíos de 1655.
Después faltaban dos más (obs-studio y spectacle, justo los que el worker no logra construir),
copiados también. Resultado:
| gioser | caja |
| corpus | sealed 929 · debt 2 | sealed 930 · debt 1 |
| KDE | sealed 1105 · debt 2 | sealed 1106 · debt 1 |
La caja no empata: gana por uno en los dos grafos.
El latido, mudado
Cron apagado en gioser, encendido en la caja, y un ciclo completo corrido a mano para mirar lo que
publica: ==> repo al día (ff) → ==> estado commiteado+pusheado, con los números buenos. El
build-state que hay en main ahora lo escribió la caja.
⚠ Lo que queda apretado es el disco: /store de la caja está al 91 %, 8,2 G libres. Un hub
sella y cosecha, así que ése es el próximo muro — scripts/store-gc.sh es la herramienta y todavía
no se corrió allá.
⇒ puerta 4 en verde. Con eso, de las ocho quedan sólo las dos a medias (5 y 7) y ninguna en rojo.
6.44 🧹 Primer store-gc en la caja: 26,8 G — y dos bugs que sólo salen fuera de GNU (2026-09-17)
El store estaba al 91 % (8,2 G libres) justo cuando la caja pasó a ser hub, que es la peor
combinación: un hub sella y cosecha.
antes: 84,7 G usados · 8,2 G libres · 91 % · 1657 artefactos
después: 57,9 G usados · 35,0 G libres · 62 % · 1320 artefactos
337 artefactos superados borrados y verificados (0 sobrevivientes), 26,8 G. El default del gc no
toca los 150 huérfanos —11,4 G, los que son el único ejemplar de su nombre— y eso está bien:
borrarlos es la diferencia entre «se reconstruye en dos minutos» y «hay que rehacer la torre».
El control que hay que correr DESPUÉS de un gc en esta caja, y que no es obvio
11 binarios de /usr/bin son symlinks que apuntan DENTRO del store — los daemons que se
instalaron estos dos días. Un gc que borre el artefacto equivocado no rompe el store: rompe el
/usr/bin de una máquina en producción. Antes de --aplicar se comprobó que los 11 enlazan al hash
vigente (y por lo tanto están en el conjunto protegido); después, que los 11 siguen resolviendo y
que los 21 entes siguen corriendo. Los dos controles pasaron.
⚠ Y la advertencia que el propio gc imprime y que en esa caja no se puede satisfacer: «sin
.config legible (ni /proc/config.gz ni /boot/config-…) ⇒ NO se protege ningún kernel por esta
vía». Con el default da igual; con --huerfanos en una máquina que arranca de su store, no.
Los dos números que el gc no sabía decir
Funcionó, pero sus dos cifras salieron vacías — y son las que uno mira para decidir si vale la pena:
==> espacio: superados · huérfanos
==> 337 artefactos borrados y VERIFICADOS · libres: Available → Available
du -sch --files0-from=- es de GNU y busybox no lo tiene ⇒ espacio() devolvía vacío. Un
número que falta se lee como un número chico.
df -h /home estaba cableado y el store no vive ahí: en gioser es un bind-mount del volumen,
en la caja es /store. Sin /home, tail -1 se quedó con la CABECERA — de ahí
«Available → Available».
Arreglados los dos… y el primer arreglo también estaba mal: xargs -d '\n' es otra extensión
GNU. Eso imprimió huérfanos ?, y ese ? es deliberado: cuando el total no se puede calcular, la
función lo DICE en vez de escribir 0 — un cero inventado habría dicho «no hay nada que ganar» con
11,4 G en huérfanos. Con tr '\n' '\0' | xargs -0, que busybox sí tiene, la caja ya contesta
espacio: superados 0 · huérfanos 11.4G.
⇒ Van cinco tropiezos del mismo día con la misma forma (/dev/tcp, expansión de llaves,
find -newermt, sustitución de procesos, y estos dos). El patrón ya no es anécdota: un script del
hub escrito en una máquina GNU no corre en la caja hasta que se prueba ahí.
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
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, respaldo | puertas 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 gitea | arranca y sirve 200, supervisado por arje — §6.11 |
arjectl para operar sin reiniciar | §6.12 |
| el gitea de gioser corriendo sobre takana, con sus datos | ensayo completo — §6.13 |
Lo que falta para el cutover, en este orden
⚠ La lista de abajo es del 2026-09-11 y los pasos 1-5 YA ESTÁN (gitea mudado y sirviendo, §6.14;
estáticos mudados, §6.18; fósiles barridos, §6.19; api.sergio mudado, §6.25; tawasuyu.net y
gioser.net mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).
El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista:
queda UN solo vhost sirviéndose desde gioser — sergio.gioser.net — y el montón de daemons propios
del usuario que ningún paquete provee. Los pasos 6 y 7 son lo único que sigue abierto.
- 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.
- 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.
- Caddy en la caja: los 6 dominios vivos del §6.9, con su TLS. Los otros 13 no se mudan.
- DNS: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
resolviendo al mismo nombre.
- Verificar desde fuera: cada dominio vivo responde 200 contra la IP nueva y un
git clone
real contra el gitea mudado.
- El resto de servicios vivos del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
planear.py ya emite.
- Borrar gioser — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
Bloqueantes conocidos, con su tamaño
git sellado sin remote-http (§6.13): afecta a clonar POR HTTPS desde una caja takana, no
al gitea que sirve. Re-sella git, raíz de perfil.base ⇒ unidad propia.
- Puerta 5 sigue a medias: la caja sirve su repo pero no se instala de él (
install reproduce y
eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
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.