arjectl en la imagen: relanzar en caliente — y el genesis NO alcanzaba para eso

El servicio que falla y agota su backoff sólo se podía recuperar reiniciando la máquina entera.
Ya no. Y no hubo que escribir nada: **el cliente existía en tawasuyu y el crate se llama `arje-ctl`**
(el binario, `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí salió mi conclusión falsa
de que había que implementarlo — el protocolo ya traía ListEntes, SpawnCardFromDisk,
StopCardFromDisk, KillEnte y EnteStatus.

`recipes/arjectl.toml` lo construye del MISMO commit que `arje-zero` (98db584f), y no por comodidad:
el bus es un protocolo entre dos binarios y un cliente de otro árbol puede conectar sin entenderse
con el init. Publica sólo `arjectl`; el crate también produce un `systemctl` de camuflaje que acá no
se instala — en una distro sin systemd, ese nombre en el PATH invita a escribir runbooks con el
verbo ajeno.

⚠ EL HUECO QUE SÓLO SE VE USÁNDOLO: el genesis de la seed dice qué arranca AL BOOT, pero
`start`/`restart` usan `SpawnCardFromDisk`, que lee `/etc/arje/cards.d/<label>.json` — y el armado
no lo escribía:

    $ arjectl start gitea
    Error: arje-zero rechazó: card gitea: No such file or directory
           (buscada en /etc/arje/cards.d/gitea.json)

Son dos preguntas distintas —qué arranca solo, y qué se puede encarnar a pedido— y arje las responde
desde sitios distintos. `inyectar-cards.py` escribe ahora los dos árboles, y en `cards.d` escribe
TODAS las cards, 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 `cards.d/{gitea,sshd}.json` · `list-units`
da PID/CPU/MEM/HILOS/reinicios · poner el `app.ini` + `arjectl start gitea` ⇒ **GET / 200 sin
reiniciar** (uptime 6 min) · `arjectl restart gitea` cambia el PID (118 → 192) y sigue en 200.

⚠ Y un aviso que costó un HTTP intermitente: **`arjectl start` sobre un Ente YA VIVO lo DUPLICA** —
`SpawnCardFromDisk` no deduplica por label y arje le da un ULID nuevo. En gitea el síntoma fue
`unable to lock level db … resource temporarily unavailable` y un `[F]`: dos servidores peleando por
el mismo estado. Para relanzar se usa `restart`, o se mira `list-units` antes. Un `start` idempotente
es trabajo de arje, no de esta imagen.

⚠ Deuda anotada, no barrida: `arjectl` va en `perfil.servidor` porque este frente es el que lo pagó.
TODA imagen de takana corre arje-zero como PID 1 y ninguna se puede operar sin él ⇒ el argumento
para subirlo a `base` es fuerte, y es una línea. Se deja como decisión.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
This commit is contained in:
Sergio
2026-09-14 17:59:11 +00:00
co-authored by Claude Opus 5
parent 8054119429
commit fd09ca77b0
5 changed files with 122 additions and 1 deletions
+46
View File
@@ -1042,6 +1042,52 @@ Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para
intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en 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. 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.
## 7. Reusar los scripts que ya existen, y no escribir de nuevo ## 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: Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
+8
View File
@@ -1300,6 +1300,14 @@ paquetes = [
# `tmux`: un build largo por SSH que muere con la sesión es un build perdido. En una caja sin # `tmux`: un build largo por SSH que muere con la sesión es un build perdido. En una caja sin
# pantalla es infraestructura, no comodidad. # pantalla es infraestructura, no comodidad.
"tmux", "tmux",
# `arjectl` (2026-09-14): el control en caliente del init. Sin esto una caja sólo se administra
# REINICIÁNDOLA, y se descubrió pagándolo: en la primera imagen con gitea, la card falló por falta
# de config, el backoff de `restart` se agotó y no había forma de pedirle a PID 1 que reintentara
# (SDD 28 §6.11). `status`, `list-units`, `start`, `stop`, `restart` sobre el bus de arje.
# ⚠ Va acá y NO en `base` sólo porque este frente es el que lo pagó; el argumento para subirlo es
# fuerte —TODA imagen de takana corre arje-zero como PID 1 y ninguna se puede operar sin él— y es
# una línea. Se deja como decisión a tomar, no como olvido.
"arjectl",
# `netup` (2026-09-10): el cliente DHCP propio que levanta la red al arrancar. Entra como RAÍZ # `netup` (2026-09-10): el cliente DHCP propio que levanta la red al arrancar. Entra como RAÍZ
# aunque su binario YA venga dentro del `product-rootfs`, y por una razón concreta: el del producto # aunque su binario YA venga dentro del `product-rootfs`, y por una razón concreta: el del producto
# está congelado en el artefacto sellado del bootstrap, así que un arreglo de netup no llega nunca # está congelado en el artefacto sellado del bootstrap, así que un arreglo de netup no llega nunca
+40
View File
@@ -0,0 +1,40 @@
# arjectl — el control en caliente del init. Sin esto, una caja de takana sólo se administra
# REINICIÁNDOLA, y eso se descubrió del peor modo: la primera imagen del perfil `servidor` levantó
# gitea, la card falló por falta de config, el backoff de `restart` se agotó y **no había forma de
# pedirle a PID 1 que volviera a intentarlo**. Hubo que reiniciar la máquina entera para arrancar un
# servicio (SDD 28 §6.11).
#
# ══ NO SE ESCRIBIÓ NADA: YA EXISTÍA, Y SE LLAMA `arje-ctl` ═════════════════════════════════════
# El protocolo del bus (`arje-bus::BusRequest`) ya trae `ListEntes`, `SpawnCardFromDisk`,
# `StopCardFromDisk`, `KillEnte` y `EnteStatus`; lo que faltaba en la imagen era el CLIENTE, que vive
# en `03_ukupacha/arje/runtime/arje-ctl` y da `status`, `list-units`, `start`, `stop` y `restart`.
# Buscarlo por `arjectl` no lo encontraba —el crate es `arje-ctl`, el binario `arjectl`— y de ahí
# salió la conclusión falsa de que había que escribirlo.
#
# ══ EL COMMIT ES EL MISMO QUE `arje-zero`, Y NO ES POR COMODIDAD ═══════════════════════════════
# El bus es un PROTOCOLO entre dos binarios: un cliente de otro commit puede hablarle a un init que
# ya no entiende lo mismo, y el síntoma sería un `arjectl` que conecta y no obtiene respuesta. Que
# `arje-zero`, `arje-polkit-compat` y éste salgan del mismo árbol es la garantía de que el cliente y
# el init fueron compilados contra la MISMA definición. De paso reusa el árbol de fuentes ya
# fetcheado (ADR 0012).
#
# ⚠ El crate publica DOS binarios: `arjectl` (soberano) y `systemctl` (drop-in de camuflaje, que
# reenvía al systemd real si systemd es el init vivo). Acá se publica **sólo `arjectl`**: la distro
# no tiene systemd y un `systemctl` en el PATH invitaría a escribir runbooks con el verbo ajeno.
# Ponerlo sería una decisión de compatibilidad, no un efecto colateral de la receta.
name = "arjectl"
version = "0.0.1"
license = "MIT"
[source]
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
commit = "98db584fd28d9ff2bf34c9cf7d7db2e1de0b3fd0"
# Este árbol COMMITEA su propio `vendor/` y el `cargo vendor` de takana lo pisaría — igual que en
# `arje-zero.toml`. Ver [[takana-cargo-vendor-clobbers-project-vendor]].
cargo_vendor_dir = ".hammer-cargo-vendor"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = ["-p", "arje-ctl", "--bin", "arjectl"]
+27
View File
@@ -7,9 +7,23 @@
# #
# NO genera las Cards: las recibe ya emitidas por `takana service-cards`, que es la única # NO genera las Cards: las recibe ya emitidas por `takana service-cards`, que es la única
# implementación de la traducción `[[service]]` → Card. Acá sólo se COMPONE la seed. # implementación de la traducción `[[service]]` → Card. Acá sólo se COMPONE la seed.
#
# ── LAS CARDS VAN A DOS SITIOS, Y HACEN FALTA LOS DOS (2026-09-14) ──────────────────────────────
# El `genesis` de la seed dice qué arranca AL BOOT. `arjectl start|restart <label>` no lee de ahí:
# usa `SpawnCardFromDisk`, que carga `/etc/arje/cards.d/<label>.json`. Medido en la imagen del
# perfil `servidor`:
#
# $ arjectl start gitea
# Error: arje-zero rechazó: card gitea: No such file or directory
# (buscada en /etc/arje/cards.d/gitea.json)
#
# O sea: con sólo el genesis, un servicio que muere tras agotar su backoff **no se puede relanzar
# sin reiniciar la máquina**. Escribir las dos no es duplicar: son dos preguntas distintas —qué
# arranca solo, y qué se puede encarnar a pedido— y arje las responde desde sitios distintos.
import json, os, sys import json, os, sys
seed_p, cards_p = sys.argv[1], sys.argv[2] seed_p, cards_p = sys.argv[1], sys.argv[2]
cards_dir = sys.argv[3] if len(sys.argv) > 3 else None
seed = json.load(open(seed_p)) seed = json.load(open(seed_p))
cards = json.load(open(cards_p)) cards = json.load(open(cards_p))
@@ -28,3 +42,16 @@ os.chmod(tmp, 0o644)
# read-only al artefacto del store, y escribirle encima mutaría el store. # read-only al artefacto del store, y escribirle encima mutaría el store.
os.replace(tmp, seed_p) os.replace(tmp, seed_p)
print(" cards en el genesis:", ", ".join(puestos) or "(ninguna nueva)") print(" cards en el genesis:", ", ".join(puestos) or "(ninguna nueva)")
# El store en disco que `arjectl` consulta. Se escriben TODAS, no sólo las nuevas: una card que ya
# estaba en el genesis (p. ej. `sshd`, que viene del product-rootfs) tampoco es relanzable sin esto.
if cards_dir:
os.makedirs(cards_dir, exist_ok=True)
for c in cards:
dst = os.path.join(cards_dir, f"{c['label']}.json")
tmp = dst + ".new"
with open(tmp, "w") as f:
json.dump(c, f, indent=2)
os.chmod(tmp, 0o644)
os.replace(tmp, dst) # `replace`, no `write`: el árbol fundido hardlinkea al store
print(" cards en", cards_dir + ":", ", ".join(c["label"] for c in cards))
+1 -1
View File
@@ -114,7 +114,7 @@ if [ -n "$SVCPATHS" ]; then
# shellcheck disable=SC2086 # shellcheck disable=SC2086
"$TAKANA" --store "$STORE" service-cards $SVCPATHS > "$WORKDIR/cards.json" "$TAKANA" --store "$STORE" service-cards $SVCPATHS > "$WORKDIR/cards.json"
echo "==> cards : $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORKDIR/cards.json") card(s) de $(echo "$SVCPATHS" | wc -l) receta(s)" echo "==> cards : $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORKDIR/cards.json") card(s) de $(echo "$SVCPATHS" | wc -l) receta(s)"
python3 scripts/gnome/inyectar-cards.py "$RFS/ente/seed.card.json" "$WORKDIR/cards.json" python3 scripts/gnome/inyectar-cards.py "$RFS/ente/seed.card.json" "$WORKDIR/cards.json" "$RFS/etc/arje/cards.d"
else else
echo "==> cards : el perfil no arranca ningún servicio de paquete" echo "==> cards : el perfil no arranca ningún servicio de paquete"
fi fi