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 1576Shell 205Rust 111Markdown 993774 Definiciones

⎇ f22beb1b35976c09694ecb7f0ee24f1be0c7dee2 scripts/mudanza/planear.py Python · 761 líneas · 44.1 KB Historia Quién tocó qué crudo

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