gioser

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 1340Shell 95Rust 63Markdown 571634 Sutichaykuna

⎇ 78e0c126cb3997c96da338d928bd4490b379ecd7 scripts/mudanza/declarar.py Python · 232 siq'ikuna · 12.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano 68fdd9eb7 · Sergio ·

1#!/usr/bin/env python3
2"""declarar.py — convierte los servicios de un censo en una SEMILLA de arje (SDD 29 etapa 3).
3
4El paso que cierra la mudanza. Copiar un binario no lo levanta al arrancar: hay que DECLARARLO. Esto
5toma lo que el censo leyó de `/proc` —la invocación real— y emite `seed.card.json`.
6
7── POR QUÉ NO ALCANZA CON `arje-absorb` ────────────────────────────────────────────────────────
8`arje-absorb` ya traduce sysvinit/runit/dinit/openrc a una Semilla, y está bien hecho. Pero lee **la
9declaración del init ajeno**, que es exactamente la que miente: en gioser `rc-status` daba `stopped`
10para cinco servicios que estaban vivos, y los que NADIE declara —los `no-declarado`, que son el
11trabajo real— no aparecen en ninguna. Absorber la declaración reproduce el agujero.
12
13Esto lee lo VIVO. Los dos se complementan: absorb para lo declarado, esto para lo que corre.
14
15── LAS TRES REGLAS ─────────────────────────────────────────────────────────────────────────────
161. **Un servicio sin `cmdline` legible NO se emite**, y se dice. Una tarjeta que no puede arrancar
17   es peor que una tarjeta ausente: la ausente falla ruidosamente al primer arranque, la rota
18   arranca el sistema y deja el servicio caído sin que nada avise.
192. **El `cwd` se preserva envolviendo**, porque el payload `Native` de arje acepta `exec`/`argv`/
20   `envp` y **NO `cwd`** (comprobado sobre la semilla real). Un servicio que depende de su
21   directorio —como `puerta-f6e393ff`, que corre con `cwd=/mnt/vvv/tawasuyu/shared/tejido`— arrancaría
22   en `/` y fallaría de un modo difícil de atribuir. Se envuelve en `sh -c 'cd … && exec …'`, que es
23   el idioma que la propia tarjeta de `sshd` ya usa.
243. **Los IDs son DETERMINISTAS**, derivados del nombre. El mismo censo produce la misma semilla byte
25   a byte, así que dos corridas se pueden comparar con `diff` — que es lo que vuelve revisable un
26   fichero generado.
27
28⚠ NO se copia el entorno. El censo no lo lee (trae tokens), así que un servicio que dependa de
29variables de entorno va a necesitar que alguien las escriba. Se avisa por nombre.
30
31Uso:
32    scripts/mudanza/declarar.py --censo censo.toml [--label mi-servidor] --out seed.card.json
33"""
34import argparse, hashlib, json, os, shlex, sys, tomllib
35
36sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37from planear import origen_binario, alternativas, _REPO   # noqa: E402
38
39CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"   # sin I, L, O, U
40
41
42def ulid_determinista(sem, nombre):
43    """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real
44    —no hace falta— pero respeta el formato, y ser determinista es lo que permite `diff`."""
45    h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest()
46    n = int.from_bytes(h, "big")
47    out = []
48    for _ in range(26):
49        out.append(CROCKFORD[n & 31])
50        n >>= 5
51    s = "".join(reversed(out))
52    return "0" + s[1:]          # el primer carácter de un ULID válido es <= '7'
53
54
55def tarjeta(nombre, cmdline, cwd, puertos):
56    """Una entrada `genesis`. El molde sale de la semilla real del producto."""
57    argv_all = shlex.split(cmdline)
58    if not argv_all:
59        return None
60    if cwd and cwd not in ("/", ""):
61        # Regla 2: `Native` no tiene `cwd`. Se envuelve, igual que la tarjeta de sshd del producto.
62        exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(cwd)} && exec {cmdline}"]
63    else:
64        exec_, argv = argv_all[0], argv_all[1:]
65    return {
66        "flow": {"input": [], "output": []},
67        "genesis": [],
68        "id": ulid_determinista("takana-mudanza", nombre),
69        "label": nombre,
70        "lifecycle": "daemon",
71        "lineage": None,
72        "payload": {"Native": {"argv": argv, "envp": [], "exec": exec_}},
73        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
74                        "networking": "full", "processes": True},
75        "priority": "normal",
76        "provides": [],
77        "requires": [],
78        "schema_version": 1,
79        "soma": {
80            "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{nombre}"},
81            "cpu_affinity": None,
82            "namespaces": {k: False for k in
83                           ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
84            "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
85        },
86        "supervision": {"Restart": {"initial": 500, "max": 20000}},
87        "_mudanza": {"puertos": puertos, "cwd_original": cwd},
88    }
89
90
91def perfil(censo, label, repo):
92    """Emite un `[perfil.<label>]` para `docs/state/targets.toml`.
93
94    ── POR QUÉ UN PERFIL Y NO UNA LISTA DE `install` ────────────────────────────────────────────
95    Mudar servicio por servicio a una caja viva es lo que uno haría en cualquier distro, y en takana
96    es ir contra el diseño: **el software de una máquina se DECLARA en `targets.toml` y viene en la
97    imagen**, reproducible y firmado. Un servidor armado a fuerza de instalaciones sueltas no se
98    puede volver a construir — que es exactamente el problema que esta mudanza existe para no repetir
99    (el caddy puesto a mano en gioser no tiene dueño ni receta: se pierde con la máquina).
100
101    Además hay un impedimento medido: `takana install` REPRODUCE desde fuente, y eso exige el LAB
102    entero en el cliente (SDD 28 §5.4). Una caja de destino no lo tiene. El perfil esquiva el
103    problema por el lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.
104
105    Lo que el perfil NO puede cubrir se lista aparte, con su motivo. Un perfil que se calla lo que le
106    falta sale N/N y describe un servidor incompleto — la lección de `foot` en `escritorio-sway`."""
107    raices, sin_receta = {}, []
108    for s_ in censo.get("servicio", []):
109        if s_.get("decision") != "muda":
110            continue
111        elegido = s_.get("alternativa") or s_["name"]
112        clase, detalle = origen_binario(dict(s_, name=elegido), repo)
113        if clase == "receta-takana":
114            import re as _re
115            m = _re.search(r'`recipes/([^`]+)\.toml`', detalle)
116            # Se agrupa POR RECETA, no por servicio: dos servicios pueden necesitar la misma
117            # (uvicorn y python3 son los dos `python3`), y repetir una raíz en un perfil no la
118            # duplica — sólo ensucia y hace creer que son dos cosas distintas.
119            receta = m.group(1) if m else elegido
120            raices.setdefault(receta, []).append((s_["name"], tuple(s_.get("ports", []))))
121        else:
122            sin_receta.append((s_["name"], clase, detalle))
123
124    L = [f"[perfil.{label}]",
125         f'descripcion = "servicios mudados desde {censo.get("maquina",{}).get("hostname","?")} '
126         f'— generado por scripts/mudanza/declarar.py"',
127         'hereda = ["base"]',
128         "# Generado desde un CENSO, no escrito a mano: cada raíz está acá porque un servicio que",
129         "# CORRÍA en la máquina vieja la necesita, y takana tiene receta para construirla.",
130         "paquetes = ["]
131    for receta in sorted(raices):
132        quienes = raices[receta]
133        nombres = ", ".join(sorted({q[0] for q in quienes}))
134        puertos = sorted({p for q in quienes for p in q[1]})
135        det = nombres + (f" · puertos {','.join(str(x) for x in puertos)}" if puertos else "")
136        nota = f"  # {det}" if det and det != receta else ""
137        L.append(f'  "{receta}",{nota}')
138    L.append("]")
139    return "\n".join(L), sin_receta
140
141
142def main():
143    ap = argparse.ArgumentParser(description="Genera una Semilla de arje desde un censo decidido.")
144    ap.add_argument("--censo", required=True)
145    ap.add_argument("--label", default="takana-servidor", help="etiqueta de la Semilla raíz")
146    ap.add_argument("--out", help="fichero de salida (default: stdout)")
147    ap.add_argument("--perfil-out", help="además, escribir un [perfil.<label>] para targets.toml")
148    a = ap.parse_args()
149
150    with open(a.censo, "rb") as f:
151        censo = tomllib.load(f)
152
153    hijas, sin_cmdline, envueltos = [], [], []
154    for s in censo.get("servicio", []):
155        if s.get("decision") != "muda":
156            continue
157        cmd = (s.get("cmdline") or "").strip()
158        if not cmd:
159            # Regla 1: no se inventa una invocación.
160            sin_cmdline.append(s["name"])
161            continue
162        t = tarjeta(s["name"], cmd, (s.get("cwd") or "").strip(), s.get("ports", []))
163        if t is None:
164            sin_cmdline.append(s["name"])
165            continue
166        if t["payload"]["Native"]["exec"] == "/bin/sh":
167            envueltos.append(s["name"])
168        hijas.append(t)
169
170    hijas.sort(key=lambda t: t["label"])    # orden estable ⇒ `diff` útil
171    semilla = {
172        "flow": {"input": [], "output": []},
173        "genesis": hijas,
174        "id": ulid_determinista("takana-mudanza-raiz", a.label),
175        "label": a.label,
176        "lifecycle": "daemon",
177        "lineage": None,
178        "payload": "Virtual",
179        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
180                        "networking": "full", "processes": True},
181        "priority": "normal",
182        "provides": ["Spawn", "Journal"],
183        "requires": [],
184        "schema_version": 1,
185        "soma": {
186            "cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
187            "cpu_affinity": None,
188            "namespaces": {k: False for k in
189                           ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
190            "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
191        },
192        "supervision": {"Restart": {"initial": 500, "max": 20000}},
193    }
194
195    txt = json.dumps(semilla, indent=1, sort_keys=True) + "\n"
196    print(f"\n══ SEMILLA '{a.label}' ══")
197    print(f"   {len(hijas)} servicio(s) declarados")
198    if envueltos:
199        print(f"   {len(envueltos)} envuelto(s) en `sh -c 'cd … && exec …'` porque dependen de su "
200              f"directorio\n     (el payload Native de arje no tiene `cwd`): {' '.join(envueltos)}")
201    if sin_cmdline:
202        print(f"\n   ⚠ {len(sin_cmdline)} servicio(s) NO se declararon: no se pudo leer su cmdline.")
203        print(f"     {' '.join(sin_cmdline)}")
204        print("     Una tarjeta que no puede arrancar es PEOR que una ausente: la ausente falla")
205        print("     al primer arranque, la rota deja el servicio caído sin que nada avise.")
206    print("   ⚠ El entorno (`envp`) va VACÍO: el censo no lee /proc/<pid>/environ porque trae")
207    print("     tokens. Un servicio que dependa de variables va a necesitar que alguien las escriba.")
208    if a.perfil_out:
209        bloque, sin_receta = perfil(censo, a.label, _REPO)
210        open(a.perfil_out, "w").write(bloque + "\n")
211        n_r = len([l for l in bloque.splitlines() if l.strip().startswith('"')])
212        print(f"\n══ PERFIL '{a.label}' ══")
213        print(f"   {n_r} receta(s) que takana YA construye → {a.perfil_out}")
214        print("   Pegalo en docs/state/targets.toml y armá la imagen: el software del servidor nuevo")
215        print("   se DECLARA, no se instala a mano. Además `takana install` reproduce desde fuente y")
216        print("   eso exige el lab entero en el cliente — el perfil lo resuelve en el hub, que sí lo tiene.")
217        if sin_receta:
218            print(f"\n   ⚠ {len(sin_receta)} servicio(s) que el perfil NO puede cubrir:")
219            for nom, cl, det in sin_receta:
220                print(f"     {nom:22} [{cl}] {det[:84]}")
221            print("     Un perfil que se calla lo que le falta sale N/N describiendo un servidor")
222            print("     incompleto. Para cada uno: escribir la receta, elegir un equivalente que sí")
223            print("     esté, o llevar el binario a mano sabiendo que no se reproduce.")
224    if a.out:
225        open(a.out, "w").write(txt)
226        print(f"\n   semilla → {a.out}")
227    else:
228        print(txt)
229
230
231if __name__ == "__main__":
232    main()