#!/usr/bin/env python3 """planear.py — convierte un CENSO decidido en un PLAN ejecutable y exportable (SDD 29 etapa 2). El plan es el producto: un documento que se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr. **Cada paso lleva su comando literal**, así que el plan se puede ejecutar con la herramienta o a mano, línea por línea, sin la herramienta y sin este repo. ── LAS TRES REGLAS, Y POR QUÉ ────────────────────────────────────────────────────────────────── 1. **Nada sin decidir se ejecuta.** Una entrada con `decision = ""` ABORTA el plan, nombrándola. El silencio no es consentimiento: si nadie dijo qué hacer con un servicio, la respuesta no es «entonces no lo muevo» —eso lo mata en silencio— ni «entonces lo muevo» —eso muda fósiles—. 2. **Lo que muere se dice por su nombre y con su tamaño, ANTES de borrar nada.** En esta mudanza `terapeuta.ec` eran 279 M sin DNS: la diferencia entre «lo decidimos» y «se nos pasó» es una línea escrita. 3. **Cada copia se verifica EN DESTINO.** «Lo copié» no prueba nada: el `rsync` que llenó el disco de la caja nueva devolvió 0 y dejó 1367 artefactos VACÍOS, y sólo se vio contando del otro lado. ── LO QUE EL PLAN SABE QUE UN GUIÓN A MANO OLVIDA ────────────────────────────────────────────── · **Dimensiona con el tamaño de COPIA, no con `du`.** Un árbol con hardlinks mide 60 G y ocupa 85 G al copiarlo sin `-H` (medido). El preflight compara contra el número correcto y falla ANTES. · **`rsync -aH` y excluyendo cachés.** Sin `-H` los hardlinks se expanden; `.dmerge` son 11 G de caché regenerable que llenó un disco. · **Los servicios `no-declarado` no se copian: se DECLARAN.** Copiar un binario no lo levanta al arrancar — el caddy de la caja nueva murió en el primer reinicio por exactamente eso. · **La traducción de la configuración es un PASO, no una advertencia.** Decir «cambiar de servidor web obliga a reescribir la config» es cierto y deja al humano con un párrafo y ninguna herramienta. El plan emite el comando literal de `traducir.py` cuando el par EXISTE —se le pregunta al registro de plugins, no se lista acá— y dice que no existe cuando no. Su verificación es HUMANA: el traductor sale ≠0 si algo quedó sin traducir, así que darlo por bueno por código de salida sería justo al revés de lo que hay que mirar. Uso: scripts/mudanza/planear.py --censo censo.toml --target root@1.2.3.4 [--decide] --out plan.toml """ import argparse, os, sys, shlex, time, tomllib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import formatos # noqa: E402 from censar import candidatos_de as candidatos # noqa: E402 _REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) _AQUI = os.path.join("scripts", "mudanza", "traducir.py") # ── tamaños ───────────────────────────────────────────────────────────────────────────────────── _MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024} def a_mib(s): """'61.5G' → MiB. Devuelve None si no se entiende: un tamaño ilegible NO se inventa como 0, porque un 0 inventado apaga el preflight justo cuando más hace falta.""" s = (s or "").strip() if not s or not s[0].isdigit(): return None try: return float(s[:-1]) * _MUL.get(s[-1].upper(), 1) if s[-1].isalpha() else float(s) except ValueError: return None def humano(mib): if mib is None: return "?" return f"{mib/1024:.1f}G" if mib >= 1024 else f"{mib:.0f}M" # ── decisión ──────────────────────────────────────────────────────────────────────────────────── DECISIONES = ("muda", "muere") def entradas(censo): """Todo lo que necesita una decisión, con una etiqueta legible.""" out = [] for s in censo.get("servicio", []): # `declarado-muerto` no necesita decisión: no corre. Se informa y se deja morir. if s.get("class") != "declarado-muerto": out.append(("servicio", s["name"], s)) for d in censo.get("dominio", []): # ⚠ Los `fosil-sin-dns` ENTRAN. Antes se filtraban con el argumento de que un dominio sin DNS # no necesita ningún paso — y es cierto para el DNS, pero no para lo que arrastra: en gioser # son 13 de 29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el caddy. Sacarlos # de la lista los volvía INVISIBLES, así que nadie decidía borrarlos y sus datos viajaban a # la caja nueva por omisión. Es exactamente el «mudar fósiles» que este plan existe para # evitar, y la regla 2 dice que lo que muere se nombra ANTES de borrar nada. out.append(("dominio", d["domain"], d)) for d in censo.get("datos", []): out.append(("datos", d["path"], d)) return out # ── servicios que NO APLICAN en una caja remota ───────────────────────────────────────────────── # Correr en el origen no significa que tenga sentido en el destino. Un servidor en un datacenter no # tiene bluetooth, ni módem, ni batería, ni teclado — y arrastrarlos es peor que inútil: algunos # PELEAN con el destino. Cada familia con su motivo, porque una recomendación sin motivo no se puede # discutir y entonces se acepta a ciegas o se ignora entera. NO_APLICA = { "hardware local (una caja remota no tiene ese dispositivo)": { "bluetoothd", "bluetooth", "modemmanager", "upowerd", "upower", "acpid", "adb", "thermald", "iio-sensor-proxy", "fwupd", "alsactl", "pcscd", "cups", "cupsd", }, "escritorio o pantalla (un servidor no los tiene)": { "waypipe", "xorg", "x", "wayland", "gdm", "sddm", "lightdm", "pipewire", "wireplumber", "pulseaudio", "carmen-dm", }, "la red del destino ya la configura su propio init — pelearían": { "networkmanager", "dhcpcd", "wpa-supplicant", "connman", "systemd-networkd", "dhclient", }, "lo provee el init del destino, no se muda": { "agetty", "getty", "console-getty", "udevd", "udevadm", "systemd-udevd", "eudev", "elogind-daemon", "elogind", "polkitd", "dbus-daemon", "dbus", }, } def _no_aplica(nombre): n = nombre.lower().replace("_", "-") for motivo, conjunto in NO_APLICA.items(): if n in conjunto: return motivo return None # ── familias de equivalentes funcionales ──────────────────────────────────────────────────────── # Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de # dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es # mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar. FAMILIAS = { "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"], "base de datos": ["postgresql", "mariadb", "mysql", "sqlite"], "caché en memoria": ["redis", "valkey", "memcached"], "forja git": ["gitea", "forgejo", "gitlab"], "contenedores": ["docker", "podman", "containerd"], "dns": ["unbound", "bind", "dnsmasq", "coredns"], "correo": ["postfix", "exim", "opensmtpd"], "base vectorial": ["qdrant", "milvus", "weaviate"], } # ── enganche con el CENTRO DE TRADUCCIÓN ──────────────────────────────────────────────────────── # El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es # cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. # Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad: # los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se # desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo. # Dónde vive la declaración del servicio en cada init de origen. _RUTA_UNIDAD = {"openrc": "/etc/init.d/{n}", "systemd": "/etc/systemd/system/{n}.service"} # Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que # nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se # adivina — se dice que hay que ubicarlo a mano. _RUTA_CONFIG = {"nginx": "/etc/nginx/nginx.conf", "apache": "/etc/apache2/apache2.conf", "httpd": "/etc/httpd/conf/httpd.conf", "caddy": "/etc/caddy/Caddyfile"} def traducciones(s): """Comandos de traducción que le corresponden a un servicio que se muda. Dos cosas distintas, y confundirlas es un error caro: · la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje; · la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy. Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer traducirla sería inventar trabajo.""" lectores, escritores = formatos.cargar() out = [] # 1) la declaración → tarjeta de arje (el init del destino) if "arje" in escritores: for fuente in s.get("declared_in", []): if fuente in lectores and fuente in _RUTA_UNIDAD: ruta = _RUTA_UNIDAD[fuente].format(n=s["name"]) out.append({ "que": f"declaración de {s['name']} ({fuente} → arje)", "cmd": f"{_AQUI} --from {fuente} --to arje \\\n" f" --in {ruta} --out {s['name']}.card.json", "nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con " "código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, " "es el acta pidiendo que la leas."}) break # 2) la configuración → sólo si se cambia de servicio alt = s.get("alternativa") if alt and alt != s["name"]: desde, hacia = s["name"].lower().replace("_", "-"), alt.lower() if desde in lectores and hacia in escritores and \ lectores[desde][1] == escritores[hacia][1]: org = _RUTA_CONFIG.get(desde, f"") dst = _RUTA_CONFIG.get(hacia, f"") out.append({ "que": f"configuración de {desde} → {hacia}", "cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n" f" --in {org} --out {os.path.basename(dst)}", "nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar " "sin resolver el acta es desplegar un servidor incompleto que además " "parece funcionar."}) else: # No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada. out.append({ "que": f"configuración de {desde} → {hacia}", "cmd": f"# NO hay traductor `{desde} → {hacia}` en el centro.\n" f"# Ver los que sí hay: {_AQUI} --list\n" f"# Hay que reescribir la configuración a mano.", "nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."}) return out def _receta(nombre, repo): """(existe, sellado) para un nombre de receta.""" import glob f = os.path.join(repo, "recipes", f"{nombre}.toml") if not os.path.exists(f): return False, False return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}"))) def alternativas(nombre, repo): """Equivalentes funcionales del servicio, con una recomendación RAZONADA. ── LOS CRITERIOS, Y POR QUÉ ÉSOS ──────────────────────────────────────────────────────────── No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que no recomendar. Se recomienda con hechos comprobables y en este orden: 1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre supera cualquier ventaja teórica del otro. 2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta. 3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin decir que ese otro tampoco está. Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna familia conocida — que es lo normal y no es un error.""" n = nombre.lower().replace("_", "-") fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None) if not fam: return None, None, None lista = [] for cand in FAMILIAS[fam]: existe, sellado = _receta(cand, repo) lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n}) yo = next((x for x in lista if x["actual"]), None) disponibles = [x for x in lista if x["sellado"]] if yo and yo["sellado"]: return (lista, n, f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia " f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera") if disponibles: otros = ", ".join(x["nombre"] for x in disponibles) return (lista, disponibles[0]["nombre"], f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí " f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una " f"decisión, no un trámite") return (lista, None, f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus " f"equivalentes. Hay que escribir la receta del que se elija") def origen_binario(e, repo=None): """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino. «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto: · `receta-takana` — lo mejor: takana ya sabe construirlo. Se instala del repo firmado. · `paquete-ajeno` — lo trae el gestor del origen. En el destino hace falta una receta, o `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena. · `suelto` — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol o escribirle una receta. Es el que se pierde al apagar el origen. Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`, `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad.""" nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "") # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`, # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica. repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if not os.path.isdir(os.path.join(repo, "recipes")): # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta. raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — " f"pasá --repo o corré desde el repo") for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or "")): if cand and os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")): import glob sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}"))) return ("receta-takana", f"takana YA lo construye: `recipes/{cand}.toml`" + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)") + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}") if pkg: return ("paquete-ajeno", f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una " f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)") if exe: return ("suelto", f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que " f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar " f"el origen") motivo = e.get("exec_motivo") if isinstance(e, dict) else "" return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano") def recomendar(tipo, e, datos_que_mueren=()): """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para que aceptarla sea barato y equivocarse sea caro de no notar. Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a ciegas o se ignora entera. Las dos son malas.""" c = e.get("class", "") if tipo == "servicio": m = _no_aplica(e.get("name", "")) if m: return "muere", m # DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no # va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos. exe = (e.get("cmdline") or "").split(" ")[0] for raiz in datos_que_mueren: if exe.startswith(raiz.rstrip("/") + "/"): return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá" if c == "no-declarado": p = e.get("ports") or [] return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, " "y NADIE lo declara" if p else "corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo") if c == "vivo": return "muda", "declarado y corriendo" return "", "" if tipo == "dominio": if c == "vivo": return "muda", "resuelve a esta máquina y contesta" if c == "ya-mudado": return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil" if c == "backend-caido": return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend" if c == "apunta-aca": # El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el # silencio como una respuesta afirmativa, que es el error que este censo evita en todos # los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero # se DICE lo que no se miró. return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. " "⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil") if c == "fosil-sin-dns": return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó " "contenido en disco o un bloque en el servidor web — eso sí viaja si " "nadie lo borra") if c == "sin-sondear": return "", "sin sondear (censado en local): hace falta mirarlo desde afuera" return "", "" if tipo == "datos": # A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec` # eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso. return "", "los datos no se recomiendan solos: sólo vos sabés si valen" return "", "" def sugerir(tipo, e, datos_que_mueren=()): return recomendar(tipo, e, datos_que_mueren)[0] def _etiqueta(tipo, nombre, e): if tipo == "datos": extra = e.get("size", "?") if e.get("size_copy") and e["size_copy"] != e.get("size"): extra += f" (copiar {e['size_copy']})" return extra if tipo == "servicio": p = e.get("ports") or [] return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "") return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "") def revisar(censo, interactivo=True): """Muestra TODO con su recomendación y su motivo, y deja elegir. ── EL ORDEN NO ES CAPRICHOSO ──────────────────────────────────────────────────────────────── Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder arrancar allá. Al revés no se puede calcular. """ todos = entradas(censo) datos = [(t, n, e) for t, n, e in todos if t == "datos"] resto = [(t, n, e) for t, n, e in todos if t != "datos"] # ── 1. datos ──────────────────────────────────────────────────────────────────────────────── if datos: print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════") print(" Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva") print(" la recomendación de los servicios, así que va primero.\n") for t, n, e in datos: marca = e.get("decision") or "—" print(f" {n:34} {_etiqueta(t,n,e):>22} decisión actual: {marca}") if interactivo: print() for t, n, e in datos: if e.get("decision") in DECISIONES: continue r = input(f" {n:34} [{_etiqueta(t,n,e)}] [m]uda/[x]muere > ").strip().lower() e["decision"] = {"m": "muda", "x": "muere"}.get(r, "") mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"] # ── 2. lo demás, con recomendación y motivo ───────────────────────────────────────────────── grupos = {"muda": [], "muere": [], "": []} for t, n, e in resto: d, motivo = recomendar(t, e, mueren) e["_rec"], e["_motivo"] = d, motivo grupos[d].append((t, n, e)) print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════") titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"} for g in ("muda", "muere", ""): if not grupos[g]: continue print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30) for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])): print(f" {t:8} {n:30} {_etiqueta(t,n,e):>18}") if e.get("_motivo"): print(f" └─ {e['_motivo']}") if t == "servicio" and e.get("decision") != "muere" and g != "muere": oc, od = origen_binario(e) print(f" └─ binario: [{oc}] {od[:96]}") if e.get("usuario") and e["usuario"] != "root": # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá # corre sin privilegios termina de root en el destino y nadie se entera. print(f" └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino " f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando") lista, rec, motivo = alternativas(n, _REPO) if lista: op = " ".join( ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"]) + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗")) for x in lista) print(f" └─ alternativas: {op} (✓ sellado · receta sin sellar · ✗ no está)") print(f" recomendado: {rec or 'ninguno'} — {motivo[:110]}") if not interactivo: return censo print("\n [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir") r = input(" > ").strip().lower() if r == "q": return censo for t, n, e in resto: if e.get("decision") in DECISIONES: continue rec = e.get("_rec", "") if r == "a" and rec: e["decision"] = rec continue sug = f"[{rec}]" if rec else "[sin recomendación]" resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n" f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower() e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "") if e["decision"] == "muda" and t == "servicio": lista, alt_rec, motivo = alternativas(n, _REPO) if lista and len([x for x in lista if x["sellado"]]) > 1: op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "") for i, x in enumerate(lista)) print(f" alternativas: {op}") print(f" recomendado: {alt_rec} — {motivo[:100]}") r2 = input(f" número, o Enter para {alt_rec}: ").strip() if r2.isdigit() and int(r2) < len(lista): e["alternativa"] = lista[int(r2)]["nombre"] else: e["alternativa"] = alt_rec return censo def validar(censo): """Regla 1: nada sin decidir se ejecuta.""" faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES] if faltan: print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr) print(" El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n", file=sys.stderr) for t, n in faltan[:40]: print(f" {t:9} {n}", file=sys.stderr) if len(faltan) > 40: print(f" … y {len(faltan)-40} más", file=sys.stderr) print("\n Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr) sys.exit(2) # ── plan ──────────────────────────────────────────────────────────────────────────────────────── _TRIPLE_SIMPLE = "'" * 3 _TRIPLE_DOBLE = '"' * 3 def bloque_toml(texto): """Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER. ⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE (`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció recién al meter un comando multilínea, o sea que estuvo latente desde el principio. Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa.""" if _TRIPLE_SIMPLE not in texto: return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"') return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE def pasos(censo, target, key): ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20" if key: ssh_opts = f"-i {key} " + ssh_opts ssh = f"ssh {ssh_opts} {target}" P = [] def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"): # `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un # humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error» # es exactamente cómo un aplicador miente. P.append({"clase": clase, "titulo": titulo, "cmd": cmd, "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""}) datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"] svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"] doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"] muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"] contenido = censo.get("contenido", []) # ── 0. preflight ──────────────────────────────────────────────────────────────────────────── # ⚠ El sitio se mide POR SISTEMA DE FICHEROS, no contra `/`. Medido en la caja takana: `/` tiene # 3,5 G libres, `/store` 19 G y `/work` 66 G — un preflight que sume 33,6 G y los compare contra # `/` da un veredicto correcto por casualidad si todo cae en `/`, y COMPLETAMENTE FALSO en cuanto # una ruta va a otro montaje: diría que no entra cuando entra, o al revés. Y además: aquí el # veredicto lo saca el propio comando (exit ≠0 si falta sitio), no un humano leyendo una columna. need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda) if x is not None) ilegibles = [d["path"] for d in datos_muda if a_mib(d.get("size_copy") or d.get("size")) is None] chks = [] for d in datos_muda: mib = a_mib(d.get("size_copy") or d.get("size")) if mib is None: continue dst = (d.get("destino") or "").strip() or d["path"] chks.append(f"chk {shlex.quote(dst)} {int(mib)}") guion = "\n".join([ "set -u", "chk() {", ' t="$1"', ' while [ ! -d "$t" ] && [ "$t" != "/" ]; do t=$(dirname "$t"); done', ' df -Pm "$t" | tail -1 | awk -v n="$2" -v r="$1" \'{print $6, $4, n, r}\'', "}", "{", *[" " + c for c in chks], "} | awk '", " { libre[$1]=$2; falta[$1]+=$3; rutas[$1]=rutas[$1] \" \" $4 }", " END { mal=0", ' for (f in falta) {', ' printf " %-16s necesita %8d MiB · libres %8d ⇐%s\\n", f, falta[f], libre[f], rutas[f]', ' if (falta[f] > libre[f]) { mal=1; printf " ✗ NO ENTRA en %s: faltan %d MiB\\n", f, falta[f]-libre[f] }', " }", ' if (mal==0) printf " ✓ entra en todos los sistemas de ficheros\\n"', " exit mal }'", ]) paso("preflight", f"el destino responde y tiene sitio ({humano(need)} en total)", f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN", verifica=f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN", nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`, y medido " "contra el sistema de ficheros de CADA destino.") # ── 1. datos ──────────────────────────────────────────────────────────────────────────────── for d in datos_muda: p = d["path"] dst = (d.get("destino") or "").strip() or p paso("datos", f"copiar {p} → {dst} ({d.get('size','?')})", f"rsync -aH --partial --info=stats2 --exclude='.dmerge' " f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(dst + '/')}", verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); " f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); " f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]'), nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). " "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y " "FALLA si no coinciden. La versión anterior imprimía el número de directorios " "vacíos y devolvía 0 igual — un guardián que siempre pasa no es un guardián. " "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: " "así se llenó un disco y quedaron 1367 artefactos vacíos.") # ── 2. servicios ──────────────────────────────────────────────────────────────────────────── for s in svc_muda: nd = s.get("class") == "no-declarado" # Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no # metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py` # sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería # justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta. for tr in traducciones(s): paso("traducir", f"traducir {tr['que']}", tr["cmd"], verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es" f" trabajo a mano que el destino NO va a hacer solo.", verifica_tipo="humano", nota=tr["nota"]) paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""), ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n" f"# Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n" f"# La del origen no sirve tal cual.\n#\n") if s.get("alternativa") and s["alternativa"] != s["name"] else "") + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n" f"# {origen_binario(s)[1]}\n" f"#\n" f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n" f"# cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n" + (f"# cwd : {s['cwd']}\n" if s.get("cwd") else "") + (f"# puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "") + f"#\n# 1) conseguir el binario según lo de arriba\n" f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n" f"# (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n" f"# 3) arrancarlo"), verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'" + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'" if s.get("ports") else ""), nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo " "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la " "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else "Declarado en el origen: traducir su unidad al init del destino.") # ── 3. dominios ───────────────────────────────────────────────────────────────────────────── for d in doms_muda: paso("dominio", f"apuntar {d['domain']} a la máquina nueva", f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → ", verifica=f"getent ahostsv4 {d['domain']} | head -1 && " f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/", nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. " "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus " "propios dominios puede disparar su fail2ban (pasó).") # ── 4. lo que muere ───────────────────────────────────────────────────────────────────────── if muere: # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible: # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se # supiera dependía de que alguien se acordara. det = [] for t, n, e in muere: tam = f" ({e.get('size')})" if e.get("size") else "" det.append(f"# {t:9} {n}{tam}") # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible: # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se # supiera dependía de que alguien se acordara. # # Se lista UN RENGLÓN POR DIRECTORIO, no uno por dominio: siete dominios `*.gioser.net` que # mueren reclaman el mismo `gioser.bak`, y repetirlo siete veces convierte el aviso en ruido. # Y se dice «podría», nunca «es»: el directorio se llama `terapeuta` y el dominio # `terapeuta.ec`. Emparejar «casi» acierta casi siempre, y el resto de las veces manda a # borrar lo que no era. if contenido: doms_muertos = [n for t, n, _ in muere if t == "dominio"] det.append("#") det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.") for c in contenido: quien = [d for d in doms_muertos if c in candidatos(d, [c])] marca = ("podría ser de: " + ", ".join(quien[:4]) + (f" (+{len(quien)-4})" if len(quien) > 4 else "")) if quien else \ "no corresponde a NINGÚN dominio que muera: nadie lo recuerda, miralo" det.append(f"# {c['path']:34} {c['size']:>6} ← {marca}") paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja", "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n" + "\n".join(det), verifica="revisión humana: leer la lista completa antes de destruir el origen", verifica_tipo="humano", nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté " "escrito ES el paso.") return P def emitir(censo, P, target): L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).", "#", "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por", "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.", 'schema = "takana-plan/1"', f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"', f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"', f'destino = "{target}"', ""] for i, p in enumerate(P, 1): L.append(f"[[paso]]") L.append(f'n = {i}') L.append(f'clase = "{p["clase"]}"') L.append(f'titulo = """{p["titulo"]}"""') L.append("cmd = " + bloque_toml(p["cmd"])) if p["verifica"]: L.append("verifica = " + bloque_toml(p["verifica"])) L.append(f'verifica_tipo = "{p["verifica_tipo"]}"') if p["nota"]: L.append("nota = " + bloque_toml(p["nota"])) L.append("") return "\n".join(L) + "\n" def main(): ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.") ap.add_argument("--censo", required=True) ap.add_argument("--target", required=True, help="usuario@host destino") ap.add_argument("--key", help="clave SSH para el destino") ap.add_argument("--decide", action="store_true", help="mostrar TODO con su recomendación y su motivo, y elegir") ap.add_argument("--revisar", action="store_true", help="sólo mostrar la revisión con recomendaciones, sin preguntar") ap.add_argument("--out", help="fichero del plan (default: stdout)") a = ap.parse_args() with open(a.censo, "rb") as f: censo = tomllib.load(f) if a.revisar: revisar(censo, interactivo=False) return if a.decide: censo = revisar(censo, interactivo=True) validar(censo) P = pasos(censo, a.target, a.key) por_clase = {} for p in P: por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1 print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══") for c, n in por_clase.items(): print(f" {c:10} {n:3} paso(s)") txt = emitir(censo, P, a.target) # ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ────────────────────── # No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea # rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino # DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y # releer cuesta milisegundos y convierte un fallo diferido en uno inmediato. try: tomllib.loads(txt) except tomllib.TOMLDecodeError as e: sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n" f" No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n" f" exportar, que son las dos únicas cosas para las que existe.") if a.out: open(a.out, "w").write(txt) print(f"\n plan → {a.out} ({len(P)} pasos, con su comando literal cada uno)") else: print(txt) if __name__ == "__main__": main()