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…
traducir: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA
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
38from ids import ulid_determinista # noqa: E402
39
40def tarjeta(nombre, cmdline, cwd, puertos):
41 """Una entrada `genesis`. El molde sale de la semilla real del producto."""
42 argv_all = shlex.split(cmdline)
43 if not argv_all:
44 return None
45 if cwd and cwd not in ("/", ""):
46 # Regla 2: `Native` no tiene `cwd`. Se envuelve, igual que la tarjeta de sshd del producto.
47 exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(cwd)} && exec {cmdline}"]
48 else:
49 exec_, argv = argv_all[0], argv_all[1:]
50 return {
51 "flow": {"input": [], "output": []},
52 "genesis": [],
53 "id": ulid_determinista("takana-mudanza", nombre),
54 "label": nombre,
55 "lifecycle": "daemon",
56 "lineage": None,
57 "payload": {"Native": {"argv": argv, "envp": [], "exec": exec_}},
58 "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
59 "networking": "full", "processes": True},
60 "priority": "normal",
61 "provides": [],
62 "requires": [],
63 "schema_version": 1,
64 "soma": {
65 "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{nombre}"},
66 "cpu_affinity": None,
67 "namespaces": {k: False for k in
68 ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
69 "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
70 },
71 "supervision": {"Restart": {"initial": 500, "max": 20000}},
72 "_mudanza": {"puertos": puertos, "cwd_original": cwd},
73 }
74
75
76def perfil(censo, label, repo):
77 """Emite un `[perfil.<label>]` para `docs/state/targets.toml`.
78
79 ── POR QUÉ UN PERFIL Y NO UNA LISTA DE `install` ────────────────────────────────────────────
80 Mudar servicio por servicio a una caja viva es lo que uno haría en cualquier distro, y en takana
81 es ir contra el diseño: **el software de una máquina se DECLARA en `targets.toml` y viene en la
82 imagen**, reproducible y firmado. Un servidor armado a fuerza de instalaciones sueltas no se
83 puede volver a construir — que es exactamente el problema que esta mudanza existe para no repetir
84 (el caddy puesto a mano en gioser no tiene dueño ni receta: se pierde con la máquina).
85
86 Además hay un impedimento medido: `takana install` REPRODUCE desde fuente, y eso exige el LAB
87 entero en el cliente (SDD 28 §5.4). Una caja de destino no lo tiene. El perfil esquiva el
88 problema por el lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.
89
90 Lo que el perfil NO puede cubrir se lista aparte, con su motivo. Un perfil que se calla lo que le
91 falta sale N/N y describe un servidor incompleto — la lección de `foot` en `escritorio-sway`."""
92 raices, sin_receta = {}, []
93 for s_ in censo.get("servicio", []):
94 if s_.get("decision") != "muda":
95 continue
96 elegido = s_.get("alternativa") or s_["name"]
97 clase, detalle = origen_binario(dict(s_, name=elegido), repo)
98 if clase == "receta-takana":
99 import re as _re
100 m = _re.search(r'`recipes/([^`]+)\.toml`', detalle)
101 # Se agrupa POR RECETA, no por servicio: dos servicios pueden necesitar la misma
102 # (uvicorn y python3 son los dos `python3`), y repetir una raíz en un perfil no la
103 # duplica — sólo ensucia y hace creer que son dos cosas distintas.
104 receta = m.group(1) if m else elegido
105 raices.setdefault(receta, []).append((s_["name"], tuple(s_.get("ports", []))))
106 else:
107 sin_receta.append((s_["name"], clase, detalle))
108
109 L = [f"[perfil.{label}]",
110 f'descripcion = "servicios mudados desde {censo.get("maquina",{}).get("hostname","?")} '
111 f'— generado por scripts/mudanza/declarar.py"',
112 'hereda = ["base"]',
113 "# Generado desde un CENSO, no escrito a mano: cada raíz está acá porque un servicio que",
114 "# CORRÍA en la máquina vieja la necesita, y takana tiene receta para construirla.",
115 "paquetes = ["]
116 for receta in sorted(raices):
117 quienes = raices[receta]
118 nombres = ", ".join(sorted({q[0] for q in quienes}))
119 puertos = sorted({p for q in quienes for p in q[1]})
120 det = nombres + (f" · puertos {','.join(str(x) for x in puertos)}" if puertos else "")
121 nota = f" # {det}" if det and det != receta else ""
122 L.append(f' "{receta}",{nota}')
123 L.append("]")
124 return "\n".join(L), sin_receta
125
126
127def main():
128 ap = argparse.ArgumentParser(description="Genera una Semilla de arje desde un censo decidido.")
129 ap.add_argument("--censo", required=True)
130 ap.add_argument("--label", default="takana-servidor", help="etiqueta de la Semilla raíz")
131 ap.add_argument("--out", help="fichero de salida (default: stdout)")
132 ap.add_argument("--perfil-out", help="además, escribir un [perfil.<label>] para targets.toml")
133 a = ap.parse_args()
134
135 with open(a.censo, "rb") as f:
136 censo = tomllib.load(f)
137
138 hijas, sin_cmdline, envueltos = [], [], []
139 for s in censo.get("servicio", []):
140 if s.get("decision") != "muda":
141 continue
142 cmd = (s.get("cmdline") or "").strip()
143 if not cmd:
144 # Regla 1: no se inventa una invocación.
145 sin_cmdline.append(s["name"])
146 continue
147 t = tarjeta(s["name"], cmd, (s.get("cwd") or "").strip(), s.get("ports", []))
148 if t is None:
149 sin_cmdline.append(s["name"])
150 continue
151 if t["payload"]["Native"]["exec"] == "/bin/sh":
152 envueltos.append(s["name"])
153 hijas.append(t)
154
155 hijas.sort(key=lambda t: t["label"]) # orden estable ⇒ `diff` útil
156 semilla = {
157 "flow": {"input": [], "output": []},
158 "genesis": hijas,
159 "id": ulid_determinista("takana-mudanza-raiz", a.label),
160 "label": a.label,
161 "lifecycle": "daemon",
162 "lineage": None,
163 "payload": "Virtual",
164 "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
165 "networking": "full", "processes": True},
166 "priority": "normal",
167 "provides": ["Spawn", "Journal"],
168 "requires": [],
169 "schema_version": 1,
170 "soma": {
171 "cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
172 "cpu_affinity": None,
173 "namespaces": {k: False for k in
174 ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
175 "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
176 },
177 "supervision": {"Restart": {"initial": 500, "max": 20000}},
178 }
179
180 txt = json.dumps(semilla, indent=1, sort_keys=True) + "\n"
181 print(f"\n══ SEMILLA '{a.label}' ══")
182 print(f" {len(hijas)} servicio(s) declarados")
183 if envueltos:
184 print(f" {len(envueltos)} envuelto(s) en `sh -c 'cd … && exec …'` porque dependen de su "
185 f"directorio\n (el payload Native de arje no tiene `cwd`): {' '.join(envueltos)}")
186 if sin_cmdline:
187 print(f"\n ⚠ {len(sin_cmdline)} servicio(s) NO se declararon: no se pudo leer su cmdline.")
188 print(f" {' '.join(sin_cmdline)}")
189 print(" Una tarjeta que no puede arrancar es PEOR que una ausente: la ausente falla")
190 print(" al primer arranque, la rota deja el servicio caído sin que nada avise.")
191 print(" ⚠ El entorno (`envp`) va VACÍO: el censo no lee /proc/<pid>/environ porque trae")
192 print(" tokens. Un servicio que dependa de variables va a necesitar que alguien las escriba.")
193 if a.perfil_out:
194 bloque, sin_receta = perfil(censo, a.label, _REPO)
195 open(a.perfil_out, "w").write(bloque + "\n")
196 n_r = len([l for l in bloque.splitlines() if l.strip().startswith('"')])
197 print(f"\n══ PERFIL '{a.label}' ══")
198 print(f" {n_r} receta(s) que takana YA construye → {a.perfil_out}")
199 print(" Pegalo en docs/state/targets.toml y armá la imagen: el software del servidor nuevo")
200 print(" se DECLARA, no se instala a mano. Además `takana install` reproduce desde fuente y")
201 print(" eso exige el lab entero en el cliente — el perfil lo resuelve en el hub, que sí lo tiene.")
202 if sin_receta:
203 print(f"\n ⚠ {len(sin_receta)} servicio(s) que el perfil NO puede cubrir:")
204 for nom, cl, det in sin_receta:
205 print(f" {nom:22} [{cl}] {det[:84]}")
206 print(" Un perfil que se calla lo que le falta sale N/N describiendo un servidor")
207 print(" incompleto. Para cada uno: escribir la receta, elegir un equivalente que sí")
208 print(" esté, o llevar el binario a mano sabiendo que no se reproduce.")
209 if a.out:
210 open(a.out, "w").write(txt)
211 print(f"\n semilla → {a.out}")
212 else:
213 print(txt)
214
215
216if __name__ == "__main__":
217 main()