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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ main scripts/mudanza/planear.py Python · 1272 siq'ikuna · 78.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

mudanza: decidir SIN teclas unitarias, `/mnt/vvv` entra al censo, y 25 rutas de secretos más 1c237f9da · 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
39import censar                                              # noqa: E402
40from censar import candidatos_de as candidatos             # noqa: E402
41
42_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
43_AQUI = os.path.join("scripts", "mudanza", "traducir.py")
44
45# ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
46
47_MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}
48
49
50def a_mib(s):
51    """'61.5G' → MiB. Devuelve None si no se entiende: un tamaño ilegible NO se inventa como 0,
52    porque un 0 inventado apaga el preflight justo cuando más hace falta."""
53    s = (s or "").strip()
54    if not s or not s[0].isdigit():
55        return None
56    try:
57        return float(s[:-1]) * _MUL.get(s[-1].upper(), 1) if s[-1].isalpha() else float(s)
58    except ValueError:
59        return None
60
61
62def humano(mib):
63    if mib is None:
64        return "?"
65    return f"{mib/1024:.1f}G" if mib >= 1024 else f"{mib:.0f}M"
66
67
68# ── decisión ────────────────────────────────────────────────────────────────────────────────────
69
70# ── TRES CAMINOS, NO DOS (pedido del usuario, 2026-09-16) ───────────────────────────────────────
71# Con `muda`/`muere` una cosa que no va a la caja nueva sólo podía MORIR, así que todo lo dudoso se
72# marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. El tercer
73# camino es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero
74# perder*. `respalda` lo copia al corral de respaldo y NO lo instala en el destino.
75#
76# ⚠ `respalda` cuenta como «no se muda» para la recomendación derivada de los servicios: un binario
77# que queda archivado no puede arrancar en la caja nueva. Confundirlos daría un servicio marcado
78# `muda` cuyo ejecutable no va a existir allá — y eso se descubre al arrancarlo, no al decidir.
79DECISIONES = ("muda", "respalda", "muere")
80
81
82def entradas(censo):
83    """Todo lo que necesita una decisión, con una etiqueta legible."""
84    out = []
85    for s in censo.get("servicio", []):
86        # `declarado-muerto` no necesita decisión: no corre. Se informa y se deja morir.
87        if s.get("class") != "declarado-muerto":
88            out.append(("servicio", s["name"], s))
89    for d in censo.get("dominio", []):
90        # ⚠ Los `fosil-sin-dns` ENTRAN. Antes se filtraban con el argumento de que un dominio sin DNS
91        # no necesita ningún paso — y es cierto para el DNS, pero no para lo que arrastra: en gioser
92        # son 13 de 29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el caddy. Sacarlos
93        # de la lista los volvía INVISIBLES, así que nadie decidía borrarlos y sus datos viajaban a
94        # la caja nueva por omisión. Es exactamente el «mudar fósiles» que este plan existe para
95        # evitar, y la regla 2 dice que lo que muere se nombra ANTES de borrar nada.
96        out.append(("dominio", d["domain"], d))
97    for d in censo.get("datos", []):
98        out.append(("datos", d["path"], d))
99    return out
100
101
102# ── servicios que NO APLICAN en una caja remota ─────────────────────────────────────────────────
103# Correr en el origen no significa que tenga sentido en el destino. Un servidor en un datacenter no
104# tiene bluetooth, ni módem, ni batería, ni teclado — y arrastrarlos es peor que inútil: algunos
105# PELEAN con el destino. Cada familia con su motivo, porque una recomendación sin motivo no se puede
106# discutir y entonces se acepta a ciegas o se ignora entera.
107NO_APLICA = {
108    "hardware local (una caja remota no tiene ese dispositivo)": {
109        "bluetoothd", "bluetooth", "modemmanager", "upowerd", "upower", "acpid", "adb",
110        "thermald", "iio-sensor-proxy", "fwupd", "alsactl", "pcscd", "cups", "cupsd",
111    },
112    "escritorio o pantalla (un servidor no los tiene)": {
113        "waypipe", "xorg", "x", "wayland", "gdm", "sddm", "lightdm", "pipewire",
114        "wireplumber", "pulseaudio", "carmen-dm",
115    },
116    "la red del destino ya la configura su propio init — pelearían": {
117        "networkmanager", "dhcpcd", "wpa-supplicant", "connman", "systemd-networkd", "dhclient",
118    },
119    "el destino tiene su propio journal (la semilla de arje declara `provides: [\"Spawn\", \"Journal\"]`)": {
120        "metalog", "syslogd", "syslog-ng", "rsyslogd", "rsyslog", "busybox-syslogd", "socklog",
121    },
122    "lo provee el init del destino, no se muda": {
123        "agetty", "getty", "console-getty", "udevd", "udevadm", "systemd-udevd", "eudev",
124        "elogind-daemon", "elogind", "polkitd", "dbus-daemon", "dbus",
125    },
126}
127
128
129def _no_aplica(nombre):
130    n = nombre.lower().replace("_", "-")
131    for motivo, conjunto in NO_APLICA.items():
132        if n in conjunto:
133            return motivo
134    return None
135
136
137# Paquetes que proveen un RUNTIME, no un programa. Que exista `recipes/nodejs.toml` no significa que
138# `openclaw` esté cubierto: el intérprete se resuelve y el programa sigue faltando.
139INTERPRETES = {"python", "python3", "nodejs", "node", "perl", "ruby", "php", "lua", "java", "jre"}
140
141# El paquete del origen y la receta del catálogo no siempre se llaman igual ni siquiera para el mismo
142# intérprete: Artix empaqueta `python` y el catálogo tiene `python3.toml`. Sin esto, tres servicios
143# salían «hace falta una receta takana» cuando el runtime YA está sellado y lo que falta es el
144# programa — dos trabajos muy distintos.
145ALIAS_INTERPRETE = {"python": "python3", "node": "nodejs", "jre": "java"}
146
147
148# ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
149# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de
150# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es
151# mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar.
152FAMILIAS = {
153    "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"],
154    "base de datos":        ["postgresql", "mariadb", "mysql", "sqlite"],
155    "caché en memoria":     ["redis", "valkey", "memcached"],
156    "forja git":            ["gitea", "forgejo", "gitlab"],
157    "contenedores":         ["docker", "podman", "containerd"],
158    "dns":                  ["unbound", "bind", "dnsmasq", "coredns"],
159    "correo":               ["postfix", "exim", "opensmtpd"],
160    "base vectorial":       ["qdrant", "milvus", "weaviate"],
161}
162
163
164# ── enganche con el CENTRO DE TRADUCCIÓN ────────────────────────────────────────────────────────
165# El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es
166# cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta.
167# Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad:
168# los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se
169# desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo.
170
171# Dónde vive la declaración del servicio en cada init de origen.
172_RUTA_UNIDAD = {"openrc":  "/etc/init.d/{n}",
173                "systemd": "/etc/systemd/system/{n}.service"}
174
175# Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que
176# nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se
177# adivina — se dice que hay que ubicarlo a mano.
178_RUTA_CONFIG = {"nginx":  "/etc/nginx/nginx.conf",
179                "apache": "/etc/apache2/apache2.conf",
180                "httpd":  "/etc/httpd/conf/httpd.conf",
181                "caddy":  "/etc/caddy/Caddyfile"}
182
183
184def traducciones(s):
185    """Comandos de traducción que le corresponden a un servicio que se muda.
186
187    Dos cosas distintas, y confundirlas es un error caro:
188      · la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje;
189      · la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy.
190    Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer
191    traducirla sería inventar trabajo."""
192    lectores, escritores = formatos.cargar()
193    out = []
194
195    # 1) la declaración → tarjeta de arje (el init del destino)
196    if "arje" in escritores:
197        for fuente in s.get("declared_in", []):
198            if fuente in lectores and fuente in _RUTA_UNIDAD:
199                ruta = _RUTA_UNIDAD[fuente].format(n=s["name"])
200                out.append({
201                    "que": f"declaración de {s['name']} ({fuente} → arje)",
202                    "cmd": f"{_AQUI} --from {fuente} --to arje \\\n"
203                           f"      --in {ruta} --out {s['name']}.card.json",
204                    "nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con "
205                            "código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, "
206                            "es el acta pidiendo que la leas."})
207                break
208
209    # 2) la configuración → sólo si se cambia de servicio
210    alt = s.get("alternativa")
211    if alt and alt != s["name"]:
212        desde, hacia = s["name"].lower().replace("_", "-"), alt.lower()
213        if desde in lectores and hacia in escritores and \
214           lectores[desde][1] == escritores[hacia][1]:
215            org = _RUTA_CONFIG.get(desde, f"<config de {desde}: ubicala>")
216            dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
217            out.append({
218                "que": f"configuración de {desde} → {hacia}",
219                "cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n"
220                       f"      --in {org} --out {os.path.basename(dst)}",
221                "nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar "
222                        "sin resolver el acta es desplegar un servidor incompleto que además "
223                        "parece funcionar."})
224        else:
225            # No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada.
226            out.append({
227                "que": f"configuración de {desde} → {hacia}",
228                "cmd": f"# NO hay traductor `{desde} → {hacia}` en el centro.\n"
229                       f"#   Ver los que sí hay:  {_AQUI} --list\n"
230                       f"#   Hay que reescribir la configuración a mano.",
231                "nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."})
232    return out
233
234
235def _receta(nombre, repo):
236    """(existe, sellado) para un nombre de receta."""
237    import glob
238    f = os.path.join(repo, "recipes", f"{nombre}.toml")
239    if not os.path.exists(f):
240        return False, False
241    return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}")))
242
243
244def alternativas(nombre, repo):
245    """Equivalentes funcionales del servicio, con una recomendación RAZONADA.
246
247    ── LOS CRITERIOS, Y POR QUÉ ÉSOS ────────────────────────────────────────────────────────────
248    No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que
249    no recomendar. Se recomienda con hechos comprobables y en este orden:
250
251      1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un
252         binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre
253         supera cualquier ventaja teórica del otro.
254      2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente
255         — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta.
256      3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin
257         decir que ese otro tampoco está.
258
259    Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna
260    familia conocida — que es lo normal y no es un error."""
261    n = nombre.lower().replace("_", "-")
262    fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None)
263    if not fam:
264        return None, None, None
265    lista = []
266    for cand in FAMILIAS[fam]:
267        existe, sellado = _receta(cand, repo)
268        lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n})
269    yo = next((x for x in lista if x["actual"]), None)
270    disponibles = [x for x in lista if x["sellado"]]
271
272    if yo and yo["sellado"]:
273        return (lista, n,
274                f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia "
275                f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera")
276    if disponibles:
277        otros = ", ".join(x["nombre"] for x in disponibles)
278        return (lista, disponibles[0]["nombre"],
279                f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí "
280                f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una "
281                f"decisión, no un trámite")
282    return (lista, None,
283            f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus "
284            f"equivalentes. Hay que escribir la receta del que se elija")
285
286
287def origen_binario(e, repo=None):
288    """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino.
289
290    «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién
291    posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de
292    takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto:
293
294      · `receta-takana`  — lo mejor: takana ya sabe construirlo. Se instala del repo firmado.
295      · `paquete-ajeno`  — lo trae el gestor del origen. En el destino hace falta una receta, o
296                           `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena.
297      · `suelto`         — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol
298                           o escribirle una receta. Es el que se pierde al apagar el origen.
299
300    Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`,
301    `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad."""
302    nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "")
303    # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos
304    # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`,
305    # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica.
306    repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
307    if not os.path.isdir(os.path.join(repo, "recipes")):
308        # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y
309        # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta.
310        raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — "
311                         f"pasá --repo o corré desde el repo")
312    # ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
313    # `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
314    # SÍ construye y tiene selladas. El censo ya le preguntó al gestor de paquetes quién posee el
315    # binario, así que ese nombre también se prueba.
316    import glob
317    for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or ""),
318                 pkg, (pkg or "").removesuffix("-bin"), ALIAS_INTERPRETE.get(pkg or "", "")):
319        if not cand or not os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
320            continue
321        # ⚠⚠ Y la trampa, que es la que haría mentir al perfil: un paquete INTÉRPRETE provee el
322        # runtime, no el programa. `openclaw` lo posee `nodejs` y `fail2ban-server` lo posee
323        # `python`; tener esas recetas NO te da esos servicios. Contarlos como cubiertos haría que el
324        # perfil saliera N/N describiendo un servidor al que le faltan cuatro programas.
325        if pkg in INTERPRETES and cand in (pkg, ALIAS_INTERPRETE.get(pkg or "", "")) and cand != nombre:
326            sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
327            return ("interprete",
328                    f"lo corre el intérprete `{pkg}`, que takana SÍ construye (`recipes/{cand}.toml`"
329                    + (", sellada)" if sellado else ", sin sellar)")
330                    + f" — pero el intérprete NO es el programa: `{nombre}` es código que hay que "
331                    f"llevar y declarar aparte. El perfil resuelve el runtime y nada más")
332        sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
333        via = "" if cand in (nombre, nombre.replace("_", "-")) else f" (por su paquete `{pkg}`)" \
334            if cand in (pkg, (pkg or "").removesuffix("-bin")) else f" (por su binario)"
335        return ("receta-takana",
336                f"takana YA lo construye{via}: `recipes/{cand}.toml`"
337                + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)")
338                + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}")
339    if pkg:
340        return ("paquete-ajeno",
341                f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "
342                f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)")
343    if exe and e.get("exec_borrado"):
344        # Su propia clase, y la más urgente del censo: el fichero YA NO EXISTE. No es «hay que
345        # llevarlo», es «se pierde en el próximo reinicio y el que está en su ruta NO es el mismo».
346        pid = e.get("pid_vivo") or (e.get("pids") or [0])[0]
347        bv, bd = e.get("bytes_vivo", ""), e.get("bytes_disco", "")
348        dif = (f" El que hay AHORA en esa ruta pesa {bd} bytes y el que corre {bv}: NO SON EL MISMO."
349               if bv and bd and bv != bd else
350               (" En esa ruta ya no hay nada." if not bd else ""))
351        return ("borrado",
352                f"⚠ el binario que CORRE fue borrado o reemplazado: `{exe}` ya no existe en disco."
353                f"{dif} Sólo vive mientras viva el pid {pid}; al reiniciar el origen se pierde PARA "
354                f"SIEMPRE. Rescatalo ya:  sudo cat /proc/{pid}/exe > {os.path.basename(exe)}  "
355                f"&& chmod +x {os.path.basename(exe)}")
356    if exe:
357        return ("suelto",
358                f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
359                f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
360                f"el origen")
361    motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
362    return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
363
364
365def recomendar(tipo, e, datos_que_mueren=(), sitios=(), locales=()):
366    """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
367    que aceptarla sea barato y equivocarse sea caro de no notar.
368
369    Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
370    ciegas o se ignora entera. Las dos son malas."""
371    c = e.get("class", "")
372
373    if tipo == "servicio":
374        m = _no_aplica(e.get("name", ""))
375        if m:
376            return "muere", m
377        # DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
378        # va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
379        exe = (e.get("cmdline") or "").split(" ")[0]
380        for raiz in datos_que_mueren:
381            if exe.startswith(raiz.rstrip("/") + "/"):
382                return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
383        if c == "no-declarado":
384            p = e.get("ports") or []
385            return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
386                            "y NADIE lo declara" if p else
387                            "corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
388        if c == "vivo":
389            return "muda", "declarado y corriendo"
390        return "", ""
391
392    if tipo == "dominio":
393        # ⚠ Evidencia más fuerte que el DNS, y sin tocar la red: si el bloque de este dominio en la
394        # config del servidor apunta a un directorio que NO EXISTE, no hay nada que servir — devuelve
395        # 502 resuelva donde resuelva. Medido en gioser: `aura`, `sigma` y `kosmofono` apuntan a
396        # raíces borradas, y son justo los que en su día devolvían los 502 que hicieron que el censo
397        # se baneara a sí mismo al sondearlos por HTTP. Se lee del fichero; no hace falta preguntar.
398        for st in sitios:
399            if e.get("domain") in (st.get("nombres") or "").split() and st.get("raiz_existe") == "no":
400                return "muere", (f"su bloque en {st['config']} apunta a `{st['raiz']}`, que NO "
401                                 f"EXISTE: no hay nada que servir, devuelve 502 resuelva donde resuelva")
402        if c == "vivo":
403            return "muda", "resuelve a esta máquina y contesta"
404        if c == "ya-mudado":
405            return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
406        if c == "backend-caido":
407            return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
408        if c == "apunta-aca":
409            # El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el
410            # silencio como una respuesta afirmativa, que es el error que este censo evita en todos
411            # los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero
412            # se DICE lo que no se miró.
413            return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. "
414                            "⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil")
415        if c == "fosil-sin-dns":
416            return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó "
417                             "contenido en disco o un bloque en el servidor web — eso sí viaja si "
418                             "nadie lo borra")
419        if c == "sin-sondear":
420            return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
421        return "", ""
422
423    if tipo == "datos":
424        # ── LO QUE SIGUE SIN RECOMENDARSE, Y LO QUE SÍ ──────────────────────────────────────────
425        # La regla de fondo no cambió: **qué datos VALEN no se deduce de la máquina** — `terapeuta.ec`
426        # eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso.
427        # Pero hay tres hechos que sí decide la máquina, y callarlos obliga a mirar 169 entradas a
428        # ojo: que el servidor web lo SIRVA, que un proceso vivo lo USE, y que sea recuperable de
429        # otro lado (un repo limpio con remoto) o regenerable (una caché). Se recomienda SÓLO sobre
430        # esos hechos, con el hecho como motivo, y el resto sale en blanco a propósito.
431        if e.get("servido_por"):
432            return "muda", ("lo SIRVE " + ", ".join(e["servido_por"]) +
433                            ": si no viaja, ese sitio deja de existir")
434        if e.get("usado_por"):
435            return "muda", ("lo usa un proceso vivo (" + ", ".join(e["usado_por"]) +
436                            "): un servicio que se muda sin sus datos arranca vacío")
437        base = os.path.basename(e.get("path", ""))
438        if base in _CACHES or base in ("swap", "lost+found"):
439            return "muere", f"`{base}` es caché o estado transitorio: se regenera solo en el destino"
440        rem = e.get("git_remoto") or ""
441        # ⚠ UN REMOTO QUE APUNTA A ESTA MISMA MÁQUINA NO ES UN RESPALDO, ES EL MISMO DISCO.
442        # Sin esto, «tiene remoto ⇒ se vuelve a clonar» recomienda ABANDONAR un repo cuyo único
443        # ejemplar se borra con la máquina. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es
444        # `ssh://git@127.0.0.1:2345/…` — el gitea de acá. El censo ya lo sabe (`[[repo]] solo_aqui`)
445        # y lo que faltaba era cruzarlo.
446        if rem and any(x and x in rem for x in locales):
447            return "respalda", (f"su remoto ({rem}) es ESTA MISMA MÁQUINA: no es un respaldo, es el "
448                                "mismo disco. Clonarlo después de borrarla no se puede")
449        if rem and not e.get("git_sucio"):
450            return "muere", (f"repo git LIMPIO con remoto ({rem}): se vuelve a clonar")
451        if rem and e.get("git_sucio"):
452            return "respalda", ("repo git con remoto pero CON CAMBIOS SIN COMMITEAR: lo commiteado "
453                                "se vuelve a clonar, lo de encima sólo está acá")
454        if e.get("git_fecha") and not rem:
455            return "respalda", (f"repo git SIN remoto (último commit {e['git_fecha']}): su historia "
456                                "existe únicamente en este disco")
457        return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
458    return "", ""
459
460
461# Nombres cuyo contenido se REGENERA. No es una lista de «cosas grandes»: es de cosas que el destino
462# vuelve a crear solo. Borrar por tamaño sería adivinar; por regenerabilidad es un hecho.
463_CACHES = {
464    ".cache", ".npm", ".npm-global", ".gradle", ".pub-cache", ".dartServer", ".cargo", ".rustup",
465    ".m2", ".gem", ".go", "go", ".yarn", ".bun", ".deno", ".venvs", ".local-cache", "node_modules",
466    "target", "__pycache__", ".mozilla-cache", ".thumbnails", ".ccache", ".sccache",
467}
468
469
470def sugerir(tipo, e, datos_que_mueren=()):
471    return recomendar(tipo, e, datos_que_mueren, sitios)[0]
472
473
474def _etiqueta(tipo, nombre, e):
475    if tipo == "datos":
476        extra = e.get("size", "?")
477        if e.get("size_copy") and e["size_copy"] != e.get("size"):
478            extra += f" (copiar {e['size_copy']})"
479        return extra
480    if tipo == "servicio":
481        p = e.get("ports") or []
482        return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
483    return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
484
485
486def revisar(censo, interactivo=True):
487    """Muestra TODO con su recomendación y su motivo, y deja elegir.
488
489    ── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
490    Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
491    servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
492    arrancar allá. Al revés no se puede calcular.
493    """
494    todos = entradas(censo)
495    datos = [(t, n, e) for t, n, e in todos if t == "datos"]
496    # Con qué se compara un remoto para saber si apunta a esta misma máquina. Sale del censo, no de
497    # una lista cableada: las IPs y el hostname los midió `identidad()`.
498    maq = censo.get("maquina", {})
499    locales = {"127.0.0.1", "localhost", "0.0.0.0"} | set(maq.get("ips", []) or [])
500    if maq.get("hostname"):
501        locales.add(maq["hostname"])
502    resto = [(t, n, e) for t, n, e in todos if t != "datos"]
503
504    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
505    if datos:
506        print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
507        print("   Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
508        print("   la recomendación de los servicios, así que va primero.\n")
509        for t, n, e in datos:
510            marca = e.get("decision") or "—"
511            rec, motivo = recomendar("datos", e, locales=locales)
512            e["_rec"], e["_motivo"] = rec, motivo
513            print(f"   {n:46} {e.get('size','?'):>6}  {marca if marca != '—' else (rec or '—'):>8}"
514                  f"  {e.get('descripcion', '')[:70]}")
515        if interactivo:
516            print("\n   [m]uda = va a la caja nueva · [r]espalda = se copia al corral y NO se instala"
517                  " · [x] muere con la máquina\n")
518            for t, n, e in datos:
519                if e.get("decision") in DECISIONES:
520                    continue
521                rec = e.get("_rec", "")
522                print(f"   {n}   {e.get('size','?')}")
523                if e.get("descripcion"):
524                    print(f"      {e['descripcion'][:100]}")
525                if e.get("_motivo"):
526                    print(f"      ↳ sugerido: {rec} — {e['_motivo'][:90]}")
527                r = input(f"      [m]uda/[r]espalda/[x]muere"
528                          f"{'/Enter=' + rec if rec else ''} > ").strip().lower()
529                e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(r, "")
530
531    # ⚠ `respalda` entra acá junto a `muere`: las dos significan «su ruta NO va a existir en el
532    # destino», que es lo único que la derivación de servicios necesita saber.
533    mueren = [e["path"] for t, n, e in datos if e.get("decision") in ("muere", "respalda")]
534
535    # ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
536    grupos = {"muda": [], "respalda": [], "muere": [], "": []}
537    for t, n, e in resto:
538        d, motivo = recomendar(t, e, mueren, censo.get("sitio", []))
539        e["_rec"], e["_motivo"] = d, motivo
540        grupos[d].append((t, n, e))
541
542    print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
543    titulos = {"muda": "se recomienda MUDAR", "respalda": "se recomienda RESPALDAR",
544               "muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"}
545    for g in ("muda", "respalda", "muere", ""):
546        if not grupos[g]:
547            continue
548        print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
549        for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
550            print(f"   {t:8} {n:30} {_etiqueta(t,n,e):>18}")
551            if e.get("_motivo"):
552                print(f"            └─ {e['_motivo']}")
553            if t == "servicio" and e.get("decision") != "muere" and g != "muere":
554                oc, od = origen_binario(e)
555                print(f"            └─ binario: [{oc}] {od[:96]}")
556                if e.get("usuario") and e["usuario"] != "root":
557                    # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
558                    # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
559                    # corre sin privilegios termina de root en el destino y nadie se entera.
560                    print(f"            └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
561                          f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
562                lista, rec, motivo = alternativas(n, _REPO)
563                if lista:
564                    op = "  ".join(
565                        ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"])
566                        + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗"))
567                        for x in lista)
568                    print(f"            └─ alternativas: {op}    (✓ sellado · receta sin sellar · ✗ no está)")
569                    print(f"               recomendado: {rec or 'ninguno'} — {motivo[:110]}")
570
571    if not interactivo:
572        return censo
573
574    print("\n   [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir")
575    r = input("   > ").strip().lower()
576    if r == "q":
577        return censo
578    for t, n, e in resto:
579        if e.get("decision") in DECISIONES:
580            continue
581        rec = e.get("_rec", "")
582        if r == "a" and rec:
583            e["decision"] = rec
584            continue
585        sug = f"[{rec}]" if rec else "[sin recomendación]"
586        resp = input(f"   {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
587                     f"            [m]uda/[r]espalda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
588        e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(resp, "")
589        if e["decision"] == "muda" and t == "servicio":
590            lista, alt_rec, motivo = alternativas(n, _REPO)
591            if lista and len([x for x in lista if x["sellado"]]) > 1:
592                op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "")
593                              for i, x in enumerate(lista))
594                print(f"            alternativas: {op}")
595                print(f"            recomendado: {alt_rec} — {motivo[:100]}")
596                r2 = input(f"            número, o Enter para {alt_rec}: ").strip()
597                if r2.isdigit() and int(r2) < len(lista):
598                    e["alternativa"] = lista[int(r2)]["nombre"]
599                else:
600                    e["alternativa"] = alt_rec
601    return censo
602
603
604def decidir_desde_fichero(censo, ruta_dec):
605    """Decidir EN LOTE desde un fichero de texto: `<ruta-o-nombre> <decision>` por línea.
606
607    ── POR QUÉ EXISTE (pedido del usuario, 2026-09-16) ─────────────────────────────────────────
608    «no sé cómo activar o desactivar cosas aquí desde shuma llimphi remoto, que las opciones son
609    con teclas unitarias». El modo interactivo de `--decide` lee una tecla por entrada, y hay
610    terminales —la consola remota de shuma, entre otras— donde eso no se puede usar. Una
611    herramienta cuya única forma de decidir exige un tipo de terminal no es una herramienta: es una
612    herramienta *para esa terminal*. El fichero funciona en cualquiera, se revisa antes de
613    aplicarlo, se versiona y se vuelve a correr — que es lo mismo que ya se pide del plan.
614
615    Formato, deliberadamente pobre (`#` comenta, se ignoran líneas vacías):
616
617        /home/sergio/mapas        muda
618        terapeuta                 respalda      # basta con que el nombre sea único
619        /var/lib/swap             muere
620
621    ⚠ Una clave que no empareja con NADA es un ERROR RUIDOSO, no un aviso: significa que alguien
622    decidió algo que no se aplicó, y el silencio ahí es exactamente la clase de fallo que este
623    programa existe para no tener. Lo mismo si empareja con varias: se nombran todas y no se toca
624    ninguna, porque elegir una por su cuenta sería adivinar.
625    """
626    lineas = []
627    with open(ruta_dec, encoding="utf-8") as fh:
628        for i, l in enumerate(fh, 1):
629            l = l.split("#", 1)[0].strip()
630            if l:
631                lineas.append((i, l))
632    todos = entradas(censo)
633    aplicadas, problemas = 0, []
634    for i, l in lineas:
635        partes = l.rsplit(None, 1)
636        if len(partes) != 2 or partes[1] not in DECISIONES:
637            problemas.append(f"línea {i}: «{l}» — falta la decisión o no es una de "
638                             f"{'/'.join(DECISIONES)}")
639            continue
640        clave, dec = partes
641        cands = [(t, n, e) for t, n, e in todos
642                 if n == clave or os.path.basename(str(n)) == clave]
643        if not cands:
644            problemas.append(f"línea {i}: «{clave}» no empareja con NADA del censo")
645            continue
646        if len(cands) > 1:
647            problemas.append(f"línea {i}: «{clave}» empareja con {len(cands)}: "
648                             + ", ".join(n for _t, n, _e in cands[:5]))
649            continue
650        cands[0][2]["decision"] = dec
651        aplicadas += 1
652    if problemas:
653        print("\n!! decisiones NO aplicadas:", file=sys.stderr)
654        for x in problemas:
655            print(f"   {x}", file=sys.stderr)
656        sys.exit("\n   No se escribe nada: un lote a medias deja decidido lo que nadie revisó.")
657    return aplicadas
658
659
660def guardar_censo(censo, ruta, respaldar=True):
661    """Vuelve a escribir el censo con las decisiones puestas, usando el MISMO emisor que lo creó.
662
663    Dos detalles que no son de estilo: se descartan las claves internas (`_rec`, `_motivo`, que
664    `revisar()` cuelga de cada entrada y NO son parte del censo), y se escribe con temporal +
665    `rename` — si la herramienta muere a mitad, el fichero anterior sigue entero. Perder el censo a
666    medio decidir es perder el trabajo que más cuesta de toda la mudanza."""
667    limpio = {}
668    for k, v in censo.items():
669        if isinstance(v, list):
670            limpio[k] = [{kk: vv for kk, vv in e.items() if not kk.startswith("_")}
671                         if isinstance(e, dict) else e for e in v]
672        else:
673            limpio[k] = v
674    # El emisor de `censar` habla en plural; el TOML, en singular. El puente va acá y no allá para
675    # no tocar el censo, que es quien define el formato.
676    puente = {
677        "fecha": limpio.get("fecha", ""), "identidad": limpio.get("maquina", {}),
678        "servicios": limpio.get("servicio", []), "huerfanos": limpio.get("huerfano", []),
679        "puertos": limpio.get("puerto", []), "dominios": limpio.get("dominio", []),
680        "datos": limpio.get("datos", []), "datos_raiz": limpio.get("datos_raiz", []),
681        "contenido": limpio.get("contenido", []), "sitios": limpio.get("sitio", []),
682        "repos": limpio.get("repo", []), "montajes": limpio.get("montaje", []),
683        "fuera_de_git": limpio.get("fuera_de_git", []),
684    }
685    texto = censar.emitir_toml(puente)
686    # Y se RELEE antes de pisar nada: el mismo guardián que el plan. Un censo que no vuelve a
687    # parsear deja la mudanza sin su fichero central y sin forma de saber cuándo se rompió.
688    try:
689        vuelto = tomllib.loads(texto)
690    except tomllib.TOMLDecodeError as e:
691        sys.exit(f"✗ el censo regenerado NO es TOML válido: {e}\n  No se escribe nada.")
692    n_dec = sum(1 for _t, _n, x in entradas(vuelto) if x.get("decision"))
693    n_esp = sum(1 for _t, _n, x in entradas(censo) if x.get("decision"))
694    if n_dec != n_esp:
695        sys.exit(f"✗ el censo regenerado perdió decisiones ({n_esp} → {n_dec}). No se escribe nada.")
696    if respaldar and os.path.exists(ruta):
697        with open(ruta, encoding="utf-8") as fh:
698            open(ruta + ".bak", "w", encoding="utf-8").write(fh.read())
699    tmp = ruta + ".tmp"
700    with open(tmp, "w", encoding="utf-8") as fh:
701        fh.write(texto)
702        fh.flush()
703        os.fsync(fh.fileno())
704    os.replace(tmp, ruta)
705
706
707def validar(censo):
708    """Regla 1: nada sin decidir se ejecuta."""
709    faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
710    if faltan:
711        print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
712        print("   El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
713              file=sys.stderr)
714        for t, n in faltan[:40]:
715            print(f"     {t:9} {n}", file=sys.stderr)
716        if len(faltan) > 40:
717            print(f"     … y {len(faltan)-40} más", file=sys.stderr)
718        print("\n   Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
719        sys.exit(2)
720
721
722# ── plan ────────────────────────────────────────────────────────────────────────────────────────
723
724_TRIPLE_SIMPLE = "'" * 3
725_TRIPLE_DOBLE = '"' * 3
726
727
728def bloque_toml(texto):
729    """Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
730
731    ⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
732    que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
733    (`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
734    se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
735    recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
736
737    Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
738    con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
739    if _TRIPLE_SIMPLE not in texto:
740        return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
741    escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
742    return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
743
744
745def incoherencias(censo):
746    """Cruza lo que la config DECLARA NECESITAR contra lo que se decidió con cada servicio.
747
748    ── LO QUE ESTO CAZA Y NADA MÁS PODÍA ───────────────────────────────────────────────────────
749    Un dominio que se muda y el servicio que lo sirve marcado `muere`. Las dos decisiones son
750    razonables por separado —el dominio se usa, el servicio parecía prescindible— y juntas dejan el
751    sitio nuevo devolviendo 502. No lo ve el DNS, no lo ve el censo de procesos y no lo ve quien
752    decide de a una entrada por vez, que es como se decide.
753
754    También caza el revés: un `reverse_proxy` a un puerto que NINGÚN servicio censado sirve. Eso ya
755    está roto HOY, en el origen, y mudarlo es mudar un 502."""
756    por_puerto, por_nombre = {}, {}
757    for s in censo.get("servicio", []):
758        por_nombre[s["name"].lower()] = s
759        for p in s.get("ports", []):
760            por_puerto[str(p)] = s
761    dec = {}
762    for d in censo.get("dominio", []):
763        dec[d["domain"]] = d.get("decision", "")
764
765    out = []
766    for st in censo.get("sitio", []):
767        doms = [d for d in (st.get("nombres") or "").split() if dec.get(d) == "muda"]
768        if not doms:
769            continue
770        for destino in (st.get("proxies") or "").split():
771            if destino.startswith("unix/"):
772                # `unix//run/php-fpm/php-fpm.sock` → el servicio se identifica por su nombre.
773                base = destino.rstrip("/").split("/")[-1].removesuffix(".sock")
774                svc = por_nombre.get(base) or por_nombre.get(base.split("-")[0])
775                clave = base
776            else:
777                clave = destino.rsplit(":", 1)[-1]
778                svc = por_puerto.get(clave)
779            if svc is None:
780                out.append((", ".join(doms), destino,
781                            "NINGÚN servicio censado sirve ese destino: el sitio ya devuelve 502 "
782                            "HOY, en el origen. Mudarlo es mudar un 502"))
783            elif svc.get("decision") == "muere":
784                out.append((", ".join(doms), f"{destino} → {svc['name']}",
785                            f"el dominio se MUDA y `{svc['name']}`, que es quien lo sirve, está "
786                            f"marcado MUERE. El sitio nuevo va a devolver 502"))
787            elif not svc.get("decision"):
788                out.append((", ".join(doms), f"{destino} → {svc['name']}",
789                            f"el dominio se muda y `{svc['name']}`, que lo sirve, NO tiene decisión"))
790    return out
791
792
793def destino_respaldo_default():
794    """El corral de respaldo sale de `scripts/respaldo-storagebox.sh`, NO de una constante nueva acá.
795
796    Dos ficheros con la misma dirección escrita a mano divergen, y el que se queda viejo no falla:
797    respalda a un sitio que nadie mira. Se leen sus defaults (`SB_USER`/`SB_HOST`/`SB_PORT`), que es
798    donde el proyecto ya guarda lo que se guarda por si acaso."""
799    ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "respaldo-storagebox.sh")
800    v = {}
801    try:
802        with open(ruta, encoding="utf-8") as fh:
803            for linea in fh:
804                for k in ("SB_USER", "SB_HOST", "SB_PORT"):
805                    if linea.startswith(f'{k}="${{{k}:-'):
806                        v[k] = linea.split(":-", 1)[1].split("}", 1)[0]
807    except OSError:
808        return "", ""
809    if not (v.get("SB_USER") and v.get("SB_HOST")):
810        return "", ""
811    return f'{v["SB_USER"]}@{v["SB_HOST"]}:respaldo-mudanza/', v.get("SB_PORT", "22")
812
813
814def pasos(censo, target, key, respaldo=None, respaldo_port=None):
815    ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
816    if key:
817        ssh_opts = f"-i {key} " + ssh_opts
818    ssh = f"ssh {ssh_opts} {target}"
819    P = []
820
821    def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"):
822        # `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un
823        # humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error»
824        # es exactamente cómo un aplicador miente.
825        P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
826                  "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
827
828    datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
829    datos_resp = [d for d in censo.get("datos", []) if d.get("decision") == "respalda"]
830    svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
831    doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
832    muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
833    contenido = censo.get("contenido", [])
834
835    # ── 0. CÓDIGO QUE SÓLO EXISTE ACÁ ───────────────────────────────────────────────────────────
836    # El primer paso del plan entero, por delante incluso del rescate de binarios: aquello es un
837    # servicio que se pierde, esto es EL CÓDIGO CON EL QUE SE VOLVERÍA A CONSTRUIR. Y no se ve desde
838    # ninguna otra parte del censo —un repo no es un proceso, ni un puerto, ni un dominio—, así que
839    # se descubre cuando ya no hay de dónde sacarlo.
840    solos = [r for r in censo.get("repo", []) if r.get("solo_aqui") == "si"]
841    if solos:
842        det = ["# Estos repositorios NO tienen ningún remoto fuera de esta máquina. Apagarla borra el",
843               "# código, y los clones de trabajo están en el mismo disco que también muere.",
844               "#",
845               "# Por cada uno: agregarle un remoto que viva en otro lado y empujar. El propio takana",
846               "# lo hace así (`scripts/espejo-setup.sh`: pushurl doble a gitea Y a un espejo privado).",
847               "#"]
848        for r in solos:
849            det.append(f"#   {r['path']}")
850            det.append(f"#      remoto actual (en esta máquina): {r['dentro'][:88]}")
851        paso("codigo", f"{len(solos)} repositorio(s) que sólo existen en la máquina que se va a borrar",
852             "\n".join(det),
853             verifica="revisión humana: cada uno tiene que tener un remoto FUERA antes de apagar nada",
854             verifica_tipo="humano",
855             nota="⚠ Es la dependencia circular de esta mudanza: `tawasuyu` produce 10 de los 13 "
856                  "binarios que nadie más provee, y su único remoto es el gitea de esta misma "
857                  "máquina. Sin esto, apagar el origen deja servicios que no se pueden reconstruir.")
858
859    # ── 0 bis. LO QUE NO VIAJA CON NINGÚN REPO ──────────────────────────────────────────────────
860    # El paso 1 salva el CÓDIGO; éste salva lo que hace que ese código sirva de algo: las llaves, los
861    # certificados, los tokens y la configuración que no está versionada en ninguna parte. Va acá
862    # arriba por la misma razón —se pierde para siempre— y con una diferencia que importa: un repo
863    # sin copia fuera se nota al intentar clonarlo, y una clave privada borrada no se nota hasta la
864    # próxima vez que alguien firma. La peor de todas es `~/.config/takana/keys/release.ed25519`: el
865    # `.pub` vive EN el repo, la privada sólo en esta máquina.
866    fg = [f for f in censo.get("fuera_de_git", []) if f.get("path") != "crontab"]
867    if fg:
868        ciegos = [f for f in fg if f.get("estado") == "sin_permiso"]
869        det = ["# Config, llaves y credenciales que no están en ningún repo. Se listan por su ruta,",
870               "# su DUEÑO y su tamaño; el censo nunca leyó su contenido.", "#"]
871        for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])):
872            duenio = f" [{f['owner']}]" if f.get("owner") else ""
873            det.append(f"#   {f['path']}{duenio}  ({f.get('size', '?')})")
874            if f.get("porque"):
875                det.append(f"#      {f['porque']}")
876        nota = ("⚠ `~` NO es un home: el censo corre como root y sólo mira `/root` salvo que se le "
877                "pidan los homes de `/etc/passwd`. Lo que se censa por su nombre se decide; lo que "
878                "sólo viaja dentro del bloque `/home` viaja sin que nadie lo haya mirado.")
879        if ciegos:
880            # Un `ls` sin permiso se lee IGUAL que un directorio ausente: decir cuál de las dos es
881            # la única forma de que «no hay nada ahí» sea una afirmación y no un silencio.
882            nota += ("  ⚠⚠ " + str(len(ciegos)) + " de estas rutas EXISTEN y el censo NO pudo leerlas "
883                     "(" + ", ".join(f["path"] for f in ciegos[:4]) + "): volvé a censar como su "
884                     "dueño antes de decidir, o vas a decidir sobre una lista incompleta.")
885        paso("secretos", f"{len(fg)} ruta(s) de config y credenciales fuera de todo repo",
886             "\n".join(det),
887             verifica="revisión humana: por cada una, decidir `muda` o `muere` — y para las que "
888                      "mudan, que el destino la tenga ANTES de apagar nada",
889             verifica_tipo="humano", nota=nota)
890
891        for f in fg:
892            if f.get("decision") != "muda":
893                continue
894            origen = f["path"]
895            dst = f.get("destino") or origen
896            # `-aHAX --numeric-ids`: permisos, ACLs y xattrs. Una llave que llega con el modo
897            # cambiado no falla al copiarse, falla al usarse («permissions are too open»), que es
898            # varios pasos más tarde y en otro sitio.
899            paso("secretos", f"copiar {origen} → {dst}  ({f.get('size', '?')})",
900                 f"rsync -aHAX --numeric-ids --partial "
901                 f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(origen)} "
902                 f"{target}:{shlex.quote(os.path.dirname(dst.rstrip('/')) or '/')}/",
903                 # Verificación POR CONTENIDO sin revelar contenido: el digest de los digests.
904                 # Contar ficheros no alcanza — una llave truncada cuenta igual que una entera.
905                 verifica=(f"h() {{ find \"$1\" -type f -exec sha256sum {{}} + 2>/dev/null "
906                           f"| awk '{{print $1}}' | sort | sha256sum | cut -c1-16; }}; "
907                           f"src=$(h {shlex.quote(origen)}); "
908                           f"dst=$({ssh} \""
909                           f"find {shlex.quote(dst)} -type f -exec sha256sum {{}} + 2>/dev/null "
910                           f"| awk '{{print \\$1}}' | sort | sha256sum | cut -c1-16\"); "
911                           f'echo "digest: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
912                 nota="El digest es de los CONTENIDOS, no de los nombres ni de la cuenta: una llave "
913                      "truncada a la mitad cuenta como un fichero igual que la entera.")
914
915    # ── 0 ter. COHERENCIA ───────────────────────────────────────────────────────────────────────
916    incoh = incoherencias(censo)
917    if incoh:
918        det = ["# Cada línea es un dominio que se MUDA cuyo backend no va a estar del otro lado.",
919               "# Las dos decisiones son razonables por separado; juntas dejan un 502.", "#"]
920        for dom, destino, motivo in incoh:
921            det.append(f"#   {dom[:44]}")
922            det.append(f"#      ↳ {destino}")
923            det.append(f"#        {motivo}")
924        paso("coherencia", f"{len(incoh)} dominio(s) que se mudan SIN su backend", "\n".join(det),
925             verifica="revisión humana: o se muda el backend, o el dominio no se muda",
926             verifica_tipo="humano",
927             nota="Nadie más puede cazar esto: no lo ve el DNS, no lo ven los procesos, y no lo ve "
928                  "quien decide de a una entrada por vez — que es como se decide.")
929
930    # ── 0 bis. RESCATE, antes que nada ──────────────────────────────────────────────────────────
931    # Va PRIMERO, antes del preflight, porque es el único paso del plan que puede volverse
932    # IMPOSIBLE mientras se piensa el resto: estos binarios ya no existen en disco y sólo viven en el
933    # inodo que sostiene su proceso. Un reinicio del origen —o que se caiga ese pid— y se pierden
934    # para siempre. Y en dos de los cuatro hay AHORA otro fichero en la misma ruta, de distinto
935    # tamaño: copiar la ruta no falla, muda otra cosa.
936    borrados = [s for s in censo.get("servicio", [])
937                if s.get("exec_borrado") and s.get("decision") == "muda"]
938    if borrados:
939        lineas, ver = [], []
940        for s in borrados:
941            pid = s.get("pid_vivo") or (s.get("pids") or [0])[0]
942            nom = os.path.basename(s.get("exec") or s["name"])
943            lineas.append(f"sudo cat /proc/{pid}/exe > rescate/{shlex.quote(nom)} && "
944                          f"chmod +x rescate/{shlex.quote(nom)}")
945            if s.get("bytes_vivo"):
946                # La verificación COMPARA contra el tamaño del que CORRE. Un `cat` de un /proc que ya
947                # no existe crea un fichero VACÍO y devuelve 0: sin comparar, el rescate «pasa».
948                ver.append(f"[ \"$(stat -c %s rescate/{shlex.quote(nom)} 2>/dev/null)\" = "
949                           f"\"{s['bytes_vivo']}\" ] || {{ echo 'FALTA {nom}'; exit 1; }}")
950        paso("rescate", f"rescatar {len(borrados)} binario(s) BORRADOS del disco (sólo mientras corran)",
951             "mkdir -p rescate\n" + "\n".join(lineas),
952             verifica="\n".join(ver) + "\necho '  ✓ los binarios rescatados coinciden con los que corren'",
953             nota="⚠ ESTE PASO CADUCA. `/proc/<pid>/exe` deja de existir cuando muere el proceso, y "
954                  "estos ficheros ya no están en su ruta. Si el origen se reinicia antes de correr "
955                  "esto, el binario que estaba andando NO se puede recuperar de ninguna manera.")
956
957    # ── 0. preflight ────────────────────────────────────────────────────────────────────────────
958    # ⚠ El sitio se mide POR SISTEMA DE FICHEROS, no contra `/`. Medido en la caja takana: `/` tiene
959    # 3,5 G libres, `/store` 19 G y `/work` 66 G — un preflight que sume 33,6 G y los compare contra
960    # `/` da un veredicto correcto por casualidad si todo cae en `/`, y COMPLETAMENTE FALSO en cuanto
961    # una ruta va a otro montaje: diría que no entra cuando entra, o al revés. Y además: aquí el
962    # veredicto lo saca el propio comando (exit ≠0 si falta sitio), no un humano leyendo una columna.
963    need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
964               if x is not None)
965    ilegibles = [d["path"] for d in datos_muda
966                 if a_mib(d.get("size_copy") or d.get("size")) is None]
967    chks = []
968    for d in datos_muda:
969        mib = a_mib(d.get("size_copy") or d.get("size"))
970        if mib is None:
971            continue
972        dst = (d.get("destino") or "").strip() or d["path"]
973        chks.append(f"chk {shlex.quote(dst)} {int(mib)}")
974    guion = "\n".join([
975        "set -u",
976        "chk() {",
977        '  t="$1"',
978        '  while [ ! -d "$t" ] && [ "$t" != "/" ]; do t=$(dirname "$t"); done',
979        '  df -Pm "$t" | tail -1 | awk -v n="$2" -v r="$1" \'{print $6, $4, n, r}\'',
980        "}",
981        "{",
982        *["  " + c for c in chks],
983        "} | awk '",
984        "  { libre[$1]=$2; falta[$1]+=$3; rutas[$1]=rutas[$1] \" \" $4 }",
985        "  END { mal=0",
986        '    for (f in falta) {',
987        '      printf "  %-16s necesita %8d MiB · libres %8d  ⇐%s\\n", f, falta[f], libre[f], rutas[f]',
988        '      if (falta[f] > libre[f]) { mal=1; printf "  ✗ NO ENTRA en %s: faltan %d MiB\\n", f, falta[f]-libre[f] }',
989        "    }",
990        '    if (mal==0) printf "  ✓ entra en todos los sistemas de ficheros\\n"',
991        "    exit mal }'",
992    ])
993    paso("preflight", f"el destino responde y tiene sitio ({humano(need)} en total)",
994         f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
995         verifica=f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
996         nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
997              "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`, y medido "
998              "contra el sistema de ficheros de CADA destino.")
999
1000    # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
1001    for d in datos_muda:
1002        p = d["path"]
1003        dst = (d.get("destino") or "").strip() or p
1004        # Ver la nota del paso de respaldo: con el censo detallado hay entradas que son FICHEROS, y
1005        # a un fichero no se le pone barra (y su verificación no es `find -type f | wc -l` en un
1006        # directorio, sino comparar el fichero mismo).
1007        es_dir = d.get("tipo", "dir") != "fichero"
1008        org, dst_r = (p + "/", dst + "/") if es_dir else (p, dst)
1009        ver = ((f"src=$(find {shlex.quote(p)} -type f | wc -l); "
1010                f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
1011                f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]') if es_dir else
1012               (f"src=$(stat -c%s {shlex.quote(p)}); "
1013                f"dst=$({ssh} 'stat -c%s {shlex.quote(dst)}' 2>/dev/null || echo ausente); "
1014                f'echo "bytes: origen=$src destino=$dst"; [ "$src" = "$dst" ]'))
1015        paso("datos", f"copiar {p} → {dst}  ({d.get('size','?')})",
1016             f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
1017             f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(org)} {target}:{shlex.quote(dst_r)}",
1018             verifica=ver,
1019             nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
1020                  "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
1021                  "FALLA si no coinciden. La versión anterior imprimía el número de directorios "
1022                  "vacíos y devolvía 0 igual — un guardián que siempre pasa no es un guardián. "
1023                  "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
1024                  "así se llenó un disco y quedaron 1367 artefactos vacíos.")
1025
1026    # ── 1 bis. lo que se RESPALDA ───────────────────────────────────────────────────────────────
1027    # Ni se muda ni se pierde. Va al corral de respaldo y NO se instala en el destino: la caja nueva
1028    # no se entera de que esto existe. Por eso es un paso propio y no un `datos` con otro destino —
1029    # su verificación es distinta y su fallo significa otra cosa (no rompe el servidor nuevo, pero
1030    # borra el original sin copia si nadie lo mira).
1031    if datos_resp:
1032        if not respaldo:
1033            # Un corral vacío convertiría cada `respalda` en un `rsync` a ninguna parte que además
1034            # devuelve 0. Abortar acá es la única forma de que no se lea como «respaldado».
1035            sys.exit("✗ hay entradas marcadas `respalda` y no sé a dónde respaldarlas.\n"
1036                     "  Pasá --respaldo <destino> (host:ruta o ruta local). No invento uno: un\n"
1037                     "  respaldo a un sitio equivocado se ve igual que un respaldo.")
1038        rsh = f"ssh -p {respaldo_port} {ssh_opts}" if respaldo_port else f"ssh {ssh_opts}"
1039        remoto = ":" in respaldo.split("/", 1)[0]
1040        for d in datos_resp:
1041            ruta = d["path"]
1042            # Aplanada: `/home/sergio/humanoid` → `home-sergio-humanoid`. El corral junta cosas de
1043            # rutas distintas, y conservar el árbol haría que dos orígenes se pisaran en el mismo
1044            # nombre. El nombre aplanado DICE de dónde salió, que es lo que hace falta para volver.
1045            nombre = ruta.strip("/").replace("/", "-")
1046            dst = respaldo.rstrip("/") + "/" + nombre
1047            # ⚠ Un FICHERO no lleva barra final y su destino es el directorio que lo contiene.
1048            # Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y
1049            # `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál
1050            # es cuál (`tipo`), así que no hay que adivinarlo.
1051            es_dir = d.get("tipo", "dir") != "fichero"
1052            org = ruta + "/" if es_dir else ruta
1053            dst_r = dst + "/" if es_dir else dst
1054            # ⚠ `--mkpath` NO es opcional, y lo cazó el guardián de dos sentidos: sin él rsync
1055            # muere con `mkdir "/home/respaldo-mudanza/prueba-guardian" failed` en cuanto el corral
1056            # tiene un nivel que todavía no existe — y el primer respaldo SIEMPRE lo tiene.
1057            base = (f"rsync -aH --mkpath --partial --partial-dir=.rsync-partial --info=stats2 "
1058                    + (f"-e {shlex.quote(rsh)} " if remoto else "")
1059                    + f"{shlex.quote(org)} {shlex.quote(dst_r)}")
1060            # La verificación se le pregunta a RSYNC, no a `du` ni a `find`: el corral puede ser un
1061            # Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías (medido). Una
1062            # corrida en seco compara tamaño y fecha CONTRA EL DESTINO REAL y dice qué falta.
1063            ver = (f"pend=$({base.replace('rsync -aH ', 'rsync -aHn ').replace('--info=stats2', chr(39)+'--out-format=%n'+chr(39))}"
1064                   f" | grep -v '/$' | wc -l); echo \"pendientes en el corral: $pend\"; [ \"$pend\" -eq 0 ]")
1065            paso("respaldo", f"respaldar {ruta} → {dst}  ({d.get('size','?')})", base,
1066                 verifica=ver,
1067                 nota="NO se instala en el destino: esto es archivo, no mudanza. Y `du` en el "
1068                      "corral puede dar MENOS que el origen sin que falte nada (un Storage Box "
1069                      "comprime: 1,3 G subidos se veían como 576 M). Por eso verifica rsync.")
1070
1071    # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
1072    for s in svc_muda:
1073        nd = s.get("class") == "no-declarado"
1074        # Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no
1075        # metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py`
1076        # sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería
1077        # justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta.
1078        for tr in traducciones(s):
1079            paso("traducir", f"traducir {tr['que']}", tr["cmd"],
1080                 verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es"
1081                          f" trabajo a mano que el destino NO va a hacer solo.",
1082                 verifica_tipo="humano", nota=tr["nota"])
1083        paso("servicio", f"declarar y levantar {s['name']}" + ("  ⚠ NO-DECLARADO" if nd else ""),
1084             ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n"
1085               f"#   Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n"
1086               f"#   La del origen no sirve tal cual.\n#\n")
1087              if s.get("alternativa") and s["alternativa"] != s["name"] else "")
1088             + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n"
1089              f"#   {origen_binario(s)[1]}\n"
1090              f"#\n"
1091              f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"
1092              f"#   cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n"
1093              + (f"#   cwd    : {s['cwd']}\n" if s.get("cwd") else "")
1094              + (f"#   puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "")
1095              + f"#\n# 1) conseguir el binario según lo de arriba\n"
1096              f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n"
1097              f"#    (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n"
1098              f"# 3) arrancarlo"),
1099             verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'"
1100                      + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'"
1101                         if s.get("ports") else ""),
1102             nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo "
1103                  "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la "
1104                  "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else
1105                  "Declarado en el origen: traducir su unidad al init del destino.")
1106
1107    # ── 3. dominios ─────────────────────────────────────────────────────────────────────────────
1108    for d in doms_muda:
1109        paso("dominio", f"apuntar {d['domain']} a la máquina nueva",
1110             f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → <IP NUEVA>",
1111             verifica=f"getent ahostsv4 {d['domain']} | head -1 && "
1112                      f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/",
1113             nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. "
1114                  "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus "
1115                  "propios dominios puede disparar su fail2ban (pasó).")
1116
1117    # ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
1118    if muere:
1119        # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
1120        # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
1121        # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
1122        # supiera dependía de que alguien se acordara.
1123        det = []
1124        for t, n, e in muere:
1125            tam = f"  ({e.get('size')})" if e.get("size") else ""
1126            det.append(f"#   {t:9} {n}{tam}")
1127
1128        # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
1129        # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
1130        # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
1131        # supiera dependía de que alguien se acordara.
1132        #
1133        # Se lista UN RENGLÓN POR DIRECTORIO, no uno por dominio: siete dominios `*.gioser.net` que
1134        # mueren reclaman el mismo `gioser.bak`, y repetirlo siete veces convierte el aviso en ruido.
1135        # Y se dice «podría», nunca «es»: el directorio se llama `terapeuta` y el dominio
1136        # `terapeuta.ec`. Emparejar «casi» acierta casi siempre, y el resto de las veces manda a
1137        # borrar lo que no era.
1138        if contenido:
1139            doms_muertos = [n for t, n, _ in muere if t == "dominio"]
1140            det.append("#")
1141            det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.")
1142            # ⚠ Un directorio que la CONFIG referencia no es un huérfano, diga lo que diga su
1143            # nombre. Sin esto el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda»
1144            # estando servido en la línea 174 del Caddyfile — y ese aviso, aplicado, tira el sitio.
1145            servidos = {}
1146            for st in censo.get("sitio", []):
1147                if st.get("raiz"):
1148                    servidos.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
1149            for c in contenido:
1150                ruta = c["path"].rstrip("/")
1151                quien = [d for d in doms_muertos if c in candidatos(d, [c])]
1152                if ruta in servidos:
1153                    marca = ("⚠ LO SIRVE la config para: " +
1154                             ", ".join(" ".join(servidos[ruta]).split()[:3]) +
1155                             " — no es huérfano; moverlo tira el sitio")
1156                elif quien:
1157                    marca = ("podría ser de: " + ", ".join(quien[:4]) +
1158                             (f" (+{len(quien)-4})" if len(quien) > 4 else ""))
1159                else:
1160                    marca = "no corresponde a NINGÚN dominio que muera ni lo sirve la config: miralo"
1161                det.append(f"#   {ruta:34} {c['size']:>6}   ← {marca}")
1162
1163        paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
1164             "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
1165             + "\n".join(det),
1166             verifica="revisión humana: leer la lista completa antes de destruir el origen",
1167         verifica_tipo="humano",
1168             nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté "
1169                  "escrito ES el paso.")
1170    return P
1171
1172
1173def emitir(censo, P, target):
1174    L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).",
1175         "#",
1176         "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por",
1177         "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.",
1178         'schema = "takana-plan/1"',
1179         f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"',
1180         f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"',
1181         f'destino = "{target}"',
1182         ""]
1183    for i, p in enumerate(P, 1):
1184        L.append(f"[[paso]]")
1185        L.append(f'n = {i}')
1186        L.append(f'clase = "{p["clase"]}"')
1187        L.append(f'titulo = """{p["titulo"]}"""')
1188        L.append("cmd = " + bloque_toml(p["cmd"]))
1189        if p["verifica"]:
1190            L.append("verifica = " + bloque_toml(p["verifica"]))
1191            L.append(f'verifica_tipo = "{p["verifica_tipo"]}"')
1192        if p["nota"]:
1193            L.append("nota = " + bloque_toml(p["nota"]))
1194        L.append("")
1195    return "\n".join(L) + "\n"
1196
1197
1198def main():
1199    ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.")
1200    ap.add_argument("--censo", required=True)
1201    ap.add_argument("--target", required=True, help="usuario@host destino")
1202    ap.add_argument("--key", help="clave SSH para el destino")
1203    ap.add_argument("--decide", action="store_true",
1204                    help="mostrar TODO con su recomendación y su motivo, y elegir")
1205    ap.add_argument("--revisar", action="store_true",
1206                    help="sólo mostrar la revisión con recomendaciones, sin preguntar")
1207    ap.add_argument("--out", help="fichero del plan (default: stdout)")
1208    ap.add_argument("--decidir", metavar="FICHERO",
1209                    help="decidir EN LOTE desde un fichero `<ruta-o-nombre> <decision>` por línea. "
1210                         "Para terminales donde no se puede pulsar una tecla por entrada")
1211    ap.add_argument("--censo-out", help="dónde escribir el censo con las decisiones tomadas "
1212                                        "(default: sobre el mismo censo, con copia `.bak`)")
1213    ap.add_argument("--respaldo", help="corral donde va lo marcado `respalda` (host:ruta o ruta "
1214                                       "local). Default: el Storage Box de respaldo-storagebox.sh")
1215    ap.add_argument("--respaldo-port", help="puerto SSH del corral (el Storage Box usa 23)")
1216    a = ap.parse_args()
1217
1218    with open(a.censo, "rb") as f:
1219        censo = tomllib.load(f)
1220    if a.revisar:
1221        revisar(censo, interactivo=False)
1222        return
1223    if a.decidir:
1224        n = decidir_desde_fichero(censo, a.decidir)
1225        destino_censo = a.censo_out or a.censo
1226        guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
1227        print(f"   {n} decisión(es) aplicadas → {destino_censo}")
1228    if a.decide:
1229        censo = revisar(censo, interactivo=True)
1230        # ── LAS DECISIONES SE GUARDAN, O NO EXISTIERON ──────────────────────────────────────────
1231        # Antes `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal
1232        # y las 169 elecciones se perdían. El censo ES la interfaz (SDD 29 §3), así que volver a
1233        # escribirlo no es comodidad, es lo que hace que decidir a medias sea posible — y decidir
1234        # 169 cosas SIEMPRE se hace a medias.
1235        destino_censo = a.censo_out or a.censo
1236        guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
1237        print(f"   decisiones guardadas → {destino_censo}")
1238    validar(censo)
1239
1240    resp_dst, resp_port = (a.respaldo, a.respaldo_port)
1241    if not resp_dst:
1242        resp_dst, resp_port = destino_respaldo_default()
1243    P = pasos(censo, a.target, a.key, resp_dst, resp_port)
1244    por_clase = {}
1245    for p in P:
1246        por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1
1247    print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══")
1248    for c, n in por_clase.items():
1249        print(f"   {c:10} {n:3} paso(s)")
1250    txt = emitir(censo, P, a.target)
1251
1252    # ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ──────────────────────
1253    # No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea
1254    # rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino
1255    # DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y
1256    # releer cuesta milisegundos y convierte un fallo diferido en uno inmediato.
1257    try:
1258        tomllib.loads(txt)
1259    except tomllib.TOMLDecodeError as e:
1260        sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n"
1261                 f"  No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n"
1262                 f"  exportar, que son las dos únicas cosas para las que existe.")
1263
1264    if a.out:
1265        open(a.out, "w").write(txt)
1266        print(f"\n   plan → {a.out}   ({len(P)} pasos, con su comando literal cada uno)")
1267    else:
1268        print(txt)
1269
1270
1271if __name__ == "__main__":
1272    main()