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 1532Shell 196Rust 108Markdown 993686 Definiciones

⎇ 74b1a0580cb18b3e6249ba9110ace800d1e04a44 scripts/mudanza/planear.py Python · 917 líneas · 54.8 KB Historia Quién tocó qué crudo

planear: cruzar la config contra las decisiones — y los puertos se adjudicaban por NOMBRE f2bffd9e4 · 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# Paquetes que proveen un RUNTIME, no un programa. Que exista `recipes/nodejs.toml` no significa que
125# `openclaw` esté cubierto: el intérprete se resuelve y el programa sigue faltando.
126INTERPRETES = {"python", "python3", "nodejs", "node", "perl", "ruby", "php", "lua", "java", "jre"}
127
128# El paquete del origen y la receta del catálogo no siempre se llaman igual ni siquiera para el mismo
129# intérprete: Artix empaqueta `python` y el catálogo tiene `python3.toml`. Sin esto, tres servicios
130# salían «hace falta una receta takana» cuando el runtime YA está sellado y lo que falta es el
131# programa — dos trabajos muy distintos.
132ALIAS_INTERPRETE = {"python": "python3", "node": "nodejs", "jre": "java"}
133
134
135# ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
136# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de
137# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es
138# mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar.
139FAMILIAS = {
140    "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"],
141    "base de datos":        ["postgresql", "mariadb", "mysql", "sqlite"],
142    "caché en memoria":     ["redis", "valkey", "memcached"],
143    "forja git":            ["gitea", "forgejo", "gitlab"],
144    "contenedores":         ["docker", "podman", "containerd"],
145    "dns":                  ["unbound", "bind", "dnsmasq", "coredns"],
146    "correo":               ["postfix", "exim", "opensmtpd"],
147    "base vectorial":       ["qdrant", "milvus", "weaviate"],
148}
149
150
151# ── enganche con el CENTRO DE TRADUCCIÓN ────────────────────────────────────────────────────────
152# El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es
153# cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta.
154# Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad:
155# los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se
156# desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo.
157
158# Dónde vive la declaración del servicio en cada init de origen.
159_RUTA_UNIDAD = {"openrc":  "/etc/init.d/{n}",
160                "systemd": "/etc/systemd/system/{n}.service"}
161
162# Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que
163# nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se
164# adivina — se dice que hay que ubicarlo a mano.
165_RUTA_CONFIG = {"nginx":  "/etc/nginx/nginx.conf",
166                "apache": "/etc/apache2/apache2.conf",
167                "httpd":  "/etc/httpd/conf/httpd.conf",
168                "caddy":  "/etc/caddy/Caddyfile"}
169
170
171def traducciones(s):
172    """Comandos de traducción que le corresponden a un servicio que se muda.
173
174    Dos cosas distintas, y confundirlas es un error caro:
175      · la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje;
176      · la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy.
177    Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer
178    traducirla sería inventar trabajo."""
179    lectores, escritores = formatos.cargar()
180    out = []
181
182    # 1) la declaración → tarjeta de arje (el init del destino)
183    if "arje" in escritores:
184        for fuente in s.get("declared_in", []):
185            if fuente in lectores and fuente in _RUTA_UNIDAD:
186                ruta = _RUTA_UNIDAD[fuente].format(n=s["name"])
187                out.append({
188                    "que": f"declaración de {s['name']} ({fuente} → arje)",
189                    "cmd": f"{_AQUI} --from {fuente} --to arje \\\n"
190                           f"      --in {ruta} --out {s['name']}.card.json",
191                    "nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con "
192                            "código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, "
193                            "es el acta pidiendo que la leas."})
194                break
195
196    # 2) la configuración → sólo si se cambia de servicio
197    alt = s.get("alternativa")
198    if alt and alt != s["name"]:
199        desde, hacia = s["name"].lower().replace("_", "-"), alt.lower()
200        if desde in lectores and hacia in escritores and \
201           lectores[desde][1] == escritores[hacia][1]:
202            org = _RUTA_CONFIG.get(desde, f"<config de {desde}: ubicala>")
203            dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
204            out.append({
205                "que": f"configuración de {desde} → {hacia}",
206                "cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n"
207                       f"      --in {org} --out {os.path.basename(dst)}",
208                "nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar "
209                        "sin resolver el acta es desplegar un servidor incompleto que además "
210                        "parece funcionar."})
211        else:
212            # No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada.
213            out.append({
214                "que": f"configuración de {desde} → {hacia}",
215                "cmd": f"# NO hay traductor `{desde} → {hacia}` en el centro.\n"
216                       f"#   Ver los que sí hay:  {_AQUI} --list\n"
217                       f"#   Hay que reescribir la configuración a mano.",
218                "nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."})
219    return out
220
221
222def _receta(nombre, repo):
223    """(existe, sellado) para un nombre de receta."""
224    import glob
225    f = os.path.join(repo, "recipes", f"{nombre}.toml")
226    if not os.path.exists(f):
227        return False, False
228    return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}")))
229
230
231def alternativas(nombre, repo):
232    """Equivalentes funcionales del servicio, con una recomendación RAZONADA.
233
234    ── LOS CRITERIOS, Y POR QUÉ ÉSOS ────────────────────────────────────────────────────────────
235    No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que
236    no recomendar. Se recomienda con hechos comprobables y en este orden:
237
238      1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un
239         binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre
240         supera cualquier ventaja teórica del otro.
241      2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente
242         — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta.
243      3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin
244         decir que ese otro tampoco está.
245
246    Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna
247    familia conocida — que es lo normal y no es un error."""
248    n = nombre.lower().replace("_", "-")
249    fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None)
250    if not fam:
251        return None, None, None
252    lista = []
253    for cand in FAMILIAS[fam]:
254        existe, sellado = _receta(cand, repo)
255        lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n})
256    yo = next((x for x in lista if x["actual"]), None)
257    disponibles = [x for x in lista if x["sellado"]]
258
259    if yo and yo["sellado"]:
260        return (lista, n,
261                f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia "
262                f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera")
263    if disponibles:
264        otros = ", ".join(x["nombre"] for x in disponibles)
265        return (lista, disponibles[0]["nombre"],
266                f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí "
267                f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una "
268                f"decisión, no un trámite")
269    return (lista, None,
270            f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus "
271            f"equivalentes. Hay que escribir la receta del que se elija")
272
273
274def origen_binario(e, repo=None):
275    """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino.
276
277    «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién
278    posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de
279    takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto:
280
281      · `receta-takana`  — lo mejor: takana ya sabe construirlo. Se instala del repo firmado.
282      · `paquete-ajeno`  — lo trae el gestor del origen. En el destino hace falta una receta, o
283                           `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena.
284      · `suelto`         — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol
285                           o escribirle una receta. Es el que se pierde al apagar el origen.
286
287    Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`,
288    `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad."""
289    nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "")
290    # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos
291    # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`,
292    # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica.
293    repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
294    if not os.path.isdir(os.path.join(repo, "recipes")):
295        # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y
296        # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta.
297        raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — "
298                         f"pasá --repo o corré desde el repo")
299    # ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
300    # `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
301    # SÍ construye y tiene selladas. El censo ya le preguntó al gestor de paquetes quién posee el
302    # binario, así que ese nombre también se prueba.
303    import glob
304    for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or ""),
305                 pkg, (pkg or "").removesuffix("-bin"), ALIAS_INTERPRETE.get(pkg or "", "")):
306        if not cand or not os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
307            continue
308        # ⚠⚠ Y la trampa, que es la que haría mentir al perfil: un paquete INTÉRPRETE provee el
309        # runtime, no el programa. `openclaw` lo posee `nodejs` y `fail2ban-server` lo posee
310        # `python`; tener esas recetas NO te da esos servicios. Contarlos como cubiertos haría que el
311        # perfil saliera N/N describiendo un servidor al que le faltan cuatro programas.
312        if pkg in INTERPRETES and cand in (pkg, ALIAS_INTERPRETE.get(pkg or "", "")) and cand != nombre:
313            sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
314            return ("interprete",
315                    f"lo corre el intérprete `{pkg}`, que takana SÍ construye (`recipes/{cand}.toml`"
316                    + (", sellada)" if sellado else ", sin sellar)")
317                    + f" — pero el intérprete NO es el programa: `{nombre}` es código que hay que "
318                    f"llevar y declarar aparte. El perfil resuelve el runtime y nada más")
319        sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
320        via = "" if cand in (nombre, nombre.replace("_", "-")) else f" (por su paquete `{pkg}`)" \
321            if cand in (pkg, (pkg or "").removesuffix("-bin")) else f" (por su binario)"
322        return ("receta-takana",
323                f"takana YA lo construye{via}: `recipes/{cand}.toml`"
324                + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)")
325                + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}")
326    if pkg:
327        return ("paquete-ajeno",
328                f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "
329                f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)")
330    if exe and e.get("exec_borrado"):
331        # Su propia clase, y la más urgente del censo: el fichero YA NO EXISTE. No es «hay que
332        # llevarlo», es «se pierde en el próximo reinicio y el que está en su ruta NO es el mismo».
333        pid = e.get("pid_vivo") or (e.get("pids") or [0])[0]
334        bv, bd = e.get("bytes_vivo", ""), e.get("bytes_disco", "")
335        dif = (f" El que hay AHORA en esa ruta pesa {bd} bytes y el que corre {bv}: NO SON EL MISMO."
336               if bv and bd and bv != bd else
337               (" En esa ruta ya no hay nada." if not bd else ""))
338        return ("borrado",
339                f"⚠ el binario que CORRE fue borrado o reemplazado: `{exe}` ya no existe en disco."
340                f"{dif} Sólo vive mientras viva el pid {pid}; al reiniciar el origen se pierde PARA "
341                f"SIEMPRE. Rescatalo ya:  sudo cat /proc/{pid}/exe > {os.path.basename(exe)}  "
342                f"&& chmod +x {os.path.basename(exe)}")
343    if exe:
344        return ("suelto",
345                f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
346                f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
347                f"el origen")
348    motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
349    return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
350
351
352def recomendar(tipo, e, datos_que_mueren=(), sitios=()):
353    """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
354    que aceptarla sea barato y equivocarse sea caro de no notar.
355
356    Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
357    ciegas o se ignora entera. Las dos son malas."""
358    c = e.get("class", "")
359
360    if tipo == "servicio":
361        m = _no_aplica(e.get("name", ""))
362        if m:
363            return "muere", m
364        # DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
365        # va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
366        exe = (e.get("cmdline") or "").split(" ")[0]
367        for raiz in datos_que_mueren:
368            if exe.startswith(raiz.rstrip("/") + "/"):
369                return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
370        if c == "no-declarado":
371            p = e.get("ports") or []
372            return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
373                            "y NADIE lo declara" if p else
374                            "corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
375        if c == "vivo":
376            return "muda", "declarado y corriendo"
377        return "", ""
378
379    if tipo == "dominio":
380        # ⚠ Evidencia más fuerte que el DNS, y sin tocar la red: si el bloque de este dominio en la
381        # config del servidor apunta a un directorio que NO EXISTE, no hay nada que servir — devuelve
382        # 502 resuelva donde resuelva. Medido en gioser: `aura`, `sigma` y `kosmofono` apuntan a
383        # raíces borradas, y son justo los que en su día devolvían los 502 que hicieron que el censo
384        # se baneara a sí mismo al sondearlos por HTTP. Se lee del fichero; no hace falta preguntar.
385        for st in sitios:
386            if e.get("domain") in (st.get("nombres") or "").split() and st.get("raiz_existe") == "no":
387                return "muere", (f"su bloque en {st['config']} apunta a `{st['raiz']}`, que NO "
388                                 f"EXISTE: no hay nada que servir, devuelve 502 resuelva donde resuelva")
389        if c == "vivo":
390            return "muda", "resuelve a esta máquina y contesta"
391        if c == "ya-mudado":
392            return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
393        if c == "backend-caido":
394            return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
395        if c == "apunta-aca":
396            # El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el
397            # silencio como una respuesta afirmativa, que es el error que este censo evita en todos
398            # los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero
399            # se DICE lo que no se miró.
400            return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. "
401                            "⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil")
402        if c == "fosil-sin-dns":
403            return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó "
404                             "contenido en disco o un bloque en el servidor web — eso sí viaja si "
405                             "nadie lo borra")
406        if c == "sin-sondear":
407            return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
408        return "", ""
409
410    if tipo == "datos":
411        # A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
412        # eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
413        return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
414    return "", ""
415
416
417def sugerir(tipo, e, datos_que_mueren=()):
418    return recomendar(tipo, e, datos_que_mueren, sitios)[0]
419
420
421def _etiqueta(tipo, nombre, e):
422    if tipo == "datos":
423        extra = e.get("size", "?")
424        if e.get("size_copy") and e["size_copy"] != e.get("size"):
425            extra += f" (copiar {e['size_copy']})"
426        return extra
427    if tipo == "servicio":
428        p = e.get("ports") or []
429        return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
430    return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
431
432
433def revisar(censo, interactivo=True):
434    """Muestra TODO con su recomendación y su motivo, y deja elegir.
435
436    ── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
437    Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
438    servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
439    arrancar allá. Al revés no se puede calcular.
440    """
441    todos = entradas(censo)
442    datos = [(t, n, e) for t, n, e in todos if t == "datos"]
443    resto = [(t, n, e) for t, n, e in todos if t != "datos"]
444
445    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
446    if datos:
447        print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
448        print("   Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
449        print("   la recomendación de los servicios, así que va primero.\n")
450        for t, n, e in datos:
451            marca = e.get("decision") or "—"
452            print(f"   {n:34} {_etiqueta(t,n,e):>22}   decisión actual: {marca}")
453        if interactivo:
454            print()
455            for t, n, e in datos:
456                if e.get("decision") in DECISIONES:
457                    continue
458                r = input(f"   {n:34} [{_etiqueta(t,n,e)}]  [m]uda/[x]muere > ").strip().lower()
459                e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
460
461    mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
462
463    # ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
464    grupos = {"muda": [], "muere": [], "": []}
465    for t, n, e in resto:
466        d, motivo = recomendar(t, e, mueren, censo.get("sitio", []))
467        e["_rec"], e["_motivo"] = d, motivo
468        grupos[d].append((t, n, e))
469
470    print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
471    titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
472               "": "SIN recomendación — hay que decidir"}
473    for g in ("muda", "muere", ""):
474        if not grupos[g]:
475            continue
476        print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
477        for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
478            print(f"   {t:8} {n:30} {_etiqueta(t,n,e):>18}")
479            if e.get("_motivo"):
480                print(f"            └─ {e['_motivo']}")
481            if t == "servicio" and e.get("decision") != "muere" and g != "muere":
482                oc, od = origen_binario(e)
483                print(f"            └─ binario: [{oc}] {od[:96]}")
484                if e.get("usuario") and e["usuario"] != "root":
485                    # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
486                    # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
487                    # corre sin privilegios termina de root en el destino y nadie se entera.
488                    print(f"            └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
489                          f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
490                lista, rec, motivo = alternativas(n, _REPO)
491                if lista:
492                    op = "  ".join(
493                        ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"])
494                        + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗"))
495                        for x in lista)
496                    print(f"            └─ alternativas: {op}    (✓ sellado · receta sin sellar · ✗ no está)")
497                    print(f"               recomendado: {rec or 'ninguno'} — {motivo[:110]}")
498
499    if not interactivo:
500        return censo
501
502    print("\n   [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir")
503    r = input("   > ").strip().lower()
504    if r == "q":
505        return censo
506    for t, n, e in resto:
507        if e.get("decision") in DECISIONES:
508            continue
509        rec = e.get("_rec", "")
510        if r == "a" and rec:
511            e["decision"] = rec
512            continue
513        sug = f"[{rec}]" if rec else "[sin recomendación]"
514        resp = input(f"   {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
515                     f"            [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
516        e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "")
517        if e["decision"] == "muda" and t == "servicio":
518            lista, alt_rec, motivo = alternativas(n, _REPO)
519            if lista and len([x for x in lista if x["sellado"]]) > 1:
520                op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "")
521                              for i, x in enumerate(lista))
522                print(f"            alternativas: {op}")
523                print(f"            recomendado: {alt_rec} — {motivo[:100]}")
524                r2 = input(f"            número, o Enter para {alt_rec}: ").strip()
525                if r2.isdigit() and int(r2) < len(lista):
526                    e["alternativa"] = lista[int(r2)]["nombre"]
527                else:
528                    e["alternativa"] = alt_rec
529    return censo
530
531
532def validar(censo):
533    """Regla 1: nada sin decidir se ejecuta."""
534    faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
535    if faltan:
536        print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
537        print("   El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
538              file=sys.stderr)
539        for t, n in faltan[:40]:
540            print(f"     {t:9} {n}", file=sys.stderr)
541        if len(faltan) > 40:
542            print(f"     … y {len(faltan)-40} más", file=sys.stderr)
543        print("\n   Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
544        sys.exit(2)
545
546
547# ── plan ────────────────────────────────────────────────────────────────────────────────────────
548
549_TRIPLE_SIMPLE = "'" * 3
550_TRIPLE_DOBLE = '"' * 3
551
552
553def bloque_toml(texto):
554    """Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
555
556    ⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
557    que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
558    (`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
559    se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
560    recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
561
562    Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
563    con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
564    if _TRIPLE_SIMPLE not in texto:
565        return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
566    escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
567    return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
568
569
570def incoherencias(censo):
571    """Cruza lo que la config DECLARA NECESITAR contra lo que se decidió con cada servicio.
572
573    ── LO QUE ESTO CAZA Y NADA MÁS PODÍA ───────────────────────────────────────────────────────
574    Un dominio que se muda y el servicio que lo sirve marcado `muere`. Las dos decisiones son
575    razonables por separado —el dominio se usa, el servicio parecía prescindible— y juntas dejan el
576    sitio nuevo devolviendo 502. No lo ve el DNS, no lo ve el censo de procesos y no lo ve quien
577    decide de a una entrada por vez, que es como se decide.
578
579    También caza el revés: un `reverse_proxy` a un puerto que NINGÚN servicio censado sirve. Eso ya
580    está roto HOY, en el origen, y mudarlo es mudar un 502."""
581    por_puerto, por_nombre = {}, {}
582    for s in censo.get("servicio", []):
583        por_nombre[s["name"].lower()] = s
584        for p in s.get("ports", []):
585            por_puerto[str(p)] = s
586    dec = {}
587    for d in censo.get("dominio", []):
588        dec[d["domain"]] = d.get("decision", "")
589
590    out = []
591    for st in censo.get("sitio", []):
592        doms = [d for d in (st.get("nombres") or "").split() if dec.get(d) == "muda"]
593        if not doms:
594            continue
595        for destino in (st.get("proxies") or "").split():
596            if destino.startswith("unix/"):
597                # `unix//run/php-fpm/php-fpm.sock` → el servicio se identifica por su nombre.
598                base = destino.rstrip("/").split("/")[-1].removesuffix(".sock")
599                svc = por_nombre.get(base) or por_nombre.get(base.split("-")[0])
600                clave = base
601            else:
602                clave = destino.rsplit(":", 1)[-1]
603                svc = por_puerto.get(clave)
604            if svc is None:
605                out.append((", ".join(doms), destino,
606                            "NINGÚN servicio censado sirve ese destino: el sitio ya devuelve 502 "
607                            "HOY, en el origen. Mudarlo es mudar un 502"))
608            elif svc.get("decision") == "muere":
609                out.append((", ".join(doms), f"{destino} → {svc['name']}",
610                            f"el dominio se MUDA y `{svc['name']}`, que es quien lo sirve, está "
611                            f"marcado MUERE. El sitio nuevo va a devolver 502"))
612            elif not svc.get("decision"):
613                out.append((", ".join(doms), f"{destino} → {svc['name']}",
614                            f"el dominio se muda y `{svc['name']}`, que lo sirve, NO tiene decisión"))
615    return out
616
617
618def pasos(censo, target, key):
619    ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
620    if key:
621        ssh_opts = f"-i {key} " + ssh_opts
622    ssh = f"ssh {ssh_opts} {target}"
623    P = []
624
625    def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"):
626        # `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un
627        # humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error»
628        # es exactamente cómo un aplicador miente.
629        P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
630                  "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
631
632    datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
633    svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
634    doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
635    muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
636    contenido = censo.get("contenido", [])
637
638    # ── 0 ter. COHERENCIA ───────────────────────────────────────────────────────────────────────
639    incoh = incoherencias(censo)
640    if incoh:
641        det = ["# Cada línea es un dominio que se MUDA cuyo backend no va a estar del otro lado.",
642               "# Las dos decisiones son razonables por separado; juntas dejan un 502.", "#"]
643        for dom, destino, motivo in incoh:
644            det.append(f"#   {dom[:44]}")
645            det.append(f"#      ↳ {destino}")
646            det.append(f"#        {motivo}")
647        paso("coherencia", f"{len(incoh)} dominio(s) que se mudan SIN su backend", "\n".join(det),
648             verifica="revisión humana: o se muda el backend, o el dominio no se muda",
649             verifica_tipo="humano",
650             nota="Nadie más puede cazar esto: no lo ve el DNS, no lo ven los procesos, y no lo ve "
651                  "quien decide de a una entrada por vez — que es como se decide.")
652
653    # ── 0 bis. RESCATE, antes que nada ──────────────────────────────────────────────────────────
654    # Va PRIMERO, antes del preflight, porque es el único paso del plan que puede volverse
655    # IMPOSIBLE mientras se piensa el resto: estos binarios ya no existen en disco y sólo viven en el
656    # inodo que sostiene su proceso. Un reinicio del origen —o que se caiga ese pid— y se pierden
657    # para siempre. Y en dos de los cuatro hay AHORA otro fichero en la misma ruta, de distinto
658    # tamaño: copiar la ruta no falla, muda otra cosa.
659    borrados = [s for s in censo.get("servicio", [])
660                if s.get("exec_borrado") and s.get("decision") == "muda"]
661    if borrados:
662        lineas, ver = [], []
663        for s in borrados:
664            pid = s.get("pid_vivo") or (s.get("pids") or [0])[0]
665            nom = os.path.basename(s.get("exec") or s["name"])
666            lineas.append(f"sudo cat /proc/{pid}/exe > rescate/{shlex.quote(nom)} && "
667                          f"chmod +x rescate/{shlex.quote(nom)}")
668            if s.get("bytes_vivo"):
669                # La verificación COMPARA contra el tamaño del que CORRE. Un `cat` de un /proc que ya
670                # no existe crea un fichero VACÍO y devuelve 0: sin comparar, el rescate «pasa».
671                ver.append(f"[ \"$(stat -c %s rescate/{shlex.quote(nom)} 2>/dev/null)\" = "
672                           f"\"{s['bytes_vivo']}\" ] || {{ echo 'FALTA {nom}'; exit 1; }}")
673        paso("rescate", f"rescatar {len(borrados)} binario(s) BORRADOS del disco (sólo mientras corran)",
674             "mkdir -p rescate\n" + "\n".join(lineas),
675             verifica="\n".join(ver) + "\necho '  ✓ los binarios rescatados coinciden con los que corren'",
676             nota="⚠ ESTE PASO CADUCA. `/proc/<pid>/exe` deja de existir cuando muere el proceso, y "
677                  "estos ficheros ya no están en su ruta. Si el origen se reinicia antes de correr "
678                  "esto, el binario que estaba andando NO se puede recuperar de ninguna manera.")
679
680    # ── 0. preflight ────────────────────────────────────────────────────────────────────────────
681    # ⚠ El sitio se mide POR SISTEMA DE FICHEROS, no contra `/`. Medido en la caja takana: `/` tiene
682    # 3,5 G libres, `/store` 19 G y `/work` 66 G — un preflight que sume 33,6 G y los compare contra
683    # `/` da un veredicto correcto por casualidad si todo cae en `/`, y COMPLETAMENTE FALSO en cuanto
684    # una ruta va a otro montaje: diría que no entra cuando entra, o al revés. Y además: aquí el
685    # veredicto lo saca el propio comando (exit ≠0 si falta sitio), no un humano leyendo una columna.
686    need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
687               if x is not None)
688    ilegibles = [d["path"] for d in datos_muda
689                 if a_mib(d.get("size_copy") or d.get("size")) is None]
690    chks = []
691    for d in datos_muda:
692        mib = a_mib(d.get("size_copy") or d.get("size"))
693        if mib is None:
694            continue
695        dst = (d.get("destino") or "").strip() or d["path"]
696        chks.append(f"chk {shlex.quote(dst)} {int(mib)}")
697    guion = "\n".join([
698        "set -u",
699        "chk() {",
700        '  t="$1"',
701        '  while [ ! -d "$t" ] && [ "$t" != "/" ]; do t=$(dirname "$t"); done',
702        '  df -Pm "$t" | tail -1 | awk -v n="$2" -v r="$1" \'{print $6, $4, n, r}\'',
703        "}",
704        "{",
705        *["  " + c for c in chks],
706        "} | awk '",
707        "  { libre[$1]=$2; falta[$1]+=$3; rutas[$1]=rutas[$1] \" \" $4 }",
708        "  END { mal=0",
709        '    for (f in falta) {',
710        '      printf "  %-16s necesita %8d MiB · libres %8d  ⇐%s\\n", f, falta[f], libre[f], rutas[f]',
711        '      if (falta[f] > libre[f]) { mal=1; printf "  ✗ NO ENTRA en %s: faltan %d MiB\\n", f, falta[f]-libre[f] }',
712        "    }",
713        '    if (mal==0) printf "  ✓ entra en todos los sistemas de ficheros\\n"',
714        "    exit mal }'",
715    ])
716    paso("preflight", f"el destino responde y tiene sitio ({humano(need)} en total)",
717         f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
718         verifica=f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
719         nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
720              "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`, y medido "
721              "contra el sistema de ficheros de CADA destino.")
722
723    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
724    for d in datos_muda:
725        p = d["path"]
726        dst = (d.get("destino") or "").strip() or p
727        paso("datos", f"copiar {p} → {dst}  ({d.get('size','?')})",
728             f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
729             f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(dst + '/')}",
730             verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); "
731                       f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
732                       f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
733             nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
734                  "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
735                  "FALLA si no coinciden. La versión anterior imprimía el número de directorios "
736                  "vacíos y devolvía 0 igual — un guardián que siempre pasa no es un guardián. "
737                  "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
738                  "así se llenó un disco y quedaron 1367 artefactos vacíos.")
739
740    # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
741    for s in svc_muda:
742        nd = s.get("class") == "no-declarado"
743        # Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no
744        # metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py`
745        # sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería
746        # justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta.
747        for tr in traducciones(s):
748            paso("traducir", f"traducir {tr['que']}", tr["cmd"],
749                 verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es"
750                          f" trabajo a mano que el destino NO va a hacer solo.",
751                 verifica_tipo="humano", nota=tr["nota"])
752        paso("servicio", f"declarar y levantar {s['name']}" + ("  ⚠ NO-DECLARADO" if nd else ""),
753             ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n"
754               f"#   Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n"
755               f"#   La del origen no sirve tal cual.\n#\n")
756              if s.get("alternativa") and s["alternativa"] != s["name"] else "")
757             + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n"
758              f"#   {origen_binario(s)[1]}\n"
759              f"#\n"
760              f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"
761              f"#   cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n"
762              + (f"#   cwd    : {s['cwd']}\n" if s.get("cwd") else "")
763              + (f"#   puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "")
764              + f"#\n# 1) conseguir el binario según lo de arriba\n"
765              f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n"
766              f"#    (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n"
767              f"# 3) arrancarlo"),
768             verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'"
769                      + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'"
770                         if s.get("ports") else ""),
771             nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo "
772                  "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la "
773                  "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else
774                  "Declarado en el origen: traducir su unidad al init del destino.")
775
776    # ── 3. dominios ─────────────────────────────────────────────────────────────────────────────
777    for d in doms_muda:
778        paso("dominio", f"apuntar {d['domain']} a la máquina nueva",
779             f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → <IP NUEVA>",
780             verifica=f"getent ahostsv4 {d['domain']} | head -1 && "
781                      f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/",
782             nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. "
783                  "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus "
784                  "propios dominios puede disparar su fail2ban (pasó).")
785
786    # ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
787    if muere:
788        # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
789        # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
790        # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
791        # supiera dependía de que alguien se acordara.
792        det = []
793        for t, n, e in muere:
794            tam = f"  ({e.get('size')})" if e.get("size") else ""
795            det.append(f"#   {t:9} {n}{tam}")
796
797        # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
798        # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
799        # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
800        # supiera dependía de que alguien se acordara.
801        #
802        # Se lista UN RENGLÓN POR DIRECTORIO, no uno por dominio: siete dominios `*.gioser.net` que
803        # mueren reclaman el mismo `gioser.bak`, y repetirlo siete veces convierte el aviso en ruido.
804        # Y se dice «podría», nunca «es»: el directorio se llama `terapeuta` y el dominio
805        # `terapeuta.ec`. Emparejar «casi» acierta casi siempre, y el resto de las veces manda a
806        # borrar lo que no era.
807        if contenido:
808            doms_muertos = [n for t, n, _ in muere if t == "dominio"]
809            det.append("#")
810            det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.")
811            # ⚠ Un directorio que la CONFIG referencia no es un huérfano, diga lo que diga su
812            # nombre. Sin esto el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda»
813            # estando servido en la línea 174 del Caddyfile — y ese aviso, aplicado, tira el sitio.
814            servidos = {}
815            for st in censo.get("sitio", []):
816                if st.get("raiz"):
817                    servidos.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
818            for c in contenido:
819                ruta = c["path"].rstrip("/")
820                quien = [d for d in doms_muertos if c in candidatos(d, [c])]
821                if ruta in servidos:
822                    marca = ("⚠ LO SIRVE la config para: " +
823                             ", ".join(" ".join(servidos[ruta]).split()[:3]) +
824                             " — no es huérfano; moverlo tira el sitio")
825                elif quien:
826                    marca = ("podría ser de: " + ", ".join(quien[:4]) +
827                             (f" (+{len(quien)-4})" if len(quien) > 4 else ""))
828                else:
829                    marca = "no corresponde a NINGÚN dominio que muera ni lo sirve la config: miralo"
830                det.append(f"#   {ruta:34} {c['size']:>6}   ← {marca}")
831
832        paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
833             "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
834             + "\n".join(det),
835             verifica="revisión humana: leer la lista completa antes de destruir el origen",
836         verifica_tipo="humano",
837             nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté "
838                  "escrito ES el paso.")
839    return P
840
841
842def emitir(censo, P, target):
843    L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).",
844         "#",
845         "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por",
846         "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.",
847         'schema = "takana-plan/1"',
848         f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"',
849         f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"',
850         f'destino = "{target}"',
851         ""]
852    for i, p in enumerate(P, 1):
853        L.append(f"[[paso]]")
854        L.append(f'n = {i}')
855        L.append(f'clase = "{p["clase"]}"')
856        L.append(f'titulo = """{p["titulo"]}"""')
857        L.append("cmd = " + bloque_toml(p["cmd"]))
858        if p["verifica"]:
859            L.append("verifica = " + bloque_toml(p["verifica"]))
860            L.append(f'verifica_tipo = "{p["verifica_tipo"]}"')
861        if p["nota"]:
862            L.append("nota = " + bloque_toml(p["nota"]))
863        L.append("")
864    return "\n".join(L) + "\n"
865
866
867def main():
868    ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.")
869    ap.add_argument("--censo", required=True)
870    ap.add_argument("--target", required=True, help="usuario@host destino")
871    ap.add_argument("--key", help="clave SSH para el destino")
872    ap.add_argument("--decide", action="store_true",
873                    help="mostrar TODO con su recomendación y su motivo, y elegir")
874    ap.add_argument("--revisar", action="store_true",
875                    help="sólo mostrar la revisión con recomendaciones, sin preguntar")
876    ap.add_argument("--out", help="fichero del plan (default: stdout)")
877    a = ap.parse_args()
878
879    with open(a.censo, "rb") as f:
880        censo = tomllib.load(f)
881    if a.revisar:
882        revisar(censo, interactivo=False)
883        return
884    if a.decide:
885        censo = revisar(censo, interactivo=True)
886    validar(censo)
887
888    P = pasos(censo, a.target, a.key)
889    por_clase = {}
890    for p in P:
891        por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1
892    print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══")
893    for c, n in por_clase.items():
894        print(f"   {c:10} {n:3} paso(s)")
895    txt = emitir(censo, P, a.target)
896
897    # ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ──────────────────────
898    # No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea
899    # rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino
900    # DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y
901    # releer cuesta milisegundos y convierte un fallo diferido en uno inmediato.
902    try:
903        tomllib.loads(txt)
904    except tomllib.TOMLDecodeError as e:
905        sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n"
906                 f"  No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n"
907                 f"  exportar, que son las dos únicas cosas para las que existe.")
908
909    if a.out:
910        open(a.out, "w").write(txt)
911        print(f"\n   plan → {a.out}   ({len(P)} pasos, con su comando literal cada uno)")
912    else:
913        print(txt)
914
915
916if __name__ == "__main__":
917    main()