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…
⎇ 78e0c126cb3997c96da338d928bd4490b379ecd7
scripts/mudanza/declarar.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano
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()