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 1101Rust 61Shell 47Markdown 371349 Sutichaykuna

⎇ 76535f8e42a4c4d1bd828bc92ef8592e890e0057 scripts/mudanza/planear.py Python · 1000 siq'ikuna · 61.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

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