tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1182Shell 87Rust 63Markdown 541592 Definiciones

mudanza etapa 3: `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca

Copiar un binario no lo levanta al arrancar. Esto toma la invocación que el censo leyó de `/proc` y
emite `seed.card.json`. Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos**, y
la tarjeta de caddy sale con su invocación real — exactamente lo que faltaba para que no muriera en
el próximo reinicio.

Por qué no alcanza con `arje-absorb`: absorb lee la DECLARACIÓN del init ajeno, que es justo la que
miente (`rc-status` daba `stopped` para cinco servicios vivos), y los `no-declarado` no aparecen en
ninguna declaración por definición. Absorber la declaración reproduce el agujero. Se complementan.

Tres reglas:
1. Un servicio sin `cmdline` legible NO se emite y se dice: una tarjeta que no arranca es peor que
   una ausente — la ausente falla ruidosamente, la rota deja el servicio caído en silencio.
2. El `cwd` se preserva ENVOLVIENDO, porque el payload `Native` acepta `exec`/`argv`/`envp` y no
   `cwd` (comprobado sobre la semilla real). 9 de 37 servicios de gioser dependen de su directorio.
3. IDs deterministas ⇒ misma entrada, misma semilla BYTE A BYTE (verificado con sha256). Un fichero
   generado que cambia en cada corrida no se puede revisar con `diff`.

**Y un bug del censo que costaba el 70 % del dato**: `cmdline` y `cwd` se leían en un solo comando, y
como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el comando
entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`. 26 de 37 servicios quedaban sin invocación.
Sondas separadas. **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

1a89592b7252782a72e323ba6c89f0b862126600 · por Sergio · (hace 1 mes) · padre 6f12c231f · ver el árbol en este commit

Delta semántico · 4

scripts/mudanza/censar.py

scripts/mudanza/declarar.py

3 archivos · +215 −7

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 2cc16e74..44e1510e 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -112,7 +112,46 @@ memoria. Ahora está escrito.
 ⚠ Se lee `cmdline` y `cwd`, **nunca `environ`**: el entorno suele traer tokens y contraseñas, y un
 censo que se guarda en un fichero y se comparte no puede llevarlos.
 
-## 4. Etapa 3 — la aplicación *(pendiente)*
+## 4. Etapa 3 — declarar y aplicar *(`declarar.py` implementado)*
+
+### 4.0 `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca
+
+Es el paso que cierra la mudanza: **copiar un binario no lo levanta al arrancar**. Toma la
+invocación que el censo leyó de `/proc` y emite `seed.card.json`.
+
+**Por qué no alcanza con `arje-absorb`**: absorb traduce sysvinit/runit/dinit/openrc a una Semilla y
+está bien hecho, pero lee **la declaración del init ajeno** — justo la que miente. En gioser
+`rc-status` daba `stopped` para cinco servicios vivos, y los `no-declarado` no aparecen en ninguna
+declaración por definición. Absorber la declaración reproduce el agujero. Los dos se complementan:
+absorb para lo declarado, esto para lo que corre.
+
+Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos.** La tarjeta de caddy sale
+con su invocación real (`/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile`), que
+es exactamente lo que faltaba para que no muriera en el próximo reinicio.
+
+**Tres reglas:**
+
+1. **Un servicio sin `cmdline` legible NO se emite**, y se dice por su nombre. Una tarjeta que no
+   puede arrancar es peor que una ausente: la ausente falla ruidosamente al primer arranque, la rota
+   arranca el sistema y deja el servicio caído sin que nada avise.
+2. **El `cwd` se preserva envolviendo.** El payload `Native` de arje acepta `exec`/`argv`/`envp` y
+   **no `cwd`** (comprobado sobre la semilla real del producto). 9 de los 37 servicios de gioser
+   dependen de su directorio; sin envolver arrancarían en `/` y fallarían de un modo difícil de
+   atribuir. Se emite `sh -c 'cd … && exec …'`, el idioma que la propia tarjeta de `sshd` ya usa.
+3. **IDs deterministas** derivados del nombre ⇒ el mismo censo produce la misma semilla **byte a
+   byte** (verificado con `sha256`), así que dos corridas se comparan con `diff`. Un fichero
+   generado que cambia en cada corrida no se puede revisar.
+
+⚠ **`envp` va vacío a propósito**: el censo no lee `/proc/<pid>/environ` porque trae tokens. Un
+servicio que dependa de variables necesita que alguien las escriba, y se avisa.
+
+⚠ **Un bug que costó el 70 % del censo y vale como lección**: `cmdline` y `cwd` se leían en un solo
+comando, y como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el
+comando entero salía ≠0 y se descartaba **también el `cmdline`**, que sí se podía leer. 26 de 37
+servicios quedaban sin invocación —el dato que da sentido al censo— y el fallo se leía como «no se
+pudo». **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**
+
+### 4.1 El aplicador *(pendiente)*
 
 Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
 
diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py
index bd7a8393..e5a3de8d 100755
--- a/scripts/mudanza/censar.py
+++ b/scripts/mudanza/censar.py
@@ -87,12 +87,20 @@ def invocaciones(t, pids):
     out = {}
     for nombre, ps in pids.items():
         pid = ps[0]
-        r, ok = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null; echo; "
-                      f"readlink /proc/{pid}/cwd 2>/dev/null")
-        if ok:
-            ls = r.split("\n")
-            out[nombre] = {"cmdline": (ls[0] if ls else "").strip(),
-                           "cwd": (ls[1] if len(ls) > 1 else "").strip()}
+        # ⚠ SONDAS SEPARADAS, y es un bug pagado: iban en un solo comando, y como `readlink
+        # /proc/<pid>/cwd` necesita permiso de ptrace (dueño o root), en cualquier proceso de root
+        # el comando entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`, que sí se podía leer.
+        # Resultado: 26 de 37 servicios sin invocación, justo el dato que da sentido al censo.
+        # Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
+        cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
+        cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
+        cmd, cwd = cmd.strip(), cwd.strip()
+        if cmd.startswith("(sonda falló"):
+            cmd = ""
+        if cwd.startswith("(sonda falló"):
+            cwd = ""
+        if cmd or cwd:
+            out[nombre] = {"cmdline": cmd, "cwd": cwd}
     return out
 
 
diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py
new file mode 100755
index 00000000..a64a42bd
--- /dev/null
+++ b/scripts/mudanza/declarar.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""declarar.py — convierte los servicios de un censo en una SEMILLA de arje (SDD 29 etapa 3).
+
+El paso que cierra la mudanza. Copiar un binario no lo levanta al arrancar: hay que DECLARARLO. Esto
+toma lo que el censo leyó de `/proc` —la invocación real— y emite `seed.card.json`.
+
+── POR QUÉ NO ALCANZA CON `arje-absorb` ────────────────────────────────────────────────────────
+`arje-absorb` ya traduce sysvinit/runit/dinit/openrc a una Semilla, y está bien hecho. Pero lee **la
+declaración del init ajeno**, que es exactamente la que miente: en gioser `rc-status` daba `stopped`
+para cinco servicios que estaban vivos, y los que NADIE declara —los `no-declarado`, que son el
+trabajo real— no aparecen en ninguna. Absorber la declaración reproduce el agujero.
+
+Esto lee lo VIVO. Los dos se complementan: absorb para lo declarado, esto para lo que corre.
+
+── LAS TRES REGLAS ─────────────────────────────────────────────────────────────────────────────
+1. **Un servicio sin `cmdline` legible NO se emite**, y se dice. Una tarjeta que no puede arrancar
+   es peor que una tarjeta ausente: la ausente falla ruidosamente al primer arranque, la rota
+   arranca el sistema y deja el servicio caído sin que nada avise.
+2. **El `cwd` se preserva envolviendo**, porque el payload `Native` de arje acepta `exec`/`argv`/
+   `envp` y **NO `cwd`** (comprobado sobre la semilla real). Un servicio que depende de su
+   directorio —como `puerta-f6e393ff`, que corre con `cwd=/mnt/vvv/tawasuyu/shared/tejido`— arrancaría
+   en `/` y fallaría de un modo difícil de atribuir. Se envuelve en `sh -c 'cd … && exec …'`, que es
+   el idioma que la propia tarjeta de `sshd` ya usa.
+3. **Los IDs son DETERMINISTAS**, derivados del nombre. El mismo censo produce la misma semilla byte
+   a byte, así que dos corridas se pueden comparar con `diff` — que es lo que vuelve revisable un
+   fichero generado.
+
+⚠ NO se copia el entorno. El censo no lo lee (trae tokens), así que un servicio que dependa de
+variables de entorno va a necesitar que alguien las escriba. Se avisa por nombre.
+
+Uso:
+    scripts/mudanza/declarar.py --censo censo.toml [--label mi-servidor] --out seed.card.json
+"""
+import argparse, hashlib, json, shlex, sys, tomllib
+
+CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"   # sin I, L, O, U
+
+
+def ulid_determinista(sem, nombre):
+    """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real
+    —no hace falta— pero respeta el formato, y ser determinista es lo que permite `diff`."""
+    h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest()
+    n = int.from_bytes(h, "big")
+    out = []
+    for _ in range(26):
+        out.append(CROCKFORD[n & 31])
+        n >>= 5
+    s = "".join(reversed(out))
+    return "0" + s[1:]          # el primer carácter de un ULID válido es <= '7'
+
+
+def tarjeta(nombre, cmdline, cwd, puertos):
+    """Una entrada `genesis`. El molde sale de la semilla real del producto."""
+    argv_all = shlex.split(cmdline)
+    if not argv_all:
+        return None
+    if cwd and cwd not in ("/", ""):
+        # Regla 2: `Native` no tiene `cwd`. Se envuelve, igual que la tarjeta de sshd del producto.
+        exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(cwd)} && exec {cmdline}"]
+    else:
+        exec_, argv = argv_all[0], argv_all[1:]
+    return {
+        "flow": {"input": [], "output": []},
+        "genesis": [],
+        "id": ulid_determinista("takana-mudanza", nombre),
+        "label": nombre,
+        "lifecycle": "daemon",
+        "lineage": None,
+        "payload": {"Native": {"argv": argv, "envp": [], "exec": exec_}},
+        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
+                        "networking": "full", "processes": True},
+        "priority": "normal",
+        "provides": [],
+        "requires": [],
+        "schema_version": 1,
+        "soma": {
+            "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{nombre}"},
+            "cpu_affinity": None,
+            "namespaces": {k: False for k in
+                           ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
+            "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
+        },
+        "supervision": {"Restart": {"initial": 500, "max": 20000}},
+        "_mudanza": {"puertos": puertos, "cwd_original": cwd},
+    }
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Genera una Semilla de arje desde un censo decidido.")
+    ap.add_argument("--censo", required=True)
+    ap.add_argument("--label", default="takana-servidor", help="etiqueta de la Semilla raíz")
+    ap.add_argument("--out", help="fichero de salida (default: stdout)")
+    a = ap.parse_args()
+
+    with open(a.censo, "rb") as f:
+        censo = tomllib.load(f)
+
+    hijas, sin_cmdline, envueltos = [], [], []
+    for s in censo.get("servicio", []):
+        if s.get("decision") != "muda":
+            continue
+        cmd = (s.get("cmdline") or "").strip()
+        if not cmd:
+            # Regla 1: no se inventa una invocación.
+            sin_cmdline.append(s["name"])
+            continue
+        t = tarjeta(s["name"], cmd, (s.get("cwd") or "").strip(), s.get("ports", []))
+        if t is None:
+            sin_cmdline.append(s["name"])
+            continue
+        if t["payload"]["Native"]["exec"] == "/bin/sh":
+            envueltos.append(s["name"])
+        hijas.append(t)
+
+    hijas.sort(key=lambda t: t["label"])    # orden estable ⇒ `diff` útil
+    semilla = {
+        "flow": {"input": [], "output": []},
+        "genesis": hijas,
+        "id": ulid_determinista("takana-mudanza-raiz", a.label),
+        "label": a.label,
+        "lifecycle": "daemon",
+        "lineage": None,
+        "payload": "Virtual",
+        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
+                        "networking": "full", "processes": True},
+        "priority": "normal",
+        "provides": ["Spawn", "Journal"],
+        "requires": [],
+        "schema_version": 1,
+        "soma": {
+            "cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
+            "cpu_affinity": None,
+            "namespaces": {k: False for k in
+                           ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
+            "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
+        },
+        "supervision": {"Restart": {"initial": 500, "max": 20000}},
+    }
+
+    txt = json.dumps(semilla, indent=1, sort_keys=True) + "\n"
+    print(f"\n══ SEMILLA '{a.label}' ══")
+    print(f"   {len(hijas)} servicio(s) declarados")
+    if envueltos:
+        print(f"   {len(envueltos)} envuelto(s) en `sh -c 'cd … && exec …'` porque dependen de su "
+              f"directorio\n     (el payload Native de arje no tiene `cwd`): {' '.join(envueltos)}")
+    if sin_cmdline:
+        print(f"\n   ⚠ {len(sin_cmdline)} servicio(s) NO se declararon: no se pudo leer su cmdline.")
+        print(f"     {' '.join(sin_cmdline)}")
+        print("     Una tarjeta que no puede arrancar es PEOR que una ausente: la ausente falla")
+        print("     al primer arranque, la rota deja el servicio caído sin que nada avise.")
+    print("   ⚠ El entorno (`envp`) va VACÍO: el censo no lee /proc/<pid>/environ porque trae")
+    print("     tokens. Un servicio que dependa de variables va a necesitar que alguien las escriba.")
+    if a.out:
+        open(a.out, "w").write(txt)
+        print(f"\n   semilla → {a.out}")
+    else:
+        print(txt)
+
+
+if __name__ == "__main__":
+    main()