Mientras se escribían el /etc/shells y el `chsh -s /bin/zsh sergio` había un overlay del FHS abierto —de un `takana install` sin --prefix sin commitear— y las DOS escrituras cayeron dentro, aunque no tenían nada que ver con ese paquete ni pasaron por takana (un `cat >` y el chsh de shadow). Medido con `mount --bind /` no recursivo: en el /etc REAL no había shells y sergio seguía en /bin/bash, mientras la pantalla mostraba las dos cosas hechas. Un discard o un reinicio lo borraba y nadie se habría enterado hasta el siguiente login. Resuelto con commit (5 ficheros promocionados: passwd, passwd-, shells, zsh, zsh-5.9). El desmontaje perezoso del §5.10 se ganó el sueldo ahí mismo: saltó en /bin y en /usr/bin. La lección, que es más grande que el caso: `install` sin --prefix NO es un experimento acotado a ese paquete — cambia la semántica de escritura de la máquina entera hasta que alguien commitea o descarta, y el overlay no avisa por ningún lado. Como mínimo `takana status` debería salir al entrar, y el `overlay listo` del install no debería leerse como «terminé». Anotado en SDD 28 §5.11.
3858 lines
230 KiB
Markdown
3858 lines
230 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**.
|
||
|
||
### 5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust *(2026-09-21)*
|
||
|
||
Pedido del usuario: «instalar zsh con el comando takana». `zsh` es el primer paquete que se instala
|
||
**con varios parches** y **con el binario fuera de `/usr/bin`**, y cada una de esas dos cosas era un
|
||
fallo distinto. Los dos son de la familia del `strip_debug` del §5.2 —algo que el `.swm` no
|
||
transportaba fiel— y los dos **se descubren sólo cerrando el lazo**, no leyendo el código.
|
||
|
||
**1. Los parches viajaban CONCATENADOS, y eso cambia el hash.** `pack` unía los N parches de la
|
||
receta en el `patch` inline único; `Recipe::hash_inputs` mete **una entrada por parche** y
|
||
`of_inputs` va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al
|
||
build, con una copia de `recipes/zsh.toml` con sus 6 parches concatenados en uno:
|
||
|
||
```
|
||
b3:0be3630d… ← expected_hash anclado (y lo que hay en /store)
|
||
b3:d6329a62… ← lo que reconstruye el receptor (= la copia concatenada, exacto)
|
||
```
|
||
|
||
O sea: `install zsh` recompilaba zsh **entero** y recién entonces moría con `expected_hash no
|
||
coincide`. **Alcance: las 16 recetas del corpus con ≥2 parches** — waterfox 12, firefox y gnupg 11,
|
||
parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2…
|
||
|
||
Arreglado con un campo `patches` (lista, en orden) en el `source_patch`; el `patch` único se sigue
|
||
leyendo para no invalidar lo ya publicado. El receptor materializa **un fichero por parche**.
|
||
Regresión con su **control negativo** en `swm_bridge`: si se concatenan, los `hash_inputs` TIENEN
|
||
que diferir, o el test pasaría por la razón equivocada.
|
||
|
||
⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho
|
||
peor: mueve el hash de las **50** recetas con parches e invalida sus artefactos sellados.
|
||
|
||
**2. `target_bin` era una adivinanza que nadie comprobaba.** `pack` publica `/usr/bin/{name}`;
|
||
`zsh` configura `--bindir=/bin`, así que su binario queda en `/bin/zsh`. El paquete se publicaba
|
||
prometiendo un path que el artefacto no tiene, y el error salía **en la máquina que instala,
|
||
después de hidratar los 1331 ficheros**:
|
||
|
||
```
|
||
Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en <prefix>/usr/bin/zsh
|
||
```
|
||
|
||
Con `--build` el artefacto está delante: ahora `pack` comprueba el path contra él y lo **corrige**
|
||
si el binario quedó en otro bindir, diciéndolo.
|
||
|
||
⚠ **Y el primer intento fue abortar, que era lo natural y estaba MAL.** Al publicar el perfil
|
||
entero con esa versión, **65 de 175 paquetes se quedaron fuera del catálogo**: son las LIBRERÍAS
|
||
—zlib, ncurses, openssl, musl-\*, gmp…—, que no traen ningún binario y existen para que otros las
|
||
resuelvan como dep. Un campo que su consumidor ni mira habría tirado un tercio del repo. Así que
|
||
no aborta: avisa, y el paquete se publica. Lo mide el barrido de abajo, no el razonamiento — esa
|
||
versión pasaba los tests igual.
|
||
|
||
**Publicando el perfil `servidor` entero con el `pack` arreglado** *(medido, store del hub)*:
|
||
|
||
| | |
|
||
|---|---|
|
||
| publicados y firmados | **162** (el índice verifica contra su `trust`) |
|
||
| **`target_bin` corregidos** | **8: `bash` `sed` `tar` `parted` `dhcpcd` `squid` `wpa_supplicant` `zsh`** |
|
||
| sin binario (librerías) | 64 — se publican, con aviso |
|
||
| sin sellar en ESTE store | 12 |
|
||
| fallaron | 1 (`os-release`, el mismo del §6.46) |
|
||
|
||
Esos 8 son paquetes que **estaban publicados prometiendo un path que no existe**: `install bash`
|
||
habría hidratado y muerto al final. Comprobado después del arreglo, contra el repo firmado servido
|
||
por HTTP: `parted` (6 parches **y** binario en `/usr/sbin`) instala en 0,17 s y responde
|
||
`parted (GNU parted) 3.7`; `bash` instala y responde `5.3.0(1)-release`.
|
||
|
||
### 5.7 El nombre ya existe: `repo.gioser.net` *(2026-09-21)*
|
||
|
||
Dado de alta en la zona de Hetzner (la DNS de `gioser.net` está ahí: `helium`/`hydrogen`/`oxygen`),
|
||
con la convención que ya usaban `takana`, `git`, `gitea`, `sergio` y `www`: **`repo` A →
|
||
2.29.29.217, ttl 60**. Resuelve *(comprobado contra Cloudflare; el resolutor de Google todavía
|
||
servía el NXDOMAIN cacheado — negativo de 1 h por el `minimum` del SOA, no es un fallo del alta)*.
|
||
|
||
⚠ **La API vieja de DNS de Hetzner ya no es la buena.** `dns.hetzner.com/api/v1/zones` con
|
||
`Auth-API-Token` devuelve **HTML de la consola web** (301 → 200 de una SPA), que es lo peor que
|
||
puede devolver: no falla, contesta. Las zonas viven hoy en la **API de cloud** y las lee el MISMO
|
||
token de `~/.config/hcloud/cli.toml`:
|
||
|
||
```sh
|
||
curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones # gioser.net = 986350
|
||
curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones/986350/rrsets
|
||
```
|
||
|
||
Durante unas horas el nombre resolvió y **no sirvió nada** —`http://` daba el 308 global de Caddy y
|
||
el HTTPS moría en el handshake (`tlsv1 alert internal error`) porque sin site block no hay
|
||
certificado—, hasta que se pudo entrar como root a la caja. **Cerrado el 2026-09-21** con
|
||
`scripts/servidor/publicar-repo.sh --install-vhost`, que publica y firma en `/srv/repo`, añade el
|
||
bloque y lo aplica; por defecto **imprime el bloque sin tocar el Caddyfile**, porque en esta caja
|
||
Caddy sirve 19 dominios y su config ya acumula 20 `.bak`.
|
||
|
||
⚠ **Publicar no es construir, y `repo-perfil.sh` no lo acotaba.** `pack --build` es instantáneo con
|
||
cache-hit y una **compilación entera** si el artefacto no está sellado. Medido al ensayar el guion:
|
||
la corrida se puso a compilar `libnftnl` —y después habría seguido con `rust`— en una caja de 4
|
||
cores que ya estaba a **load 46**. `build-repo.sh` llevaba `timeout` desde siempre; el que se corre
|
||
en producción, no. Ahora `BUILD_TIMEOUT` (120 s) y `SKIP_UNSEALED=1`: un vencimiento **no es un
|
||
error**, significa «ése no estaba sellado». Con eso, el perfil entero publica **173 anclados, 0 sin
|
||
ancla, 2 saltados (`os-release`, `rust`)** sin compilar nada.
|
||
|
||
**El `--install-vhost`, probado en sus dos caminos** *(con un Caddy de juguete, porque el de la caja
|
||
no tiene el admin abierto)*:
|
||
|
||
| camino | qué pasó |
|
||
|---|---|
|
||
| reload OK | `✓ la config valida` → `✓ recargado`; el admin muestra el `srv1` nuevo sirviendo el repo **y el sitio que ya estaba sigue en pie** |
|
||
| reload FALLA | copia restaurada **byte a byte** (`b"repo.local" not in` el fichero final), y no recarga nada |
|
||
|
||
El orden es lo único que lo hace seguro: copia → añadir → `caddy validate` → **sólo entonces**
|
||
`reload`. Un reload con la config rota no tira el sitio nuevo: tira los 19.
|
||
|
||
⚠ **Y root no escribe en el árbol del usuario.** `work_root` sale de `dirname(store)/work` ⇒ con el
|
||
store en `/store` es `/work`, donde `sources` y `out` son **enlaces a `/work/sergio/work/…`**, que es
|
||
del usuario (CLAUDE.md §3 bis). Publicar como root dejaría ahí directorios de root y el siguiente
|
||
build suyo moriría con un permiso denegado que no menciona la causa — es exactamente lo que ya pasó
|
||
cuando root hizo `git pull` en el clon de tawasuyu y lo congeló para el dueño (`publicar-webs.sh`).
|
||
El guion, si corre como root y nadie fijó `TAKANA_WORK`, lo manda a `/var/tmp/takana-publicar-work`
|
||
y lo dice. Comprobado que el override no mueve el hash: `zsh` sella en `b3:0be3630d…` igual.
|
||
|
||
⚠ **Y trae su propia guarda contra el error más fácil de cometer:** si el `takana` de la caja es
|
||
anterior al §5.5 (se detecta porque no conoce `outdated`), **aborta antes de publicar**. Con uno
|
||
viejo el repo sale con las 16 recetas multi-parche irreproducibles y 8 paquetes prometiendo un
|
||
binario que no existe — y eso no se nota hasta que alguien instala.
|
||
|
||
⚠ **Lo que queda vivo y no se tocó:** un `install <librería>` DIRECTO sigue fallando tras hidratar,
|
||
porque el `.swm` exige que el `target_bin` exista y para una librería no hay respuesta buena. Hacer
|
||
`target_bin` opcional toca el formato ([SDD 06](06-swm-format.md)) y es su propia unidad de trabajo.
|
||
Como dep funcionan, que es para lo que están en el catálogo.
|
||
|
||
**El lazo, después:** `takana install zsh --repo http://… --require-signed` ⇒ cache-hit del hash
|
||
anclado, **1331 ficheros hidratados en 0,14 s**, y el binario corre (`zsh 5.9`). Antes: recompilar
|
||
zsh entero para morir al final.
|
||
|
||
### 5.6 `takana outdated` — el aviso que faltaba *(2026-09-21)*
|
||
|
||
Ningún verbo comparaba lo instalado con el catálogo (`upgrade` es de **árboles/generaciones**, no de
|
||
paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. `outdated` la da leyendo
|
||
sólo el índice firmado y la DB de instalados — no construye, no baja `.swm`, no escribe nada.
|
||
|
||
**Compara por hash, no por versión**, y eso es lo único que lo hace útil: un repo se re-publica sin
|
||
que cambie la versión upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el
|
||
lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo
|
||
sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil.
|
||
|
||
`scripts/servidor/avisar-actualizaciones.sh` lo corre por cron y **sólo grita cuando la lista de
|
||
novedades cambia** (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se
|
||
disfraza de «al día»: lo dice, sale ≠ 0 y **conserva el último estado bueno**.
|
||
|
||
⚠ **Lo que sigue sin poder hacerse, y no lo arregla ningún verbo:** actualizar en una máquina sin
|
||
lab. El aviso llega a cualquiera; el `install` que lo resuelve sigue siendo reproducir desde fuente
|
||
(§5.4). Por eso `outdated` **no actualiza**: instalar es verificar, y eso no se cuelga de un cron.
|
||
|
||
### 5.8 Puerta 5, de verdad: `repo.gioser.net` sirve el repo firmado *(2026-09-21)*
|
||
|
||
```
|
||
$ takana install zsh --repo https://repo.gioser.net --trust ./trust --require-signed
|
||
release: trusted (by release)
|
||
repo: bajados 6 .swm de https://repo.gioser.net
|
||
caché: artefacto ya en el store hash=b3:0be3630d… name=zsh
|
||
→ hidratados 1331 archivo(s) real 0m0,286s
|
||
```
|
||
|
||
**173 paquetes anclados, 0 sin ancla**, índice firmado con la clave de release de la caja y
|
||
certificado `CN=repo.gioser.net` de Let's Encrypt. Los 2 que faltan (`os-release`, `rust`) no
|
||
estaban sellados y el `SKIP_UNSEALED` los saltó en vez de ponerse a compilar.
|
||
|
||
**Lo que había que descubrir para poder aplicarlo, y no estaba escrito en ningún sitio:**
|
||
|
||
- **`caddy reload` NO sirve en esta caja.** El Caddyfile lleva `admin off` (por eso el `:2019`
|
||
siempre estuvo cerrado) y el `reload` de Caddy habla justamente por ese admin. La recarga real es
|
||
**`arjectl restart caddy`**: caddy es un Ente de arje (`/etc/arje/cards.d/caddy.json`,
|
||
`Restart{initial:1000,max:30000}`), no un proceso suelto.
|
||
- **Un restart no es un reload: levanta los 19 dominios o ninguno.** Por eso el guion saca un
|
||
**testigo** de la config PREVIA —el primer dominio con nombre propio, acá `gitea.gioser.net`— y
|
||
espera a que vuelva a responder; si no vuelve, restaura la copia y revierte. Comprobado después:
|
||
los 8 dominios vivos siguen en pie (`gitea` 301 a `git`, el resto 200).
|
||
- **El certificado tarda más que la comprobación.** El guion dio `✗ todavía no responde` y la misma
|
||
URL respondía **200 unos segundos después**: Caddy pide el certificado en la primera petición.
|
||
El mensaje ahora dice «reintentá el curl antes de buscar nada» en vez de mandar a leer logs.
|
||
|
||
**El espejo provisional se quitó.** Mientras no hubo root, el catálogo estuvo servido **sin firmar**
|
||
bajo `takana.gioser.net/repo` (un árbol escribible sin root, con certificado válido). Funcionó
|
||
—`install zsh` en 0,22 s— y sirvió para probar el mecanismo, pero un origen sin firma no es un
|
||
origen: con `repo.gioser.net` firmado en pie, se borró. Lo que quedó de aquello vale la pena
|
||
recordarlo: **firmar con una clave que no es la de release habría sido peor que no firmar**, porque
|
||
una autoría inventada se lee igual que una real; se publicó sin firma y con `--require-signed`
|
||
rechazándolo, que es lo único honesto que se podía hacer sin la clave.
|
||
|
||
⚠ **Sigue siendo un solo origen** (ADR 0014). La caja se sirve a sí misma; la lista de `--repo` de
|
||
los clientes debería llevar también el Storage Box. El primer origen roto, si es el único, es el
|
||
último.
|
||
|
||
### 5.9 El aviso, enchufado en la caja *(2026-09-21)*
|
||
|
||
`41 * * * *` en el crontab de root → `/usr/local/bin/avisar-actualizaciones`, un envoltorio de tres
|
||
líneas que **sólo fija las rutas de la caja** (`REPO`, `DB`, `TRUST`, `STATE`, `TAKANA`) y llama al
|
||
guion del repo. La lógica no se copia a propósito: el día que el guion cambie, el cron no se queda
|
||
con una versión vieja — y se comprobó en vivo, arreglando el guion en el clon y viendo cambiar la
|
||
salida del envoltorio **sin volver a desplegar nada**.
|
||
|
||
Tres piezas que faltaban en la caja y ahora están:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `/var/lib/hammer/trust/release.ed25519.pub` | el almacén de confianza **por defecto** de la CLI, que no existía ⇒ `install`/`outdated` verifican la firma **sin pasar `--trust`** |
|
||
| `/usr/local/bin/takana` | build release con el arreglo del §5.5. ⚠ El `PATH` de la caja **no incluye `/usr/local/bin`**, así que un `takana` a secas sigue siendo el del store (que no conoce `outdated`). El cron usa rutas absolutas. Ponerlo canónico es reconstruir la receta `takana` e hidratarla — su propia unidad de trabajo |
|
||
| `/var/log/takana-actualizaciones.log` | una línea por hora |
|
||
|
||
⚠ **Y la primera corrida real destapó un defecto del propio aviso**: con la DB de instalados vacía
|
||
decía **«al día»**. Es verdad y no dice nada — peor, se lee como «comprobado y correcto» cuando lo
|
||
cierto es que *no había nada que comprobar*. Un aviso que suena igual cuando vigila que cuando no
|
||
vigila nada es exactamente cómo se deja de mirar un aviso. Ahora distingue: *«nada instalado por
|
||
takana en esta máquina (DB …) — no hay qué comparar»*. Control en la caja con una DB de juguete
|
||
(un `zsh` a otro hash): grita `1 con novedad · hash distinto`, y en la segunda corrida calla.
|
||
|
||
Hoy la caja no instala de su propio repo —viene de imágenes—, así que el aviso dirá esa línea hasta
|
||
que lo haga; se pone igual para que el día que instale algo ya esté, y porque una línea por hora no
|
||
es ruido. Cada hora y no cada 30 minutos: el repo cambia cuando alguien publica.
|
||
|
||
### 5.10 `takana install zsh` en el sistema de verdad — cinco cosas que faltaban *(2026-09-21)*
|
||
|
||
El usuario escribió `sudo takana install zsh` en la caja y le contestó **«paquete 'zsh' no está en
|
||
el repo /var/lib/hammer/repo. Disponibles: (ninguno)»**. Reproducido tal cual. Debajo de ese
|
||
mensaje había cinco cosas distintas, y ninguna se ve hasta que alguien instala de verdad.
|
||
|
||
**1. El repo por defecto no existía.** `DEFAULT_REPO` está cableado a `/var/lib/hammer/repo` y el
|
||
repo publicado vive en `/srv/repo`. Un enlace (`/var/lib/hammer/repo → /srv/repo`) hace que
|
||
`install <nombre>` sin `--repo` funcione **y sin pasar por la red**: un directorio local sirve
|
||
igual que la URL.
|
||
|
||
**2. El lab no se encontraba desde `/root`.** `install` reproduce desde fuente y el toolchain entra
|
||
en el `ArtifactHash`, así que sin `.dev-fs` ni siquiera puede calcular el hash a comparar (§5.4).
|
||
La resolución es `TAKANA_LAB` → hermano del store → hacia arriba desde el CWD; con el store en
|
||
`/store` y el CWD en `/root`, ninguna acertaba. Enlace `/.dev-fs → /work/dev-fs` y la regla del
|
||
**hermano del store** acierta desde cualquier directorio y para cualquier usuario. *(Los dos labs
|
||
de la caja tienen el mismo `apk db` byte a byte, así que el `LabFingerprint` es el mismo.)*
|
||
|
||
**3. El `takana` del sistema no podía leer los paquetes nuevos.** Los `.tkn` de hoy llevan los
|
||
parches como **lista** (§5.5) y un cliente anterior ignora ese campo —serde salta lo desconocido—,
|
||
así que reconstruiría **sin parches** y moriría contra el `expected_hash`. Se reemplazó por el
|
||
build release. ⚠ Y se comprobó ANTES de tocar que `/usr/bin/takana` tenía `enlaces=1` e inodo
|
||
distinto al del store: es una copia, no un hardlink. **Un `cp` encima de un fichero hidratado que
|
||
SÍ fuera hardlink reescribiría el artefacto del store por debajo** — se borra y se pone uno nuevo,
|
||
nunca se sobrescribe.
|
||
|
||
**4. ⚠ La instalación quedó PARTIDA, y eso no lo dice ningún mensaje.** Sin `--prefix`, `install`
|
||
abre un overlay sobre el FHS… pero sólo sobre **siete** directorios clásicos (`/bin`, `/usr/bin`,
|
||
`/sbin`, `/usr/sbin`, `/lib`, `/usr/lib`, `/etc`). **`/usr/share` no está entre ellos**, así que de
|
||
los 1331 ficheros de zsh, **1296 fueron directos al FHS real** y sólo los 2 binarios quedaron en el
|
||
upper esperando el `commit`. El mensaje final dice `apply OK` igual. Un `discard` en ese estado
|
||
habría borrado el binario y dejado 1296 ficheros huérfanos: *el experimento no era reversible y
|
||
parecía que sí*.
|
||
|
||
**5. ⚠ `commit` no puede desmontar `/bin` en una máquina viva.** Los procesos tienen su ejecutable
|
||
**mapeado** desde ahí —empezando por PID 1, `/usr/bin/arje-zero`— y un ejecutable mapeado pin-ea el
|
||
montaje. Resultado: desmontó 5 de 7 targets, murió con `target is busy` en `/bin`, y dejó **dos
|
||
overlays montados con el estado sin promocionar**, que es el peor de los finales. El comentario del
|
||
propio `do_umount_lenient` prometía el desmontaje perezoso desde la Fase 2 y **el código nunca
|
||
pasaba `-l`**. Arreglado: ante `busy` reintenta perezoso, que desengancha el montaje ya mismo (las
|
||
rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el
|
||
último proceso que lo miraba se muere.
|
||
|
||
**El arreglo, probado en la caja viva** con un segundo paquete instalado de cero:
|
||
|
||
```
|
||
$ takana install tree ⇒ overlay 1790022033-1575-000000
|
||
$ takana commit 1790022033-1575-000000
|
||
WARN takana_overlay: umount: el target estaba ocupado (procesos con ejecutables mapeados ahí,
|
||
normal en un FHS vivo) ⇒ desmontaje perezoso target=/usr/bin
|
||
commit OK — 1 archivo(s) promocionados, 0 eliminado(s) (anotado en el diario)
|
||
⇒ overlays montados: 0 · /usr/bin/tree real · `tree v2.3.2` corriendo
|
||
```
|
||
|
||
Mientras tanto la caja se dejó limpia a mano: los dos binarios copiados al `/bin` REAL —visto a
|
||
través de un `mount --bind /` no recursivo, porque el overlay tapaba el destino—, los dos montajes
|
||
sueltos en perezoso y el estado borrado. Ahora: `zsh 5.9` en `/bin/zsh`, 1296 ficheros en
|
||
`/usr/share/zsh`, **cero overlays**, y `takana installed` lo lista.
|
||
|
||
**Y la trampa del §5.7 se cobró su pieza**: root dejó un `/work/swm-recipes` **suyo** dentro del
|
||
`/work` del usuario (el catálogo de recetas que `install` materializa; `work_root` sale de
|
||
`dirname(store)/work`). Devuelto a `sergio`. El guion de publicar ya exporta `TAKANA_WORK` para
|
||
esto; `install` a secas no tiene esa defensa, y es deuda anotada.
|
||
|
||
### 5.11 `chsh` no funcionaba, y las dos razones eran distintas *(2026-09-21)*
|
||
|
||
Pedido del usuario después de instalar zsh. Dos capas, y sólo la primera es un arreglo.
|
||
|
||
**1. `/etc/shells` no existía.** `chsh` de shadow rechaza cualquier shell que no esté en esa lista
|
||
—para un usuario normal es un rechazo duro, no un aviso— y el fichero **no estaba en la caja**.
|
||
Escrito con lo que EXISTE, comprobado `-x` uno por uno (`/bin/sh`, `/bin/ash`, `/bin/bash`,
|
||
`/bin/zsh`) y no copiado de otra distro: *un shell listado que no está deja a quien lo elija sin
|
||
poder entrar, y eso no se descubre hasta el siguiente login*. Con eso, `chsh -s /bin/zsh sergio`
|
||
funciona y `su - sergio` entra con `zsh 5.9`.
|
||
|
||
**2. ⚠ Pero `sergio` sigue sin poder cambiárselo él mismo, y eso no es un fichero que falte.**
|
||
|
||
```
|
||
$ su sergio -c "chsh -s /bin/bash"
|
||
Cannot change ID to root.
|
||
```
|
||
|
||
Los cinco binarios de shadow que necesitan setuid root —`chsh`, `passwd`, `su`, `chfn`,
|
||
`newgrp`— salen del store como **`r-xr-xr-x`**. No es que la hidratación pierda el bit: la receta
|
||
lleva **`--disable-account-tools-setuid`**, heredado del APKBUILD de Alpine (donde esos binarios
|
||
los hace setuid el *empaquetado*, no el configure — y acá no hay quien se lo reponga).
|
||
|
||
Y hay una segunda capa debajo: **`ArtifactHash::of_tree` sólo hashea el bit de EJECUCIÓN**
|
||
(`mode & 0o111`). O sea que hoy el modelo de artefactos **no puede ni expresar ni garantizar** un
|
||
binario setuid: aunque la receta lo pusiera, el hash no lo cubriría y nadie notaría si se pierde o
|
||
si aparece.
|
||
|
||
⇒ **Quitar la bandera no alcanza y no es un arreglo de receta: es una decisión de seguridad.** Un
|
||
setuid root en una distro de artefactos sellados necesita contestar quién lo pone, quién lo
|
||
verifica y qué significa que el hash no lo cubra. Anotado, sin decidir.
|
||
|
||
⚠⚠ **Y lo que de verdad hay que llevarse de esto: un overlay abierto se traga TODA la máquina.**
|
||
Mientras el `/etc/shells` y el `chsh` se escribían, había un overlay abierto sobre el FHS —de un
|
||
`takana install` sin `--prefix` que alguien había dejado sin commitear— y **las dos escrituras
|
||
cayeron dentro de él**, aunque no tenían nada que ver con ese paquete ni pasaron por takana (fueron
|
||
un `cat >` y el `chsh` de shadow). Medido con un `mount --bind /` no recursivo, que enseña el
|
||
directorio real sin el overlay encima:
|
||
|
||
| | `/etc` real | lo que se veía |
|
||
|---|---|---|
|
||
| `/etc/shells` | **no existe** | existe |
|
||
| shell de `sergio` | **`/bin/bash`** | `/bin/zsh` |
|
||
|
||
O sea: el trabajo estaba hecho «según la pantalla» y **un `discard` o un reinicio lo borraba**. Se
|
||
resolvió con `commit` (5 ficheros promocionados: `passwd`, `passwd-`, `shells`, `zsh`, `zsh-5.9`) y
|
||
ahí el desmontaje perezoso del §5.10 se ganó el sueldo: saltó en `/bin` **y** en `/usr/bin`.
|
||
|
||
⇒ **`install` sin `--prefix` no es «un experimento para ese paquete»: cambia la semántica de
|
||
escritura de la máquina entera hasta que alguien commitea o descarta.** Cualquiera que toque `/etc`
|
||
mientras tanto —otro agente, un servicio, un humano por ssh— escribe en el upper sin enterarse. El
|
||
overlay no avisa: no hay nada en el prompt, ni en `mount` a simple vista, ni un aviso al entrar.
|
||
Como mínimo `takana status` tendría que salir en el arranque de sesión, y `install` debería decir
|
||
**qué queda pendiente** en vez de un `overlay listo` que se lee como «terminé». Anotado.
|
||
|
||
Consecuencia práctica mientras tanto: **los cambios de shell y de contraseña en esta caja los hace
|
||
root**. Y una observación que sale de mirar esto: no hay `/etc/shadow`; las contraseñas viven en
|
||
`/etc/passwd`, que es legible por todos, con hash DES clásico. No lo toqué — es su propia unidad de
|
||
trabajo y afecta al arranque de la caja.
|
||
|
||
---
|
||
|
||
## 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ó.*
|
||
|
||
### 6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía *(2026-09-15)*
|
||
|
||
Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:
|
||
|
||
| servicio | por qué muere |
|
||
|---|---|
|
||
| **`qdrant`** (`:6333`, `:6334`) | es la base vectorial de `gioser_api`, y **ese backend es un fósil**: `api.gioser.net` da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta `recipes/qdrant.toml` queda en el catálogo —**catálogo ≠ imagen**, y eso no es deuda—, pero no entra en ningún perfil. |
|
||
| **`act_runner`** (`:41027`) | el runner de CI del gitea, un binario suelto en `~/.local/bin` que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado. |
|
||
| **`fail2ban-server`** | **tawasuyu ya tiene el sustituto**, y es mejor: `shared/cortafuegos` (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa **dos sets dinámicos del kernel** (`ban4`/`ban6`, `flags dynamic`, `timeout`) con `limit rate over N/minute` **por IP de origen**. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor. |
|
||
|
||
#### 🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae `nf_tables`
|
||
|
||
$ nft list ruleset
|
||
netlink: Error: cache initialization failed: Invalid argument
|
||
|
||
`nft 1.1.6` **está instalado** (`nftables` es raíz de `perfil.servidor` y está sellado). Lo que falta
|
||
está un piso más abajo, en el `.config` que el artefacto publica:
|
||
|
||
CONFIG_NETFILTER=y ← sí
|
||
CONFIG_NF_CONNTRACK=y ← sí
|
||
# CONFIG_NF_TABLES is not set ← AQUÍ
|
||
# CONFIG_NETFILTER_ADVANCED is not set ← y ésta es la causa: NF_TABLES cuelga de ella
|
||
|
||
Es otra vez la lección del §6.4 muro 1: **la receta dice lo que se CAMBIÓ; sólo el `.config` sellado
|
||
dice lo que QUEDÓ**. `linux-generic` sale de un `make defconfig`, y defconfig deja `NETFILTER_ADVANCED`
|
||
apagado, que arrastra a `NF_TABLES` con él.
|
||
|
||
⇒ **Hoy la caja no tiene cortafuegos de ninguna clase**: ni fail2ban (que no se muda), ni el
|
||
sustituto (que no arranca), ni una regla. Con `:22022`, `:2345`, `:1137`, `:80` y `:443` públicos,
|
||
eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con
|
||
`CONFIG_NETFILTER_ADVANCED=y` + `NF_TABLES` (y `NFT_CT`, `NFT_LIMIT`, `NFT_COUNTER`, `NFT_LOG`),
|
||
que es trabajo del SDD 22 y termina en un **reinicio de la caja de producción**.
|
||
|
||
#### La mitigación que NO depende del kernel, hecha hoy
|
||
|
||
El `sshd` de la caja ofrecía `password` y `keyboard-interactive` — su config eran **cuatro líneas**
|
||
y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin
|
||
fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es **sólo claves**, con los
|
||
cuatro controles corridos: la clave entra ✅, `:22022` contesta `Permission denied (publickey)` sin
|
||
ofrecer contraseña ✅, `:2345` igual ✅, y el `git ls-remote` por SSH sigue funcionando ✅.
|
||
|
||
**Lo que enseña:** *una defensa que se muda tiene que mudarse con su capacidad, no con su nombre*.
|
||
Decidir «fail2ban muere porque tenemos algo mejor» es correcto **y** deja un hueco abierto hasta que
|
||
ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.
|
||
|
||
### 6.31 ✅ La caja arrancó con `nf_tables` — y el init la levantó ENTERA sola *(2026-09-15)*
|
||
|
||
$ nft list ruleset
|
||
$ ← sin error: vacío, que es lo correcto en una caja sin reglas
|
||
|
||
El kernel `b3:63fdd57c…` bootea, y con él **`nft` funciona por primera vez en una máquina takana**.
|
||
Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un
|
||
`set ... flags dynamic; timeout 10s` con `add @ban4 { ip saddr timeout 10s limit rate over 5/minute }
|
||
drop` — aceptado por el kernel (`rc=0`) y releído tal cual. Eso es el ban por IP **dentro del
|
||
kernel**, sin daemon.
|
||
|
||
#### Lo que el reinicio probó de paso, y vale más que el kernel
|
||
|
||
**La Semilla levantó los OCHO entes sola, todos con `↻ 0`:** `sshd`, `gitea`, `caddy`, `squid`,
|
||
`crond`, `chronyd`, `sergioh-api` y `gioser-php` — o sea los dos servicios enjaulados en `qorpa`
|
||
(con su `newuidmap`, su overlay y su `harkaq`) **arrancan desde el genesis igual que un binario
|
||
nativo**. Todo lo que se fue editando en `/ente/seed.card.json` a lo largo del día era, hasta este
|
||
momento, una declaración sin probar: un reinicio es el único examen que toma.
|
||
|
||
Desde fuera, al minuto: los siete dominios en **200**, el proxy en **407** y `git ls-remote` por
|
||
SSH:2345 devolviendo el commit. Cero intervención.
|
||
|
||
#### ⚠ `reboot` NO reinicia una caja con arje-zero
|
||
|
||
`reboot` de busybox le pide a PID 1 que lo haga, y **arje-zero no implementa ese protocolo**: el
|
||
comando vuelve, no dice nada, y la máquina sigue arriba (`up 4 days` después de «reiniciar»). Lo que
|
||
sí funciona es **`reboot -f`**, que llama a `reboot(2)` directo saltándose al init. Con `sync` antes,
|
||
claro: el corte es duro por definición.
|
||
|
||
⇒ **Y falta la pieza**: un init que gobierna un servidor de producción tiene que atender un apagado
|
||
ordenado. Hoy la única vía es el corte duro, que ya costó el `write_atomic` no durable del §6.13.
|
||
|
||
#### 🧨 Casi reinicio el HUB por un backtick en un `echo`
|
||
|
||
Escribiendo el aviso del reinicio, el mensaje decía ``… no atiende el `reboot` normal …`` dentro de
|
||
comillas DOBLES. La shell ejecutó lo que había entre backticks: **`reboot`, en gioser**. No pasó nada
|
||
por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»—
|
||
y el hub lleva 11 días arriba, con la granja y el cron encima.
|
||
|
||
Es la **tercera vez en el día** que un backtick dentro de un texto se ejecuta
|
||
([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario.
|
||
La regla deja de ser de estilo: **nunca un backtick dentro de comillas dobles en una shell** —
|
||
comillas simples, o `$(printf '%s' …)`, o directamente no poner el acento grave en el texto.
|
||
|
||
#### Lo que queda para cerrar el frente del cortafuegos
|
||
|
||
El kernel ya no estorba; falta **la política**: `PoliticaEntrada` (.ron) → `generate-input` →
|
||
`apply-input` del cortafuegos de tawasuyu, con los puertos reales de la caja (`22022`, `2345`, `80`,
|
||
`443`, `1137`). ⚠ Y hay que aplicarla con **interruptor de hombre muerto** (aplicar, esperar, y
|
||
`flush` automático si nadie confirma): una regla mal puesta en una caja **sin consola serie** deja
|
||
la única salida en `hcloud server enable-rescue`.
|
||
|
||
### 6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo *(2026-09-15)*
|
||
|
||
La caja filtra: `table inet tawasuyu_input`, **`policy drop`**, 15 reglas de puerto, y el set `ban4`
|
||
llenándose solo con lo que golpea desde internet. A los pocos minutos: **27 IPs baneadas y paquetes
|
||
tirados por el contador**. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del
|
||
kernel y sin daemon.
|
||
|
||
La política vive en `scripts/servidor/cortafuegos-entrada.ron` (formato `PoliticaEntrada` del
|
||
cortafuegos de tawasuyu) y declara los cinco puertos públicos: `22022` admin, `2345` git, `80`, `443`
|
||
y `1137` el proxy. El reglaset se genera con `cortafuegos generate-input` **en el hub** —el binario es
|
||
glibc y la caja es musl— y se aplica allá con `nft -f`.
|
||
|
||
#### 🧨 El primer baneado fui yo, en segundos
|
||
|
||
Aplicada la primera versión, desde fuera: `ssh` ✅, `https` ✅… y **`http`, el proxy y el git por SSH
|
||
muertos**. Al minuto, la caja entera dejó de contestarme.
|
||
|
||
La causa no es una regla mal escrita: **el set `@ban4` es UNO SOLO para todos los servicios**, y la
|
||
regla `ip saddr @ban4 drop` está antes que todo. O sea que pasarse de tasa en *un* puerto te tira en
|
||
*todos*. Y el `nuevas_por_min: 5` del puerto de administración —correcto contra internet— es
|
||
exactamente lo que **me** pasa a mí: cada comando remoto de esta sesión es una conexión nueva.
|
||
|
||
Lo que devolvió la máquina fue el **interruptor de hombre muerto**: antes de aplicar se deja
|
||
programado un `nft flush ruleset` a 180 s, y sólo se desarma si la verificación desde fuera sale
|
||
bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.
|
||
|
||
⚠ **Y el tipo ya lo avisaba.** `PoliticaEntrada` tiene un campo `confiables` cuyo comentario dice,
|
||
palabra por palabra, lo que me acababa de pasar: *«un `nuevas_por_min` chico en el puerto de SSH es
|
||
exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre
|
||
seis sesiones en un minuto sos vos»*. Copié el `.ron` de ejemplo en vez de leer el tipo. Es
|
||
[[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea:
|
||
`confiables: ["204.168.193.248", "154.197.1.13"]` — el hub y el worker, que se aceptan **antes** de
|
||
la lógica de tasa y por lo tanto no pueden entrar al set.
|
||
|
||
#### Lo que este kernel todavía no puede, dicho en voz alta
|
||
|
||
`nft -c` rechazó **cinco reglas** antes de aplicar nada:
|
||
|
||
tcp dport 22022 ct count over 10 drop
|
||
^^^^^^^^ Error: Could not process rule: No such file or directory
|
||
|
||
`ct count` es `CONFIG_NFT_CONNLIMIT`, que el kernel de hoy no trae (el `.config` lo dice:
|
||
`# CONFIG_NFT_CONNLIMIT is not set`). Es el tope de conexiones **concurrentes** por servicio — el ban
|
||
por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan
|
||
**comentadas, no borradas**, en el fichero que se aplica: *lo que la política declara y el kernel no
|
||
puede tiene que verse*. El símbolo ya entró a `recipes/linux-generic.toml` y vuelve con el próximo
|
||
kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.
|
||
|
||
#### Que sobreviva al reinicio: un servicio `oneshot`
|
||
|
||
`recipes/nftables.toml` declara ahora su `[[service]]` con `lifecycle = "oneshot"`: **cargar un
|
||
reglaset no es un daemon, es un acto**. Va **primero en el genesis**, antes que sshd y los demás:
|
||
entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más
|
||
corto posible. Probado de verdad — `nft flush ruleset` y después `arjectl start cortafuegos`: las 15
|
||
reglas vuelven.
|
||
|
||
Su guarda sale **78 nombrando el fichero** si el reglaset no está, en vez de dejar la caja sin
|
||
filtrar creyendo que está protegida: *un cortafuegos ausente y uno vacío se ven igual desde afuera
|
||
hasta que alguien prueba*.
|
||
|
||
### 6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó *(2026-09-16)*
|
||
|
||
El usuario preguntó si el proxy estaba caído. **No lo estaba**: el ente `squid` llevaba horas
|
||
`corriendo` con `↻ 0`, escuchando en `:1137` y contestando `407` a quien preguntara. Lo que estaba
|
||
caído era **el acceso**: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.
|
||
|
||
La prueba, en una línea: **`154.197.1.2` —una IP que está en la ACL de squid, o sea gente con
|
||
contraseña— apareció en `@ban4`**. Y el contador del ban iba en **38.246 paquetes tirados**.
|
||
|
||
#### La causa no es la tasa: es la RÁFAGA
|
||
|
||
tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop
|
||
|
||
`burst 5` significa «tolero cinco conexiones por encima del promedio». **Un navegador o un agente
|
||
detrás de un proxy abre decenas de CONNECT en el mismo instante**, aunque su promedio sea bajísimo:
|
||
seis de golpe y ya estás en el set. Y como **`@ban4` es UN SOLO set consultado antes que todo**,
|
||
caer por el puerto del proxy te tira también el web y el git.
|
||
|
||
⚠ El tipo del cortafuegos ya traía el campo (`rafaga`, default 5) con un comentario que decía
|
||
«para un servicio web va alto (100-200)». Otra vez: **leí el `.ron` de ejemplo y no el tipo**.
|
||
|
||
#### Lo que se hizo, en orden de urgencia
|
||
|
||
1. **`nft flush set … ban4/ban6`** — servicio restaurado en el acto.
|
||
2. **Los usuarios del proxy a `confiables`**: la misma lista que la ACL de squid (21 IPs + 5 redes).
|
||
Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido;
|
||
lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
|
||
3. **`rafaga` por servicio, según su clientela**: ssh 5 · git 20 · web 100 · **proxy 200**. Y el
|
||
proxy pasa a `1200/min` con ban de 60 s: red de contención contra un flood, no control de uso.
|
||
4. Control después: **cero autorizados en el set**, 24 baneados (escáneres, que es lo que se quiere),
|
||
y tráfico real pasando — `154.194.14.37 … CONNECT api.deepseek.com:443 sigma`, 298 K tunelados.
|
||
|
||
⚠⚠ **Y un fallo de método que casi lo tapa: `nft -f` SUMA a la tabla existente.** Cargué el reglaset
|
||
corregido encima del viejo y quedaron **30 reglas**: las viejas ADELANTE, así que los `confiables`
|
||
nuevos no llegaban a evaluarse nunca. Se vio contando (`grep -c dport` daba 30 y no 15) y mirando
|
||
qué `saddr` salía primero. Por eso `cortafuegos apply-input` **borra la tabla antes de cargar** —
|
||
usar `nft -f` a pelo se saltea esa mitad.
|
||
|
||
#### La hora exacta, reconstruida del `access.log`
|
||
|
||
El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —`45.234.61.160`,
|
||
autenticada como `sergio`, **2709 peticiones**— tuvo estos silencios:
|
||
|
||
| silencio | desde | hasta |
|
||
|---:|---|---|
|
||
| **93,2 min** | 16-09 **11:59:37** UTC | **13:32:49** UTC ← cuando vacié los sets |
|
||
| 50,1 min | 10:44:58 | 11:35:06 |
|
||
| 10,0 min | 11:45:06 | 11:55:05 |
|
||
|
||
Los tres son el **`timeout` de 10 minutos del ban disparándose una y otra vez**: cada vez que volvía,
|
||
su primera ráfaga lo baneaba de nuevo. `154.197.1.2`, que usa el proxy de forma sostenida pero sin
|
||
ráfagas, sólo perdió huecos de 5 minutos.
|
||
|
||
🧨 **Y la exención que tenía no servía: su IP no estaba en la red eximida.** La ACL histórica de squid
|
||
exime `45.234.60.0/24` y él entra desde `45.234.61.160` — **una red de al lado**. Parecía cubierto y
|
||
no lo estaba: la pertenencia se MIDE (`ip in red`), no se deduce del parecido del prefijo. Con el
|
||
`/23` que nft dedujo al unir los dos `/24`, ahora sí.
|
||
|
||
#### Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa
|
||
|
||
El uso real de esta caja es **intensivo** —varias sesiones de agente en paralelo, cada una abriendo
|
||
túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el
|
||
atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y
|
||
contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado
|
||
(60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope
|
||
GLOBAL `syn_nuevas_por_seg`, que no distingue usuarios ni banea a nadie por ser rápido.
|
||
|
||
#### Lo que esto enseña del cortafuegos como herramienta
|
||
|
||
Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia **no se ve al
|
||
aplicarlo**: las reglas cargan, el `nft -c` pasa, los sitios responden, y el daño aparece sólo cuando
|
||
un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es
|
||
el que no corrí: **una ráfaga de conexiones desde una IP que NO esté en `confiables`**.
|
||
|
||
### 6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M
|
||
|
||
Censo fresco de gioser (`censar.py --local --probe-dns`, sólo lee), para saber **qué falta de
|
||
verdad** en vez de arrastrar la lista del 11.
|
||
|
||
```
|
||
servicios vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
|
||
dominios apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
|
||
datos /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M
|
||
```
|
||
|
||
**De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: `sergio.gioser.net`.**
|
||
Comprobado desde fuera, resolución + HTTP, no por el fichero de config:
|
||
|
||
| dominio | resuelve a | HTTP |
|
||
|---|---|---|
|
||
| `tawasuyu.net` · `gioser.net` · `git.tawasuyu.net` · `takana.gioser.net` · `hifas.gioser.net` · `api.sergio.gioser.net` | `2.29.29.217` | 200 |
|
||
| `gitea.gioser.net` | `2.29.29.217` | 301 |
|
||
| **`sergio.gioser.net`** | **`204.168.193.248` (gioser)** | 200 |
|
||
| `api.summa.gioser.net` | `154.197.1.2` (otra máquina) | — |
|
||
| `terapeuta.ec`, `andino.ec`, `api.sigma…` y 4 más | **sin DNS** | — |
|
||
|
||
⇒ la lista de «tres vhosts» del §9 quedó vieja: `gioser.net` y `tawasuyu.net` ya están mudados
|
||
(§6.26) con su origen vivo a propósito. **Lo único que ata el DNS a gioser es la consola de `sergio`.**
|
||
|
||
#### El rescate del §6.20 sigue vigente — y caduca de verdad
|
||
|
||
Seis procesos corren hoy desde un **inodo borrado**. Cuatro tienen su copia en
|
||
`work/mudanza/rescate/` y el sha256 del `/proc/<pid>/exe` vivo **coincide bit a bit** con lo
|
||
rescatado el 12. Los otros dos (`/usr/local/bin/tejido` y `/usr/local/bin/shuma-daemon`) **no están
|
||
en el rescate**, pero en disco hay una versión **más nueva** de cada uno (hoy 11:13 y 14:20): no se
|
||
perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: *ese
|
||
paso caduca, y quién caducó se ve mirando `/proc`, no el directorio*.
|
||
|
||
#### 🕳️ Y el hallazgo del día: `-e` contesta «no existe» cuando lo que pasa es «no puedo mirar»
|
||
|
||
El censo tiene tres estados a propósito (`ausente` / `sin_permiso` / `ok`) porque un `ls` sin
|
||
permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba **sobre la
|
||
propia ruta**: cuando el que no deja pasar es un **ANCESTRO**, `[ -e "$p" ]` contesta que no, y la
|
||
ruta caía en `ausente` — que el censo **descarta sin decir nada**.
|
||
|
||
```
|
||
/root/.local drwx------ root root ← 0700
|
||
/root/.local/bin 572 M de binarios a mano que NINGÚN paquete provee
|
||
```
|
||
|
||
Corrido como `sergio`, el censo decía que `/root/.local/bin` no existe. Corrido como root, son
|
||
**572 M** de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano.
|
||
Arreglado —si un ancestro existe y no es atravesable, el estado es `sin_permiso`— y probado en los
|
||
dos sentidos, que es lo que distingue un guardián de un adorno:
|
||
|
||
```
|
||
ancestro 0700 de otro dueño → sin_permiso (y sale en el ⚠ del resumen)
|
||
ruta que NO existe de verdad → ausente (y NO se reporta) ← el control que tiene que pasar
|
||
```
|
||
|
||
Con el arreglo, el censo como `sergio` pasa de **2 rutas ciegas a 13** — entre ellas el home entero
|
||
de `artix`, que antes no figuraba de ninguna manera. **El censo se corre como root**; la lista de
|
||
ciegas es el recibo de lo que una corrida sin privilegio no puede ver.
|
||
|
||
#### Lo que queda, y por qué no lo decide el programa
|
||
|
||
Descontados los que ya se decidieron (§6.30: `qdrant`, `act_runner`, `fail2ban` mueren) y los que
|
||
provee el init del destino, lo que sigue vivo en gioser es **un montón de daemons de tawasuyu que
|
||
ningún paquete provee** — binarios a mano en `/usr/local/bin` (1,2 G) y `~/.local/bin` (765 M + 572 M
|
||
en el home de root):
|
||
|
||
```
|
||
shuma-daemon · shuma-gateway (:7378) la consola de sergio.gioser.net — el único vhost que queda
|
||
willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
|
||
matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
|
||
puerta-f6e393ffbef3999e (:34221,:44961) binario de test en target/debug, BORRADO del disco
|
||
webhook-deploy.py
|
||
```
|
||
|
||
`planear.py --revisar` los saca con su motivo, pero **la decisión es del usuario y el programa no la
|
||
va a inventar**: son las herramientas propias del usuario, no servicios de un catálogo. Y los
|
||
**datos** tampoco se recomiendan solos (§3): `/home` son 25 G que incluyen 6,8 G de `.claude` y
|
||
1,3 G de binarios sueltos.
|
||
|
||
### 6.35 🧳 Los binarios sueltos, desglosados — y qué de `.claude` viaja *(2026-09-16)*
|
||
|
||
Decisión del usuario sobre el §6.34: **shuma se construye desde tawasuyu** (ya está encolado:
|
||
`recipes/incoming/shuma-{gateway,daemon}.toml`), **los daemons de tawasuyu viven todos**, y de
|
||
`.claude` decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.
|
||
|
||
#### Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable
|
||
|
||
| clase | cuánto | qué es | se reconstruye |
|
||
|---|---|---|---|
|
||
| **tawasuyu** | 57 ficheros · **1003 M** | `pata-llimphi` 105 M, `nahual-shell-llimphi` 98 M, `shuma-shell-llimphi` 76 M, los 14 `mirada-*`, `pata-*`, `shuma-*`, `sandokan-*`, `willay-*`, `pacha`, `tejido`, `tupu`, `matilda`, `thasnuna`… | **sí**: son salida del monorepo, que ya vive en el gitea de la caja |
|
||
| **terceros** | 10 ficheros · **841 M** | `kiro-cli*` 571 M (tres binarios), `kcl` 95 M, `qdrant` 91 M (muere, §6.30), `gitea-runner` 21 M, `act_runner` 21 M (muere), `listmonk` 19 M, `hcloud` 18 M, `goaccess` 3,4 M | no, pero **se vuelven a bajar de su origen** |
|
||
| **respaldo / duplicado** | 21 ficheros · **876 M** | `/root/.local/bin` es **una copia entera de `kiro-cli*`** (571 M) más el CLI de `claude` (219 M); el resto son `.respaldo-FECHA`, `.previo` y `.bak` | no hace falta |
|
||
| **guiones** | 27 ficheros · **< 1 M** | `git-deploy`, `webhook-deploy.py`, `update-squid-allowed-ips.sh`, `mirada-session*`, `huellita-aviso`… | **NO** ← lo único de verdad frágil |
|
||
|
||
⇒ **lo caro no es lo valioso**. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no
|
||
está en ningún repo ni en ningún paquete son **27 guiones que suman menos de un megabyte** — el
|
||
pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.
|
||
|
||
Y los **572 M de `/root/.local/bin`** que el censo no veía (§6.34) resultaron ser exactamente eso:
|
||
la copia de `kiro-cli*` que ya está en el home de `sergio`. La lección no cambia —un censo que dice
|
||
«no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era
|
||
duplicado.
|
||
|
||
#### `.claude`: 6,8 G en disco, **15 M es lo que hace falta para seguir funcionando igual**
|
||
|
||
Criterio, porque es lo que me deja trabajar allá como acá:
|
||
|
||
| viaja | qué | tamaño |
|
||
|---|---|---|
|
||
| ✅ | `projects/*/memory/` de los 18 proyectos — la memoria destilada | **6,2 M** |
|
||
| ✅ | `settings.json`, `plugins/`, `history.jsonl` | ~8,6 M |
|
||
| ⛔ | `jobs/` (5,3 G), `downloads/` (228 M), `archivo-sesiones/` (286 M), `file-history/`, `shell-snapshots/`, `cache/`, `paste-cache/` | 6,1 G |
|
||
| ⚠ | `projects/*/*.jsonl` — los transcripts crudos (973 M) | ver abajo |
|
||
|
||
Los **transcripts no hacen falta para funcionar**: la memoria es su destilado y es lo que se lee al
|
||
arrancar una sesión. Lo que se pierde sin ellos es `--resume` de una sesión vieja y la posibilidad
|
||
de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en
|
||
`/work` y los quiere para el store— sino al **Storage Box del respaldo**, que es donde vive lo que
|
||
se guarda por si acaso. La memoria, en cambio, viaja con la máquina.
|
||
|
||
#### ⚠ Y una trampa de ruta que haría todo esto inútil
|
||
|
||
**La memoria se indexa POR RUTA**: el directorio se llama `-mnt-vvv-takana` porque el repo vive en
|
||
`/mnt/vvv/takana`. En la caja el repo está en **`/opt/takana`** ⇒ copiar `memory/` tal cual deja los
|
||
163 ficheros en disco y **ninguno se encuentra**, sin que nada falle. Las dos salidas son renombrar
|
||
el directorio a la ruta de destino (`-opt-takana`) o darle al repo la misma ruta que acá. Se elige
|
||
al copiar, no después.
|
||
|
||
#### 🕳️ La caja no tiene usuario `sergio`
|
||
|
||
`/etc/passwd` de `2.29.29.217`: `root`, `sshd`, `gitea`, `proxy`. Nada más. En gioser,
|
||
`shuma-daemon` corre como `sergio` —y `shuma-gateway` también—, así que la tarjeta de arje que los
|
||
declare tiene que **crear la cuenta primero**; y el payload `Native` de arje **no tiene campo de
|
||
usuario** (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la
|
||
misma advertencia que ya lleva `recipes/incoming/shuma-daemon.toml` en su cabecera, escrita donde se
|
||
va a leer.
|
||
|
||
### 6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO *(2026-09-16)*
|
||
|
||
Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. **Cada copia se
|
||
verificó del lado del destino**, que es la regla que el `rsync` con exit 0 y 1367 artefactos vacíos
|
||
dejó escrita (§6.13, y la regla 2 de `aplicar.py`).
|
||
|
||
| qué | a dónde | verificación en destino |
|
||
|---|---|---|
|
||
| **los 27 guiones** (75 KB) | `takana:/work/rescate-guiones/` | `sha256sum -c SHA256SUMS` ⇒ **27 OK** |
|
||
| **la memoria de Claude** (733 ficheros, 6,3 M) + `settings.json`, `plugins`, `history.jsonl` | `takana:/root/.claude/` | 733 ficheros en origen y **733 en destino** |
|
||
| **los transcripts crudos** (1 966 ficheros, 1,3 G) | Storage Box `claude-gioser/` | `rsync -an` de vuelta: **1 solo fichero pendiente**, y es el transcript de la sesión que estaba escribiéndolo |
|
||
| **la clave PRIVADA de release** | `takana:/root/.config/takana/keys/` 0600 | sha256 idéntico en los dos lados **y** su pública == `trust/release.ed25519.pub` |
|
||
| credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile) | `takana:/work/mudanza-secretos/` 0700 | 27 ficheros en origen y **27 en destino** |
|
||
| estado de los daemons que viven (`sigma`, `tupu`, `willay`, `sandokan-watch.json`) + `/var/www/para-respaldar` | `takana:/work/mudanza-estado/` | 351 ficheros y 68 M en los dos lados |
|
||
|
||
#### ⚠ Las credenciales NO se instalaron en su sitio, a propósito
|
||
|
||
La caja **ya tiene** su `/root/.ssh` (con `github5`), su crontab (el latido de la caja, §6.29) y su
|
||
`/etc/caddy/Caddyfile` con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de
|
||
gioser rompe lo que hoy funciona. Quedan en `/work/mudanza-secretos/` con su `LEEME.txt`, y se
|
||
instalan **cuando se mude el hub**, que es otra unidad de trabajo. El `.gitconfig` es el caso más
|
||
claro: trae los `insteadOf` que **reescriben remotos**, así que instalarlo cambia a dónde empuja
|
||
`git push` sin que nadie lo pida.
|
||
|
||
La **única** excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede
|
||
volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que
|
||
alguien publica.
|
||
|
||
#### 🔍 Un «576 M contra 1,3 G» que parecía una copia a medias
|
||
|
||
`du -sh` en el Storage Box decía **576 M** de los 1,3 G subidos. Con el antecedente del rsync que
|
||
llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box **comprime**, y su shell
|
||
restringida no tiene `find` ni acepta tuberías, así que el conteo de ficheros —la verificación
|
||
obvia— ahí no se puede hacer. Lo que sí se puede es **preguntarle a rsync**: una corrida `-an`
|
||
compara tamaño y fecha contra el destino real y dijo **1 fichero pendiente**, el que estaba
|
||
creciendo mientras se copiaba.
|
||
|
||
⇒ *la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina*. Y un
|
||
número que no cuadra merece una segunda medición antes de un diagnóstico: `du` medía otra cosa.
|
||
|
||
#### Lo que NO se movió, y no es olvido
|
||
|
||
**~10 G de árboles personales en `/home/sergio`** —`android-sdk` 1 G, `maps`, `imm`, `humanoid`,
|
||
`hifas`, `fdroid`, `valens-conversion`, `pyswisseph`, `accordion`…— y `/opt` (1,2 G, que es
|
||
`android-sdk` otra vez y `google`). **Qué datos valen no se deduce de la máquina** (§3), y éstos son
|
||
proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha
|
||
por criterio ajeno.
|
||
|
||
De `/var/lib` (8,1 G) se movió sólo el estado de los daemons que viven: **6,1 G son swap** y 1,9 G
|
||
el gitea, que ya está mudado desde el §6.14.
|
||
|
||
### 6.37 ✅ CUTOVER DE `sergio.gioser.net` — el último vhost, y gioser deja de servir *(2026-09-16)*
|
||
|
||
**Ya no queda ningún dominio sirviéndose desde gioser.** El último era éste, y lo que lo ataba no
|
||
era el sitio sino su consola: `/shuma/*`. Verificado desde fuera, contra la IP nueva:
|
||
|
||
```
|
||
https://sergio.gioser.net/ → 200 408 bytes, la SPA
|
||
https://sergio.gioser.net/shuma/ → 200 «shuma-gateway ok» ← el gateway, no la SPA
|
||
https://sergio.gioser.net/ruta-inventada → 200 la SPA (el fallback, que es lo correcto)
|
||
```
|
||
|
||
⚠ **El `200` de `/shuma/` había que mirarlo dos veces.** El bloque termina en
|
||
`try_files {path} {path}/ /index.html`, así que **cualquier ruta inexistente devuelve 200 con la
|
||
SPA**: un `curl -o /dev/null` que sólo mira el código habría dado el mismo verde con el proxy mal
|
||
puesto. Lo que decide es el CUERPO — 17 bytes que dicen `shuma-gateway ok` — y el control es
|
||
compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con `/shuma/rpc`: las
|
||
dos máquinas contestan el MISMO `400 {"error":"bad json: …"}`.
|
||
|
||
#### Lo que hubo que construir, en orden
|
||
|
||
1. **Las dos recetas** (§6.34) sellaron en el worker: `b3:107dfb54…-shuma-gateway` (6,3 M) y
|
||
`b3:f9b92acc…-shuma-daemon` (5,6 M) — los hashes que `takana hash` había anticipado antes de
|
||
encolarlas. `file` dice **`statically linked`**: nada de cargador, que era exactamente lo que le
|
||
faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
|
||
2. **La cuenta, declarada y sin privilegio.** `[[user]] shuma` (uid 967) en la receta del daemon, y
|
||
el descenso con `setuidgid` dentro del `argv` de la Card, igual que gitea — porque el payload
|
||
`Native` de arje **no tiene campo de usuario**. Declararlo «como estaba» lo habría puesto de
|
||
root, y esto es un demonio que abre PTYs y lanza shells.
|
||
3. **⚠ Y la shell de la cuenta NO es un detalle.** El default de `[[user]]` es `/bin/false`, que es
|
||
lo correcto para gitea o squid y **exactamente lo contrario** para éste: con la shell inerte el
|
||
servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante.
|
||
Un fallo que no se ve al desplegar: se ve al usarlo. Va `shell = "/bin/sh"`.
|
||
4. **La config que no se regenera.** El `gateway-token` (32 B) y `keys/identity.x25519` viajaron a
|
||
`/var/lib/shuma/.config/shuma/`: generar unos nuevos deja fuera a los clientes ya emparejados.
|
||
Por eso la guarda de la Card **exige** el token y no lo crea, y dice de dónde sale.
|
||
5. **Las dos Cards** entran en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
|
||
(son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla
|
||
quedó con 13 entradas y vuelve a parsear.
|
||
6. **Declarado en `perfil.servidor`**: los dos paquetes y los dos labels en `servicios`. Entran como
|
||
PAR — el gateway sin el daemon es un puente a ninguna parte.
|
||
|
||
`[[user]]` y `[[service]]` están **fuera de `hash_inputs`**, así que declarar todo eso no movió
|
||
ningún ArtifactHash: comprobado en los dos, antes y después.
|
||
|
||
#### 🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado
|
||
|
||
Aquel cutover dejó escrito «un `PUT` sobre el rrset no puede cambiar el tipo: hay que `DELETE` del
|
||
CNAME y `POST` del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el `PUT` tampoco
|
||
sirve —`can't update records with this endpoint`— pero existe una acción que lo hace **atómica**:
|
||
|
||
```
|
||
POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records {"records":[{"value":"…"}]}
|
||
```
|
||
|
||
Sin ventana sin registro, que es lo que sí tiene el `DELETE`+`POST`. Con TTL 60 la resolución
|
||
pública cambió **en menos de 8 segundos**, y volver atrás es la misma llamada con la IP vieja.
|
||
|
||
#### El origen sigue vivo, a propósito
|
||
|
||
Como en §6.26: en gioser `shuma-daemon`, `shuma-gateway` y su bloque de caddy **siguen corriendo**.
|
||
Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras
|
||
la máquina exista. Lo que ya no existe es la dependencia: **ningún dominio resuelve a gioser**.
|
||
|
||
⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra
|
||
`shuma-askpass` («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es
|
||
otra receta — no bloquea la consola.
|
||
|
||
### 6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja *(2026-09-16)*
|
||
|
||
De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:
|
||
|
||
| | cuenta | qué hace, comprobado |
|
||
|---|---|---|
|
||
| `matilda` | **root** | lee el access log de caddy y archivó **78 ficheros** en `/var/lib/tupu/takana/` |
|
||
| `pacha` | `pacha` (965) | vivo, 0 reinicios, con SUS reglas (`reglas.ron`), no las de fábrica |
|
||
| `pacha-secretos` | `pacha` | vivo, 0 reinicios — y su binario en gioser era un **inodo borrado** |
|
||
| `sandokan-watch` | root | **frenado a propósito**: no puede trabajar acá (ver abajo) |
|
||
|
||
#### ⚠ Tres corren como ROOT, y está declarado en vez de heredado
|
||
|
||
Lo natural sería una cuenta sin privilegio por servicio, como `shuma` o `gitea`. Con el trío de la
|
||
telemetría **no se puede**, y las dos razones son medidas:
|
||
|
||
1. `matilda` lee `/var/log/caddy/requests.json`, que caddy crea **`-rw------- root root`** y recrea
|
||
igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
|
||
2. `tupu`, `matilda` y `sandokan-watch` escriben en el MISMO `/var/lib/tupu`, y en gioser sus 322
|
||
ficheros son `root:root`. Bajar uno solo deja **mezcla de dueños en un árbol compartido**: escribe
|
||
el primero y los otros fallan al modificar.
|
||
|
||
⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se
|
||
quiera: que caddy escriba con `mode 640` y que el árbol sea de una cuenta propia. `pacha` y
|
||
`pacha-secretos` sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.
|
||
|
||
#### 🕳️ La caja se llamaba «(none)» — y eso se archivaba
|
||
|
||
`matilda` archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a `/var/lib/tupu/**none**/`:
|
||
|
||
```
|
||
$ hostname → (none)
|
||
$ cat /etc/hostname → no existe
|
||
```
|
||
|
||
**Ninguna caja takana fija su hostname**: no lo hace `netup`, no está en el product-rootfs, no hay
|
||
`/etc/hostname`. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no
|
||
falla: archiva bajo `none`, y dos máquinas distintas archivarían en la misma carpeta.
|
||
|
||
Puesto `/etc/hostname` + una Card **OneShot** `hostname` en el genesis, para que sobreviva al
|
||
reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en
|
||
`/var/lib/tupu/takana/`. Quedan 72 ficheros huérfanos bajo `none/`: son los 20 minutos anteriores.
|
||
|
||
⇒ **lo correcto es que lo haga `netup`**, que es quien configura la identidad de red de la máquina;
|
||
la Card es la mitigación de hoy, y está dicho en su propia guarda.
|
||
|
||
#### 🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve
|
||
|
||
`sandokan-watch` arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo
|
||
si uno lo corre a mano:
|
||
|
||
```
|
||
sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada
|
||
```
|
||
|
||
En gioser lee el `auth.log` de un syslog que **en takana no existe** — no hay systemd ni syslog. Su
|
||
Card ahora lleva una guarda que sale **78** nombrando el problema, así que el servicio no queda
|
||
verde mintiendo; y **no entra al `genesis`**, sólo a `cards.d`, porque no tiene sentido que arranque
|
||
al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.
|
||
|
||
#### Dos herramientas que no existen donde se prueba
|
||
|
||
Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:
|
||
|
||
- **`/dev/tcp/host/puerto` no existe en busybox `ash`** ⇒ un barrido de puertos contra gioser dio
|
||
«cerrado» para los cinco, incluidos `:80` y `:443`, que sirven. Con `curl telnet://` el resultado
|
||
es el contrario: `:2345` y `:443` **abiertos**, `:22` cerrado.
|
||
- **La expansión de llaves (`{a,b}`) tampoco** ⇒ un `ls /store/*-{matilda,pacha}` dijo que los
|
||
artefactos no habían llegado. Habían llegado.
|
||
|
||
Es la misma lección del §6.23 (`ps` y `dig` ausentes) y merece repetirse: *una prueba que no puede
|
||
pasar se ve igual que una que todavía no pasó*.
|
||
|
||
### 6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» *(2026-09-16)*
|
||
|
||
Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y **frenadas a propósito**,
|
||
cada una por un motivo distinto y con su guarda diciéndolo.
|
||
|
||
| corriendo | | frenado | por qué |
|
||
|---|---|---|---|
|
||
| `matilda` · `tupu` | root, escriben `/var/lib/tupu/takana/` | `sandokan-watch` | en takana no hay `auth.log` ni journalctl |
|
||
| `pacha` · `pacha-secretos` | cuenta `pacha` (965) | `tejido` | **misma identidad libp2p que el de gioser** |
|
||
| `willay-daemon` | root, con su índice mudado | `willay-crosscheck` | necesita el roster de tejido |
|
||
| `shuma-daemon` · `shuma-gateway` | cuenta `shuma` (§6.37) | `thasnuna` | pide `claude` autenticado y `sandokan-mcp` |
|
||
|
||
#### ⚠ La regla que sirvió para los vhosts NO sirve para una identidad
|
||
|
||
Con los sitios web la pauta fue: **mudá el DNS y dejá el origen encendido**, así volver cuesta un
|
||
minuto (§6.26). Con `tejido` eso es exactamente lo que NO hay que hacer, y el README del propio
|
||
tejido lo dice: *«la clave que el roster atesta ES la identidad de transporte libp2p»*
|
||
(`~/.tejido/device.seed`). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio:
|
||
son **el mismo PeerId en dos sitios**, o sea dos impostores mutuos para el resto de la flota.
|
||
|
||
⇒ `tejido` se **intercambia**: se apaga allá, se enciende acá, en ese orden. Está todo listo para el
|
||
intercambio —binario, cuenta `tejido` (964), identidad instalada 700 en `/var/lib/tejido/.tejido/`—
|
||
y su Card vive en `cards.d` pero **no en el `genesis`**, para que un reinicio no lo encienda solo.
|
||
|
||
Y arrastra a otro: `willay-crosscheck` **no es independiente**. Corriendo su línea a mano:
|
||
|
||
```
|
||
Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)
|
||
```
|
||
|
||
Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.
|
||
|
||
#### Lo que cada guarda evita
|
||
|
||
- `thasnuna`: su `INSTALAR.md` —escrito hoy por el frente tawasuyu para esta misma mudanza— pide
|
||
tres cosas, y dos no están: `sandokan-mcp` en el PATH («el agente contesta pero no tiene
|
||
herramientas») y **`claude` instalado y AUTENTICADO** («no hay anfitrión»). El CLI de claude es un
|
||
binario **glibc de ~300 M**: en una caja musl pide jaula qorpa, como `sergioh-api`.
|
||
- Y de ahí sale un hallazgo que vale para el respaldo entero: ese documento explica por qué el token
|
||
no va en la Card, y el motivo está medido en la máquina vieja — **la Card `openrc-openclaw` de
|
||
gioser lleva la API key de su proveedor EN CLARO dentro del JSON de `/etc/arje/cards.d/`**, que es
|
||
un directorio que se respalda y se copia. Un secreto dentro de una Card viaja a todas partes.
|
||
- `willay-crosscheck`: `--label` **nombra a la máquina**. En gioser decía `momento`; copiarlo tal
|
||
cual habría hecho que la caja nueva se anunciara con el nombre de la que se borra. Ahora sale de
|
||
`hostname`, y si no hay, la guarda lo dice.
|
||
|
||
#### 🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene
|
||
|
||
Van con el §6.38 y ya son patrón, no anécdota:
|
||
|
||
| la prueba | lo que dijo | lo que pasaba |
|
||
|---|---|---|
|
||
| `/dev/tcp/host/puerto` | los 5 puertos de gioser «cerrados» | busybox `ash` no tiene `/dev/tcp` |
|
||
| `ls /store/*-{a,b}` | «los artefactos no llegaron» | busybox no expande llaves |
|
||
| `find … -newermt "-2 minutes"` | «tupu no escribe hace 2 min» | tupu **sí** escribe: su `mtime` avanza cada 10 s |
|
||
|
||
El de `tupu` es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene
|
||
**tamaño constante** (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba
|
||
nada. Lo que decide es el `mtime`, medido dos veces con 40 s de por medio.
|
||
|
||
### 6.40 🚪 Las ocho puertas, medidas de nuevo *(2026-09-16)* — y la que falta es UNA
|
||
|
||
La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:
|
||
|
||
| # | puerta | estado | medido |
|
||
|---|---|---|---|
|
||
| 1 | arranca takana puro, se entra por SSH | ✅ | sigue en pie |
|
||
| 2 | el store está entero | ✅ | **1632 artefactos, 0 vacíos** (eran 1362) · ⚠ `/store` al 88 %, 11,3 G libres |
|
||
| 3 | los cuatro grafos idénticos | ✅ | §6.7 |
|
||
| 4 | **la granja late en la caja nueva** | ❌ | **el latido sigue en gioser**: la caja no tiene el cron |
|
||
| 5 | el repo se sirve y el host se actualiza solo | ⬖ | sirve; consumir exige el lab entero (SDD 27 §4) |
|
||
| 6 | cada dominio vivo responde 200 desde fuera | ✅ | **los 8 contra `2.29.29.217`** — y `sergio` ya entre ellos |
|
||
| 7 | el respaldo corre desde la caja | ⬖ | el script **corre y lista** el Storage Box desde la caja; falta una corrida completa |
|
||
| 8 | lo fósil, anotado y decidido | ✅ | §6.9 + `docs/state/mudanza-decisiones.txt` |
|
||
|
||
#### La puerta 4 es ahora una línea de crontab — y por qué no la puse
|
||
|
||
La caja ya tiene **todo** lo que el latido necesita, comprobado uno por uno: `git`, `python3`,
|
||
`rsync`, `ssh`, `takana`, `flock`, la llave del worker en `/root/.ssh/github5` y el `.fleet` con
|
||
`dev.gioser.net`. Le faltaban dos costuras y están hechas:
|
||
|
||
```
|
||
/opt/takana/store -> /store (el script espera `./store`; acá es partición)
|
||
/opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)
|
||
```
|
||
|
||
Y el repo de `/opt/takana`, que estaba **5 días atrasado**, quedó en `origin/main` exacto (0
|
||
diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes.
|
||
⚠ Esos 92 no eran trabajo local: eran **contenido que ya estaba río arriba** —el cambio de SSH a
|
||
HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio»
|
||
que en realidad está *atrasado*, que es el mismo espejismo que el §5 del informe de tawasuyu
|
||
describe para el monorepo.
|
||
|
||
Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:
|
||
|
||
```
|
||
▶ dev.gioser.net (154.197.1.13) load: 2.72 · store: 895 artefactos
|
||
══ AVANCE KDE ══ 197/198 sellado
|
||
══ CRON DE COSECHA ══ ⚠ NO instalado en el crontab
|
||
```
|
||
|
||
**No lo instalé, y el motivo es el mismo que el de `tejido`**: con el cron puesto en las dos
|
||
máquinas, las dos harían `rsync --delete` del repo al worker y las dos cosecharían y commitearían.
|
||
El latido **se intercambia, no se duplica** — se apaga en gioser y se enciende acá, en ese orden, y
|
||
es el último movimiento antes del borrado.
|
||
|
||
⇒ **de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.**
|
||
|
||
### 6.41 🔀 INTERCAMBIO de `tejido` — la identidad cambió de máquina, no de dueño *(2026-09-17)*
|
||
|
||
Hecho, y en este orden, que es el único posible: **apagar allá → encender acá**.
|
||
|
||
```
|
||
gioser tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334) → detenidos
|
||
takana tejido 4102 · willay-crosscheck 4103 → corriendo
|
||
```
|
||
|
||
**La prueba de que la identidad VIAJÓ y no se generó otra** no es que el servicio arranque —eso
|
||
pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que
|
||
la semilla sea la misma:
|
||
|
||
```
|
||
device.seed gioser cad6f35b633e8637… caja cad6f35b633e8637… ✓ idéntica
|
||
roster.postcard gioser 590f2713075ac934… caja 590f2713075ac934… ✓ idéntica
|
||
```
|
||
|
||
El `PeerId` sale de esa semilla, así que sigue siendo `12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L`.
|
||
|
||
#### ⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr
|
||
|
||
Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso
|
||
que corría en gioser:
|
||
|
||
```
|
||
antes: /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
|
||
ahora: /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u… ← mismo PeerId, otra IP
|
||
```
|
||
|
||
Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. **El PeerId
|
||
no hay que tocarlo; la IP sí, en cada equipo del roster.**
|
||
|
||
#### 🧱 Sin el cortafuegos, el intercambio habría sido un verde falso
|
||
|
||
El reglaset de la caja abría `80, 443, 1137, 2345, 22022` **y nada más**: `4102` estaba cerrado. Con
|
||
el relay encendido y el puerto filtrado, `arjectl status` diría «corriendo · 0 reinicios» y la flota
|
||
no llegaría — el peor de los verdes. Se abrieron `4102` (relay) y `4103` (crosscheck), con su ban
|
||
por tasa como el resto. **No se abrió `33097`**: ése era el puerto efímero de un `tejido serve`
|
||
(cliente), no un servicio que alguien busque.
|
||
|
||
Y el reglaset quedó **idempotente**, que era deuda del §6.33: sus dos primeras líneas crean y borran
|
||
la tabla antes de definirla, así que `nft -f` **reemplaza** en vez de acumular. Comprobado contando:
|
||
15 reglas `dport` antes, 21 después (no 36).
|
||
|
||
⚠ **La política que genera ese fichero NO EXISTE EN NINGÚN DISCO.** Su cabecera dice «GENERADO, no
|
||
editar a mano — regenerar con `cortafuegos generate-input --policy <ruta>`», pero esa ruta no está
|
||
ni en la caja, ni en gioser, ni en el repo (sólo dos `politica_*_ejemplo.ron` en tawasuyu), y el
|
||
binario `cortafuegos` tampoco está instalado en ninguna de las dos. **El generado sobrevivió a su
|
||
fuente**: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su
|
||
primera línea pide no hacer.
|
||
|
||
#### 🆔 `takana` acepta ULIDs que `arje-zero` RECHAZA
|
||
|
||
La Card de tejido no encarnaba: `arje-zero rechazó: card tejido JSON: invalid character at line 7`.
|
||
La línea 7 es el `id`, y el id era `01M2EKDA00TEJ1D0RELAY00001` — que tiene una **`L`**, carácter
|
||
**excluido del alfabeto Crockford** de ULID (junto con `I`, `O`, `U`). `takana service-cards` lo
|
||
validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.
|
||
|
||
Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre
|
||
termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los
|
||
`id` del repo con el alfabeto correcto había marcado justo ése. Era el único con `L` (venía de
|
||
«RELAY»); ahora es `01M2EKDA00TEJ1D0RE1AY00001`.
|
||
|
||
#### Estabilidad, medida y no supuesta
|
||
|
||
`willay-crosscheck` marcaba **4296 reinicios**, que asusta hasta mirar de dónde vienen: son las
|
||
horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y
|
||
1 m 53 s de vida, está estable. `tejido`: 0 reinicios. Los dos puertos aceptan conexión desde fuera.
|
||
|
||
⚠ De paso, un detalle feo: `arjectl status | head -2` hace que **arjectl paniquee** con
|
||
`failed printing to stdout: Broken pipe`. No rompe nada, pero un `EPIPE` sin manejar en una
|
||
herramienta de operación aparece justo cuando alguien la encadena con `head`/`grep -q`.
|
||
|
||
#### Cómo volver, si hiciera falta
|
||
|
||
Está escrito en `work/mudanza/volver-atras-tejido.txt`: apagar los dos de la caja y relanzar los tres
|
||
de gioser con las líneas exactas que se leyeron de `/proc/<pid>/environ` y `cmdline`. **Nunca las dos
|
||
a la vez**: es la misma identidad.
|
||
|
||
### 6.42 ❌ El latido NO se puede mudar todavía — y el motivo redefine qué es un «hub» *(2026-09-17)*
|
||
|
||
Se intentó el último intercambio: apagar el cron de la cosecha en gioser y encenderlo en la caja.
|
||
**Salió mal, se vio en el primer ciclo, y se volvió atrás en minutos.** Queda escrito porque el
|
||
motivo no era ninguno de los que se habían previsto.
|
||
|
||
#### Lo que sí funcionó
|
||
|
||
La caja tiene todo lo del latido y el ciclo corrió de punta a punta: sembró el repo al worker, leyó
|
||
su manifiesto, regeneró los ocho ficheros de estado, pasó los vigías, **se puso al día por
|
||
fast-forward y commiteó+empujó**. El `git config` de identidad y el `pull --ff-only` nuevo hicieron
|
||
su parte.
|
||
|
||
#### 🕳️ Lo que publicó, que es el problema
|
||
|
||
```
|
||
totals que publicó la CAJA : {recipes: 1112, nodes: 1115, unhashable: 1112, ajeno: 3}
|
||
totals de gioser : {recipes: 1112, nodes: 1115, sealed: 1105, never: 5, debt: 2}
|
||
```
|
||
|
||
**`unhashable: 1112`** — no es «no está sellado»: es que **no se pudo CALCULAR el hash de ninguna
|
||
receta**. Y la causa es la misma que el §5.2 documenta desde el otro lado: **el lab entra en el
|
||
ArtifactHash**, así que una máquina sin el rootfs del lab no puede hashear nada. La caja de
|
||
producción no lo tiene — a propósito, no es un olvido.
|
||
|
||
El daño era real y silencioso: el commit `45b5685a` dejó en `main` un `build-state*.json` que dice
|
||
que **el corpus entero no existe**. Cualquiera que lo lea —o cualquier vigía que lo compare— vería
|
||
1112 recetas en cero.
|
||
|
||
#### La lección, que vale más que el intento
|
||
|
||
**Un hub no es «la máquina que tiene el repo y la llave»: es la que tiene el LAB.** El latido parece
|
||
tarea de cron —siembra, cosecha, commitea— pero su tercer paso es *pensar sobre el corpus*, y eso
|
||
exige el toolchain. Por eso la puerta 4 **no** era una línea de crontab, aunque todas las piezas que
|
||
se miraron (git, python3, rsync, ssh, takana, flock, `.fleet`, la llave) estuvieran.
|
||
|
||
⇒ Las salidas son tres, y ninguna es «poner el cron»:
|
||
1. **darle el lab a la caja** (`/.dev-fs/…`) — deja de ser sólo servidor y pasa a ser también hub;
|
||
2. **partir el latido**: siembra+cosecha en la caja, regeneración del grafo donde haya lab;
|
||
3. **que el hub sea otra máquina** (el LXC prestado ya tiene lab y muele 24/7).
|
||
|
||
Es decisión del usuario, y es **lo único que queda entre esto y borrar gioser**.
|
||
|
||
#### Vuelta atrás, en minutos
|
||
|
||
Cron de la caja borrado, cron de gioser reactivado (estaba comentado, no borrado). El propio latido
|
||
de gioser **repara el daño solo** en su siguiente ciclo: regenera el grafo desde SU store —que sí
|
||
tiene el lab— y vuelve a publicar los números buenos.
|
||
|
||
#### 🧰 Y de paso, tres cosas que el ciclo destapó en la caja
|
||
|
||
- **`poda-fuentes.sh` no corre en takana**: `/dev/fd/63: No such file or directory` y
|
||
`viejos: unbound variable`. Es **sustitución de procesos** (`<(...)`), que busybox `ash` no tiene —
|
||
el cuarto tropiezo del día con la misma forma. El podador de `work/sources` es justo lo que un hub
|
||
necesita para no llenarse.
|
||
- Los vigías corrieron y hablaron: **1 ofensor nuevo de raíces (`bzip2`)**, **3 herramientas
|
||
selladas que no se pueden invocar**, y `servicios.txt` con 7 errores y 30 avisos. Son hallazgos del
|
||
corpus, no de la mudanza, y quedan anotados donde el humano los mira.
|
||
- `arjectl status | head -2` **hace paniquear a arjectl** (`failed printing to stdout: Broken pipe`).
|
||
No rompe nada, pero un `EPIPE` sin manejar en una herramienta de operación aparece justo cuando
|
||
alguien la encadena.
|
||
|
||
### 6.43 🧪 La caja ya es un HUB: el lab viajó, y con él la puerta 4 *(2026-09-17)*
|
||
|
||
El §6.42 dejó dicho qué faltaba y el usuario eligió: **darle el lab a la caja**. Hecho, y el latido
|
||
ya late desde allá.
|
||
|
||
#### El lab no se copia: se PINEA y se trae
|
||
|
||
`scripts/lab-image.sh` empaqueta `.dev-fs/alpine` de forma **determinista** (`--sort=name
|
||
--mtime=@1 --owner=0 --group=0 --numeric-owner`, excluyendo `var/log/apk.log`, que es lo único que
|
||
difiere entre dos labs por lo demás iguales), lo publica al Storage Box con el sha en el nombre, y
|
||
del otro lado `--traer` **verifica el sha antes de extraer**. Eso es lo que hace que las dos máquinas
|
||
hasheen igual, y es un contrato, no una copia.
|
||
|
||
**Y el empaquetado de hoy devolvió el sha que el repo ya pineaba:**
|
||
|
||
```
|
||
empaquetado hoy en gioser : d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
|
||
pineado en bootstrap-devfs.sh: d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
|
||
```
|
||
|
||
O sea dos cosas de una: **el lab de gioser nunca derivó** desde que se publicó esa imagen, y **el
|
||
empaquetado es reproducible de verdad** — no era una promesa del comentario.
|
||
|
||
#### La prueba que decide, y no es que arranque
|
||
|
||
Con el lab instalado, la caja calcula los MISMOS `ArtifactHash` que gioser:
|
||
|
||
```
|
||
zlib b3:dc363f26… busybox b3:2a2b1280…
|
||
caddy b3:c915987d… shuma-daemon b3:f9b92acc…
|
||
```
|
||
|
||
Cuatro de cuatro. Si el lab fuera otro, estos números serían otros y el store no lo notaría — es el
|
||
agujero que `hash_inputs` cerró (§lab en el hash) y la razón por la que esto se verifica con hashes
|
||
y no con un «arrancó bien».
|
||
|
||
#### Los stores, convergidos — y una trampa que me tendí solo
|
||
|
||
El grafo seguía discrepando porque el store de la caja no tenía 24 artefactos vigentes de gioser
|
||
(3,0 G). Al copiarlos con `rsync -a --files-from=<lista de DIRECTORIOS>` pasó lo peor posible:
|
||
|
||
```
|
||
sent 2,281 bytes … total size is 0 ← no copió NADA
|
||
artefactos en la caja: 1631 → 1655 ← pero creó los 24 DIRECTORIOS
|
||
```
|
||
|
||
**`--files-from` no recursa sin `-r`**: creó 24 directorios vacíos en el store. Y un directorio vacío
|
||
en el store **es un cache-hit** (§3 de CLAUDE.md, [[artefacto-vacio-envenena-cache]]): `build` lo
|
||
habría dado por sellado y habría salido OK sin construir. Se detectó al contar, se borraron los 24 y
|
||
se repitió con `-r`: 3,18 GB transferidos, **0 vacíos de 1655**.
|
||
|
||
Después faltaban dos más (`obs-studio` y `spectacle`, justo los que el worker no logra construir),
|
||
copiados también. Resultado:
|
||
|
||
| | gioser | caja |
|
||
|---|---|---|
|
||
| corpus | sealed 929 · debt 2 | **sealed 930 · debt 1** |
|
||
| KDE | sealed 1105 · debt 2 | **sealed 1106 · debt 1** |
|
||
|
||
La caja no empata: **gana por uno** en los dos grafos.
|
||
|
||
#### El latido, mudado
|
||
|
||
Cron apagado en gioser, encendido en la caja, y un ciclo completo corrido a mano para mirar lo que
|
||
publica: `==> repo al día (ff)` → `==> estado commiteado+pusheado`, con los números buenos. El
|
||
`build-state` que hay en `main` ahora lo escribió la caja.
|
||
|
||
⚠ **Lo que queda apretado es el disco**: `/store` de la caja está al **91 %, 8,2 G libres**. Un hub
|
||
sella y cosecha, así que ése es el próximo muro — `scripts/store-gc.sh` es la herramienta y todavía
|
||
no se corrió allá.
|
||
|
||
⇒ **puerta 4 en verde.** Con eso, de las ocho quedan sólo las dos a medias (5 y 7) y ninguna en rojo.
|
||
|
||
### 6.44 🧹 Primer `store-gc` en la caja: 26,8 G — y dos bugs que sólo salen fuera de GNU *(2026-09-17)*
|
||
|
||
El store estaba al **91 %** (8,2 G libres) justo cuando la caja pasó a ser hub, que es la peor
|
||
combinación: un hub sella y cosecha.
|
||
|
||
```
|
||
antes: 84,7 G usados · 8,2 G libres · 91 % · 1657 artefactos
|
||
después: 57,9 G usados · 35,0 G libres · 62 % · 1320 artefactos
|
||
```
|
||
|
||
**337 artefactos superados borrados y verificados (0 sobrevivientes), 26,8 G.** El default del gc no
|
||
toca los **150 huérfanos** —`11,4 G`, los que son el único ejemplar de su nombre— y eso está bien:
|
||
borrarlos es la diferencia entre «se reconstruye en dos minutos» y «hay que rehacer la torre».
|
||
|
||
#### El control que hay que correr DESPUÉS de un gc en esta caja, y que no es obvio
|
||
|
||
**11 binarios de `/usr/bin` son symlinks que apuntan DENTRO del store** — los daemons que se
|
||
instalaron estos dos días. Un gc que borre el artefacto equivocado no rompe el store: rompe el
|
||
`/usr/bin` de una máquina en producción. Antes de `--aplicar` se comprobó que los 11 enlazan al hash
|
||
**vigente** (y por lo tanto están en el conjunto protegido); después, que los 11 siguen resolviendo y
|
||
que los 21 entes siguen corriendo. Los dos controles pasaron.
|
||
|
||
⚠ Y la advertencia que el propio gc imprime y que en esa caja **no se puede satisfacer**: «sin
|
||
`.config` legible (ni `/proc/config.gz` ni `/boot/config-…`) ⇒ NO se protege ningún kernel por esta
|
||
vía». Con el default da igual; con `--huerfanos` en una máquina que arranca de su store, no.
|
||
|
||
#### Los dos números que el gc no sabía decir
|
||
|
||
Funcionó, pero sus dos cifras salieron vacías — y son las que uno mira para decidir si vale la pena:
|
||
|
||
```
|
||
==> espacio: superados · huérfanos
|
||
==> 337 artefactos borrados y VERIFICADOS · libres: Available → Available
|
||
```
|
||
|
||
1. **`du -sch --files0-from=-` es de GNU y busybox no lo tiene** ⇒ `espacio()` devolvía vacío. *Un
|
||
número que falta se lee como un número chico.*
|
||
2. **`df -h /home` estaba cableado** y el store no vive ahí: en gioser es un bind-mount del volumen,
|
||
en la caja es `/store`. Sin `/home`, `tail -1` se quedó con la CABECERA — de ahí
|
||
«Available → Available».
|
||
|
||
Arreglados los dos… y el primer arreglo **también estaba mal**: `xargs -d '\n'` es otra extensión
|
||
GNU. Eso imprimió `huérfanos ?`, y ese `?` es deliberado: cuando el total no se puede calcular, la
|
||
función lo DICE en vez de escribir 0 — un cero inventado habría dicho «no hay nada que ganar» con
|
||
11,4 G en huérfanos. Con `tr '\n' '\0' | xargs -0`, que busybox sí tiene, la caja ya contesta
|
||
**`espacio: superados 0 · huérfanos 11.4G`**.
|
||
|
||
⇒ Van **cinco** tropiezos del mismo día con la misma forma (`/dev/tcp`, expansión de llaves,
|
||
`find -newermt`, sustitución de procesos, y estos dos). El patrón ya no es anécdota: **un script del
|
||
hub escrito en una máquina GNU no corre en la caja hasta que se prueba ahí.**
|
||
|
||
### 6.45 🧯 `poda-fuentes` tampoco corría — y la causa no era el script: **la caja no tiene `/dev/fd`** *(2026-09-17)*
|
||
|
||
El latido, desde la caja, moría así en su paso de poda:
|
||
|
||
```
|
||
poda-fuentes.sh: line 72: /dev/fd/63: No such file or directory
|
||
poda-fuentes.sh: line 73: viejos: unbound variable
|
||
⚠ poda de fuentes falló (rc=1) — sigo
|
||
```
|
||
|
||
El segundo error es consecuencia del primero (el `mapfile` nunca corrió) y el primero **no nombra la
|
||
causa**: la sustitución de procesos de bash (`<(...)`) necesita `/dev/fd`, y **una caja takana no lo
|
||
tiene**. gioser sí: `/dev/fd -> /proc/self/fd`. O sea que no era este script: **ningún `<(...)` de
|
||
ningún script del hub funcionaba allá**, y éste fue el primero en toparse.
|
||
|
||
Por eso los arreglos son dos, y los dos hacían falta:
|
||
|
||
1. **En la caja**: `/dev/fd -> /proc/self/fd`, con una Card **OneShot** en el `genesis` para que
|
||
sobreviva al reinicio (como la de `hostname`). Comprobado después:
|
||
`bash -c 'cat <(echo funciona)'` responde. ⇒ *lo correcto a futuro es que lo haga el init o el
|
||
product-rootfs, no una Card.* Van dos parches de esta forma; es una lista que conviene no alargar.
|
||
2. **En el script**: fuera `<(...)` (fichero temporal) y fuera `df -B1 --output=avail`, que es GNU —
|
||
con `df -k` + awk. Sin eso el script corría pero decía **`libres 0.0 G`**, porque el `df` fallaba
|
||
en silencio y el 0 se lee como un disco lleno.
|
||
|
||
Con las dos cosas, la poda hizo su trabajo por primera vez en la caja:
|
||
|
||
```
|
||
2 árbol(es) por encima del suelo · borrados 2 · libres 46.1 G · LIBERADO 5.4 G
|
||
```
|
||
|
||
⇒ **En total, 32,2 G recuperados hoy en la caja**: 26,8 G de artefactos superados (§6.44) y 5,4 G de
|
||
árboles de fuentes. `/store` al 62 %, `/work` al 29 %.
|
||
|
||
### 6.46 🚪 Puerta 5 verde: la caja publica su repo firmado **y se instala de él** *(2026-09-17)*
|
||
|
||
La puerta 5 estaba a medias desde el §5.4 y el motivo era el de siempre: *el host no se puede
|
||
actualizar de sí mismo porque `install` reproduce desde fuente y eso exige el LAB*. Con el lab ya
|
||
instalado (§6.43), se cerró en dos movimientos, los dos corridos **en la caja**:
|
||
|
||
**1. Publicar** — con su propia clave de release, la que se mudó el 2026-09-16 a
|
||
`/root/.config/takana/keys/`:
|
||
|
||
```
|
||
==> ✓ el índice verifica contra ./trust
|
||
publicado: 171 con expected_hash anclado, 3 sin ancla. FALLARON: os-release
|
||
```
|
||
|
||
**2. Instalar de ese repo, en modo estricto** (`--require-signed`, que exige autoría del catálogo
|
||
verificada y no se conforma con que el `.swm` reproduzca):
|
||
|
||
```
|
||
release: trusted (by release)
|
||
install duf 0.9.1 ← dist/repo/duf-0.9.1.tkn
|
||
deps: resolviendo catálogo para 1 dep(s): go
|
||
caché: artefacto ya en el store hash=b3:abb3527d… name=duf
|
||
apply OK — source_patch=1 config_edit=0 init_rule=0 file_drop=0
|
||
registrado duf (2 fichero(s))
|
||
```
|
||
|
||
Se instaló bajo `--prefix /tmp/prueba-install` y con `--db` propio, para no tocar el sistema.
|
||
|
||
⚠ **Y el matiz honesto, que vale más que el verde**: el artefacto **ya estaba en el store**, así que
|
||
el camino que se ejercitó fue *resolver + verificar firma + hidratar*, **no** *reproducir desde
|
||
fuente*. Lo que el lab destrabó y sí quedó probado aparte es el **hash** (§6.43: cuatro de cuatro
|
||
iguales a gioser), que era exactamente donde moría antes —«no pude leer el apk db del lab»—. Queda
|
||
sin ejercitar el caso «paquete que NO está en el store», que en una caja de producción además es
|
||
discutible que se quiera: un servidor no tiene por qué compilar.
|
||
|
||
⇒ `os-release` es la única receta del perfil que no se pudo publicar; es la que lleva el logo y la
|
||
identidad de la distro, y su fallo queda anotado para mirarlo aparte.
|
||
|
||
### 6.47 🚪 Las ocho, al cierre del 2026-09-17
|
||
|
||
| # | puerta | estado |
|
||
|---|---|---|
|
||
| 1 | arranca takana puro, se entra por SSH | ✅ |
|
||
| 2 | el store está entero | ✅ **1320 artefactos, 0 vacíos** tras el gc · `/store` al 62 % |
|
||
| 3 | los cuatro grafos idénticos | ✅ |
|
||
| 4 | **la granja late en la caja** | ✅ **§6.43** — con el lab pineado; la caja gana por uno en los dos grafos |
|
||
| 5 | **el repo se sirve y el host se instala de él** | ✅ **§6.46** — 171 paquetes publicados y `install --require-signed` en verde |
|
||
| 6 | cada dominio vivo responde 200 | ✅ los 8 |
|
||
| 7 | el respaldo corre desde la caja | ✅ **§6.48** — corrida completa, 0 errores, y **0 artefactos de la caja sin respaldar** |
|
||
| 8 | lo fósil, anotado y decidido | ✅ |
|
||
|
||
⇒ **LAS OCHO EN VERDE** (§6.48 cerró la última). Lo que separa a esto del borrado de gioser ya no es
|
||
técnico: es la decisión del usuario, que es como el §6 dice que tiene que ser — a mano, con las ocho
|
||
en verde, **nunca por automatización**.
|
||
|
||
Y lo que queda vivo en gioser es el trabajo de la mudanza, no servicios: los ~10 G de árboles
|
||
personales de `/home` sin decidir (§6.35) y el montón de `[[datos]]` que el censo dejó sin decisión.
|
||
|
||
### 6.48 ✅ Puerta 7: el respaldo corre desde la caja — y el parcial que la envenenaba *(2026-09-17)*
|
||
|
||
```
|
||
respaldados (manifiesto del Storage Box) : 3686
|
||
en el store de la caja : 1320
|
||
de la caja, NO respaldados : 0 ← la cuenta que decide
|
||
errores y reintentos en la corrida : 0 y 0
|
||
```
|
||
|
||
**Las ocho puertas quedan en verde.**
|
||
|
||
#### ⚠ Pero la primera corrida NO terminó, y el motivo vale por sí solo
|
||
|
||
Se cayó **40 veces seguidas con el MISMO fichero**:
|
||
|
||
```
|
||
open ".../fonts/dejavu/.rsync-partial/DejaVuSans-Oblique.ttf" failed: No such file… (2)
|
||
.. store: corte de red (rsync 23). Intento 39; reanudo en 60s.
|
||
!! store: 40 intentos y sigue cayéndose. Dejo lo subido y paro.
|
||
```
|
||
|
||
No era red. Una transferencia interrumpida **el 11 de septiembre** había dejado ese parcial en el
|
||
destino con el modo del origen —`-r--r--r--`, porque el store es de sólo lectura por diseño— y
|
||
**ningún intento posterior puede reescribirlo**. El fallo es ETERNO: esa ruta quedaba envenenada
|
||
para siempre, y el bucle la golpeaba cada 60 segundos llamándola «corte de red». Es exactamente la
|
||
pared que la cabecera de esa función dice que no hay que golpear cuarenta veces.
|
||
|
||
Dos arreglos, y el segundo importa más que el primero:
|
||
|
||
1. **`--chmod=Fu+w`** en el rsync: los ficheros del respaldo quedan escribibles por su dueño, así que
|
||
un corte a mitad se RETOMA en vez de trabarse. El precio —no conservar el bit de sólo-lectura en
|
||
la copia— es bajo: lo que se respalda es el contenido, y los permisos los reconstruye el store.
|
||
2. **Al `rsync 23` se le dan TRES intentos, no cuarenta**, y al tercero se NOMBRA la causa probable.
|
||
El 23 es «some files were not transferred», que tanto puede ser un cable como una pared; tratarlo
|
||
siempre como cable es lo que convirtió un fallo permanente en cuarenta minutos de ruido.
|
||
|
||
#### 🪤 Y dos veces el mismo error mío: `pgrep -f <patrón>` se encuentra a SÍ MISMO
|
||
|
||
Para saber si el respaldo seguía vivo usé `pgrep -f respaldo-storagebox`… y el patrón está en la
|
||
línea de comando del propio `pgrep` (y del `ssh` que lo lanza). Resultado: **decía «sigue corriendo»
|
||
para siempre**, incluso con el script muerto hacía rato — y el vigía que armé con esa condición
|
||
nunca podía dispararse. Pasó dos veces en la misma tarde, en las dos máquinas.
|
||
|
||
La forma correcta es el truco del corchete —`ps ... | grep "[r]espaldo-storagebox"`—, que no coincide
|
||
consigo mismo porque el patrón literal `[r]espaldo` no aparece en la línea del grep. Es hermano del
|
||
`pkill -f` que mata tu propia shell, ya anotado en las memorias.
|
||
|
||
### 6.49 📋 Lo único que queda: 150 decisiones sobre datos *(2026-09-17)*
|
||
|
||
Con las ocho puertas en verde, de la mudanza no queda trabajo técnico: queda **decidir**. La hoja
|
||
está en `docs/state/mudanza-decisiones-pendientes.txt` — 150 entradas, **31,9 G**, ordenadas por
|
||
tamaño, con lo que la herramienta se anima a sugerir por un HECHO entre corchetes y **en blanco lo
|
||
que sólo sabe el dueño**:
|
||
|
||
```
|
||
/home/sergio/.claude 6.8G [ ] lo más nuevo: 2026-09-16
|
||
/home/sergio/.local 6.2G [muda ] lo usa act_runner, tejido, thasnuna
|
||
/var/lib/swap 6.1G [muere] (es swap)
|
||
/home/sergio/.cache 4.3G [muere] caché
|
||
/var/lib/gitea 1.9G [ ] ← ya mudado en el §6.14; la entrada es un fósil del censo
|
||
/home/sergio/imm 200M [ ] lo más nuevo: 2026-03-23
|
||
```
|
||
|
||
Se aplica en una pasada con `planear.py --decidir`, que ya no necesita teclas unitarias (§3 bis).
|
||
|
||
⚠ Y la hoja **dice qué no cubre**, porque una lista titulada «lo que falta» que omite 329 G se lee
|
||
mal: no están `/mnt/vvv` —el monorepo, este repo, el store, `rustup`— ni los servicios y dominios.
|
||
Lo de `/mnt/vvv` no necesita decisión: **el store está respaldado (puerta 7) y los repos viven en el
|
||
gitea de la caja**.
|
||
|
||
#### Lo último que se rescató antes de cerrar
|
||
|
||
`/mnt/vvv/gioserv` tenía **26 entradas sin commitear** y su remoto era
|
||
`/var/lib/gitea/repositories/sergio/gioserv.git` — una ruta LOCAL de la máquina que se borra. Lo
|
||
commiteado está a salvo (su `HEAD` es el mismo que el del gitea nuevo); lo que no estaba en ningún
|
||
lado —el `git diff HEAD` completo (`api/main.py`, +92/−23, y 25 borrados) más los 4 no rastreados—
|
||
quedó en `takana:/work/mudanza-secretos/gioserv-pendiente/`, verificado: **0 ficheros del origen
|
||
faltan en el destino**.
|
||
|
||
*No se reapuntó el remoto ni se commiteó nada*: cambiar el remoto de un clon ajeno y decidir si ese
|
||
`api/main.py` se publica es del dueño del repo, no de la mudanza. El `LEEME.txt` del paquete lleva el
|
||
comando exacto para reapuntarlo.
|
||
|
||
### 6.50 🤖 El agente corre en la caja — y puede construir *(2026-09-17)*
|
||
|
||
Pedido del usuario, y es la condición que él mismo puso para borrar gioser: *«no borramos gioser
|
||
hasta tenerte corriendo de aquel lado, con capacidad de compilar tawasuyu y las recetas de takana»*.
|
||
|
||
```
|
||
$ claude -p "Decí exactamente: CLAUDE CORRIENDO EN LA CAJA"
|
||
CLAUDE CORRIENDO EN LA CAJA
|
||
```
|
||
|
||
Eso es un turno REAL —API, credenciales, respuesta— desde `2.29.29.217`.
|
||
|
||
#### Por qué va enjaulado, y qué se le concedió
|
||
|
||
El CLI es un **ELF glibc de 360 M** (`interpreter /lib64/ld-linux-x86-64.so.2`, `NEEDED libc.so.6`)
|
||
y la caja es musl: no hay camino nativo. Es el caso del ADR 0015, el mismo de `sergioh-api`. La
|
||
instancia `claude` declara, y **conviene decir que es ANCHA en vez de disimularlo**: red, `nesting`,
|
||
y seis directorios —`/opt/takana` (el trabajo), `/work/dev-fs` (lab + zig/go), `/store`,
|
||
`/root/.claude` (memoria y credenciales), `/root/.ssh` (ro) y el CLI (ro)—. Un agente que construye,
|
||
commitea y empuja necesita lo mismo que un humano haciendo ese trabajo; lo que la jaula compra es
|
||
que ese alcance esté **escrito y acotado**, no que sea pequeño.
|
||
|
||
⚠ El `takana` que construye **no se instala adentro**: sale del store, que ya está concedido. Es
|
||
estático musl y corre igual dentro de una imagen glibc — comprobado (`takana 0.0.1`).
|
||
|
||
#### La caja construye: dos recetas selladas hoy
|
||
|
||
Antes faltaba una pieza que el lab no traía: **`.dev-fs/tools`** (zig 0.13/0.16 y go, 1 G). El
|
||
primer intento murió con `zig no encontrado en .dev-fs/tools/zig/zig` — la imagen del lab empaqueta
|
||
`alpine/` y **nada más**. Copiado eso:
|
||
|
||
```
|
||
intel-ucode → sellado: 156 signatures + GenuineIntel.bin (17 MB)
|
||
sof-firmware → sellado: 2 firmware + 8 topologías (IPC3 v2.2, TigerLake)
|
||
```
|
||
|
||
#### 🧱 Lo que NO funciona, y su rodeo
|
||
|
||
**`takana build` NO corre DENTRO de la jaula**, aunque la instancia declare `nesting = true`:
|
||
|
||
```
|
||
bwrap: Can't mount proc on /proc: Operation not permitted
|
||
```
|
||
|
||
Y no es que el anidamiento esté roto: **un `bwrap --dev-bind / / --proc /proc` a mano SÍ funciona
|
||
adentro**, con y sin `--unshare-pid`. Lo que falla es el userns anidado que arma el sandbox del
|
||
build. Probado también con `root = false` —la sospecha que el propio código documenta (§`grants.root`
|
||
⚠ del 2026-09-03)— y falla igual: no es el remapeo a root.
|
||
|
||
El rodeo que sí anda, y quedó escrito en el lanzador para que el agente lo use:
|
||
|
||
```sh
|
||
ssh -p 22022 -i /root/.ssh/github5 root@127.0.0.1 \
|
||
'cd /opt/takana && flock -o work/.farm-build.lock takana --store /store build <receta>'
|
||
```
|
||
|
||
Con eso, **desde dentro de la jaula se selló `sof-firmware`**. El agente vive enjaulado y le pide
|
||
los builds al anfitrión, que es donde el sandbox del build tiene los permisos que necesita.
|
||
|
||
#### Cómo se entra
|
||
|
||
`claude` en el PATH de la caja (`/usr/bin/claude`), que envuelve
|
||
`takana qorpa run claude -- env HOME=/root …`. El manifiesto y el lanzador están versionados en
|
||
`scripts/servidor/`, porque una instancia que sólo existe en la caja se pierde con la caja.
|
||
|
||
⚠ Pendiente menor: la memoria del agente está en `/root/.claude/projects/-mnt-vvv-takana/`, y allá el
|
||
repo vive en `/opt/takana` ⇒ **el índice por ruta no coincide**. Se resuelve renombrando el
|
||
directorio a `-opt-takana` o dándole al repo la misma ruta; hasta entonces el agente arranca sin su
|
||
memoria del proyecto aunque los 733 ficheros estén en disco.
|
||
|
||
### 6.50 bis 🔑 La cuenta necesitaba sus LLAVES, no su contraseña *(2026-09-17)*
|
||
|
||
Creada la cuenta, el primer intento del usuario falló:
|
||
|
||
```
|
||
$ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
|
||
sergio@gioser.net: Permission denied (publickey).
|
||
```
|
||
|
||
Y era exacto: **el `sshd` de la caja tiene `PasswordAuthentication no`**, así que la contraseña
|
||
`tawasuyu` sirve para `su` y para la consola, **no para entrar por SSH**. Lo que faltaba era su
|
||
`~/.ssh/authorized_keys`, que se creó vacío con la cuenta. Copiado el de gioser —dos llaves,
|
||
`tawasuyu` y `sergio@tatata`— entra:
|
||
|
||
```
|
||
$ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
|
||
ENTRA como sergio en takana · /home/sergio
|
||
$ claude -p "…" → ENTRÉ POR SSH Y ABRÍ CLAUDE
|
||
```
|
||
|
||
Dos detalles que hacen que esto se sienta como la máquina de siempre: **`gioser.net` ya resuelve a la
|
||
caja**, y **la llave de host es la MISMA que la de gioser** (se copiaron en el cutover del gitea,
|
||
§6.25) — comprobado: `SHA256:ltSl+rgr2Uc…` en las dos. Por eso no aparece el aviso rojo de
|
||
«REMOTE HOST IDENTIFICATION HAS CHANGED» al entrar al nombre de siempre.
|
||
|
||
⚠ El `sshd` escucha en **22022 y 2345**, los dos generales: `2345` es el puerto que los clones de
|
||
git ya usaban, y sirve igual para entrar.
|
||
|
||
### 6.50 ter 🧰 `sudo` y el taller: dónde se programa en la caja *(2026-09-17)*
|
||
|
||
**`sudo` no andaba por dos cosas a la vez, y una era el corte de energía.** `/usr/bin/sudo` **no
|
||
tenía el bit setuid** (`sudo: must be owned by uid 0 and have the setuid bit set`), y además
|
||
`/etc/sudoers.d/sergio` **había desaparecido**: lo escribí minutos antes del `reset` del §6.51 y se
|
||
fue con el mismo corte que dejó el cortafuegos en NULs. Repuestos los dos —y con `sync`—,
|
||
`sudo id` devuelve `uid=0(root)`. `visudo` no está instalado, pero **no hace falta**: el fichero
|
||
suelto en `/etc/sudoers.d/` es la forma correcta, y `sudo` lo valida solo.
|
||
|
||
⚠ `doas` sigue dando `Operation not permitted` aun estando setuid. No hizo falta perseguirlo —`sudo`
|
||
resolvió— pero queda anotado como rareza sin explicar.
|
||
|
||
#### Dónde están los repositorios
|
||
|
||
**Los 44 están en la caja**, en el gitea que se mudó el 14-sep: `/var/lib/gitea/repositories/`
|
||
(`sergio/` 939 M, `tawasuyu/` 547 M). Lo que NO había era un **clon de trabajo**: sólo `/opt/takana`,
|
||
que es el del hub.
|
||
|
||
```
|
||
~/src → /work/sergio ← el taller
|
||
/work/sergio/tawasuyu 478 M ← clonado, origin al gitea de la caja
|
||
```
|
||
|
||
⚠ **El taller va en `/work`, no en el home.** La raíz de esta caja tiene **2,4 G libres** y `/work`
|
||
**43 G**: un clon del monorepo en `/home` llena la raíz y tumba la máquina.
|
||
|
||
⚠ **Y clonar acá tiene dos trampas medidas:**
|
||
1. **El `git` del corpus no tiene `remote-https`** (`git: 'remote-https' is not a git command`), así
|
||
que **no se puede clonar por HTTPS** desde la propia caja. Es la deuda ya anotada del §bloqueantes.
|
||
2. Clonar por **SSH** (`ssh://gitea@git.gioser.net:2345/…`) exige que la llave esté registrada **en
|
||
la cuenta de gitea**, no basta con el `authorized_keys` del sistema. Hasta que se registre, el
|
||
camino es el **sistema de ficheros** (`git clone /var/lib/gitea/repositories/<org>/<repo>.git`) —
|
||
con dos detalles: el directorio de cada organización es `0700` del usuario `gitea`, así que hay
|
||
que clonar como root y `chown` después; y git rechaza el repo por `dubious ownership` hasta que se
|
||
le dice `git config --global --add safe.directory "*"`.
|
||
|
||
Y el agente se abre sobre cualquiera de ellos:
|
||
|
||
```
|
||
PROY=/work/sergio/tawasuyu claude
|
||
→ «Es tawasuyu (/work/sergio/tawasuyu, origin git.gioser.net:2345/…), último commit 55e5f0f27»
|
||
```
|
||
|
||
### 6.50 quater 📦 Cómo se instala software de tawasuyu en la caja — y por qué NO con `actualizar-servidor.sh` *(2026-09-17)*
|
||
|
||
Pregunta del usuario: *«¿ahí tengo la confianza de `install-tawasuyu` y `actualizar-servidor`? ¿o
|
||
cómo es la forma de instalar, por ejemplo, `shuma-tui`?»*
|
||
|
||
**Esos guiones existen y siguen ahí** —el monorepo está clonado en `/work/sergio/tawasuyu`— pero lo
|
||
que hacen es:
|
||
|
||
```
|
||
cargo build --release <crates> …y después… install -m755 <nuevo> /usr/local/bin/<x>
|
||
```
|
||
|
||
O sea: **producen exactamente la clase de binario que la mudanza encontró irreproducible** — 2,7 G
|
||
de sueltos que ningún paquete provee (§6.35), de los cuales tres corrían desde un inodo BORRADO.
|
||
Además `actualizar-servidor.sh` escribe servicios en `/etc/init.d/`, que en esta caja no existe: el
|
||
init es arje.
|
||
|
||
#### La forma de acá, hecha de punta a punta con `shuma-tui`
|
||
|
||
```
|
||
1. receta recipes/shuma-tui.toml — pinneada al commit del monorepo, zig-cc, musl, estática
|
||
2. build takana build ⇒ b3:f795ee0f… sellado en el store
|
||
3. viaja worker → hub → caja (rsync del artefacto, que es content-addressed)
|
||
4. instalar ln -s /store/<hash>-shuma-tui/usr/bin/shuma-tui /usr/bin/
|
||
5. control shuma-tui --help → contesta
|
||
```
|
||
|
||
Los pasos 3 y 4 son el atajo honesto de hoy; el camino completo es el del §6.46 —publicar en el repo
|
||
firmado y `takana install <nombre> --require-signed`—, que ya está probado y es lo que convierte
|
||
«copiar un binario» en «instalar un paquete».
|
||
|
||
⚠ **El paso 2 NO se puede hacer en la caja todavía**, y el motivo es la deuda ya conocida: su `git`
|
||
**no tiene `remote-https`**.
|
||
|
||
```
|
||
git: 'remote-https' is not a git command
|
||
Error: git ["clone","--mirror",…,"https://git.tawasuyu.net/…"] falló
|
||
```
|
||
|
||
Las recetas del corpus que bajan tarballs con `curl` **sí** construyen ahí (hoy se sellaron
|
||
`intel-ucode` y `sof-firmware`); las que clonan por HTTPS —todas las de tawasuyu— hay que
|
||
construirlas en el worker, que sí tiene git completo. Arreglar eso es re-sellar `git` con libcurl, y
|
||
es raíz de `perfil.base`: unidad propia.
|
||
|
||
### 6.50 quinquies 🔑 Las fuentes git se traen por SSH — y la raíz de 5,9 G tiró la caja entera *(2026-09-17)*
|
||
|
||
El §6.50 quater dejó anotado el muro: la caja no tiene `git-remote-http`, así que las 36 recetas que
|
||
clonan de `git.tawasuyu.net` —y las ~90 de GitHub— no se podían construir ahí. La salida es la que
|
||
pidió el usuario: **usar SSH**. Y sale gratis en hashes, porque **la URL es un LOCALIZADOR y no entra
|
||
en `hash_inputs`** (ADR 0013): el mismo commit por otro transporte sella el mismo artefacto.
|
||
|
||
No se toca ninguna receta —el worker sigue clonando por HTTPS, que es lo que puede— sino la caja:
|
||
|
||
```
|
||
git config --global url."ssh://gitea@git.gioser.net:2345/".insteadOf "https://git.tawasuyu.net/"
|
||
git config --global url."ssh://git@github.com/".insteadOf "https://github.com/"
|
||
```
|
||
|
||
Tres cosas que costaron una vuelta cada una, las tres MEDIDAS:
|
||
|
||
1. **`takana` clona bien, pero `cargo vendor` clona aparte.** cargo honra el `insteadOf`, pero usa su
|
||
propio cliente SSH: primero se plantó por la clave del host —la tenía como `[git.gioser.net]:2345`
|
||
y cargo la busca **sin puerto**, misma clave reexpresada— y después por autenticación, porque no
|
||
sabe leer `IdentityFile` de `~/.ssh/config`. Se resuelve delegando en el git de verdad:
|
||
|
||
```toml
|
||
# ~/.cargo/config.toml
|
||
[net]
|
||
git-fetch-with-cli = true
|
||
```
|
||
|
||
2. **La huella de `github.com` se PINEA, no se acepta a ciegas.** `ssh-keyscan` dio
|
||
`SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU`, idéntica a la que GitHub publica, y **por eso**
|
||
entró a `known_hosts`. La clave de esta caja autentica como deploy key de otro repo y aun así lee
|
||
repos públicos ajenos: comprobado con `uutils/coreutils`.
|
||
|
||
3. **Y la que dolió: la raíz son 5,9 G y `cargo` la llenó.** `CARGO_HOME` estaba en `/root/.cargo`, o
|
||
sea en `sda2`. El vendor del monorepo bajó 2,7 G, la raíz llegó al **100 %**, y la caja **no se
|
||
degradó: se cayó entera** — sin HTTP, sin SSH y **sin responder al ping**, con Hetzner informando
|
||
`running`. Hubo que entrar por rescate otra vez.
|
||
|
||
Lo que el disco lleno deja como rastro, y conviene saber leer:
|
||
|
||
```
|
||
caddy → «write error: write /var/log/caddy/requests.json: no space left on device»
|
||
mis → /root/.ssh/config quedó con 105 BYTES NULOS al final: el append entró a medias
|
||
escrituras (el mismo daño que el corte de luz le hizo al reglaset del cortafuegos, §6.51)
|
||
```
|
||
|
||
**Un `>>` que falla por ENOSPC no deja el fichero como estaba: lo deja extendido y relleno de
|
||
ceros.** Es exactamente la clase de avería que el §3 de `CLAUDE.md` describe para los artefactos
|
||
vacíos — llega hasta el final diciendo que todo fue bien.
|
||
|
||
**Reparado en el rescate, y las tres piezas son permanentes:**
|
||
|
||
| qué | dónde | por qué |
|
||
|---|---|---|
|
||
| `/root/.cargo` → `/work/cargo-home` | symlink | 2,7 G de caché no caben en una raíz de 5,9 G |
|
||
| poda horaria de logs | `/usr/local/sbin/poda-logs.sh` + cron `17 * * * *` | `ente-gitea.log` había llegado **solo** a 114 M y **nadie lo rotaba** |
|
||
| `.ssh/config` y `.gitconfig` | reescritos | tenían NULs de las escrituras a medias |
|
||
|
||
Tras el arranque: raíz al **54 %**, los 18 entes corriendo y **los 7 dominios de la caja en 200**
|
||
(`terapeuta.ec` sigue en gioser a propósito — se decidió respaldar, no mudar).
|
||
|
||
> **La lección, que no es sobre cargo.** La raíz de esta caja es **5,9 G** y todo lo que crece sin
|
||
> dueño vive ahí: logs, cachés, spool. No hay margen para «ya lo limpio después», y el modo de fallo
|
||
> no es un error: es la máquina entera apagándose en silencio. Cualquier cosa que acumule bytes en la
|
||
> caja **se declara dónde vive antes de correrla por primera vez**.
|
||
|
||
#### El control que cierra esto: la caja construyó una receta de tawasuyu de punta a punta
|
||
|
||
`puriy-costura` era la única de las 36 que **no** estaba en el store de la caja — o sea, un fallo de
|
||
caché de verdad, no un cache-hit disfrazado de éxito ([[cache-congela-regresiones]] es justo esto).
|
||
La caja la hizo entera y sola: clonó por SSH (111,7 M de mirror), vendoreó, compiló y selló.
|
||
|
||
```
|
||
hash que predice el HUB b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
|
||
hash que selló la CAJA b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
|
||
contenido 2,2 M · usr/bin/puriy-costura · corre y muestra su ayuda
|
||
```
|
||
|
||
**Idéntico**, que es lo único que prueba que el transporte no contamina el artefacto. Con esto la
|
||
caja cumple la condición que puso el usuario para poder borrar gioser —«con capacidad de compilar
|
||
tawasuyu y las recetas de takana»— **desde fuente y por su cuenta**, no por artefactos que le llegan
|
||
del worker.
|
||
|
||
El guion `scripts/servidor/fuentes-por-ssh.sh` deja esto reproducible: idempotente, con la huella de
|
||
github pineada (si el host anuncia otra, **sale con error en vez de aceptarla**) y con un control
|
||
final que CLONA de los dos orígenes. Probados sus dos negativos: un repo inexistente lo hace fallar,
|
||
y una huella cambiada la rechaza.
|
||
|
||
### 6.50 sexies ✅ Pagada la deuda: el `git` del corpus vuelve a clonar por HTTPS *(2026-09-17)*
|
||
|
||
El §6.50 quinquies dejó la caja trayendo fuentes por SSH porque su `git` no tenía `remote-https`.
|
||
Eso era el síntoma; **la causa estaba un paso antes de donde todos mirábamos** —incluida la nota que
|
||
yo mismo había escrito, que culpaba al Makefile—.
|
||
|
||
El tarball de git trae `configure`, y takana lo corre. Su test de libcurl es `AC_CHECK_LIB`, que
|
||
enlaza `conftest.c -lcurl` **y nada más**. Con libcurl **estática** eso no resuelve: faltan
|
||
`-lssl -lcrypto -lz`, que son sus privadas.
|
||
|
||
```
|
||
checking for SHA1_Init in -lcrypto... yes
|
||
checking for curl_global_init in -lcurl... no ← acá se decidía todo
|
||
checking for XML_ParserCreate in -lexpat... no
|
||
```
|
||
|
||
El test falla, `config.mak.autogen` se lleva `NO_CURL=YesPlease`, el Makefile excluye
|
||
`git-remote-http`, `git-http-fetch` y `git-http-push` —**y conserva `git-http-backend`, que es el
|
||
lado servidor y no usa curl**: esa asimetría en el artefacto era la firma del fallo, y estuvo a la
|
||
vista desde el 2026-09-14—. El build **termina en verde**. Es el §3 de `CLAUDE.md` en su forma más
|
||
cara: un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
|
||
|
||
**La cura, tres líneas en la receta**, cada una con su motivo:
|
||
|
||
| línea | por qué |
|
||
|---|---|
|
||
| `LIBS="$(curl-config --libs)"` al `configure` | le da al test lo que el enlace estático necesita — y sale de `curl-config`, no escrito a mano: si curl gana privadas mañana, las trae solo |
|
||
| guardián sobre `config.mak.autogen` | el modo de fallo es **silencioso** ⇒ se comprueba el HECHO (`NO_CURL=YesPlease`) y se corta, en vez de enterarse meses después |
|
||
| `NO_EXPAT=1` | con curl encendido el Makefile agrega `http-push.o`, que pide `-lexpat`, y expat no está en deps. `http-push` es el push por WebDAV: clonar y traer no pasa por ahí |
|
||
|
||
**Medido sobre el artefacto, nunca sobre el `git` del host** (`ls $(git --exec-path) | grep remote-http`):
|
||
`b3:215742cb…` trae `git-remote-http`, `git-remote-https`, `git-http-fetch` y los `ftp`. Y **clona**
|
||
—no `ls-remote`: `clone`, que es donde murió la vez del ubsan—: árbol en disco y `rev-list` contesta.
|
||
En la caja, hidratado y con la reescritura a SSH **apagada**, `git clone https://…` funciona, y
|
||
`git clone --mirror` —el comando exacto que usa `takana build` para traer fuentes— también.
|
||
|
||
> **El radio, medido en vez de temido.** La nota vieja decía que arreglar esto «re-sella `git`, que es
|
||
> raíz de `perfil.base` ⇒ radio grande». `yupana radio git` dice otra cosa: **cero** dependientes
|
||
> directos y **cero** transitivos. Toca las 7 imágenes que lo incluyen —que es re-ensamblar, no
|
||
> reconstruir—. El miedo escrito a mano había sobrestimado el costo de arreglarlo, y eso lo mantuvo
|
||
> roto tres días.
|
||
|
||
Las reescrituras `url.insteadOf` quedaron **retiradas** de la caja: ya no hacen falta.
|
||
`scripts/servidor/fuentes-por-ssh.sh` se conserva como salida de emergencia, no como el camino normal.
|
||
|
||
### 6.52 🧩 Las proc-macros SÍ se pueden compilar en la caja — falta un `cc`, no un parche *(2026-09-18)*
|
||
|
||
Con `rust` sellado, la caja compila y corre binarios **estáticos** sin tocar nada. Lo que no compilaba
|
||
es cualquier **dylib**, y eso incluye **todas las proc-macros** (`serde_derive`, `clap_derive`…), o sea
|
||
media ecología de Rust:
|
||
|
||
```
|
||
ld.lld: error: unable to find library -lgcc_s
|
||
ld.lld: error: unable to find library -lc
|
||
```
|
||
|
||
#### Por qué el `-L` obvio empeora las cosas
|
||
|
||
La reacción natural es darle el camino: `-L /usr/lib`. **Y eso rompe los estáticos**, que hoy
|
||
funcionan. Cuatro controles lo aíslan:
|
||
|
||
| | enlace | resultado |
|
||
|---|---|---|
|
||
| A | estático, sin `-L` | ✅ enlaza — es lo que hay hoy |
|
||
| B | estático, con `libgcc.a` a la vista | ✅ enlaza — el arreglo de `gcc-libs` no rompe nada |
|
||
| C | estático, con `-L /usr/lib` + `libgcc.a` | ❌ rompe |
|
||
| D | estático, **sólo** con `-L /usr/lib` | ❌ rompe ← la culpable |
|
||
|
||
```
|
||
ld.lld: error: relocation R_X86_64_32S cannot be used against symbol '__malloc_size_classes';
|
||
recompile with -fPIC ← sale de /usr/lib/libc.a, que NO es PIC
|
||
```
|
||
|
||
rustc enlaza musl **static-PIE** con su copia *self-contained*; meter `/usr/lib` en el camino le hace
|
||
preferir la `libc.a` del sistema, que no sirve para PIE. **El camino de librerías no puede ser
|
||
global: el estático lo quiere fuera y el dinámico lo quiere dentro.** Ningún `RUSTFLAGS` global
|
||
arregla esto — apaga un caso y enciende el otro.
|
||
|
||
#### Lo que sí funciona, medido de punta a punta
|
||
|
||
Quien sabe distinguir los dos casos es un **driver de C**. La caja no tiene `gcc` ni `clang`
|
||
—`clang18` en el store son sólo cabeceras y librerías, sin binario— **pero sí tiene `zig`**: el
|
||
artefacto `seed-zig` del bootstrap trae `zig 0.16.0` y **corre**. `zig cc` es un driver de C completo
|
||
con musl adentro.
|
||
|
||
```
|
||
cc → zig cc
|
||
RUSTFLAGS → -C linker=cc -C linker-flavor=gcc -C link-self-contained=no
|
||
```
|
||
|
||
Las tres banderas hacen falta y cada una por su motivo: `linker`+`linker-flavor` para que el enlace
|
||
pase por el driver, y `link-self-contained=no` porque si no rustc pide su propio `rust-lld` —que este
|
||
toolchain **no tiene**, ya que `rust.lld = true` es incompatible con el `llvm-config` externo (§ la
|
||
receta lo documenta)— y muere con *«the self-contained linker was requested… it wasn't found»*.
|
||
⚠ La variante fina `-C link-self-contained=-linker` **no sirve**: es inestable en este triple y exige
|
||
`-Z unstable-options`. La gruesa, `=no`, es estable.
|
||
|
||
Con eso, la cadena completa que nunca había compilado:
|
||
|
||
```
|
||
1. la proc-macro ⇒ libpm.so
|
||
2. el programa que la usa ⇒ imprime «PROC-MACRO VIVA»
|
||
3. y el estático de siempre ⇒ sigue enlazando
|
||
```
|
||
|
||
#### Entregado: `scripts/servidor/cc-por-zig.sh`
|
||
|
||
El envoltorio existe y está puesto en la caja. **No mete nada nuevo en el corpus**: usa el `seed-zig`
|
||
que YA está en el store —producto del bootstrap (SDD 11), como `stage1-rootfs`— y sólo lo envuelve en
|
||
`/usr/bin/cc` y `/usr/bin/c++`, más las tres banderas en `/etc/profile.d/`. Volver a zig el
|
||
compilador de C **oficial** de la distro es otra cosa —ahí sí haría falta receta y pin propios— y es
|
||
decisión de ADR, no de un guion de puesta a punto.
|
||
|
||
Es idempotente y sus cuatro controles preguntan por el HECHO:
|
||
|
||
```
|
||
✓ C: C en takana ← compila y EJECUTA un hola mundo
|
||
✓ rust: proc-macro: 42 ← la macro enlaza, el que la usa corre
|
||
✓ rust estático (sin banderas): estático ok ← el remedio no rompió lo que funcionaba
|
||
✓ entorno: una shell de login trae RUSTFLAGS puesto
|
||
```
|
||
|
||
Ese último control encontró algo de paso, y es la clase de cosa que este repo persigue: **en la caja
|
||
`/etc/profile.d` no lo leía NADIE**, porque `/etc/profile` no existía. El fichero de las banderas
|
||
habría quedado presente, con contenido y sin ningún efecto — y el `bash_completion.sh` que ya estaba
|
||
ahí llevaba quién sabe cuánto igual de mudo. Se creó el `/etc/profile` con el bucle estándar. La
|
||
caché de `zig cc` vive en `/work/zig-cache` (54 M) y **no** en la raíz de 5,9 G, por lo aprendido en
|
||
§6.50 quinquies.
|
||
|
||
### 6.53 🔧 Borré `/usr/bin/id` probando el setuid *(2026-09-18)*
|
||
|
||
Para saber si el bit setuid elevaba en esta caja copié `busybox` sobre **`/usr/bin/id`** —porque
|
||
busybox elige el applet por `argv[0]` y necesitaba un nombre válido— y después borré «mis» copias de
|
||
prueba. Una de ellas **era el `id` del sistema**. Resultado, del lado del usuario:
|
||
|
||
```
|
||
$ claude --dangerously-skip-permissions
|
||
/usr/bin/claude: line 23: id: not found
|
||
$ id
|
||
-bash: /usr/bin/id: No such file or directory
|
||
```
|
||
|
||
No quedaba ninguno: `/bin/id` tampoco existía. Se restauró como sus hermanos de identidad —`whoami`
|
||
y `groups` apuntan a `../../bin/busybox`— y se comprobó con `id` de root, de `sergio` y con el
|
||
`id -un` que usa el lanzador. Barrido posterior: **0 enlaces colgados** en `/bin`, `/usr/bin`,
|
||
`/sbin` y `/usr/sbin`, y presentes los ocho comandos de los que dependen el arranque y el lanzador.
|
||
|
||
> **La lección, que es de método:** para probar una propiedad del sistema —¿eleva el setuid?— **no se
|
||
> usa un nombre que el sistema ya ocupa**. La sonda correcta era la que terminé escribiendo: cinco
|
||
> líneas de C compiladas con el `cc` nuevo a un nombre inventado (`probe-suid`). Copiar sobre `id`
|
||
> fue elegir el camino corto, y el camino corto se llevó un binario del que depende hasta el script
|
||
> que abre al agente. Es la misma familia que el `/etc/shadow` del §6.51: **la caja no avisa cuando
|
||
> le falta algo esencial — se entera el que lo usa**.
|
||
|
||
### 6.54 🧭 Trabajar DESDE la jaula: lo que el agente de adentro reportó *(2026-09-18)*
|
||
|
||
El agente enjaulado pasó un informe de defectos —el primero hecho **desde adentro**— y vale más que
|
||
cualquier inspección desde el anfitrión, porque son las cosas con las que se choca al usarla.
|
||
|
||
#### Lo que se arregló, y dónde vive cada arreglo
|
||
|
||
| # | defecto | dónde queda el arreglo |
|
||
|---|---|---|
|
||
| 1 | sin entrada en `/etc/passwd` para el uid de adentro ⇒ `ssh`/`git` mueren con *No user exists for uid 1001* | `jaula-preparar.sh` (upper) |
|
||
| 2 | `/etc/ssh/ssh_config.d` es de la capa inferior, dueño fuera del mapa ⇒ `ssh` **aborta**, no degrada | `jaula-preparar.sh` (upper) |
|
||
| 3 | `~/.ssh` en `ro` ⇒ `ssh` no puede escribir `known_hosts` | **manifiesto**: pasa a `rw` + semilla desde el anfitrión |
|
||
| 4 | sin clave para el worker ⇒ `Permission denied (publickey)`, cero granja | clave **propia** de la caja autorizada en el worker |
|
||
| 5 | sin `rsync`/`python`/`jq`/`b3sum`, y sin `takana` ejecutable | **manifiesto**: `packages`; y un `takana` que sale del store |
|
||
|
||
El **1 y el 2 viven en el `upper`**, que por el D3 es caché descartable: al primer `recreate` se
|
||
pierden. Por eso existe `scripts/servidor/jaula-preparar.sh`, que los repone —escribiendo como la
|
||
persona, no como root, para no dejarle ficheros que después no puede tocar—.
|
||
|
||
Sobre el **4**: se autorizó en el worker la clave **de la caja** (`sergio@caja`), no la de root. Es
|
||
una credencial distinta y revocable: quitarla del `authorized_keys` del worker corta ese acceso sin
|
||
tocar ningún otro. Comprobado: `ssh root@dev.gioser.net hostname` desde la caja contesta `PruebasIA`.
|
||
|
||
Sobre el **5**: `takana` **no se compila adentro**. El binario es el musl estático del store y corre
|
||
igual dentro de una imagen glibc; lo que fallaba es que
|
||
`/opt/takana/target/release/takana` del anfitrión es un enlace a `/usr/bin/takana`, y **adentro
|
||
`/usr/bin` es el de Arch**. Queda `/work/sergio/bin/takana`, que elige el más reciente del store —no
|
||
un hash cableado, para que sobreviva a un re-sellado— y el `target/release/takana` del clon apunta
|
||
ahí.
|
||
|
||
#### Las tres trampas de orientación, que no son defectos pero cuestan una hora
|
||
|
||
- **El store real es `/store`**, 61 G y 1333 artefactos. Los runbooks y el `CLAUDE.md` dicen
|
||
`takana build --store ./store` porque están escritos para el HUB; copiado literal en la caja, eso
|
||
apunta a un store vacío dentro del clon. Desde la caja va `--store /store`.
|
||
- **Hay dos checkouts y no son intercambiables**: `/work/sergio/takana` es el de trabajo —suyo, con
|
||
push propio—, y `/opt/takana` es el del sistema: el que usan el cron, la granja y los servicios.
|
||
Editar el segundo por costumbre es pisarle el árbol al latido.
|
||
- **`scripts/farm/.fleet` ausente NO es un defecto** —el propio informe lo retiró—: `flota-permanente`
|
||
está versionado y `cosecha-cron` lo repone.
|
||
|
||
### 6.55 🔓 «Una sola sesión» es del KERNEL; «un solo Claude» era nuestro *(2026-09-18)*
|
||
|
||
El usuario chocó con `Resource busy` al abrir un segundo agente y lo llamó por su nombre: *«no quiero
|
||
restricciones arbitrarias»*. Tiene razón en la queja y conviene separar qué es qué, porque las tres
|
||
cosas se sienten igual desde afuera y son muy distintas:
|
||
|
||
**1. Lo que impone el kernel.** Dos overlays sobre el **mismo `upper`** los niega `overlayfs`, no
|
||
nosotros: la capa mutable se corrompería. `qorpa` reintenta seis veces y después explica la causa en
|
||
vez de dejar el error crudo de bwrap. **Eso no se toca.**
|
||
|
||
**2. Lo que era empaquetado nuestro** — y sí se toca: *una instancia por persona*. De ahí salía «un
|
||
solo Claude». Instancias distintas tienen `upper` distintos y conviven sin problema. Medido: crear la
|
||
segunda cuesta **0 s y 16 K**, porque la imagen se comparte de sólo lectura.
|
||
|
||
**3. Lo que era un accidente**, y se arregló al encontrarlo:
|
||
|
||
| | estaba | ahora |
|
||
|---|---|---|
|
||
| `/var/lib/hammer/qorpa/instances` | `root:root 755` ⇒ una persona **no podía crear su propia instancia** | `root:sergio 2775` (setgid) |
|
||
| `~/.ssh` en la jaula | `ro` ⇒ `ssh` no escribe `known_hosts` | `rw` |
|
||
| `/store` en la jaula | `ro` ⇒ el agente lee y **no puede sellar** | `rw` |
|
||
| `claude` | abría `/opt/takana` siempre | abre `$PWD` |
|
||
| `/usr/bin/claude` | **copia** congelada del guion | enlace al del repo |
|
||
|
||
⚠ **Y queda un defecto de verdad, que hoy bloquea el punto 2**: aprovisionar una instancia nueva
|
||
muere con `bwrap: Can't mkdir parents for /run/user/0: Permission denied`. La causa está medida:
|
||
**esta caja no tiene `/run/user`** —no hay logind— y `qorpa.rs:1542` arma la ruta del runtime dir
|
||
como `/run/user/<uid>` sin alternativa. `XDG_RUNTIME_DIR` no alcanza: lo honra otra rama del código
|
||
(`1244`/`1549`), no ésta. Es arreglo en el crate, no en un manifiesto.
|
||
|
||
> **Lo que conviene no confundir.** La jaula restringe al **agente**, no a la persona: `sergio` tiene
|
||
> `sudo`, entra por ssh y manda en la caja entera. Y dentro de la jaula el alcance es **declarativo**
|
||
> (D7): lo que no está en `grants` no se ve, pero agregarlo es editar un fichero, no pedir permiso.
|
||
> Lo indefendible no es que haya límites — es que un límite **accidental** parezca de diseño, que es
|
||
> exactamente lo que pasó con los cinco de la tabla.
|
||
|
||
### 6.51 🔥 Me dejé afuera de la caja — 20 minutos caída, y tres causas encadenadas *(2026-09-17)*
|
||
|
||
Crear una cuenta de usuario tiró el servidor de producción. Queda escrito entero porque **ninguna de
|
||
las tres causas era la que parecía**, y las tres siguen ahí para quien venga.
|
||
|
||
#### 1. `/etc/shadow` no existía, y crearlo cerró el SSH de root
|
||
|
||
La caja nunca tuvo `/etc/shadow`: todas las cuentas llevan `x` en `/etc/passwd` y entran por llave.
|
||
Para darle contraseña a `sergio` creé el fichero con **todas las cuentas bloqueadas (`!`)** y la suya
|
||
con su hash. Efecto inmediato: **`ssh` de root empezó a dar `Permission denied (publickey)`** con la
|
||
misma llave de siempre. Este `sshd` **rechaza la llave de una cuenta cuyo hash está bloqueado**.
|
||
|
||
⇒ La forma correcta acá es la vieja: **el hash va en el campo 2 de `/etc/passwd`** y no hay
|
||
`/etc/shadow`. Así quedó, y así funciona `su - sergio`.
|
||
|
||
#### 2. La caja NO reinicia por ACPI
|
||
|
||
Con el SSH cerrado, `hcloud server reboot` (ACPI) **no hizo absolutamente nada**: arje-zero no
|
||
atiende esa señal. La web siguió sirviendo como si nada. Para un mantenimiento remoto eso importa:
|
||
**la única forma de reiniciar esta caja desde fuera es `reset`, que es un corte de energía.**
|
||
|
||
#### 3. Y el corte de energía dejó el cortafuegos a medio escribir — eso fue lo que la aisló
|
||
|
||
Tras el `reset` la caja **arrancaba** —los logs de arje lo probaban: gitea y caddy escribiendo— pero
|
||
**nadie llegaba**. La causa, encontrada mirando el fichero:
|
||
|
||
```
|
||
$ head -c 90 /etc/takana/cortafuegos-entrada.nft | od -c
|
||
0000000 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
|
||
```
|
||
|
||
**El reglaset era TODO NULs.** Se había escrito minutos antes del corte: ext4 journaló el tamaño y
|
||
los datos nunca llegaron al disco. Un fichero de 5 KB de ceros — o, peor y más probable en el primer
|
||
arranque, **medio fichero válido**: la tabla con `policy drop` y las reglas de `accept` cortadas.
|
||
Eso es exactamente una caja que arranca, corre todo, y no deja entrar a nadie.
|
||
|
||
⇒ **Después de escribir algo que el arranque necesita, `sync` ANTES de cualquier `reset`.** Y el
|
||
corolario amargo: un `reset` no es un `reboot`, y lo que se perdió no avisa.
|
||
|
||
#### Lo que el rodeo destapó, y vale más que el susto
|
||
|
||
- **El cortafuegos NO se estaba aplicando en ningún arranque.** Su card sale 78 si `nft -c` falla, y
|
||
con el fichero corrupto fallaba siempre: la caja llevaba días **arrancando sin filtrar**, en
|
||
silencio, porque «no aplicar nada» es su modo seguro y nadie mira un 78 en un log de boot.
|
||
Reparado el fichero, ahora el arranque deja **21 reglas `dport`**, comprobado tras un reinicio.
|
||
- **`netup` es DHCP-only y falló dos veces seguidas** («sin respuesta DHCP (tipo 2)»). Una caja con
|
||
IP fija no debería depender de que el DHCP conteste: si no contesta no hay segundo intento, y el
|
||
síntoma —desde fuera— es idéntico al de una máquina que no bootea. Se le añadió al init una
|
||
**dirección estática de respaldo** con la forma que pide Hetzner (IP `/32` + puerta `scope link`).
|
||
- **Esa red de respaldo no corría**: su guarda era `[ -d /sys/class/net/eth0 ]` y el init **no monta
|
||
`/sys`**. El diagnóstico que lo probó tuvo que escribirse a FICHERO, porque `/dev/kmsg` no
|
||
sobrevive al reinicio y sin red no hay forma de mirar. *Cuando no hay red, el log tiene que estar
|
||
en el disco.*
|
||
- Del rescate salió el mapa real de particiones, que no era el que yo creía:
|
||
`sda2 hammer-root` · `sda3 hammer-state` · `sda4 hammer-work` · `sdb hammer-store`.
|
||
|
||
#### El costo, dicho sin adornos
|
||
|
||
**~20 minutos de caída** de los ocho dominios, cuatro ciclos rescate↔sistema y tres `reset` duros —
|
||
uno de ellos, el que corrompió el fichero, disparado por mí a los 400 s de espera **mientras la caja
|
||
estaba haciendo su trabajo**. La lección que queda no es «hay que tener cuidado»: es que **en esta
|
||
caja el diagnóstico remoto cuesta un ciclo de 6 a 8 minutos**, así que conviene gastar el primero en
|
||
dejar evidencia en disco antes que en probar una corazonada.
|
||
|
||
## 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; `tawasuyu.net` y
|
||
`gioser.net` mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).
|
||
|
||
**El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista:
|
||
queda UN solo vhost sirviéndose desde gioser — `sergio.gioser.net` — y el montón de daemons propios
|
||
del usuario que ningún paquete provee.** Los pasos 6 y 7 son lo único que sigue abierto.
|
||
|
||
|
||
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.
|