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 1534Shell 201Rust 109Markdown 993716 Definitions

⎇ 2fa89ce3fa233fdcc4be348d011986f78b03b5de scripts/mudanza/planear.py Python · 701 lines · 40.6 KB History Who touched what raw

censar: tres nombres equivocados que llegaban al plan, y los 7 «binario desconocido» eran permisos 2fa89ce3f · Sergio ·

1#!/usr/bin/env python3
2"""planear.py — convierte un CENSO decidido en un PLAN ejecutable y exportable (SDD 29 etapa 2).
3
4El plan es el producto: un documento que se revisa, se versiona, se lleva a otro proveedor y se
5vuelve a correr. **Cada paso lleva su comando literal**, así que el plan se puede ejecutar con la
6herramienta o a mano, línea por línea, sin la herramienta y sin este repo.
7
8── LAS TRES REGLAS, Y POR QUÉ ──────────────────────────────────────────────────────────────────
91. **Nada sin decidir se ejecuta.** Una entrada con `decision = ""` ABORTA el plan, nombrándola. El
10   silencio no es consentimiento: si nadie dijo qué hacer con un servicio, la respuesta no es
11   «entonces no lo muevo» —eso lo mata en silencio— ni «entonces lo muevo» —eso muda fósiles—.
122. **Lo que muere se dice por su nombre y con su tamaño, ANTES de borrar nada.** En esta mudanza
13   `terapeuta.ec` eran 279 M sin DNS: la diferencia entre «lo decidimos» y «se nos pasó» es una
14   línea escrita.
153. **Cada copia se verifica EN DESTINO.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
16   de la caja nueva devolvió 0 y dejó 1367 artefactos VACÍOS, y sólo se vio contando del otro lado.
17
18── LO QUE EL PLAN SABE QUE UN GUIÓN A MANO OLVIDA ──────────────────────────────────────────────
19· **Dimensiona con el tamaño de COPIA, no con `du`.** Un árbol con hardlinks mide 60 G y ocupa 85 G
20  al copiarlo sin `-H` (medido). El preflight compara contra el número correcto y falla ANTES.
21· **`rsync -aH` y excluyendo cachés.** Sin `-H` los hardlinks se expanden; `.dmerge` son 11 G de
22  caché regenerable que llenó un disco.
23· **Los servicios `no-declarado` no se copian: se DECLARAN.** Copiar un binario no lo levanta al
24  arrancar — el caddy de la caja nueva murió en el primer reinicio por exactamente eso.
25· **La traducción de la configuración es un PASO, no una advertencia.** Decir «cambiar de servidor
26  web obliga a reescribir la config» es cierto y deja al humano con un párrafo y ninguna herramienta.
27  El plan emite el comando literal de `traducir.py` cuando el par EXISTE —se le pregunta al registro
28  de plugins, no se lista acá— y dice que no existe cuando no. Su verificación es HUMANA: el
29  traductor sale ≠0 si algo quedó sin traducir, así que darlo por bueno por código de salida sería
30  justo al revés de lo que hay que mirar.
31
32Uso:
33    scripts/mudanza/planear.py --censo censo.toml --target root@1.2.3.4 [--decide] --out plan.toml
34"""
35import argparse, os, sys, shlex, time, tomllib
36
37sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38import formatos                                            # noqa: E402
39
40_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
41_AQUI = os.path.join("scripts", "mudanza", "traducir.py")
42
43# ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
44
45_MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}
46
47
48def a_mib(s):
49    """'61.5G' → MiB. Devuelve None si no se entiende: un tamaño ilegible NO se inventa como 0,
50    porque un 0 inventado apaga el preflight justo cuando más hace falta."""
51    s = (s or "").strip()
52    if not s or not s[0].isdigit():
53        return None
54    try:
55        return float(s[:-1]) * _MUL.get(s[-1].upper(), 1) if s[-1].isalpha() else float(s)
56    except ValueError:
57        return None
58
59
60def humano(mib):
61    if mib is None:
62        return "?"
63    return f"{mib/1024:.1f}G" if mib >= 1024 else f"{mib:.0f}M"
64
65
66# ── decisión ────────────────────────────────────────────────────────────────────────────────────
67
68DECISIONES = ("muda", "muere")
69
70
71def entradas(censo):
72    """Todo lo que necesita una decisión, con una etiqueta legible."""
73    out = []
74    for s in censo.get("servicio", []):
75        # `declarado-muerto` no necesita decisión: no corre. Se informa y se deja morir.
76        if s.get("class") != "declarado-muerto":
77            out.append(("servicio", s["name"], s))
78    for d in censo.get("dominio", []):
79        # ⚠ Los `fosil-sin-dns` ENTRAN. Antes se filtraban con el argumento de que un dominio sin DNS
80        # no necesita ningún paso — y es cierto para el DNS, pero no para lo que arrastra: en gioser
81        # son 13 de 29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el caddy. Sacarlos
82        # de la lista los volvía INVISIBLES, así que nadie decidía borrarlos y sus datos viajaban a
83        # la caja nueva por omisión. Es exactamente el «mudar fósiles» que este plan existe para
84        # evitar, y la regla 2 dice que lo que muere se nombra ANTES de borrar nada.
85        out.append(("dominio", d["domain"], d))
86    for d in censo.get("datos", []):
87        out.append(("datos", d["path"], d))
88    return out
89
90
91# ── servicios que NO APLICAN en una caja remota ─────────────────────────────────────────────────
92# Correr en el origen no significa que tenga sentido en el destino. Un servidor en un datacenter no
93# tiene bluetooth, ni módem, ni batería, ni teclado — y arrastrarlos es peor que inútil: algunos
94# PELEAN con el destino. Cada familia con su motivo, porque una recomendación sin motivo no se puede
95# discutir y entonces se acepta a ciegas o se ignora entera.
96NO_APLICA = {
97    "hardware local (una caja remota no tiene ese dispositivo)": {
98        "bluetoothd", "bluetooth", "modemmanager", "upowerd", "upower", "acpid", "adb",
99        "thermald", "iio-sensor-proxy", "fwupd", "alsactl", "pcscd", "cups", "cupsd",
100    },
101    "escritorio o pantalla (un servidor no los tiene)": {
102        "waypipe", "xorg", "x", "wayland", "gdm", "sddm", "lightdm", "pipewire",
103        "wireplumber", "pulseaudio", "carmen-dm",
104    },
105    "la red del destino ya la configura su propio init — pelearían": {
106        "networkmanager", "dhcpcd", "wpa-supplicant", "connman", "systemd-networkd", "dhclient",
107    },
108    "lo provee el init del destino, no se muda": {
109        "agetty", "getty", "console-getty", "udevd", "udevadm", "systemd-udevd", "eudev",
110        "elogind-daemon", "elogind", "polkitd", "dbus-daemon", "dbus",
111    },
112}
113
114
115def _no_aplica(nombre):
116    n = nombre.lower().replace("_", "-")
117    for motivo, conjunto in NO_APLICA.items():
118        if n in conjunto:
119            return motivo
120    return None
121
122
123# ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
124# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de
125# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es
126# mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar.
127FAMILIAS = {
128    "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"],
129    "base de datos":        ["postgresql", "mariadb", "mysql", "sqlite"],
130    "caché en memoria":     ["redis", "valkey", "memcached"],
131    "forja git":            ["gitea", "forgejo", "gitlab"],
132    "contenedores":         ["docker", "podman", "containerd"],
133    "dns":                  ["unbound", "bind", "dnsmasq", "coredns"],
134    "correo":               ["postfix", "exim", "opensmtpd"],
135    "base vectorial":       ["qdrant", "milvus", "weaviate"],
136}
137
138
139# ── enganche con el CENTRO DE TRADUCCIÓN ────────────────────────────────────────────────────────
140# El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es
141# cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta.
142# Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad:
143# los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se
144# desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo.
145
146# Dónde vive la declaración del servicio en cada init de origen.
147_RUTA_UNIDAD = {"openrc":  "/etc/init.d/{n}",
148                "systemd": "/etc/systemd/system/{n}.service"}
149
150# Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que
151# nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se
152# adivina — se dice que hay que ubicarlo a mano.
153_RUTA_CONFIG = {"nginx":  "/etc/nginx/nginx.conf",
154                "apache": "/etc/apache2/apache2.conf",
155                "httpd":  "/etc/httpd/conf/httpd.conf",
156                "caddy":  "/etc/caddy/Caddyfile"}
157
158
159def traducciones(s):
160    """Comandos de traducción que le corresponden a un servicio que se muda.
161
162    Dos cosas distintas, y confundirlas es un error caro:
163      · la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje;
164      · la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy.
165    Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer
166    traducirla sería inventar trabajo."""
167    lectores, escritores = formatos.cargar()
168    out = []
169
170    # 1) la declaración → tarjeta de arje (el init del destino)
171    if "arje" in escritores:
172        for fuente in s.get("declared_in", []):
173            if fuente in lectores and fuente in _RUTA_UNIDAD:
174                ruta = _RUTA_UNIDAD[fuente].format(n=s["name"])
175                out.append({
176                    "que": f"declaración de {s['name']} ({fuente} → arje)",
177                    "cmd": f"{_AQUI} --from {fuente} --to arje \\\n"
178                           f"      --in {ruta} --out {s['name']}.card.json",
179                    "nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con "
180                            "código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, "
181                            "es el acta pidiendo que la leas."})
182                break
183
184    # 2) la configuración → sólo si se cambia de servicio
185    alt = s.get("alternativa")
186    if alt and alt != s["name"]:
187        desde, hacia = s["name"].lower().replace("_", "-"), alt.lower()
188        if desde in lectores and hacia in escritores and \
189           lectores[desde][1] == escritores[hacia][1]:
190            org = _RUTA_CONFIG.get(desde, f"<config de {desde}: ubicala>")
191            dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
192            out.append({
193                "que": f"configuración de {desde} → {hacia}",
194                "cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n"
195                       f"      --in {org} --out {os.path.basename(dst)}",
196                "nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar "
197                        "sin resolver el acta es desplegar un servidor incompleto que además "
198                        "parece funcionar."})
199        else:
200            # No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada.
201            out.append({
202                "que": f"configuración de {desde} → {hacia}",
203                "cmd": f"# NO hay traductor `{desde} → {hacia}` en el centro.\n"
204                       f"#   Ver los que sí hay:  {_AQUI} --list\n"
205                       f"#   Hay que reescribir la configuración a mano.",
206                "nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."})
207    return out
208
209
210def _receta(nombre, repo):
211    """(existe, sellado) para un nombre de receta."""
212    import glob
213    f = os.path.join(repo, "recipes", f"{nombre}.toml")
214    if not os.path.exists(f):
215        return False, False
216    return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}")))
217
218
219def alternativas(nombre, repo):
220    """Equivalentes funcionales del servicio, con una recomendación RAZONADA.
221
222    ── LOS CRITERIOS, Y POR QUÉ ÉSOS ────────────────────────────────────────────────────────────
223    No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que
224    no recomendar. Se recomienda con hechos comprobables y en este orden:
225
226      1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un
227         binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre
228         supera cualquier ventaja teórica del otro.
229      2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente
230         — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta.
231      3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin
232         decir que ese otro tampoco está.
233
234    Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna
235    familia conocida — que es lo normal y no es un error."""
236    n = nombre.lower().replace("_", "-")
237    fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None)
238    if not fam:
239        return None, None, None
240    lista = []
241    for cand in FAMILIAS[fam]:
242        existe, sellado = _receta(cand, repo)
243        lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n})
244    yo = next((x for x in lista if x["actual"]), None)
245    disponibles = [x for x in lista if x["sellado"]]
246
247    if yo and yo["sellado"]:
248        return (lista, n,
249                f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia "
250                f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera")
251    if disponibles:
252        otros = ", ".join(x["nombre"] for x in disponibles)
253        return (lista, disponibles[0]["nombre"],
254                f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí "
255                f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una "
256                f"decisión, no un trámite")
257    return (lista, None,
258            f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus "
259            f"equivalentes. Hay que escribir la receta del que se elija")
260
261
262def origen_binario(e, repo=None):
263    """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino.
264
265    «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién
266    posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de
267    takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto:
268
269      · `receta-takana`  — lo mejor: takana ya sabe construirlo. Se instala del repo firmado.
270      · `paquete-ajeno`  — lo trae el gestor del origen. En el destino hace falta una receta, o
271                           `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena.
272      · `suelto`         — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol
273                           o escribirle una receta. Es el que se pierde al apagar el origen.
274
275    Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`,
276    `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad."""
277    nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "")
278    # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos
279    # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`,
280    # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica.
281    repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
282    if not os.path.isdir(os.path.join(repo, "recipes")):
283        # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y
284        # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta.
285        raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — "
286                         f"pasá --repo o corré desde el repo")
287    for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or "")):
288        if cand and os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
289            import glob
290            sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
291            return ("receta-takana",
292                    f"takana YA lo construye: `recipes/{cand}.toml`"
293                    + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)")
294                    + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}")
295    if pkg:
296        return ("paquete-ajeno",
297                f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "
298                f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)")
299    if exe:
300        return ("suelto",
301                f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
302                f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
303                f"el origen")
304    motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
305    return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
306
307
308def recomendar(tipo, e, datos_que_mueren=()):
309    """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
310    que aceptarla sea barato y equivocarse sea caro de no notar.
311
312    Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
313    ciegas o se ignora entera. Las dos son malas."""
314    c = e.get("class", "")
315
316    if tipo == "servicio":
317        m = _no_aplica(e.get("name", ""))
318        if m:
319            return "muere", m
320        # DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
321        # va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
322        exe = (e.get("cmdline") or "").split(" ")[0]
323        for raiz in datos_que_mueren:
324            if exe.startswith(raiz.rstrip("/") + "/"):
325                return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
326        if c == "no-declarado":
327            p = e.get("ports") or []
328            return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
329                            "y NADIE lo declara" if p else
330                            "corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
331        if c == "vivo":
332            return "muda", "declarado y corriendo"
333        return "", ""
334
335    if tipo == "dominio":
336        if c == "vivo":
337            return "muda", "resuelve a esta máquina y contesta"
338        if c == "ya-mudado":
339            return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
340        if c == "backend-caido":
341            return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
342        if c == "apunta-aca":
343            # El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el
344            # silencio como una respuesta afirmativa, que es el error que este censo evita en todos
345            # los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero
346            # se DICE lo que no se miró.
347            return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. "
348                            "⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil")
349        if c == "fosil-sin-dns":
350            return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó "
351                             "contenido en disco o un bloque en el servidor web — eso sí viaja si "
352                             "nadie lo borra")
353        if c == "sin-sondear":
354            return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
355        return "", ""
356
357    if tipo == "datos":
358        # A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
359        # eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
360        return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
361    return "", ""
362
363
364def sugerir(tipo, e, datos_que_mueren=()):
365    return recomendar(tipo, e, datos_que_mueren)[0]
366
367
368def _etiqueta(tipo, nombre, e):
369    if tipo == "datos":
370        extra = e.get("size", "?")
371        if e.get("size_copy") and e["size_copy"] != e.get("size"):
372            extra += f" (copiar {e['size_copy']})"
373        return extra
374    if tipo == "servicio":
375        p = e.get("ports") or []
376        return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
377    return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
378
379
380def revisar(censo, interactivo=True):
381    """Muestra TODO con su recomendación y su motivo, y deja elegir.
382
383    ── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
384    Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
385    servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
386    arrancar allá. Al revés no se puede calcular.
387    """
388    todos = entradas(censo)
389    datos = [(t, n, e) for t, n, e in todos if t == "datos"]
390    resto = [(t, n, e) for t, n, e in todos if t != "datos"]
391
392    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
393    if datos:
394        print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
395        print("   Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
396        print("   la recomendación de los servicios, así que va primero.\n")
397        for t, n, e in datos:
398            marca = e.get("decision") or "—"
399            print(f"   {n:34} {_etiqueta(t,n,e):>22}   decisión actual: {marca}")
400        if interactivo:
401            print()
402            for t, n, e in datos:
403                if e.get("decision") in DECISIONES:
404                    continue
405                r = input(f"   {n:34} [{_etiqueta(t,n,e)}]  [m]uda/[x]muere > ").strip().lower()
406                e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
407
408    mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
409
410    # ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
411    grupos = {"muda": [], "muere": [], "": []}
412    for t, n, e in resto:
413        d, motivo = recomendar(t, e, mueren)
414        e["_rec"], e["_motivo"] = d, motivo
415        grupos[d].append((t, n, e))
416
417    print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
418    titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
419               "": "SIN recomendación — hay que decidir"}
420    for g in ("muda", "muere", ""):
421        if not grupos[g]:
422            continue
423        print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
424        for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
425            print(f"   {t:8} {n:30} {_etiqueta(t,n,e):>18}")
426            if e.get("_motivo"):
427                print(f"            └─ {e['_motivo']}")
428            if t == "servicio" and e.get("decision") != "muere" and g != "muere":
429                oc, od = origen_binario(e)
430                print(f"            └─ binario: [{oc}] {od[:96]}")
431                if e.get("usuario") and e["usuario"] != "root":
432                    # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
433                    # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
434                    # corre sin privilegios termina de root en el destino y nadie se entera.
435                    print(f"            └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
436                          f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
437                lista, rec, motivo = alternativas(n, _REPO)
438                if lista:
439                    op = "  ".join(
440                        ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"])
441                        + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗"))
442                        for x in lista)
443                    print(f"            └─ alternativas: {op}    (✓ sellado · receta sin sellar · ✗ no está)")
444                    print(f"               recomendado: {rec or 'ninguno'} — {motivo[:110]}")
445
446    if not interactivo:
447        return censo
448
449    print("\n   [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir")
450    r = input("   > ").strip().lower()
451    if r == "q":
452        return censo
453    for t, n, e in resto:
454        if e.get("decision") in DECISIONES:
455            continue
456        rec = e.get("_rec", "")
457        if r == "a" and rec:
458            e["decision"] = rec
459            continue
460        sug = f"[{rec}]" if rec else "[sin recomendación]"
461        resp = input(f"   {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
462                     f"            [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
463        e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "")
464        if e["decision"] == "muda" and t == "servicio":
465            lista, alt_rec, motivo = alternativas(n, _REPO)
466            if lista and len([x for x in lista if x["sellado"]]) > 1:
467                op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "")
468                              for i, x in enumerate(lista))
469                print(f"            alternativas: {op}")
470                print(f"            recomendado: {alt_rec} — {motivo[:100]}")
471                r2 = input(f"            número, o Enter para {alt_rec}: ").strip()
472                if r2.isdigit() and int(r2) < len(lista):
473                    e["alternativa"] = lista[int(r2)]["nombre"]
474                else:
475                    e["alternativa"] = alt_rec
476    return censo
477
478
479def validar(censo):
480    """Regla 1: nada sin decidir se ejecuta."""
481    faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
482    if faltan:
483        print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
484        print("   El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
485              file=sys.stderr)
486        for t, n in faltan[:40]:
487            print(f"     {t:9} {n}", file=sys.stderr)
488        if len(faltan) > 40:
489            print(f"     … y {len(faltan)-40} más", file=sys.stderr)
490        print("\n   Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
491        sys.exit(2)
492
493
494# ── plan ────────────────────────────────────────────────────────────────────────────────────────
495
496_TRIPLE_SIMPLE = "'" * 3
497_TRIPLE_DOBLE = '"' * 3
498
499
500def bloque_toml(texto):
501    """Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
502
503    ⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
504    que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
505    (`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
506    se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
507    recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
508
509    Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
510    con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
511    if _TRIPLE_SIMPLE not in texto:
512        return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
513    escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
514    return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
515
516
517def pasos(censo, target, key):
518    ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
519    if key:
520        ssh_opts = f"-i {key} " + ssh_opts
521    ssh = f"ssh {ssh_opts} {target}"
522    P = []
523
524    def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"):
525        # `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un
526        # humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error»
527        # es exactamente cómo un aplicador miente.
528        P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
529                  "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
530
531    datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
532    svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
533    doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
534    muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
535
536    # ── 0. preflight ────────────────────────────────────────────────────────────────────────────
537    need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
538               if x is not None)
539    ilegibles = [d["path"] for d in datos_muda
540                 if a_mib(d.get("size_copy") or d.get("size")) is None]
541    paso("preflight", f"el destino responde y tiene sitio (hacen falta {humano(need)})",
542         f"{ssh} 'df -Pm / | tail -1'",
543         verifica=f"la columna 'Available' debe ser > {int(need)} MiB",
544         verifica_tipo="humano",
545         nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
546              "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`.")
547
548    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
549    for d in datos_muda:
550        p = d["path"]
551        paso("datos", f"copiar {p}  ({d.get('size','?')})",
552             f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
553             f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(p + '/')}",
554             verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); "
555                       f"dst=$({ssh} 'find {shlex.quote(p)} -type f | wc -l'); "
556                       f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
557             nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
558                  "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
559                  "FALLA si no coinciden. La versión anterior imprimía el número de directorios "
560                  "vacíos y devolvía 0 igual — un guardián que siempre pasa no es un guardián. "
561                  "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
562                  "así se llenó un disco y quedaron 1367 artefactos vacíos.")
563
564    # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
565    for s in svc_muda:
566        nd = s.get("class") == "no-declarado"
567        # Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no
568        # metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py`
569        # sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería
570        # justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta.
571        for tr in traducciones(s):
572            paso("traducir", f"traducir {tr['que']}", tr["cmd"],
573                 verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es"
574                          f" trabajo a mano que el destino NO va a hacer solo.",
575                 verifica_tipo="humano", nota=tr["nota"])
576        paso("servicio", f"declarar y levantar {s['name']}" + ("  ⚠ NO-DECLARADO" if nd else ""),
577             ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n"
578               f"#   Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n"
579               f"#   La del origen no sirve tal cual.\n#\n")
580              if s.get("alternativa") and s["alternativa"] != s["name"] else "")
581             + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n"
582              f"#   {origen_binario(s)[1]}\n"
583              f"#\n"
584              f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"
585              f"#   cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n"
586              + (f"#   cwd    : {s['cwd']}\n" if s.get("cwd") else "")
587              + (f"#   puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "")
588              + f"#\n# 1) conseguir el binario según lo de arriba\n"
589              f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n"
590              f"#    (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n"
591              f"# 3) arrancarlo"),
592             verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'"
593                      + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'"
594                         if s.get("ports") else ""),
595             nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo "
596                  "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la "
597                  "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else
598                  "Declarado en el origen: traducir su unidad al init del destino.")
599
600    # ── 3. dominios ─────────────────────────────────────────────────────────────────────────────
601    for d in doms_muda:
602        paso("dominio", f"apuntar {d['domain']} a la máquina nueva",
603             f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → <IP NUEVA>",
604             verifica=f"getent ahostsv4 {d['domain']} | head -1 && "
605                      f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/",
606             nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. "
607                  "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus "
608                  "propios dominios puede disparar su fail2ban (pasó).")
609
610    # ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
611    if muere:
612        det = []
613        for t, n, e in muere:
614            tam = f"  ({e.get('size')})" if e.get("size") else ""
615            det.append(f"#   {t:9} {n}{tam}")
616        paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
617             "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
618             + "\n".join(det),
619             verifica="revisión humana: leer la lista completa antes de destruir el origen",
620         verifica_tipo="humano",
621             nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté "
622                  "escrito ES el paso.")
623    return P
624
625
626def emitir(censo, P, target):
627    L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).",
628         "#",
629         "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por",
630         "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.",
631         'schema = "takana-plan/1"',
632         f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"',
633         f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"',
634         f'destino = "{target}"',
635         ""]
636    for i, p in enumerate(P, 1):
637        L.append(f"[[paso]]")
638        L.append(f'n = {i}')
639        L.append(f'clase = "{p["clase"]}"')
640        L.append(f'titulo = """{p["titulo"]}"""')
641        L.append("cmd = " + bloque_toml(p["cmd"]))
642        if p["verifica"]:
643            L.append("verifica = " + bloque_toml(p["verifica"]))
644            L.append(f'verifica_tipo = "{p["verifica_tipo"]}"')
645        if p["nota"]:
646            L.append("nota = " + bloque_toml(p["nota"]))
647        L.append("")
648    return "\n".join(L) + "\n"
649
650
651def main():
652    ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.")
653    ap.add_argument("--censo", required=True)
654    ap.add_argument("--target", required=True, help="usuario@host destino")
655    ap.add_argument("--key", help="clave SSH para el destino")
656    ap.add_argument("--decide", action="store_true",
657                    help="mostrar TODO con su recomendación y su motivo, y elegir")
658    ap.add_argument("--revisar", action="store_true",
659                    help="sólo mostrar la revisión con recomendaciones, sin preguntar")
660    ap.add_argument("--out", help="fichero del plan (default: stdout)")
661    a = ap.parse_args()
662
663    with open(a.censo, "rb") as f:
664        censo = tomllib.load(f)
665    if a.revisar:
666        revisar(censo, interactivo=False)
667        return
668    if a.decide:
669        censo = revisar(censo, interactivo=True)
670    validar(censo)
671
672    P = pasos(censo, a.target, a.key)
673    por_clase = {}
674    for p in P:
675        por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1
676    print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══")
677    for c, n in por_clase.items():
678        print(f"   {c:10} {n:3} paso(s)")
679    txt = emitir(censo, P, a.target)
680
681    # ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ──────────────────────
682    # No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea
683    # rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino
684    # DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y
685    # releer cuesta milisegundos y convierte un fallo diferido en uno inmediato.
686    try:
687        tomllib.loads(txt)
688    except tomllib.TOMLDecodeError as e:
689        sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n"
690                 f"  No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n"
691                 f"  exportar, que son las dos únicas cosas para las que existe.")
692
693    if a.out:
694        open(a.out, "w").write(txt)
695        print(f"\n   plan → {a.out}   ({len(P)} pasos, con su comando literal cada uno)")
696    else:
697        print(txt)
698
699
700if __name__ == "__main__":
701    main()