Los dos sitios que quedaban con trafico real sirven desde la caja `takana`, con TLS publico y
verificados desde fuera. Los servicios viejos SIGUEN corriendo en gioser a proposito (decision del
usuario: mover el DNS y dejar el origen vivo, para poder volver en un minuto).
· `tawasuyu.net` + `www`: la raiz en gioser era EL MONOREPO ENTERO (145 G) servido por HTTP. El log
de accesos dice que las unicas rutas con trafico son `/`, `/descargas` y `/web`, asi que se
replicaron solo los dos subarboles que el sitio usa (1,2 M + 3,8 G) con la MISMA estructura, para
que cada `rewrite` siga siendo el mismo.
· `gioser.net` + `www`: 428 M de estaticos + `/reencuentro`, que necesita PHP. `/hooks/*` NO se
muda: lo atiende `webhook-deploy.py`, que redespliega aura/sigma/summa/brahman — los cuatro
FOSILES del §6.19 — y tiene cero peticiones. Muere con la caja vieja.
· `sergio` dejo de ser `CNAME -> www` y tiene su A propio (apuntando a gioser, sin cambio visible).
Sin eso, mover `www` se lo llevaba puesto: en esa zona casi todo cuelga de `www`.
PHP no entra al corpus y no hace falta: `php-fpm 8.5.10` corre en la instancia `gioser-php` (ADR
0015), supervisado por arje. Control contra el original en los dos sentidos: POST con la trampa
anti-robots da `{"ok":true}` 200 y GET da 405, igual que en gioser.
EL MURO, que es general y no de PHP: para montar en `/work/www/gioser-web`, bwrap crea `/work` y
`/work/www` —que la imagen no trae— y los crea **0700 root**. Adentro somos root y a mano todo
funciona; pero un servicio que BAJA de privilegio (php-fpm a `http`, o cualquier instancia con
`run_as`) no puede ni atravesarlos, y el sintoma es un «File not found» sobre un fichero que ESTA.
Medido con el control que lo separa de un problema de permisos del anfitrion: como `http`, leer un
fichero de la IMAGEN funciona y leer el directorio CONCEDIDO da Permission denied.
`grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y SOLO los que la
imagen no trae: hacerlo sobre `/etc` o `/home` le cambiaria los modos a la imagen. Con test, y
probado rompiendolo a proposito (sale `["/etc","/etc/php"]` en vez de `["/etc/php"]`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 KiB
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/vvval 89 % (27 G libres) (medido), - es fija y sin backup por pedido explícito del usuario (blindaje anti-borrado en
deadman.shyfarm-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.netcuesta €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 endocs/state/targets.toml. Ver §3.4.console=ttyS0en 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 —
netuphace DHCPv4 y Hetzner entrega un/64estático. La caja arrancaría con IPv4 sola. Aceptable para empezar, pero hay que saberlo antes, no descubrirlo. - Un
.imgdel perfil servidor que el rescue pueda escribir. Eshydrate-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.shmira lo correcto. console=tty1 console=ttyS0viene 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.eth0con/32dinámico por DHCP — idéntico a gioser ⇒ es exactamente el caso denetup.
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_oomen 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í. sshdtraePasswordAuthentication yes(conPermitRootLogin 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.shfirma hoy con una clave efímera por defecto (keygen releasesi no se le pasaKEY=) (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 necesitatakanainstalado 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.shaborta si no la encuentra en vez de inventar una efímera, y tras firmar verifica el índice contratrust/.- 88/88 paquetes del perfil
servidorpublicados conexpected_hashanclado. - La caja los sirve con
caddy file-serversobre/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
muslemitalibc.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 baselineof_treedel 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 escritoscripts/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.shcorre 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.tomly 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 elCARGO_HOMEde 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 concd: /mnt/vvv/takana: No such file or directory. Ahora se deriva de dónde está el script, como el resto descripts/. -
El binario de desarrollo.
HAMMER = ROOT/target/release/takana(§6.7). -
El store no estaba donde el script creía. Usaba
$RAIZ/storecableado: 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»). AhoraSTOREes 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/takanade la caja llegó porrsync --exclude=.git: es una COPIA, no un clon. Una corrida real desde ahí habría borrado.gitdel 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(escotillaREPO_INCOMPLETO=1), probada en los tres sentidos.La regla que sale: un
--deleteconvierte «respaldar» en «sincronizar», y sincronizar desde un origen incompleto no sube menos — BORRA. Cualquier respaldo con--deletenecesita 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=zstdyrecipes/rsync.tomllo construía con--disable-zstd— porque cuando se escribió,zstdno 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 nonevszlibx zlib none; radio cero,rsyncno 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=undefinedestaba enCFLAGS, no sólo enLDFLAGS. El motivo original era legítimo —laslibz.amaterializadas traen referencias__ubsan_handle_*que bajo-staticno se resuelven solas, y el flag al enlazar trae el runtime de zig— pero enCFLAGSinstrumenta 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.sha1collisiondetectionlee 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_ACCESSlo 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
giteaNO tenía receta y que «es la que más peso tiene». Las dos cosas eran falsas:recipes/gitea.tomlexiste desde el 2026-09-09 y su artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.Lo destapó
planear.pyal cruzar cada binario contrarecipes/: 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 --mergehacíafs::writesobre<rootfs>/etc/passwd, y ese fichero y el del artefactoproduct-rootfsson el mismo inode (medido: 1225824,2 links, modo444) — 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
genesissólo tenía lo que horneatakana-bootstrap(sshd,console-getty,hammerd):servidor-image.shno 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 --servicesresponde 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 exportaCCapuntando 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 conCC="zig cc"a secas, que es-mcpu=native. No falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y elArtifactHashno puede cazarlo, porque la CPU del builder no entra enhash_inputs— dos workers distintos sellan bytes distintos bajo la misma dirección. Arreglado en el builder y en la receta; re-hashea las cinco recetascgo = 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 (
1001en 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.inide gioser escucha en127.0.0.1:3002, porque allá caddy hace de proxy. Copiado tal cual, la caja sirve — pero sólo desde dentro. ElHTTP_ADDR/HTTP_PORTy elROOT_URLson parte de la adaptación, no del copiado. -
🧨 El
gitde 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 sessionMedido sobre el ARTEFACTO sellado:
/usr/libexec/git-core/traegit-remote-ext,git-remote-fdygit-http-backend(el lado SERVIDOR), y nogit-remote-http/-https;stringsdel binario da 0 referencias a libcurl, pese a quecurlestá en[deps] build. Nadie lo había notado porque en el hub elgitque 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-sellagit, que es raíz deperfil.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 cuentagitea:969+ las cards engenesisycards.d. - Datos en dos fases: 1,66 GB / 18 047 ficheros con el origen VIVO (17 s), y en el corte el
rsync --deleteincremental +sqlite3 ".backup"(integrity_check ok). - Parar el origen de verdad.
rc-service gitea stopdice «already stopped» con el proceso vivo (elrc-statusque miente, §Los tres hechos), y matarlo no alcanza: lo revivearje-zero, que lo tiene como cardopenrc-giteaconRestart— 9001 reinicios acumulados en el contador. Se paró conarjectl stop openrc-gitea, y para eso hubo que extraer su card delgenesisy escribirla en/etc/arje/cards.d/(§6.12:SpawnCardFromDisk/StopCardFromDiskleen de ahí, no del genesis). - DNS por la API de Hetzner Cloud (
/v1/zones): el token dehcloudtambién gestiona el DNS desde la unificación — la API viejadns.hetzner.com/api/v1redirige a la consola. Y unPUTsobre el rrset no puede cambiar el tipo: hay queDELETEdel CNAME yPOSTdel 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 caddyy 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
rsyncau647150:gitea/— 1,7 G (datos/+gitea.dbde 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: elrsyncpreservó 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 venvfallaba conPermission denieden el directorio concedido mientras andaba perfecto en/tmp. El árbol del sitio va aroot:rootdel anfitrión, que es lo que el mapa de la jaula sí alcanza.- Una instancia, un
run: el segundoqorpa runsobre la misma instancia es rechazado por el kernel («dos overlays con el mismouppercorromperían la capa mutable»). Correcto y hay que saberlo: con el servicio arriba no se entra a la instancia a hacer mantenimiento. psde la caja no ve los procesos ydigno existe en el hub. Dos herramientas ausentes que se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó ununtilde 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.tomles 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
upperes caché (D3) y se tira. Y las 84 dependencias quedaron pineadas enrequirements.lock— elrequirements.txtdel sitio no pinea nada, así que sin el lock unrecreatetraería otras versiones. - La tarjeta
sergioh-apiva en/etc/arje/cards.d/y en elgenesisde/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/binen la tarjeta: eltakanade la caja es el delcommitpineado enrecipes/takana.tomly 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.neten gioser era el monorepo entero (145 G) servido por HTTP. El log dice que las únicas rutas con tráfico son/,/descargasy/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 cadarewritesiga siendo el mismo. /hooks/*NO se muda: lo atiendewebhook-deploy.py, que redespliegaaura_*,sigma_*,summa_*ybrahman— 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
rustestá en la caja pero no instalado:rust-toolchain-binestá sellado en el store y declarado enperfil.servidor, yrustc 1.97.0corre invocándolo por su ruta del store — pero no está en elPATHporque 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.claudeno 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 desergio. Lo que pesa no es el binario sino su estado: 6,8 G de~/.claude, de los cuales 5,3 G sonjobsy 976 Mprojects— y ahí vive la memoria, indexada POR RUTA del repo. Mudarlo es una decisión sobre qué se lleva, no una copia.
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). El estado vigente de
la mudanza está en la tabla del final de §6.25; lo que sigue vivo en gioser son tres vhosts
(sergio con su /shuma, gioser.net/www, tawasuyu.net) y los servicios del censo.
- Actualizar la caja a la imagen nueva (gitea +
arjectl+ las cuentas de[[user]]).takana upgradeestá probado (§6.13 del SDD 27 y §5.4 acá); elddes 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 -aHde/var/lib/giteay/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 clonereal contra el gitea mudado. - El resto de servicios vivos del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
planear.pyya emite. - Borrar gioser — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
Bloqueantes conocidos, con su tamaño
gitsellado sinremote-http(§6.13): afecta a clonar POR HTTPS desde una caja takana, no al gitea que sirve. Re-sellagit, raíz deperfil.base⇒ unidad propia.- Puerta 5 sigue a medias: la caja sirve su repo pero no se instala de él (
installreproduce 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.