`planear.py --revisar` sobre el censo ordena lo que queda, y lo primero no eran los servicios del usuario sino TRES CAPACIDADES que ninguna metrica reclama: el disparador periodico, la hora y la rotacion de logs. Los tres paquetes ya estaban sellados y declarados en `perfil.servidor` desde que ese perfil nacio — lo que faltaba era el eslabon que los ARRANCA, igual que con squid. · `chronyd`: la caja iba **15 s atrasada** y nadie la corregia. Ahora stratum 3 contra los NTP de Hetzner, 0,000005 s de NTP. Sin hora no hay TLS ni firmas, y el sintoma no se parece a la causa. · `crond`: no existia el latido. Verificado con una entrada `* * * * *` en el crontab REAL. · `logrotate`: `/var/log/squid` sin rotar. Diario, con `squid -k rotate` (squid mantiene los ficheros abiertos: un rename a secas lo deja escribiendo en un inode que nadie puede leer). · `scripts/respaldo-gitea.sh` + `17 3 * * *`: cierra el hueco del §6.15 — los 44 repos vivian en UNA copia y el respaldo se hacia a mano. Sube 23+21 repos (1,5 G) y un `gitea.db` de 332 M tomado con `.backup`, consistente con el servidor vivo. El control no es que el script salga 0: es CONTAR los repos de los dos lados. `chrony` y `cronie` NO declaraban `[[service]]` — el paquete llegaba a la imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron) y el perfil los habilita. TRES MEDICIONES que valen mas que el resultado: 1. **La deriva era de gioser, no de la caja**: despues de sincronizar, el HUB quedo 3 s adelantado. Medir «contra el otro» sin un tercero no dice quien esta mal. 2. **El spool de cronie es `/var/spool/cron/<usuario>`**, no `…/crontabs/<usuario>`: `crontab -l` mostraba las entradas y `crontabs/` estaba vacio. Un crontab restaurado en el sitio equivocado es un fichero perfecto que nadie lee. 3. 🧨 **`sqlite3` seguia INERTE en la caja** (`compressBound: symbol not found`): el ultimo de los 23 del §6.4. Le faltaba `zlib-shared`, sellado y declarado en `perfil.base` desde el 11-09 — pero la caja se instalo antes. Sin el no habia snapshot consistente. Un paquete declarado no es un paquete instalado: en una caja viva vale lo que el `upgrade` proyecto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1841 lines
111 KiB
Markdown
1841 lines
111 KiB
Markdown
# SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella
|
||
|
||
Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en
|
||
Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo
|
||
takana»). Todo número marcado *(medido)* salió de un comando corrido ese día sobre `gioser`.
|
||
|
||
Continúa [SDD 19](19-lanzamiento-publico.md) (lanzamiento público) y
|
||
[SDD 27](27-perfiles-instalables-y-el-instalador.md) (metapaquetes e instalador). **No repite el
|
||
instalador**: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega.
|
||
|
||
---
|
||
|
||
## 0. La corrección de encuadre: ya estamos en Hetzner
|
||
|
||
`gioser` **es** un servidor Hetzner (hcloud id `126324694`, hel1, 4 cores / 7,6 GiB) *(medido)*. La
|
||
pregunta no era «cómo llegar a Hetzner» sino **cómo tener una caja de takana** en vez de ser un
|
||
inquilino más de una máquina compartida que:
|
||
|
||
- sirve **19 dominios** por Caddy,
|
||
- tiene `/mnt/vvv` al **89 %** (27 G libres) *(medido)*,
|
||
- es **fija y sin backup** por pedido explícito del usuario (blindaje anti-borrado en `deadman.sh` y
|
||
`farm-down.sh`, ver [ADR 0014](adr/0014-distribucion-multiorigen.md) y la memoria del proyecto),
|
||
- y **no publica nada de takana**: cero entradas de takana en el Caddyfile *(medido)*.
|
||
|
||
### 0.1 El hallazgo que cambia el tamaño del problema
|
||
|
||
```
|
||
$ ps -p 1 -o comm=,args=
|
||
arje-zero /usr/local/sbin/arje-zero
|
||
$ cat /proc/cmdline
|
||
BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
|
||
init=/usr/local/sbin/arje-zero loglevel=4
|
||
```
|
||
|
||
**El init de takana ya gobierna un servidor de producción real.** Caddy, gitea, sshd, qdrant y
|
||
act-runner cuelgan directo de PID 1 *(medido: `ps -eo pid,ppid,comm | awk '$2==1'`)*.
|
||
|
||
Pero **eso no es takana puro**: es Artix con nuestro init inyectado por `init=` en el cmdline. El
|
||
kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.
|
||
|
||
### 0.2 ⚠ La declaración del arranque no existe en ningún sitio legible
|
||
|
||
`rc-status` de OpenRC dice `stopped` para **caddy, gitea, sshd, cronie y act-runner**, y los cinco
|
||
están **vivos** *(medido, los dos comandos)*. `/etc/arje/cards.d` tiene **4 tarjetas** (`acpid`,
|
||
`openrc-openclaw`, `squid`, `zram-swap`) y ninguna de ellas los levanta.
|
||
|
||
⇒ **La secuencia de arranque de esta caja no está escrita.** Es lo menos reproducible de toda la
|
||
mudanza y lo primero que hay que capturar — ver §4.
|
||
|
||
---
|
||
|
||
## 1. Inventario de lo que hay que mudar *(medido 2026-09-09)*
|
||
|
||
### Datos en `/mnt/vvv` (217 G de 255)
|
||
|
||
| | |
|
||
|---|---|
|
||
| `tawasuyu` | **124 G** |
|
||
| `takana` | **91 G** — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios |
|
||
| `rustup` | 29 G |
|
||
| `act-runner` · `gioserv` · `_respaldo` | 6,5 · 5,4 · 4,1 G |
|
||
| ~20 dirs `*-standalone` | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… |
|
||
|
||
### El volumen `harkaq-cosecha` (250 G, 148 G usados)
|
||
|
||
| | |
|
||
|---|---|
|
||
| `store` | **55 G**, **1327 artefactos** — lo irreemplazable |
|
||
| `work-sources` + `cargo-home` + `gopath` | ~85 G — **caché regenerable**, no se muda |
|
||
| `work-tarballs` · `devfs-cache` · `work-out` | 3,2 G · 4,5 G · 397 M |
|
||
|
||
**El store no se copia por red.** Los dos volúmenes están en `hel1`; un volumen hcloud se desengancha
|
||
de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.
|
||
|
||
### Servicios vivos, todos hijos de arje-zero
|
||
|
||
`caddy` :80/:443 · `gitea` :3002 · `sshd` :2345 · `act_runner` (el CI de tawasuyu) · `qdrant`
|
||
:6333/:6334 · `openclaw` · `zeroclaw` · `shuma-gateway` :7378 · `tejido` :4102 y :33097 ·
|
||
`puerta-f6e393ff` · `uvicorn` :8771 · `python3` :8770 · `squid` :1137 · `fail2ban` · `glances` ·
|
||
`php-fpm` · `metalog` · **`adb` :5037**.
|
||
|
||
### Lo que no está en ningún repo
|
||
|
||
`/var/lib/gitea` (**2,2 G**: SQLite + los repos de git de todo, **incluido el `origin` de takana**) ·
|
||
`/var/www` (287 M) · `/home/sergio/gioser-web/` · `/etc/caddy/Caddyfile` **+ 20 ficheros `.bak`** ·
|
||
los certificados de letsencrypt · los crontabs · `/etc/arje/cards.d` · las 3 credenciales de
|
||
`~/.config/hammer/*.env` · las llaves `~/.ssh/{github5,sergio}` · el token hcloud · y **la memoria
|
||
del agente en `~/.claude/`**.
|
||
|
||
### 1.1 La basura, contada — porque una mudanza fiel copia también la basura
|
||
|
||
De los 19 dominios del Caddyfile *(probados uno por uno desde la propia caja)*:
|
||
|
||
| responden 200 | rotos |
|
||
|---|---|
|
||
| gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | **api.gioser.net → 502** (backend caído) |
|
||
| | **aura · sigma · kosmofono · terapeuta.ec → sin DNS** |
|
||
|
||
Y sus raíces **no existen en disco**: `/var/www` sólo tiene `terapeuta`, `goaccess`, `ogisoer`,
|
||
`git-tawasuyu`, `letsencrypt` y un `.bak` — no hay `aura_frontend`, ni `sigma`, ni `summa/frontend`,
|
||
ni `kosmofono`. **La mitad del Caddyfile describe un servidor que ya no existe.**
|
||
|
||
⇒ **Regla de la mudanza: no se muda lo que no responde.** Lo que no tiene DNS ni ficheros se anota y
|
||
se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir *declarado / vivo / fósil*.
|
||
|
||
---
|
||
|
||
## 2. La caja: qué, cuánto y por qué
|
||
|
||
Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) *(medido)*:
|
||
|
||
| tipo | recursos | €/mes |
|
||
|---|---|---:|
|
||
| **`cx53`** | **16 c / 32 G / 320 G** | **29,49** |
|
||
| `cpx41` | 8 c / 16 G / 240 G | 32,49 |
|
||
| `ccx23` (dedicado) | 4 c / 16 G / 160 G | 85,99 |
|
||
|
||
**Elegido `cx53`**: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana +
|
||
rustup (~244 G) si algún día se muda todo.
|
||
|
||
**Decisiones del usuario (2026-09-09):**
|
||
|
||
1. **La caja PUBLICA; el LXC prestado sigue moliendo.** `dev.gioser.net` cuesta €0 y es la máquina
|
||
con más RAM (6 c / 16 G + 8 G swap). Separar *servir* de *compilar* mantiene la caja chica y
|
||
barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker.
|
||
2. **«Producción» = operativo pero privado.** Sin descarga pública todavía ⇒ **no** se activan los
|
||
bloqueantes legales del [SDD 19 §2](19-lanzamiento-publico.md) (espejo de fuentes público por
|
||
HTTP, marca de terceros). Lo que sí hace falta igual es la **clave de release estable** (§5).
|
||
3. **gioser se BORRA** cuando la mudanza esté comprobada. Eso convierte esto en un experimento con
|
||
fecha de caducidad, y cambia el diseño entero: ver §6.
|
||
|
||
⚠ **El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito.**
|
||
`deadman.sh` y `farm-down.sh` lo protegen por lista negra de nombre **y** por ausencia del label
|
||
`role=hammer-worker`. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a
|
||
mano, con la comprobación del §6 en verde y no antes.
|
||
|
||
---
|
||
|
||
## 3. Instalar takana puro en una caja remota
|
||
|
||
### 3.1 Lo que ya está, y por qué está más cerca de lo que parece
|
||
|
||
**El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI.** *(medido en gioser: no
|
||
existe `/sys/firmware/efi`; GPT + GRUB; `/boot` es vfat de 1 G.)* Y `scripts/takana-install.sh`
|
||
escribe **exactamente ese layout**: GPT + GRUB BIOS, `SeaBIOS → GRUB → kernel → arje-zero PID1`.
|
||
El camino EFI —que fue el que más trabajo costó ([ADR 0010](adr/0010-arranque-grafo-mirada.md))— es
|
||
el que Hetzner **no** necesita.
|
||
|
||
| pieza | estado *(medido)* |
|
||
|---|---|
|
||
| kernel con virtio | `recipes/linux.toml` compila `VIRTIO`, `VIRTIO_PCI`, `VIRTIO_BLK`, `VIRTIO_NET`, `EXT4_FS` **`=y`** ⇒ sin initramfs |
|
||
| red | `crates/netup` = cliente **DHCPv4 propio** en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por **DHCP en `eth0`** (`/32`, `net.ifnames=0`, DNS `185.12.64.2`) ⇒ es justo su caso |
|
||
| acceso | `recipes/openssh.toml` sellado; el `product-rootfs` vigente **ya trae `sshd`**; `takana-install.sh` acepta `AUTHKEYS` |
|
||
| servir | `recipes/caddy.toml` existe |
|
||
| escritura a disco | `scripts/takana-install.sh` (no interactivo) y `scripts/takana-live-install.sh` (TUI), probado por `scripts/install-tui-test.sh` |
|
||
|
||
### 3.2 El camino remoto: rescue → `dd` → reboot
|
||
|
||
Hetzner Cloud tiene un **sistema de rescate** (Debian por SSH) que se activa desde la API sin tocar
|
||
la caja. Desde ahí se escribe la imagen al disco y se reinicia. **No hace falta ISO, ni consola, ni
|
||
medio físico** — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.
|
||
|
||
### 3.3 Los cuatro huecos, nombrados
|
||
|
||
1. **`perfil.servidor`** — **HECHO hoy** en `docs/state/targets.toml`. Ver §3.4.
|
||
2. **`console=ttyS0` en el cmdline** — el único salvavidas si el arranque falla y no hay teclado.
|
||
Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil.
|
||
3. **IPv6** — `netup` hace DHCPv4 y Hetzner entrega un `/64` **estático**. La caja arrancaría con
|
||
IPv4 sola. Aceptable para empezar, pero hay que saberlo **antes**, no descubrirlo.
|
||
4. **Un `.img` del perfil servidor** que el rescue pueda escribir. Es `hydrate-profile.py` +
|
||
`install-image.sh`, los dos ya existentes — ver §7.
|
||
|
||
### 3.4 El perfil, y la deuda que declara *(hecho 2026-09-09)*
|
||
|
||
`[perfil.servidor]` hereda `cli` (que hereda `base`, [SDD 27 §7.1](27-perfiles-instalables-y-el-instalador.md))
|
||
y añade **5 raíces que existen** (`openssh`, `caddy`, `curl`, `wget`, `tmux`) y **5 que NO**:
|
||
|
||
| raíz que no resuelve | por qué está declarada igual |
|
||
|---|---|
|
||
| **`takana`** | **el propio takana no tiene receta.** El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como `cargo build` sobre un clon. Es EL bloqueante del §5 |
|
||
| `chrony` | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» |
|
||
| `nftables` | `dev.gioser.net` está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así |
|
||
| `cronie` | el latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. **Decisión pendiente** |
|
||
| `logrotate` | en una caja que sirve, el log de acceso crece hasta llenar el disco |
|
||
|
||
Quedan **`wanted`** en el grafo, y **`wanted` no es `debt`** ⇒ `drenaje.json` seguirá diciendo
|
||
`deuda=0`. Se declaran igual porque **una deuda escrita se ve y una omitida no**: sin ellas el perfil
|
||
saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
|
||
la lección de `foot` en `escritorio-sway` (121/121 sellado y sin emulador de terminal), pagada por
|
||
adelantado. **Cuenta esperada: 5.** Un sexto `wanted` es un error de tipeo, no una receta nueva.
|
||
|
||
### 3.5 La caja, creada *(2026-09-10)*
|
||
|
||
| | |
|
||
|---|---|
|
||
| nombre / id | `takana` / `165447050` |
|
||
| tipo | **`cx33`** — 4 c / 7,6 GiB / 76,3 G — **€8,49/mes** |
|
||
| ubicación | **hel1** — la misma que los volúmenes `vvv` y `harkaq-cosecha` y que el Storage Box |
|
||
| IPv4 / IPv6 | `2.29.29.217` · `2a01:4f9:c014:2f9c::/64` |
|
||
| imagen inicial | `debian-13` — **desechable**: existe sólo para recibir ficheros hasta el `dd` del §3.2 |
|
||
| label | `role=takana-server` |
|
||
| protección de borrado | **NO**, a propósito: es la caja de sacrificio |
|
||
|
||
⚠ **El label importa**: `farm-down.sh` y `deadman.sh` borran **sólo** lo que lleva
|
||
`role=hammer-worker`. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla.
|
||
Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se
|
||
protege por no haber nacido de `farm-up`.
|
||
|
||
⚠ **`cx53` no se pudo comprar: sale `Available: no` en las TRES ubicaciones.** Y la disponibilidad
|
||
**cambia por día** — `cx33` salió `no` en hel1 el 2026-09-09 y `yes` el 2026-09-10, con la misma
|
||
consulta. ⇒ **el precio de la tabla no dice que la máquina exista**: `hcloud server-type list`
|
||
devuelve precios para tipos que no se pueden crear. Consultar `Available` por ubicación, y volver a
|
||
consultarlo el día que se compra.
|
||
|
||
#### Lo que la caja confirma, medido EN ELLA y no inferido de gioser
|
||
|
||
1. **Arranca por BIOS** — no existe `/sys/firmware/efi`… **y sin embargo la imagen trae un ESP**
|
||
(`sda15`, 244 M vfat en `/boot/efi`). O sea: **la presencia de una partición EFI no indica
|
||
arranque EFI**. Si el instalador decidiera la rama mirando particiones en vez de
|
||
`/sys/firmware/efi`, elegiría mal en esta caja exacta. `takana-live-install.sh` mira lo correcto.
|
||
2. **`console=tty1 console=ttyS0` viene en el cmdline de Debian.** Confirma que el serial es lo que
|
||
Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola
|
||
web no muestra nada.
|
||
3. **`eth0` con `/32` dinámico por DHCP** — idéntico a gioser ⇒ es exactamente el caso de `netup`.
|
||
|
||
#### Dos cosas pendientes en la caja, anotadas
|
||
|
||
- **7,6 GiB de RAM y CERO swap** — la misma clase de máquina que ya provocó `global_oom` en gioser.
|
||
Coherente con la decisión de §2.1 (la caja **publica**, el LXC muele), pero significa que **no se
|
||
construye pesado acá** mientras siga así.
|
||
- **`sshd` trae `PasswordAuthentication yes`** (con `PermitRootLogin without-password`, así que root
|
||
por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está
|
||
igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian.
|
||
|
||
### 3.6 La imagen del `perfil.servidor`, armada y validada *(2026-09-10)*
|
||
|
||
**Receta del ensamblado** (todo con scripts que ya existían, §7):
|
||
|
||
```sh
|
||
# 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero)
|
||
cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/
|
||
# 2. userland del perfil encima
|
||
scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep
|
||
# 3. ⚠ restaurar el init (ver abajo) y meter la clave
|
||
ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys
|
||
# 4. imagen
|
||
ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \
|
||
CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \
|
||
./scripts/install-image.sh
|
||
```
|
||
|
||
Resultado: **86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse)**.
|
||
|
||
#### El kernel correcto es `linux-generic`, y `linux.toml` NO sirve para hcloud
|
||
|
||
El disco de una caja hcloud lo maneja **`virtio_scsi`** (driver `sd`, disco `sda`) — no `virtio_blk`.
|
||
`recipes/linux.toml` **apaga SCSI explícitamente** (`-d SCSI`) ⇒ su kernel arranca y **no ve el
|
||
disco**. `linux-generic`, `linux-metal` y `linux-metal-dual` sí sirven.
|
||
|
||
⚠ **Y ninguna receta nombra `SCSI_VIRTIO`.** Está encendido igual, porque `make defconfig` lo trae y
|
||
`-e SCSI -e BLK_DEV_SD` lo mantiene. Se supo mirando el `.config` que `linux-generic` **publica en su
|
||
artefacto** (`/boot/config-7.1.2-generic`), no leyendo la receta. La receta dice lo que se cambió;
|
||
sólo el `.config` sellado dice lo que quedó.
|
||
|
||
#### ⚠ La hidratación PISA `/sbin/init`, y el resultado arranca igual
|
||
|
||
`busybox` entra en la clausura del perfil y su `/sbin/init -> ../bin/busybox` **gana por llegar
|
||
después**, borrando el `-> /usr/bin/arje-zero` del product-rootfs. La imagen habría arrancado
|
||
perfecta **con el init de busybox**: no takana puro, y sin un solo error. Se tapa por las dos puntas
|
||
— restaurando el symlink al ensamblar **y** con `init=/usr/bin/arje-zero` explícito en el cmdline,
|
||
que es lo que hace gioser y es autoritativo pase lo que pase con el symlink.
|
||
|
||
**Y la guarda que existía para esto estaba INVERTIDA** (arreglada en el mismo commit): `[ -e
|
||
"$ROOTFS/sbin/init" ]` sigue el symlink y lo resuelve contra el *host*, así que el symlink ABSOLUTO
|
||
a arje-zero daba falso y **el rootfs correcto era rechazado**, mientras que el relativo a busybox
|
||
—el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si
|
||
sirve; ésta fallaba al revés.
|
||
|
||
#### EXDEV, dos veces en la misma tarde
|
||
|
||
`store` y `work/out` son bind-mounts de `/dev/sdb`; `work/` está en `/dev/sdc`. Un hardlink no cruza
|
||
un montaje **aunque sea el mismo disco**, así que reventaron (1) `hydrate-profile.py` con el destino
|
||
fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de `install-image.sh`, que
|
||
lo tenía cableado a `work/`. Ahora `STAGE` es env. Ver [[hardlink-cruza-mount-exdev]].
|
||
|
||
#### La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH
|
||
|
||
```
|
||
SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs
|
||
→ Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1"
|
||
→ netup (DHCP) → ssh-keygen -A → sshd
|
||
```
|
||
|
||
Y desde fuera: `ssh -p 2299 root@127.0.0.1` **entra**, `/proc/1/comm` dice `arje-zero`, `ip addr` da
|
||
la IP que negoció `netup`. Es la puerta 1 del §6 ensayada en local antes de gastar la caja.
|
||
|
||
El card `sshd` de `/ente/seed.card.json` ya hacía el trabajo entero sin que hubiera que tocar nada:
|
||
`sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"` — red, claves de host y
|
||
demonio en una línea. Lo único que hubo que inyectar fue `authorized_keys`.
|
||
|
||
#### Un resto del renombre, encontrado por el arranque
|
||
|
||
```
|
||
arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2)
|
||
```
|
||
|
||
**`arje-zero` invoca el binario por el nombre VIEJO (`hammer`).** No es fatal —sigue arrancando— pero
|
||
el menú de arranque por grafo del [ADR 0010](adr/0010-arranque-grafo-mirada.md) queda muerto. Y viene
|
||
de tawasuyu, pineado por `commit` en `recipes/arje-zero.toml`, así que **no se arregla desde este
|
||
repo**: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae `takana` (§3.4).
|
||
|
||
### 3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo
|
||
|
||
La imagen validada en QEMU se escribió a la caja, arrancó… y **no se pudo entrar**. `hcloud` decía
|
||
`running`, el puerto 22 cerrado, y desde fuera `Connection timed out` — el modo de fallo más caro de
|
||
todos, porque no hay consola serie en hcloud para mirar.
|
||
|
||
La causa, medida entrando al rescue de la propia caja:
|
||
|
||
```
|
||
inet 2.29.29.217/32 scope global eth0
|
||
default via 172.31.1.1 dev eth0
|
||
172.31.1.1 dev eth0 scope link ← ESTA es la que faltaba
|
||
```
|
||
|
||
**La IPv4 viene con prefijo `/32` y la puerta (`172.31.1.1`) no pertenece a ninguna red conectada.**
|
||
`netup` mandaba el default con `RTA_GATEWAY` y scope UNIVERSE y nada más, así que el kernel lo
|
||
rechazaba con `ENETUNREACH`: la máquina se quedaba **con IP y sin salida**, contestando el DHCP y
|
||
levantando sshd, sin nada visiblemente roto por dentro.
|
||
|
||
**Control causal**, en un netns con una dummy y una `/32`:
|
||
|
||
```
|
||
ip route add default via 172.31.1.1 dev dummy0 ⇒ Error: Nexthop has invalid gateway.
|
||
ip route add 172.31.1.1 dev dummy0 scope link ⇒ ok
|
||
ip route add default via 172.31.1.1 dev dummy0 ⇒ ACEPTADO
|
||
```
|
||
|
||
Arreglado con `add_onlink_host_route` (dst `/32`, scope LINK) antes del default.
|
||
|
||
**Por qué no había aparecido nunca**: `netup` se validaba contra el DHCP de QEMU slirp, que entrega
|
||
un `/24` con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad.
|
||
Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO
|
||
entorno donde se probaba no contenía el caso.
|
||
|
||
**Y destapó un segundo problema de fondo**: el binario de `netup` venía dentro del `product-rootfs`,
|
||
o sea **congelado en un artefacto sellado del bootstrap** ⇒ arreglar netup no llegaba a la imagen.
|
||
Por eso `netup` pasa a ser **raíz de `perfil.servidor`**: así la hidratación lo proyecta encima y la
|
||
imagen lleva el vigente. Verificado por sha256 (imagen `a23df20e…`, product-rootfs `a84b0a0d…`).
|
||
|
||
### 3.8 ✅ Puerta 1, pasada *(2026-09-10)*
|
||
|
||
```
|
||
PID 1 : arje-zero kernel : 7.1.2 (linux-generic propio)
|
||
IP : 2.29.29.217 rutas : default via 172.31.1.1 + 172.31.1.1 scope link
|
||
montajes : /dev/sda3 -> /store /dev/sda4 -> /var/lib/hammer
|
||
salida : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup
|
||
```
|
||
|
||
**Una caja Hetzner Cloud corriendo takana puro**: nuestro kernel, nuestro init, nuestro cliente DHCP,
|
||
nuestro sshd — sin una línea de Debian debajo. El camino entero es
|
||
`scripts/servidor-image.sh` + rescue + `dd`, y la escritura de los 7,1 G comprimidos tarda **~17 s**
|
||
(gioser y la caja están las dos en hel1).
|
||
|
||
Lo que la caja **todavía no** tiene, y es el paso 2: `takana` (sin receta, §3.4), el store real (el
|
||
volumen no se engancha hasta acá) y las otras cuatro raíces `wanted`.
|
||
|
||
---
|
||
|
||
## 4. La utilidad de migración: absorber lo VIVO, no lo declarado
|
||
|
||
`arje-absorb` ya existe, está sellado (`recipes/arje-absorb.toml`) y su `--help` dice: lee
|
||
`sysvinit | runit | dinit | openrc | auto` y **emite una Tarjeta Semilla** con cada servicio del init
|
||
ajeno como hija de arje-zero. Tiene parsers para los cinco (`systemd.rs`, `openrc.rs`, `runit.rs`,
|
||
`dinit.rs`, `sysvinit.rs`).
|
||
|
||
**Pero lee la declaración, y en gioser la declaración miente** (§0.2). Un `arje-absorb --from openrc`
|
||
sobre esta caja produciría una Semilla que **no levanta el servidor**, y lo haría en silencio: la
|
||
Semilla sería sintácticamente perfecta.
|
||
|
||
⇒ Lo que falta es un **segundo lector**: absorber del **proceso vivo** (`/proc`: `ppid==1`, `cmdline`,
|
||
`cwd`, entorno, fds en LISTEN) y **reconciliar** ambas lecturas marcando dónde discrepan. Eso es lo
|
||
que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito.
|
||
|
||
La utilidad debe clasificar en tres, no en dos:
|
||
|
||
| clase | criterio | qué se hace |
|
||
|---|---|---|
|
||
| **vivo** | proceso corriendo **y** declarado | se muda |
|
||
| **declarado, muerto** | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | **fósil: se anota y se deja morir** |
|
||
| **vivo, no declarado** | proceso con `ppid==1` que ningún init declara (caddy, gitea, sshd, act-runner) | **se escribe la tarjeta que faltaba** — es el trabajo real |
|
||
|
||
**Se construye al final**, estrenándose con la migración gioser → caja nueva, con el caso del
|
||
`rc-status` que miente como test de aceptación. Su especificación es el §1 de este documento.
|
||
|
||
---
|
||
|
||
## 5. Que el servidor sirva sus propios paquetes a su propio host
|
||
|
||
Existe el camino entero: `scripts/build-repo.sh` (corpus → `.swm` firmados + índice),
|
||
`takana install --repo` que **ya acepta lista HTTP multi-origen** y verifica cada paquete contra el
|
||
`digest` del índice firmado ([ADR 0014](adr/0014-distribucion-multiorigen.md)), `takana upgrade` con
|
||
rollback, y caddy para servir `dist/repo` con un `file_server`.
|
||
|
||
Lo que falta **no es servir**, son dos cosas:
|
||
|
||
1. **La clave.** `build-repo.sh` firma hoy con una **clave efímera** por defecto (`keygen release` si
|
||
no se le pasa `KEY=`) *(medido, línea 33)*. Sin clave estable, «firmado» no significa nada. Es el
|
||
[SDD 19 §3.2](19-lanzamiento-publico.md) y es requisito aunque el repo sea privado.
|
||
2. **La receta de `takana`** (§3.4). El host necesita `takana` instalado para consumir los paquetes,
|
||
y hoy no puede instalarlo desde el repo porque takana no está en el repo.
|
||
|
||
### 5.1 Hecho *(2026-09-10)*: clave estable, repo firmado, y la caja sirviéndolo
|
||
|
||
- **`trust/`** — la clave PÚBLICA de release en el repo; la privada en `~/.config/takana/keys/`
|
||
(0600, fuera de git). `scripts/repo-perfil.sh` **aborta si no la encuentra** en vez de inventar una
|
||
efímera, y tras firmar **verifica** el índice contra `trust/`.
|
||
- **88/88** paquetes del perfil `servidor` publicados con `expected_hash` anclado.
|
||
- **La caja los sirve** con `caddy file-server` sobre `/srv/repo`.
|
||
|
||
**El control de la firma, en los dos sentidos**, contra el repo servido por la caja:
|
||
|
||
| `--trust` | resultado |
|
||
|---|---|
|
||
| `./trust` | `release: trusted (by release)` ⇒ instala |
|
||
| un directorio vacío | `release: unknown-key … clave no confiada` ⇒ **ABORTA** |
|
||
|
||
### 5.2 ⚠ Lo que el lazo destapó: `strip_debug` no viajaba en el paquete
|
||
|
||
La primera instalación real de `takana` desde su propio repo falló:
|
||
|
||
```
|
||
Error: expected_hash no coincide:
|
||
declarado = b3:941d1857… obtenido = b3:2727050e…
|
||
```
|
||
|
||
`why-differs` lo nombró: los dos binarios pesaban **exactamente lo mismo** y sólo divergían en
|
||
`.shstrtab` — la firma de un `strip` que corrió una vez y la otra no. `swm_bridge` re-inyecta con
|
||
cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`, `deps`… y **se olvidaba de
|
||
`strip_debug`**, que ni siquiera existía en `SwmBuild`. Como **entra en `hash_inputs`**, el receptor
|
||
reconstruía en otra dirección.
|
||
|
||
**Alcance: 40 recetas del corpus, 18 de las 88 de este perfil.** Una quinta parte del repo no se
|
||
podía instalar, y nadie lo sabía porque **nunca se había cerrado el lazo**. Arreglado, con test de
|
||
regresión y su control. Tras el arreglo, `install takana` da cache-hit en el hash anclado.
|
||
|
||
### 5.3 ⚠ Y el loopback nunca se levantaba
|
||
|
||
Al intentar que la caja instalara de sí misma por `http://127.0.0.1`: **timeout de 30 s**, con caddy
|
||
escuchando en `0.0.0.0:80`. `lo` estaba **`DOWN` y sin dirección** — con systemd o OpenRC lo levanta
|
||
el init; con arje-zero no lo hacía **nadie**. El síntoma no era «connection refused», que se
|
||
diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en `netup` (es quien
|
||
configura la red) y verificado desde la imagen, sin tocar la caja a mano.
|
||
|
||
### 5.4 🚧 Lo que NO se puede todavía, y por qué importa
|
||
|
||
Con todo lo anterior en verde, la caja **sigue sin poder instalar de su propio repo**:
|
||
|
||
```
|
||
release: trusted (by release) ← la firma verifica
|
||
repo: bajados 2 .swm de http://127.0.0.1 ← descarga de sí misma
|
||
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
|
||
El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
|
||
calcular un hash comparable
|
||
```
|
||
|
||
O sea: **la mitad de servir está cerrada y probada; la de consumir, no** — y no por un detalle de
|
||
instalación, sino porque `install` **reproduce desde fuente** y eso exige el LAB ENTERO en el
|
||
cliente. No es «falta un compilador»: el lab entra en el `ArtifactHash`, así que sin él no se puede
|
||
ni calcular el hash a comparar.
|
||
|
||
Es exactamente el punto que [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md) dejó planteado
|
||
y sin decidir —*reproducir o hidratar*— visto desde el otro lado: para un servidor, o para
|
||
cualquiera que no sea un hub de build, **reproducir no es una opción**. El camino normal tiene que
|
||
ser hidratar artefactos firmados, con `--reproduce` como camino auditable. Mientras esa decisión no
|
||
se tome, el repo firmado sirve para distribuir **a hubs**, no a usuarios.
|
||
|
||
⚠ **Y una caja que se sirve a sí misma es un solo origen** — justo lo que el ADR 0014 existe para no
|
||
tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también
|
||
el Storage Box o gioser. Si no, el primer origen roto es el último.
|
||
|
||
**Lo que esto vale**: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad»
|
||
([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
|
||
laptop de nadie**.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
1. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo
|
||
más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del
|
||
selfhost y hay que rehacerlo a propósito, no de rebote.
|
||
2. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio:
|
||
Python estático con módulos de extensión es notoriamente arisco.
|
||
3. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta
|
||
honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo
|
||
gioser, que es justo lo que se quiere apagar.
|
||
|
||
Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md):
|
||
las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le
|
||
mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**.
|
||
|
||
### 6.5 ✅ El muro, derribado: el corpus publica su propio cargador *(2026-09-11)*
|
||
|
||
De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la **1**, que resultó ser más barata
|
||
de lo que parecía y desbloquea la 2 en vez de competir con ella.
|
||
|
||
**`recipes/musl-shared.toml`** — variante dinámica de musl que publica `/usr/lib/libc.so` (4,2 M) y
|
||
`/lib/ld-musl-x86_64.so.1`. En musl el cargador Y la libc son el mismo objeto.
|
||
|
||
**Por qué una variante y no `--enable-shared` en la canónica**, con su control: `musl` es componente
|
||
de **Stage 1** y su `of_tree` es el baseline de `selfhost-verify`. Tocarla obliga a rehacer ese
|
||
baseline a propósito. Con el patrón `*-shared` —que el corpus ya usa 23 veces— la canónica no se
|
||
mueve: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
|
||
además **`musl` no es dep de nadie** (medido: sólo de sí misma; el enlace estático lo resuelve el
|
||
musl que trae zig), así que esto **suma sin mover un solo ArtifactHash del corpus**.
|
||
|
||
#### ⚠ Y hubo que cambiar de compilador, medido
|
||
|
||
Con `zig-cc` el `libc.so` sale con **1586 símbolos dinámicos** contra los **1654** del musl de Alpine,
|
||
y los **68 que faltan son exactamente** los que musl implementa en ensamblador x86_64 — `memset`,
|
||
`memcpy`, `memmove`, `memcmp`, `strlen` y toda la familia matemática (`ceil floor sqrt fmod exp log
|
||
sin cos tan fma round trunc`) — más los `__stack_chk_*`.
|
||
|
||
No es que no se compilen: en el `libc.a` del musl canónico **están**. Lo que pasa es que **zig los
|
||
resuelve con su propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0**, fuera de la tabla dinámica.
|
||
El síntoma es un cargador que arranca, reloca y muere con `memset: symbol not found` — que se lee
|
||
como «el binario está roto», no como «a la libc le faltan símbolos».
|
||
|
||
Con `compiler = "gcc"`: **1651 símbolos**, `memset` y `ceil` presentes.
|
||
|
||
#### El resultado, en la caja
|
||
|
||
| antes | después |
|
||
|---|---|
|
||
| `sh: python3: not found` (con `command -v` contestando `/usr/bin/python3`) | `Python 3.12.10` |
|
||
| 23 binarios inertes de 726 | **1** |
|
||
|
||
`perl 5.040002`, `readelf`/`objdump`/`nm` (GNU Binutils 2.45.1) y el resto corren. El único que
|
||
quedaba era **`sqlite3`**, con `Error loading shared library libz.so.1`: la zlib canónica también es
|
||
`--disable-shared` y nadie publicaba ese soname. `zlib-shared` ya existía sellado en el corpus —
|
||
sólo faltaba declararla en `base`.
|
||
|
||
**La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente**: con el cargador
|
||
publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario
|
||
menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.
|
||
|
||
### 6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB
|
||
|
||
Con `python3` vivo, `takana hash` en la caja **seguía fallando**:
|
||
|
||
```
|
||
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
|
||
El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.
|
||
```
|
||
|
||
**El lab es parte de la IDENTIDAD, no del entorno.** Una máquina sin él no puede ni preguntar «¿cuál
|
||
es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado
|
||
el lab (tarball pineado + `tools/`, bajo `/store/dev-fs` porque la raíz son 6 G), el **hash testigo
|
||
coincide byte a byte**: `takana hash recipes/zlib.toml` da `b3:dc363f26…` en las dos máquinas.
|
||
|
||
⚠ **Y la caja no podía desempacar su propio lab**: el `tar` del rootfs es el de busybox y contesta
|
||
`tar: unrecognized option: zstd`. La distro comprime todo con zstd —la imagen del lab, el respaldo,
|
||
el `dd` remoto— y la imagen no traía el binario. `zstd` ya tenía receta sellada; declarado en `base`.
|
||
|
||
#### ⚠⚠ Copiar un store con `rsync -a` es una fábrica de vacíos, y ahora se sabe POR QUÉ
|
||
|
||
La primera copia murió con `No space left on device` en un `.so` de Qt, dejando el destino al 100 %
|
||
y **1367 de 1368 artefactos VACÍOS**. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.
|
||
|
||
La causa, medida:
|
||
|
||
| medición sobre el store de gioser | |
|
||
|---|---:|
|
||
| `du -sh` (hardlinks contados UNA vez) | **60 G** |
|
||
| `du -sh --count-links` (hardlinks EXPANDIDOS) | **85 G** |
|
||
| `.dmerge` (la caché que hardlinkea al store) | **11 G** |
|
||
|
||
**`rsync` sin `-H` no preserva hardlinks: los expande en copias enteras.** Así que «el store son 60 G»
|
||
—que es lo que dice `du` y lo que uno planifica— se convierte en 85 G + lo que `.dmerge` expanda al
|
||
otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se
|
||
llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.
|
||
|
||
La copia correcta es **`rsync -aH --exclude='.dmerge'`**: `-H` preserva los enlaces (60 G reales) y
|
||
`.dmerge` no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.
|
||
|
||
**La lección de método**: `du -sh` sobre un árbol con hardlinks **no** dice cuánto ocupa copiarlo.
|
||
Para dimensionar una copia hay que medir con `--count-links`, o preservar los enlaces.
|
||
|
||
#### Y el instrumental apunta al binario de DESARROLLO, no al instalado
|
||
|
||
La primera corrida de `build-state.py` en la caja devolvió **875 recetas `unhashable`** — todas —
|
||
con el lab bien sembrado y `takana hash` funcionando a mano. La causa:
|
||
|
||
```python
|
||
HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
|
||
```
|
||
|
||
Los scripts asumen un **árbol de desarrollo**: `target/release/takana`. En una caja **instalada** el
|
||
binario es `/usr/bin/takana` y `target/` ni existe (la siembra lo excluye a propósito). No falla
|
||
ruidosamente: cada `hash` falla y el grafo sale entero como `unhashable`, que se lee como «este
|
||
corpus no se puede hashear» y no como «no encontré el binario».
|
||
|
||
Se destraba con `HAMMER=/usr/bin/takana STORE=/store`, que la propia cabecera del script documenta.
|
||
Pero la deuda queda anotada: **el instrumental debería caer a `command -v takana`** antes de rendirse,
|
||
porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.
|
||
|
||
### 6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte
|
||
|
||
```
|
||
gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
|
||
caja: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
|
||
```
|
||
|
||
`base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3`
|
||
(`chrony`, `cronie`, `logrotate` — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.
|
||
|
||
**Costó tres intentos, y los dos fallos son la parte que vale.**
|
||
|
||
**Intento 1 — `unhashable 875`**, todas. Ver arriba: los scripts apuntan a `target/release/takana`.
|
||
|
||
**Intento 2 — dos nodos divergentes**: `aichat` y `zola`, `sealed` en gioser y `never` en la caja. No
|
||
era deriva ni pérdida: **ninguno de los dos está en disco en gioser tampoco**. Son los «2 sellados
|
||
que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime.
|
||
`build-state.py` lee `work/farm-sellados.txt` y `work/respaldo-sellados.txt` — y **`work/` está
|
||
gitignored**, así que un hub recién sembrado no los tiene y degrada esos nodos a `never` sin que nada
|
||
falle.
|
||
|
||
Es la misma familia que `scripts/farm/.fleet`: **un fichero fuera de git del que depende una medición
|
||
compartida**. La regla que sale: *sembrar un hub no es clonar el repo — es repo + store + lab +
|
||
los manifiestos*. Copiados los dos ficheros, los grafos coinciden exactamente.
|
||
|
||
### 6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco
|
||
|
||
**Probado desde la caja** (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):
|
||
|
||
- llevada la clave de la granja (`~/.ssh/github5` → `/root/.ssh/github5`, 0600) y escrito
|
||
`scripts/farm/.fleet` — los dos están fuera de git a propósito y hay que copiarlos a mano;
|
||
- la caja **alcanza al worker**: `PruebasIA`, load 3, 775 artefactos;
|
||
- `estado-granja.sh` corre allá y lee bien el worker y el avance KDE (198/199);
|
||
- y el paso que cierra el lazo: se **cosechó un artefacto real del worker** (`speedtest-go`, 12 M)
|
||
con el mismo transporte de la granja, llegó **con su `.hammer/recipe.toml`** y **su binario CORRE
|
||
en la caja** (`speedtest-go v1.7.10`). Worker construye → hub cosecha → binario ejecuta.
|
||
|
||
#### ✅ CERRADA (2026-09-11) con el volumen `takana-store`
|
||
|
||
Resuelto el disco (§6.12), la cosecha completa corrió: **1369 → 1575 artefactos, 0 vacíos**, que son
|
||
exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —`speedup 3.14`, el ahorro de
|
||
`-H`— y `/store` queda en 73,7 G de 97,9 (79 %).
|
||
|
||
⚠ El enlace con el worker va a **11 MB/s**, no a los 90 MB/s de gioser↔caja: el LXC vive en el
|
||
Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.
|
||
|
||
⚠ **Lo que faltaba no era mecanismo: era DISCO.**
|
||
|
||
| | |
|
||
|---|---:|
|
||
| artefactos en el worker que la caja no tiene | **206** |
|
||
| a ~113 MB de media (el artefacto medio del corpus) | **~23 G** |
|
||
| libre en `/store` de la caja | **3,7 G** (61,5 de 68,7 · 94 %) |
|
||
|
||
Una cosecha completa **no entra**, y forzarla repetiría exactamente el llenado que dejó 1367
|
||
artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una
|
||
de ingeniería:
|
||
|
||
1. **Enganchar `harkaq-cosecha` (250 G) a la caja.** Es el plan original y no cuesta nada extra —
|
||
pero hay que DESENGANCHARLO de gioser, y ahí vive el store, `work/sources`, los tarballs y el
|
||
`CARGO_HOME` de la granja. O sea: **es el cutover**. gioser deja de construir en ese mismo
|
||
instante, y hoy está moliendo el árbol KDE.
|
||
2. **Un volumen nuevo para la caja** (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y
|
||
destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta.
|
||
3. **Podar el store de la caja** — deshace parte de la mudanza. No.
|
||
|
||
### 6.9 Puerta 8: los 19 dominios, sondeados uno por uno *(2026-09-11)*
|
||
|
||
El Caddyfile de gioser declara 19 sitios. Sondeados por DNS **y** por HTTP desde fuera, sólo **6**
|
||
los sirve gioser de verdad:
|
||
|
||
| dominio | DNS | HTTP | qué es |
|
||
|---|---|---|---|
|
||
| `gioser.net` · `tawasuyu.net` | gioser | **200** | **VIVO** — las dos landings |
|
||
| `git.gioser.net` · `git.tawasuyu.net` | gioser | **200** | **VIVO** — el gitea (aloja el `origin` de takana) |
|
||
| `sergio.gioser.net` · `api.sergio.gioser.net` | gioser | **200** | **VIVO** |
|
||
| `summa` · `api.summa` · `dev.summa` | **154.197.1.2** | 200 | **YA MUDADOS** a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva |
|
||
| `api.gioser.net` | gioser | **502** | backend caído |
|
||
| `mail.sigma.gioser.net` | gioser | **502** | backend caído |
|
||
| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **SIN DNS** | — | fósil: ni DNS ni ficheros en `/var/www` |
|
||
| `terapeuta.ec` · `andino.ec` | **SIN DNS** | — | fósil con datos: 279 M en `/var/www/terapeuta` |
|
||
| `gitea.gioser.net` | **SIN DNS** | — | era un `redir` a `git.gioser.net`; inofensivo |
|
||
|
||
**Lo que esto cambia respecto del §1.1**: no son «4 fósiles» sino **13 de 19 entradas que no hay que
|
||
mudar** — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a
|
||
mudar son **6 sitios**, y de esos el que pesa es el **gitea**, porque aloja el `origin` de takana.
|
||
|
||
#### ✅ DECIDIDO (usuario, 2026-09-11)
|
||
|
||
| fósil | decisión |
|
||
|---|---|
|
||
| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **mueren** — ni DNS ni ficheros |
|
||
| `gitea.gioser.net` | **muere** — era un `redir`, y `git.gioser.net` lo cubre |
|
||
| `api.gioser.net` · `mail.sigma.gioser.net` | **mueren** — backend caído, sin dueño |
|
||
| `summa` · `api.summa` · `dev.summa` | **no se mudan**: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser |
|
||
| **`terapeuta.ec` · `andino.ec`** | **MUEREN** — decisión explícita del usuario. Los 279 M de `/var/www/terapeuta` se van con la caja; **no se archivan** |
|
||
|
||
⇒ De las 19 entradas del Caddyfile, **13 no se mudan**. La superficie real es de **6 sitios**, y el
|
||
que pesa es el gitea porque aloja el `origin` de takana.
|
||
|
||
⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no va a poder
|
||
recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.
|
||
|
||
### 6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo
|
||
|
||
Llevadas las credenciales (`~/.config/hammer/*.env`, 0600) y la clave, **la caja alcanza el Storage
|
||
Box** por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá:
|
||
**3140 artefactos**, y de paso el script detecta **2 VACÍOS en el respaldo** (`gnome-desktop` ×2) que
|
||
excluye del manifiesto — su propia guarda, funcionando.
|
||
|
||
Para llegar ahí hubo que arreglar **cinco** cosas, y todas son de la misma familia: *el instrumental
|
||
asume que el hub es gioser*. El `--seco` ahora completa los tres pasos desde la caja y lee la
|
||
ocupación del box (111 G de 1 T).
|
||
|
||
1. **La raíz cableada.** `RAIZ="${RAIZ:-/mnt/vvv/takana}"` ⇒ desde cualquier otra máquina el script
|
||
moría con `cd: /mnt/vvv/takana: No such file or directory`. Ahora se deriva de dónde está el
|
||
script, como el resto de `scripts/`.
|
||
2. **El binario de desarrollo.** `HAMMER = ROOT/target/release/takana` (§6.7).
|
||
3. **El store no estaba donde el script creía.** Usaba `$RAIZ/store` cableado: en gioser es un
|
||
bind-mount DENTRO del repo, pero en una caja instalada es una partición en `/store` ⇒
|
||
`change_dir "/opt/takana/store" failed`. Y rsync devuelve **23**, que está en la lista de
|
||
reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta
|
||
(«un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»).
|
||
Ahora `STORE` es env, con una guarda que aborta ANTES del bucle si la ruta no existe.
|
||
4. **🚨 Y una que habría costado el historial.** El paso [2/3] sube el repo **con `--delete`** —
|
||
correcto, para eso está git. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`:
|
||
es una COPIA, no un clon. **Una corrida real desde ahí habría borrado `.git` del Storage Box**, o
|
||
sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una
|
||
guarda que aborta si la raíz no tiene `.git` (escotilla `REPO_INCOMPLETO=1`), probada en los tres
|
||
sentidos.
|
||
|
||
**La regla que sale**: un `--delete` convierte «respaldar» en «sincronizar», y sincronizar desde
|
||
un origen incompleto no sube menos — BORRA. Cualquier respaldo con `--delete` necesita una guarda
|
||
de completitud del ORIGEN, no sólo del destino.
|
||
5. **El rsync del corpus no puede correr el respaldo del corpus.** El script exige
|
||
`--compress-choice=zstd` y `recipes/rsync.toml` lo construía con `--disable-zstd` — porque cuando
|
||
se escribió, `zstd` no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el
|
||
script lo clasifica como «no de red» y **no reintenta**, así que el respaldo simplemente no se
|
||
hacía. Arreglado por los dos lados: la receta enciende zstd (control: `zstd zlibx zlib none` vs
|
||
`zlibx zlib none`; radio cero, `rsync` no es dep de nadie) y **el script degrada a zlib avisando**
|
||
en vez de morir.
|
||
|
||
### 6.11 ⚠ La distro traía un `git` que NO PODÍA CLONAR
|
||
|
||
Para cerrar la puerta 7 la caja necesita un **clon de verdad** (el `--delete` del respaldo exige un
|
||
origen completo, §6.10). Llevada la clave del gitea —que es `~/.ssh/tawasuyu`, **no** la de la
|
||
granja— `git ls-remote` contesta… y `git clone` muere:
|
||
|
||
```
|
||
fatal: fetch-pack: invalid index-pack output
|
||
```
|
||
|
||
El informe completo, que la traza corta escondía:
|
||
|
||
```
|
||
panic: load of misaligned address 0x… for type 'const uint32_t',
|
||
which requires 4 byte alignment
|
||
in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
|
||
→ git_hash_update → unpack_entry_data → cmd_index_pack
|
||
```
|
||
|
||
**Dos causas, encadenadas:**
|
||
|
||
1. **`-fsanitize=undefined` estaba en `CFLAGS`**, no sólo en `LDFLAGS`. El motivo original era
|
||
legítimo —las `libz.a` materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no
|
||
se resuelven solas, y el flag *al enlazar* trae el runtime de zig— pero en `CFLAGS` **instrumenta
|
||
el código de git**. Un arreglo de ENLACE convertido en una mina de RUNTIME, justo en la ruta de
|
||
hash: por donde pasa todo lo que git recibe.
|
||
2. **`sha1collisiondetection` lee palabras de 32 bits SIN alinear.** Es el backend SHA1 por defecto
|
||
de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota
|
||
jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte.
|
||
`-DSHA1DC_FORCE_ALIGNED_ACCESS` lo hace leer byte a byte: quita el UB **en la fuente** en vez de
|
||
esconderlo.
|
||
|
||
Con las dos: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas**. Radio cero
|
||
(`git` no es dep de nadie), pero **sí es raíz de `perfil.base`** ⇒ esto arregla la distro entera.
|
||
|
||
**La lección**: `ls-remote` andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el
|
||
informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.
|
||
|
||
### 6.12 ⚠ La caja dejó de ser desechable — y `hydrate` no cruza el store
|
||
|
||
Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:
|
||
|
||
**1. Re-escribir la imagen con `dd` ya NO es una actualización, es una pérdida.** El `dd` sobrescribe
|
||
el disco local, y ahí viven ahora `/opt/takana` (el clon), `/root/.ssh` (las dos claves),
|
||
`/root/.config/hammer` (las credenciales del respaldo), `/srv/repo` y la configuración de caddy. El
|
||
store se salva sólo porque está en el volumen. **Y hay una trampa peor**: la imagen etiqueta su
|
||
partición local como `hammer-store`, así que tras un `dd` habría **dos filesystems con esa etiqueta**
|
||
y el `findfs` del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición
|
||
vacía. ⇒ el `dd` es para PROVISIONAR; actualizar es `takana upgrade` (que existe, con generaciones y
|
||
rollback) y está sin probar en esta caja.
|
||
|
||
**2. `takana hydrate` no puede proyectar al root vivo.** Hidratar enlaza en DURO, y en una caja
|
||
instalada el store es **siempre** otra partición que `/`:
|
||
|
||
```
|
||
Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link
|
||
```
|
||
|
||
No es culpa del volumen: en el layout original `/store` es `sda4` y `/` es `sda2`, también distintos.
|
||
**Hidratar al FHS vivo nunca fue posible en una caja instalada** — funciona en el hub porque ahí
|
||
store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces
|
||
(§6.6), ahora a nivel de sistema y no de herramienta.
|
||
|
||
Para instalar el `git` arreglado se copió el artefacto a mano. Eso **funciona y no es el camino**: el
|
||
camino es `takana upgrade`, y probarlo es su propia unidad de trabajo.
|
||
|
||
### 6.13 ✅ `takana upgrade`, probado de verdad — y el bug de durabilidad que destapó
|
||
|
||
§6.12 dejó dicho que el camino de actualización de una caja instalada es `takana upgrade`, no `dd` ni
|
||
`hydrate`. Probado con un paquete que la caja **realmente necesitaba**: `zstd`.
|
||
|
||
| paso | resultado |
|
||
|---|---|
|
||
| `upgrade apply …-zstd-cli` | `✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados` |
|
||
| la prueba de uso | `zstd -dc lab-image.tar.zst \| tar -xf -` ⇒ **la caja desempaca su propio lab** |
|
||
| `upgrade rollback` | `✓ generación 2 revertida · 9 restaurados, 1 borrados` — `zstd` se va, `libzstd.a` vuelve |
|
||
| re-`apply` + **corte duro sin `sync`** | sobrevive: generación viva 1, manifiesto íntegro |
|
||
|
||
✔ **Y cruza la frontera que `hydrate` no puede**: el store está en el volumen y `/` en el disco local,
|
||
y `upgrade` copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.
|
||
|
||
#### ⚠ El bug: `write_atomic` era atómico pero NO DURABLE
|
||
|
||
El primer intento pareció funcionar y **se perdió al reiniciar**. La caja volvió con:
|
||
|
||
```
|
||
pending.json 0 bytes
|
||
generations/2/manifest.json 0 bytes
|
||
upgrade status → Error: json: EOF while parsing a value
|
||
upgrade recover → Error: json: EOF while parsing a value
|
||
```
|
||
|
||
`write_atomic` hacía temporal + `rename`: atómico frente a otros PROCESOS, no frente a un CORTE.
|
||
`rename` sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a
|
||
bloques nunca escritos — cero bytes. Y `hcloud server reset` **es un corte duro**, no un apagado
|
||
limpio: ahí está la mitad operativa de la lección.
|
||
|
||
Lo grave no es perder un upgrade: es que **`recover` —que existe exactamente para «un apply
|
||
interrumpido por un corte»— abortaba con la huella más probable de ese corte**.
|
||
|
||
Arreglado: `fsync` del temporal antes del `rename` y `fsync` del directorio después (hacen falta los
|
||
dos), y un `pending.json` vacío se reporta como `PendingCorrupt`, nombrando el corte y apuntando al
|
||
árbol de respaldos. Con tests y su control.
|
||
|
||
**Verificado en la máquina, en los dos sentidos**: con el binario viejo, apply + reset duro ⇒ estado
|
||
en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.
|
||
|
||
#### ⚠⚠ `upgrade apply` NO es un gestor de paquetes: cada generación ES UN ÁRBOL
|
||
|
||
Lo destapó usarlo dos veces seguidas. Tras aplicar `zstd-cli` y después `rsync`:
|
||
|
||
```
|
||
✓ generación 2 aplicada
|
||
+ 0 añadidos, ~ 8 pisados, - 1 retirados ← el `zstd` de la generación 1
|
||
$ zstd --version
|
||
AUSENTE
|
||
```
|
||
|
||
**Aplicar un árbol nuevo RETIRA los ficheros del anterior**, porque una generación es el estado
|
||
completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/**producto**
|
||
del store»— y con que exista `rollback`: lo que se revierte es un sistema, no un paquete.
|
||
|
||
⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente.
|
||
**La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace
|
||
`servidor-image.sh`— sellarlo, y aplicar ESO como una generación.** Eso además sería, de verdad, la
|
||
«actualización en sitio probada» del [SDD 19 §5.2](19-lanzamiento-publico.md): una imagen entera, con
|
||
rollback, sobre una caja viva.
|
||
|
||
Mientras tanto la caja quedó con `zstd` y `rsync` puestos a mano — funciona y **no es el camino**,
|
||
igual que el `git`.
|
||
|
||
⚠ Lo que NO se arregló: si el manifiesto se pierde, `recover` no puede deshacer solo — no sabe qué se
|
||
tocó. El árbol de respaldos tiene la información; reconstruir desde ahí es su propia unidad.
|
||
|
||
### 6.2 Lo que la mudanza tiene que producir, además de la mudanza
|
||
|
||
El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es
|
||
un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
|
||
una receta candidata con un caso de uso comprobado detrás:
|
||
|
||
| lo que corre hoy en Artix | receta |
|
||
|---|---|
|
||
| `caddy` | ✅ existe (`recipes/caddy.toml`) |
|
||
| `openssh` | ✅ existe |
|
||
| `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo |
|
||
| `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ |
|
||
| `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` |
|
||
|
||
> **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más
|
||
> peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su
|
||
> artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.
|
||
>
|
||
> Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4
|
||
> ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete
|
||
> ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea.
|
||
>
|
||
> Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.
|
||
|
||
**Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve
|
||
el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*.
|
||
|
||
---
|
||
|
||
### 6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE *(2026-09-14)*
|
||
|
||
`servidor-image.sh` + QEMU: **PID 1 = `arje-zero`, gitea encarnado por él (`ppid=1`), corriendo como
|
||
`uid=916`, escuchando en :3000 y devolviendo `200` con `<title>takana git</title>`**, con su
|
||
`gitea.db` creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana:
|
||
los que había venían horneados en el bootstrap.
|
||
|
||
La cadena entera, eslabón por eslabón: `[[user]]` → `/etc/passwd` de la imagen · `[[service]]` →
|
||
`takana service-cards` → `genesis` de la seed → arje lo encarna → `setuidgid` → sirve.
|
||
|
||
#### Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN
|
||
|
||
Los tres pasan el sellado, el `hash`, el resolutor de servicios y el armado. Ninguno se ve sin
|
||
arrancar la imagen.
|
||
|
||
1. **⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store.** `takana users --merge` hacía
|
||
`fs::write` sobre `<rootfs>/etc/passwd`, y ese fichero y el del artefacto `product-rootfs` son
|
||
**el mismo inode** (medido: 1225824, `2 links`, modo `444`) — el rootfs se arma con hardlinks.
|
||
Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro
|
||
del producto. Se salvó porque el store es de sólo lectura y salió `Permission denied`: confiar en
|
||
eso es confiar en un permiso. Ahora escribe por temporal + `rename`, con un control que comprueba
|
||
que el fichero del store conserva su contenido y baja a 1 link.
|
||
|
||
2. **La imagen traía el binario, la cuenta… y nadie lo arrancaba.** El `genesis` sólo tenía lo que
|
||
hornea `takana-bootstrap` (`sshd`, `console-getty`, `hammerd`): `servidor-image.sh` **no inyectaba
|
||
las Cards** — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo
|
||
dice: `targets.py --services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era
|
||
el paso que lleva esa declaración a la imagen.
|
||
|
||
3. **⚡ `trap invalid opcode`: el binario sólo corría en la CPU que lo compiló.** Con la card ya en el
|
||
genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta `CC` apuntando a un wrapper
|
||
con `-mcpu=baseline` (y su comentario ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y
|
||
**la fase Go del propio builder lo pisaba** con `CC="zig cc"` a secas, que es `-mcpu=native`. No
|
||
falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el `ArtifactHash` no puede
|
||
cazarlo, porque la CPU del builder no entra en `hash_inputs` — **dos workers distintos sellan bytes
|
||
distintos bajo la misma dirección**. Arreglado en el builder y en la receta; re-hashea las cinco
|
||
recetas `cgo = true` (`gitea`, `usql`, `sq`, `gocryptfs`, `naabu`).
|
||
|
||
#### Lo que NO está probado, y falta para la mudanza
|
||
|
||
El `app.ini`, el usuario del sitio y los datos se pusieron **a mano** dentro de la VM para llegar al
|
||
200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — `/etc/gitea` ya está en
|
||
`scripts/mudanza/rutas-fuera-de-git.txt`.
|
||
|
||
Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para que arje volviera a
|
||
intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en
|
||
caliente. Para una caja de producción eso es una pieza que falta, no una comodidad.
|
||
|
||
### 6.12 🎛️ `arjectl` en la imagen: control en caliente *(2026-09-14)*
|
||
|
||
El §6.11 cerró con «la imagen no trae `arjectl`, hubo que reiniciar la máquina para arrancar un
|
||
servicio». Ya lo trae, y **no hubo que escribir nada**: el cliente existía en tawasuyu y se llama
|
||
**`arje-ctl`** (el crate; el binario es `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí
|
||
salió la conclusión falsa de que faltaba implementarlo — el protocolo (`arje-bus::BusRequest`) ya
|
||
traía `ListEntes`, `SpawnCardFromDisk`, `StopCardFromDisk`, `KillEnte` y `EnteStatus`.
|
||
|
||
`recipes/arjectl.toml` lo construye **del mismo commit que `arje-zero`** (`98db584f`), y eso no es
|
||
comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no
|
||
entenderse con el init. Publica **sólo `arjectl`**; el crate también produce un `systemctl` de
|
||
camuflaje que acá no se instala — un `systemctl` en el PATH de una distro sin systemd invita a
|
||
escribir runbooks con el verbo ajeno.
|
||
|
||
#### El hueco que sólo se ve usándolo: el genesis no alcanza
|
||
|
||
Con `arjectl` en la imagen, el primer intento falló con un mensaje que nombra la causa:
|
||
|
||
$ arjectl start gitea
|
||
Error: arje-zero rechazó: card gitea: No such file or directory
|
||
(buscada en /etc/arje/cards.d/gitea.json)
|
||
|
||
`start`/`restart` usan `SpawnCardFromDisk`, que lee **`/etc/arje/cards.d/<label>.json`** — y el
|
||
armado sólo escribía el `genesis` de la seed. Son dos preguntas distintas: *qué arranca solo* y *qué
|
||
se puede encarnar a pedido*. Con sólo la primera, un servicio que agota su backoff **no se puede
|
||
relanzar sin reiniciar la máquina**. `inyectar-cards.py` escribe ahora los dos árboles (y en
|
||
`cards.d` escribe TODAS, no sólo las nuevas: `sshd` viene del product-rootfs y tampoco era
|
||
relanzable).
|
||
|
||
#### Medido, con la VM arrancada UNA sola vez
|
||
|
||
| | |
|
||
|---|---|
|
||
| la imagen trae | `/etc/arje/cards.d/{gitea,sshd}.json` |
|
||
| `arjectl list-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
|
||
|
||
1. **El uid del origen no es el del destino.** Los ficheros llegan con su uid NUMÉRICO (`1001` en el
|
||
rsync de prueba; el gitea de gioser es **969**) y el `[[user]]` declaraba 916. O se chownean 2 G
|
||
—lento, y hay que acordarse— o gitea no puede leer sus datos, **y eso no falla al copiar: falla
|
||
al arrancar**. La receta pasa a declarar **969**, alineado con el origen.
|
||
2. **El `app.ini` de gioser escucha en `127.0.0.1:3002`**, porque allá caddy hace de proxy. Copiado
|
||
tal cual, la caja sirve — pero sólo desde dentro. El `HTTP_ADDR`/`HTTP_PORT` y el `ROOT_URL` son
|
||
parte de la adaptación, no del copiado.
|
||
3. 🧨 **El `git` de la distro NO puede clonar por HTTP.** `git clone http://…` dentro de la imagen:
|
||
|
||
git: 'remote-http' is not a git command. See 'git --help'.
|
||
fatal: remote helper 'http' aborted session
|
||
|
||
Medido sobre el ARTEFACTO sellado: `/usr/libexec/git-core/` trae `git-remote-ext`, `git-remote-fd`
|
||
y `git-http-backend` (el lado SERVIDOR), y **no** `git-remote-http`/`-https`; `strings` del binario
|
||
da **0** referencias a libcurl, pese a que `curl` está en `[deps] build`. Nadie lo había notado
|
||
porque en el hub el `git` que se usa es el de Artix, no el sellado. Es
|
||
[[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que
|
||
hace falta. **Un hub nuevo no podría clonar el repo por HTTPS.** Arreglarlo re-sella `git`, que es
|
||
raíz de `perfil.base` ⇒ unidad propia, no de paso.
|
||
|
||
### 6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH *(2026-09-14)*
|
||
|
||
**Hecho y verificado desde fuera:** `git.gioser.net` y `git.tawasuyu.net` responden **200 con TLS
|
||
válido desde `2.29.29.217`**, `git clone` funciona **por HTTPS y por SSH:2345**, y `gitea` y `caddy`
|
||
corren supervisados por `arje-zero` en la caja. El gitea de gioser está **parado**.
|
||
|
||
**Mudanza incremental, no big-bang.** Los DNS de gioser son casi todos `CNAME → www`, así que mover
|
||
`www` habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo
|
||
`git` (en las dos zonas) de CNAME a **A propio con TTL 60**, que es reversible en un minuto.
|
||
|
||
#### La secuencia que funcionó, en orden
|
||
|
||
1. Caja actualizada: `arjectl` + `gitea` + la cuenta `gitea:969` + las cards en `genesis` y `cards.d`.
|
||
2. Datos en dos fases: **1,66 GB / 18 047 ficheros** con el origen VIVO (17 s), y en el corte el
|
||
`rsync --delete` incremental + `sqlite3 ".backup"` (`integrity_check ok`).
|
||
3. **Parar el origen de verdad.** `rc-service gitea stop` dice *«already stopped»* con el proceso
|
||
vivo (el `rc-status` que miente, §Los tres hechos), y matarlo **no alcanza**: lo revive
|
||
`arje-zero`, que lo tiene como card `openrc-gitea` con `Restart` — **9001 reinicios** acumulados
|
||
en el contador. Se paró con **`arjectl stop openrc-gitea`**, y para eso hubo que extraer su card
|
||
del `genesis` y escribirla en `/etc/arje/cards.d/` (§6.12: `SpawnCardFromDisk`/`StopCardFromDisk`
|
||
leen de ahí, no del genesis).
|
||
4. DNS por la **API de Hetzner Cloud** (`/v1/zones`): el token de `hcloud` **también gestiona el
|
||
DNS** desde la unificación — la API vieja `dns.hetzner.com/api/v1` redirige a la consola. Y un
|
||
`PUT` sobre el rrset **no puede cambiar el tipo**: hay que `DELETE` del CNAME y `POST` del A.
|
||
5. TLS: el primer intento de ACME **falló contra gioser** (`502`) porque el challenge salió antes de
|
||
que propagara; con el DNS ya al día, `arjectl restart caddy` y *certificate obtained successfully*.
|
||
|
||
#### La identidad SSH se muda con el servicio
|
||
|
||
`git clone ssh://…:2345` daba **`REMOTE HOST IDENTIFICATION HAS CHANGED`**: es otra máquina. Se
|
||
copiaron las **claves de host** de gioser (`/etc/ssh/ssh_host_*`) a la caja ⇒ los clones existentes
|
||
no notan nada. El precio es que cambia también la identidad del `:22` de administración, y hay que
|
||
limpiar el `known_hosts` propio — a los clientes del servicio no les afecta, que es lo que importa.
|
||
|
||
Y el `sshd` del producto escucha sólo en `:22`: el git por SSH necesitó `Port 2345` y que el usuario
|
||
`gitea` tenga **shell real** (su `authorized_keys` fuerza `command="gitea serv …"`, y con
|
||
`/bin/false` no se ejecuta nada).
|
||
|
||
#### Lo que esto cambia para la granja
|
||
|
||
Las 23 recetas que ahora clonan por `https://git.tawasuyu.net/…` **apuntan a la caja**. Comprobado
|
||
tras el cambio: el worker resuelve `2.29.29.217` y `git ls-remote` responde. La granja depende ya
|
||
del servidor nuevo, que es el sentido de la mudanza.
|
||
|
||
#### Cómo se revierte, si hiciera falta
|
||
|
||
`arjectl start openrc-gitea` en gioser y devolver los dos `git` a `CNAME → www`. Con TTL 60, minutos.
|
||
|
||
### 6.15 Cerrar el intermedio: el viejo apagado de verdad, el `:22` cerrado y los datos respaldados
|
||
|
||
#### ⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor
|
||
|
||
Media hora después del corte, gioser volvía a servir en `:3002`. `arjectl list-units` **ya no lo
|
||
mostraba** (el `stop` de arje seguía en pie) y el proceso tenía **`ppid ≠ 1`**: lo había levantado
|
||
**OpenRC**, que lo tenía en el runlevel `default`. O sea **dos supervisores para el mismo servicio**
|
||
—la card `openrc-gitea` de arje y el runlevel de OpenRC— y parar uno no para el otro.
|
||
|
||
rc-update del gitea default # que no vuelva
|
||
rc-service gitea stop # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo
|
||
|
||
Antes del corte pasaba lo contrario (`rc-service … stop` decía *«already stopped»* con el proceso
|
||
vivo, porque quien lo tenía era arje). **La pregunta no es «¿está parado?» sino «¿quién lo tiene?»**,
|
||
y hay que responderla dos veces.
|
||
|
||
#### El origen deja de servir git, y se comprueba que no se rompió lo demás
|
||
|
||
Los tres bloques (`git.gioser.net`, `git.tawasuyu.net`, `gitea.gioser.net`) salen del Caddyfile de
|
||
gioser —con respaldo previo, `caddy validate` y `reload`—, y quedan 16. Control inmediato:
|
||
`sergio.gioser.net` y `hifas.gioser.net` siguen en **200**. `gitea.gioser.net` pasa a `A` → la caja,
|
||
donde ya está su `redir`.
|
||
|
||
#### El `:22` cerrado, sin perder el acceso
|
||
|
||
Administración en **22022**, git por SSH en **2345**, y el **22 cerrado** (`Connection refused`) para
|
||
sacarse de encima el barrido de bots. La secuencia importa y es la única segura: **añadir** el puerto
|
||
nuevo → reiniciar → **entrar por él** → sólo entonces quitar el 22. Comprobado en ese orden, y el
|
||
`git clone ssh://…:2345` sigue funcionando después.
|
||
|
||
#### 🧨 Los datos del gitea NUNCA estuvieron respaldados
|
||
|
||
`respaldo-storagebox.sh` respalda el **store de artefactos**, no `/var/lib/gitea`. O sea que los 44
|
||
repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí
|
||
lo hace urgente: **gioser se borra**. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo
|
||
+ `rsync` a `u647150:gitea/` — **1,7 G** (`datos/` + `gitea.db` de 314 M).
|
||
|
||
⚠ **Al Storage Box se entra por el puerto 23**, no el 22: el 22 da SFTP/SCP restringido y contesta
|
||
`Permission denied (publickey,password)`, que se lee como *«no tengo la clave»* cuando la clave está
|
||
perfecta. Pasó en las dos máquinas antes de mirar el script (`SB_PORT=23`).
|
||
|
||
**Pendiente**: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.
|
||
|
||
### 6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó
|
||
|
||
La pregunta correcta después de un cutover no es «¿arrancó?» sino **«¿entró algo en el viejo
|
||
mientras tanto?»**. Se contesta comparando **todos los refs de los dos lados**:
|
||
|
||
```sh
|
||
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**:
|
||
|
||
```sh
|
||
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](adr/0015-imagenes-ajenas.md) (`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
|
||
|
||
1. **`chown`**: el `rsync` preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del
|
||
rango mapeado ⇒ ni siendo root adentro se puede escribir. `python -m venv` fallaba con
|
||
`Permission denied` en el directorio concedido mientras andaba perfecto en `/tmp`. El árbol del
|
||
sitio va a `root:root` del anfitrión, que es lo que el mapa de la jaula sí alcanza.
|
||
2. **Una instancia, un `run`**: el segundo `qorpa run` sobre la misma instancia es rechazado por el
|
||
kernel («dos overlays con el mismo `upper` corromperían la capa mutable»). Correcto y hay que
|
||
saberlo: con el servicio arriba **no se entra a la instancia a hacer mantenimiento**.
|
||
3. **`ps` de la caja no ve los procesos y `dig` no existe en el hub**. Dos herramientas ausentes que
|
||
se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un `until` de
|
||
espera de DNS girando cinco minutos contra una condición que **nunca podía cumplirse**. Un
|
||
guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.
|
||
|
||
#### Lo que quedó declarado, y lo que no
|
||
|
||
- `instance.toml` es la verdad: `packages = ["python","python-pip"]`, `network = true` (pacman, pip y
|
||
la propia API de Gemini la necesitan) y **un solo dir concedido** (`/work/sergioh` → `/srv/sergioh`,
|
||
rw). Ni sockets, ni dispositivos, ni nada más.
|
||
- El código y el venv viven **fuera** de la instancia, en el anfitrión: el `upper` es caché (D3) y se
|
||
tira. Y las 84 dependencias quedaron pineadas en `requirements.lock` — el `requirements.txt` del
|
||
sitio no pinea nada, así que sin el lock un `recreate` traería otras versiones.
|
||
- La tarjeta `sergioh-api` va en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
|
||
(son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido, §6.12), con dos
|
||
guardas que salen 78 nombrando el arreglo si falta la instancia o el venv.
|
||
- ⚠ **Todavía invoca con `HARKAQ_BIN=/usr/bin`** en la tarjeta: el `takana` de la caja es el del
|
||
`commit` pineado en `recipes/takana.toml` y no trae aún la búsqueda en `/usr/bin`. Sale solo cuando
|
||
se suba ese pin.
|
||
|
||
#### El estado de la mudanza tras esto
|
||
|
||
| | |
|
||
|---|---|
|
||
| **mudado hoy** | `api.sergio.gioser.net` (DNS: CNAME→`www` **borrado**, A→`2.29.29.217` TTL 60) |
|
||
| **pendiente inmediato** | apagar el `sergioh_backend` de gioser **cuando propague** (OpenRC *y* arje: `rc-update del` + `rc-service stop` + `arjectl stop openrc-…`, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado |
|
||
| **lo que falta de `sergio.gioser.net`** | el frontend ya está copiado en `/work/www/sergioh`; falta `/shuma/*` → `shuma-gateway` (:7378), que es glibc dinámico y **cabe en esta misma jaula** (el binario está rescatado en `/work/rescate-binarios/`) pero cuelga de `shuma-daemon`, que es un sistema entero, no un binario |
|
||
| **y los otros dos vivos** | `gioser.net`/`www` (estáticos + `/hooks/*` en :8770 + `/reencuentro` con php-fpm) y `tawasuyu.net` (estático, raíz en `/mnt/vvv/tawasuyu`) |
|
||
|
||
### 6.26 ✅ `tawasuyu.net` y `gioser.net` MUDADOS — y el PHP que no existe en el corpus *(2026-09-15)*
|
||
|
||
**Los dos sitios que quedaban con tráfico real sirven desde la caja `takana`**, con TLS público y
|
||
verificados desde fuera. Los servicios viejos **siguen corriendo en gioser a propósito** (decisión del
|
||
usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).
|
||
|
||
| dominio | qué es | estado |
|
||
|---|---|---|
|
||
| `tawasuyu.net`, `www.` | landing + `/pkg` wasm + `/descargas` (3,8 G) | **200 desde 2.29.29.217** |
|
||
| `gioser.net`, `www.` | 5 raíces estáticas (428 M) + `/reencuentro` con PHP | **200 desde 2.29.29.217** |
|
||
| `sergio.gioser.net` | frontend + `/shuma/*` | **se queda en gioser**: su consola no tiene camino todavía |
|
||
|
||
**`sergio` dejó de ser un `CNAME → www` y tiene su A propio** (apuntando a gioser, sin cambio
|
||
visible). Sin eso, mover `www` se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo:
|
||
en esta zona **casi todo cuelga de `www`**. Lo que sí quedó colgando son tres fósiles (`api`,
|
||
`mail.sigma`, `monitor`): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están
|
||
muertos hace meses; se anotan, no se mudan.
|
||
|
||
**Dos cosas que el log de accesos decidió mejor que cualquier intuición:**
|
||
- La raíz de `tawasuyu.net` en gioser era **el monorepo entero (145 G) servido por HTTP**. El log
|
||
dice que las únicas rutas con tráfico son `/`, `/descargas` y `/web` ⇒ se replicaron **sólo** los
|
||
dos subárboles que el sitio usa (1,2 M + 3,8 G), con la MISMA estructura para que cada `rewrite`
|
||
siga siendo el mismo.
|
||
- **`/hooks/*` NO se muda**: lo atiende `webhook-deploy.py`, que redespliega `aura_*`, `sigma_*`,
|
||
`summa_*` y `brahman` — **los cuatro son fósiles del §6.19**, y tiene cero peticiones. Muere con la
|
||
caja vieja. Un servicio vivo que sólo sirve a muertos es basura, no deuda.
|
||
|
||
#### PHP: la segunda jaula, y por qué no entra al corpus
|
||
|
||
`/reencuentro/` es una página con **un** `guardar.php`. PHP no está en el corpus y no tiene por qué
|
||
estar: lo sirve **`php-fpm 8.5.10` dentro de la instancia `gioser-php`** (ADR 0015), por
|
||
`127.0.0.1:9000`, supervisado por arje. Control contra el original, en los dos sentidos: `POST` con la
|
||
trampa anti-robots → `{"ok":true}` 200 igual que en gioser; `GET` → **405** igual que en gioser.
|
||
|
||
⚠ **El webroot entra a la jaula EN LA MISMA RUTA** (`/work/www/gioser-web`): el `SCRIPT_FILENAME` de
|
||
FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta
|
||
«File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN
|
||
(`/work/etc/gioser-php/php-fpm.d/`) y entra por concesión: el `upper` de la instancia es caché y
|
||
`recreate` se la llevaría.
|
||
|
||
#### 🧨 Los padres que inventa bwrap son **0700** — y eso deja la concesión fuera de alcance
|
||
|
||
El muro real del día, y es general, no de PHP:
|
||
|
||
$ curl -X POST https://gioser.net/reencuentro/guardar.php
|
||
File not found. ← y el fichero ESTÁ, montado, con sus modos correctos
|
||
|
||
Para montar en `/work/www/gioser-web`, bwrap tiene que crear `/work` y `/work/www` —que la imagen de
|
||
Arch no trae— y **los crea `drwx------ root`**. Adentro somos root, así que todo a mano funciona; pero
|
||
**un servicio que baja de privilegio** (php-fpm a `http`, o cualquier instancia con `run_as`) no puede
|
||
ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como
|
||
`http`, leer un fichero **de la imagen** funciona y leer el **directorio concedido** da
|
||
`Permission denied`.
|
||
|
||
⇒ `grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y **sólo los que la
|
||
imagen no trae**: hacerlo sobre `/etc` o `/home` le cambiaría los modos a la imagen. Con test que
|
||
falla a propósito si la condición se rompe (`left: ["/etc","/etc/php"]` contra `right: ["/etc/php"]`).
|
||
Misma familia que el `--perms 1777` que ya hacía falta antes del `--tmpfs /tmp`.
|
||
|
||
⚠ La caja corre el `takana` del `commit` pineado en `recipes/takana.toml`, así que **allá el arreglo
|
||
todavía no está**: el camino se abrió a mano en el `upper` de la instancia (`chmod 755 /work
|
||
/work/www`). Es exactamente lo que el arreglo vuelve innecesario en el próximo `takana upgrade`.
|
||
|
||
#### Y el estado de `rust` y de `claude`, que no es el que parece
|
||
|
||
- **`rust` está en la caja pero no instalado**: `rust-toolchain-bin` está **sellado en el store** y
|
||
declarado en `perfil.servidor`, y `rustc 1.97.0` corre invocándolo por su ruta del store — pero
|
||
**no está en el `PATH`** porque la caja se instaló antes de esa declaración. Proyectarlo son 798 M
|
||
sobre una raíz de **5,8 G con 2,9 G libres**: entra, pero el layout de la imagen (§6.24) merece la
|
||
revisión primero.
|
||
- **`claude` no está mudado**, y es otro caso de jaula: el binario de gioser es **glibc dinámico**
|
||
(`/lib64/ld-linux-x86-64.so.2`), o sea el mismo montón que el backend de `sergio`. Lo que pesa no
|
||
es el binario sino su estado: **6,8 G de `~/.claude`**, de los cuales 5,3 G son `jobs` y 976 M
|
||
`projects` — y ahí vive la memoria, **indexada POR RUTA del repo**. Mudarlo es una decisión sobre
|
||
qué se lleva, no una copia.
|
||
|
||
### 6.27 🧦 `squid` mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init *(2026-09-15)*
|
||
|
||
**El proxy de salida sirve desde la caja**: `squid 7.7` en `2.29.29.217:1137`, con la config, el
|
||
`passwd` de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia `squid` y
|
||
supervisado por arje. **El de gioser sigue vivo e intacto** (7.6), así que los clientes que apuntan
|
||
a la IP vieja no se enteraron.
|
||
|
||
Es un servicio con gente encima: el `access.log` del origen mostraba `CONNECT api.anthropic.com` y
|
||
`CONNECT claude.ai` **en el minuto anterior a mudarlo**, desde IPs de Venezuela y con tres usuarios
|
||
autenticados. Por eso se mudó entero y sin reescribir nada.
|
||
|
||
Control en los dos sentidos: desde una IP no autorizada, **407 Proxy Authentication Required**
|
||
—igual que el original—; desde `localhost`, que su propia config permite, **TCP_TUNNEL/200 a
|
||
claude.ai y a api.anthropic.com**.
|
||
|
||
#### Los cuatro muros, y el cuarto es el que vale
|
||
|
||
1. **`provision` con la config ya montada aborta**: pacman dice «squid.conf.default exists in
|
||
filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar
|
||
**primero**, montar **después**. Queda escrito en el manifiesto.
|
||
2. **`/dev/shm` lo crea bwrap 0755 root**, y squid —que baja a `proxy`— muere con
|
||
`FATAL: shm_open(...): (13) Permission denied`. Un `chmod 1777` en el arranque.
|
||
3. **El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco**: cada `run` tiene su propio
|
||
namespace de PIDs, así que el `/run/squid.pid` de la corrida anterior dice `PID 2`… y en la nueva
|
||
hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ `rm -f` en el arranque.
|
||
Es una trampa general de cualquier servicio con pidfile dentro de una jaula con `--unshare-pid`.
|
||
4. 🧨 **El rango de subuid se buscaba por `$USER` — y un init no lo pone.** Bajo arje la tarjeta
|
||
arranca con `PATH` y `HOME` y poco más; el nombre salía **vacío**, no había rango, y la jaula caía
|
||
al **userns de un solo id**. Squid moría en `setgid(15): (22) Invalid argument` y el supervisor lo
|
||
reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para `""` en
|
||
/etc/subuid»— pero **lo que se ve en el bucle es el error de squid, no el nuestro: la degradación
|
||
silenciosa la paga el de más abajo**. Y el mismo comando a mano, desde una shell con `$USER`,
|
||
funcionaba perfecto: *el síntoma dependía de quién lo arrancaba*.
|
||
⇒ `nombre_de_usuario()` deriva el nombre del **uid real** leyendo `/etc/passwd`, y el entorno pasa
|
||
a ser el último recurso. El uid es el hecho; `$USER` es una etiqueta ([[la-etiqueta-no-es-el-hecho]]).
|
||
Las tarjetas llevan además `USER`/`LOGNAME` explícitos, que era el arreglo del día.
|
||
|
||
⚠ **`arjectl start` sobre un Ente ya vivo lo DUPLICA** (se vio con `gioser-php`: dos entes, uno
|
||
peleando por el mismo `upper`). Y matar el proceso de adentro **no libera la instancia**: el
|
||
`takana qorpa run` de afuera sigue vivo y el siguiente `run` choca. Para relanzar, `stop` y después
|
||
`start`, mirando `status` en el medio.
|
||
|
||
#### Y la pregunta que corresponde: ¿no va en una receta?
|
||
|
||
**Sí, y sigue pendiente.** El propio §6.2 pone a `squid` en la lista de recetas que esta mudanza
|
||
tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en
|
||
minutos, sin compilar nada—, no la respuesta final. La diferencia con `php-fpm` importa:
|
||
**PHP no queremos que entre al corpus** (es un intérprete ajeno al proyecto, y la jaula es su lugar
|
||
definitivo), mientras que **`squid` es C, se compila con el lab y su sitio natural es una receta**
|
||
como la de `gitea`. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.
|
||
|
||
### 6.28 📦 `squid` YA NO ESTÁ ENJAULADO: corre desde el corpus *(2026-09-15)*
|
||
|
||
La jaula del §6.27 duró unas horas, que es lo que tenía que durar. **Hoy el proxy de producción es
|
||
el artefacto sellado `b3:932ba096…`**, proyectado en la caja y arrancado por arje sin bwrap de por
|
||
medio: `pid 11548 · uid=968 · exe=/usr/sbin/squid`. La instancia `qorpa/squid` se borró — el
|
||
manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.
|
||
|
||
`recipes/squid.toml`: 7.7, estático de verdad (binario **y** los cuatro helpers), `[[user]] proxy`
|
||
con uid 968 y `[[service]]` del SDD 30. Declarado en `perfil.servidor` como paquete y en `servicios`.
|
||
|
||
**La config NO viaja en el paquete y es a propósito**: `squid.conf`, el `passwd` de los tres usuarios
|
||
y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar
|
||
y sale **78 nombrando cuál falta**, en vez de dejar un bucle de reinicios.
|
||
|
||
#### Los tres muros de la receta, en orden
|
||
|
||
1. **La URL de upstream devolvía una página HTML de 8985 bytes con un 200.**
|
||
`squid-cache.org/Versions/v7/squid-7.7.tar.xz` no es el tarball. Pinear su sha256 habría anclado
|
||
la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de
|
||
GitHub, que además trae `configure` generado.
|
||
2. 🧨 **`--export-dynamic` desactivaba el enlace estático, y el binario sellaba igual.** El primer
|
||
artefacto tenía `usr/sbin/squid` **dinámico con `NEEDED libc.so`** mientras sus helpers salían
|
||
estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a
|
||
`link = "static"`. La cadena tiene tres eslabones y ninguno es obvio: `squid_LDFLAGS` trae
|
||
`-export-dynamic` (para módulos eCAP, apagados) → **el wrapper del lab quita `-static` de
|
||
cualquier enlace que mencione `--export-dynamic`**, a propósito, para poder enlazar las `.so`
|
||
dlopen-ables de Python → y **libtool vuelve a añadirlo solo** en cuanto hay un `-dlopen`, así que
|
||
sacarlo de la variable no alcanza. Se vacía `export_dynamic_flag_spec` en el `libtool` GENERADO,
|
||
con guarda que aborta si el campo no aparece: *un `sed` que no acierta deja pasar el problema y
|
||
el binario sale dinámico sin que nada falle*.
|
||
⚠ Y `-dlopen force` **no sobra**: vaciar `squid_LDFLAGS` entero rompe el enlace con
|
||
`undefined symbol: lt__PROGRAM__LTX_preloaded_symbols`, que emite el propio libtool porque hay un
|
||
`-dlopen`. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
|
||
3. **`--disable-log-daemon-helpers` compilaba perfecto y no arrancaba.** El default de `access_log`
|
||
es `daemon:`, así que sin `log_file_daemon` squid muere con
|
||
`FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory`. **Sólo se
|
||
ve levantando el servicio con la config real; el sellado, el hash y el `-k parse` pasan los tres.**
|
||
Es la familia de [[subcomando-sin-driver]] otra vez.
|
||
|
||
#### El control que hay que correr antes de tocar producción
|
||
|
||
Con la config REAL del origen, en el hub y en un puerto aparte: `-k parse` sin FATAL, arranque
|
||
completo, **`CONNECT claude.ai` y `CONNECT api.anthropic.com` con TCP_TUNNEL/200**, el `access.log`
|
||
escrito por el log daemon, y el `basic_ncsa_auth` leyendo el `passwd` de verdad (control negativo:
|
||
con una clave mala contesta `ERR Wrong password`). Los dos primeros intentos de esta receta habrían
|
||
llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
|
||
|
||
### 6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano *(2026-09-15)*
|
||
|
||
`planear.py --revisar` sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
|
||
los servicios del usuario sino **tres capacidades que ninguna métrica reclama**: el disparador
|
||
periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
|
||
`perfil.servidor` desde que ese perfil nació — **lo que faltaba era el eslabón que los ARRANCA**.
|
||
|
||
| | antes | ahora |
|
||
|---|---|---|
|
||
| `chronyd` | la caja iba **15 s atrasada** y nadie la corregía | stratum 3 contra `ntp{1,2,3}.hetzner.de`, **0,000005 s** de NTP |
|
||
| `crond` | no existía el latido | ejecuta el crontab real, verificado con una entrada `* * * * *` |
|
||
| `logrotate` | `/var/log/squid` sin rotar | diario con `squid -k rotate`, 14 copias |
|
||
| respaldo del gitea | **a mano, una vez** (§6.15) | `17 3 * * *`, con el snapshot consistente de la sqlite |
|
||
|
||
**El respaldo, medido:** `scripts/respaldo-gitea.sh` sube **44 repos (23 `sergio` + 21 `tawasuyu`,
|
||
1,5 G)** y un `gitea.db` de **332 M** tomado con `.backup` —consistente con el servidor vivo— al
|
||
Storage Box. El control no es que el script salga 0: es **contar los repos de los dos lados**, que
|
||
es lo que se hizo.
|
||
|
||
⚠ **`chrony` y `cronie` NO declaraban `[[service]]`**, como `squid` antes: el paquete llegaba a la
|
||
imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron)
|
||
y `perfil.servidor` los habilita en `servicios`.
|
||
|
||
⚠ **La config va aparte y el servicio sale 78 si falta**, igual que gitea: a qué NTP se pregunta y
|
||
qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
|
||
eligió es peor que una sin hora, porque nadie lo mira.
|
||
|
||
#### Tres cosas medidas que valen más que el resultado
|
||
|
||
1. **La deriva era de gioser, no de la caja.** Antes de chrony la caja iba 15 s atrás; después, la
|
||
caja quedó en hora de NTP y **el hub pasó a estar 3 s adelantado**. La máquina de referencia era
|
||
la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
|
||
2. **El spool de cronie es `/var/spool/cron/<usuario>`, no `…/crontabs/<usuario>`.** `crontab -l`
|
||
mostraba las entradas y `crontabs/` estaba vacío. El día que alguien restaure un crontab a mano
|
||
en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
|
||
intuición: meter un `* * * * *` en el crontab REAL y ver si dispara.
|
||
3. 🧨 **`sqlite3` seguía inerte en la caja** (`Error relocating: compressBound: symbol not found`):
|
||
es el último de los 23 binarios del §6.4, y le faltaba `zlib-shared` — **sellado y declarado en
|
||
`perfil.base` desde el 2026-09-11**, pero la caja se instaló antes. Sin él no había snapshot
|
||
consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. *Un paquete declarado no
|
||
es un paquete instalado: en una caja viva, lo que vale es lo que el `upgrade` proyectó.*
|
||
|
||
## 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](adr/0015-imagenes-ajenas.md))
|
||
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](27-perfiles-instalables-y-el-instalador.md), 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.
|
||
|
||
|
||
1. **Actualizar la caja a la imagen nueva** (gitea + `arjectl` + las cuentas de `[[user]]`).
|
||
`takana upgrade` está probado (§6.13 del SDD 27 y §5.4 acá); el `dd` es para provisionar, no para
|
||
actualizar — sobrescribe el disco local y re-etiqueta la partición.
|
||
2. **Mudar los datos del gitea**: `sqlite3 … ".backup"` (1,5 s, consistente con el servidor vivo) +
|
||
`rsync -aH` de `/var/lib/gitea` y `/etc/gitea`. El corte final se hace con el gitea del origen
|
||
**parado**, para que el último snapshot no pierda escrituras.
|
||
3. **Caddy en la caja**: los **6 dominios vivos** del §6.9, con su TLS. Los otros 13 no se mudan.
|
||
4. **DNS**: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
|
||
resolviendo al mismo nombre.
|
||
5. **Verificar desde fuera**: cada dominio vivo responde 200 contra la IP nueva **y** un `git clone`
|
||
real contra el gitea mudado.
|
||
6. **El resto de servicios vivos** del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
|
||
`planear.py` ya emite.
|
||
7. **Borrar gioser** — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
|
||
|
||
### Bloqueantes conocidos, con su tamaño
|
||
|
||
- **`git` sellado sin `remote-http`** (§6.13): afecta a clonar POR HTTPS *desde* una caja takana, no
|
||
al gitea que sirve. Re-sella `git`, raíz de `perfil.base` ⇒ unidad propia.
|
||
- **Puerta 5** sigue a medias: la caja sirve su repo pero no se instala de él (`install` reproduce y
|
||
eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
|
||
|
||
**El volumen del store NO se engancha a la caja hasta que pase la puerta 1** — ya pasó, y el store
|
||
local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.
|