Files
takana/scripts/mudanza/planear.py
T
SergioandClaude Opus 5 f2bffd9e4b planear: cruzar la config contra las decisiones — y los puertos se adjudicaban por NOMBRE
La config del servidor declara de qué servicios depende: cada `reverse_proxy` y cada `php_fastcgi`
apuntan a algo. Cruzarlo contra la decisión tomada sobre cada servicio caza un fallo que ninguna otra
parte ve: **el dominio se MUDA y el servicio que lo sirve está marcado MUERE**. Las dos decisiones
son razonables por separado y juntas dejan el sitio nuevo devolviendo 502 — no lo ve el DNS, no lo
ven los procesos, y no lo ve quien decide de a una entrada por vez, que es como se decide.

En gioser apareció el revés: dos sitios proxean a un puerto que NINGÚN servicio censado sirve, o sea
que ya devuelven 502 hoy, en el origen — `mail.sigma.gioser.net` → :9000 y `api.gioser.net` → :8000.
Confirmado aparte con `ss -lntp`: no hay nada escuchando en ninguno de los dos.

**Y un falso positivo que hubo que matar primero.** La primera corrida acusaba a `sergio.gioser.net`
de mudarse dejando atrás a `shuma`. Falso, y la causa estaba en el CENSO: los puertos se adjudicaban
por prefijo de nombre (`proc.startswith(name[:15])`), así que un `shuma` DECLARADO-MUERTO se quedaba
con el 7378 — que lo escucha `shuma-gateway` (pid 294, verificado con `ss -lntp`). Un
declarado-muerto por definición no puede estar escuchando. Ahora se atan por PID, que es lo único sin
ambigüedad, con caída al nombre sólo para servicios VIVOS cuando `ss` no da el pid.

El puerto es la señal más fuerte de que algo sirve, así que colgárselo al servicio equivocado
envenena todo lo que se derive de él — acá se derivó un guardián acusando al inocente, que es la
forma más rápida de que un guardián se deje de leer.

De paso, el lector de Caddy entiende `php_fastcgi` (antes caía en «directiva no reconocida») y
registra a dónde proxea cada sitio, incluidos los bloques anidados.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-12 03:03:49 +00:00

918 lines
55 KiB
Python
Executable File

#!/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
# Paquetes que proveen un RUNTIME, no un programa. Que exista `recipes/nodejs.toml` no significa que
# `openclaw` esté cubierto: el intérprete se resuelve y el programa sigue faltando.
INTERPRETES = {"python", "python3", "nodejs", "node", "perl", "ruby", "php", "lua", "java", "jre"}
# El paquete del origen y la receta del catálogo no siempre se llaman igual ni siquiera para el mismo
# intérprete: Artix empaqueta `python` y el catálogo tiene `python3.toml`. Sin esto, tres servicios
# salían «hace falta una receta takana» cuando el runtime YA está sellado y lo que falta es el
# programa — dos trabajos muy distintos.
ALIAS_INTERPRETE = {"python": "python3", "node": "nodejs", "jre": "java"}
# ── 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"<config de {desde}: ubicala>")
dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
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")
# ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
# `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
# SÍ construye y tiene selladas. El censo ya le preguntó al gestor de paquetes quién posee el
# binario, así que ese nombre también se prueba.
import glob
for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or ""),
pkg, (pkg or "").removesuffix("-bin"), ALIAS_INTERPRETE.get(pkg or "", "")):
if not cand or not os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
continue
# ⚠⚠ Y la trampa, que es la que haría mentir al perfil: un paquete INTÉRPRETE provee el
# runtime, no el programa. `openclaw` lo posee `nodejs` y `fail2ban-server` lo posee
# `python`; tener esas recetas NO te da esos servicios. Contarlos como cubiertos haría que el
# perfil saliera N/N describiendo un servidor al que le faltan cuatro programas.
if pkg in INTERPRETES and cand in (pkg, ALIAS_INTERPRETE.get(pkg or "", "")) and cand != nombre:
sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
return ("interprete",
f"lo corre el intérprete `{pkg}`, que takana SÍ construye (`recipes/{cand}.toml`"
+ (", sellada)" if sellado else ", sin sellar)")
+ f" — pero el intérprete NO es el programa: `{nombre}` es código que hay que "
f"llevar y declarar aparte. El perfil resuelve el runtime y nada más")
sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
via = "" if cand in (nombre, nombre.replace("_", "-")) else f" (por su paquete `{pkg}`)" \
if cand in (pkg, (pkg or "").removesuffix("-bin")) else f" (por su binario)"
return ("receta-takana",
f"takana YA lo construye{via}: `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 and e.get("exec_borrado"):
# Su propia clase, y la más urgente del censo: el fichero YA NO EXISTE. No es «hay que
# llevarlo», es «se pierde en el próximo reinicio y el que está en su ruta NO es el mismo».
pid = e.get("pid_vivo") or (e.get("pids") or [0])[0]
bv, bd = e.get("bytes_vivo", ""), e.get("bytes_disco", "")
dif = (f" El que hay AHORA en esa ruta pesa {bd} bytes y el que corre {bv}: NO SON EL MISMO."
if bv and bd and bv != bd else
(" En esa ruta ya no hay nada." if not bd else ""))
return ("borrado",
f"⚠ el binario que CORRE fue borrado o reemplazado: `{exe}` ya no existe en disco."
f"{dif} Sólo vive mientras viva el pid {pid}; al reiniciar el origen se pierde PARA "
f"SIEMPRE. Rescatalo ya: sudo cat /proc/{pid}/exe > {os.path.basename(exe)} "
f"&& chmod +x {os.path.basename(exe)}")
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=(), sitios=()):
"""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":
# ⚠ Evidencia más fuerte que el DNS, y sin tocar la red: si el bloque de este dominio en la
# config del servidor apunta a un directorio que NO EXISTE, no hay nada que servir — devuelve
# 502 resuelva donde resuelva. Medido en gioser: `aura`, `sigma` y `kosmofono` apuntan a
# raíces borradas, y son justo los que en su día devolvían los 502 que hicieron que el censo
# se baneara a sí mismo al sondearlos por HTTP. Se lee del fichero; no hace falta preguntar.
for st in sitios:
if e.get("domain") in (st.get("nombres") or "").split() and st.get("raiz_existe") == "no":
return "muere", (f"su bloque en {st['config']} apunta a `{st['raiz']}`, que NO "
f"EXISTE: no hay nada que servir, devuelve 502 resuelva donde resuelva")
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, sitios)[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, censo.get("sitio", []))
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 incoherencias(censo):
"""Cruza lo que la config DECLARA NECESITAR contra lo que se decidió con cada servicio.
── LO QUE ESTO CAZA Y NADA MÁS PODÍA ───────────────────────────────────────────────────────
Un dominio que se muda y el servicio que lo sirve marcado `muere`. Las dos decisiones son
razonables por separado —el dominio se usa, el servicio parecía prescindible— y juntas dejan el
sitio nuevo devolviendo 502. No lo ve el DNS, no lo ve el censo de procesos y no lo ve quien
decide de a una entrada por vez, que es como se decide.
También caza el revés: un `reverse_proxy` a un puerto que NINGÚN servicio censado sirve. Eso ya
está roto HOY, en el origen, y mudarlo es mudar un 502."""
por_puerto, por_nombre = {}, {}
for s in censo.get("servicio", []):
por_nombre[s["name"].lower()] = s
for p in s.get("ports", []):
por_puerto[str(p)] = s
dec = {}
for d in censo.get("dominio", []):
dec[d["domain"]] = d.get("decision", "")
out = []
for st in censo.get("sitio", []):
doms = [d for d in (st.get("nombres") or "").split() if dec.get(d) == "muda"]
if not doms:
continue
for destino in (st.get("proxies") or "").split():
if destino.startswith("unix/"):
# `unix//run/php-fpm/php-fpm.sock` → el servicio se identifica por su nombre.
base = destino.rstrip("/").split("/")[-1].removesuffix(".sock")
svc = por_nombre.get(base) or por_nombre.get(base.split("-")[0])
clave = base
else:
clave = destino.rsplit(":", 1)[-1]
svc = por_puerto.get(clave)
if svc is None:
out.append((", ".join(doms), destino,
"NINGÚN servicio censado sirve ese destino: el sitio ya devuelve 502 "
"HOY, en el origen. Mudarlo es mudar un 502"))
elif svc.get("decision") == "muere":
out.append((", ".join(doms), f"{destino}{svc['name']}",
f"el dominio se MUDA y `{svc['name']}`, que es quien lo sirve, está "
f"marcado MUERE. El sitio nuevo va a devolver 502"))
elif not svc.get("decision"):
out.append((", ".join(doms), f"{destino}{svc['name']}",
f"el dominio se muda y `{svc['name']}`, que lo sirve, NO tiene decisión"))
return out
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 ter. COHERENCIA ───────────────────────────────────────────────────────────────────────
incoh = incoherencias(censo)
if incoh:
det = ["# Cada línea es un dominio que se MUDA cuyo backend no va a estar del otro lado.",
"# Las dos decisiones son razonables por separado; juntas dejan un 502.", "#"]
for dom, destino, motivo in incoh:
det.append(f"# {dom[:44]}")
det.append(f"# ↳ {destino}")
det.append(f"# {motivo}")
paso("coherencia", f"{len(incoh)} dominio(s) que se mudan SIN su backend", "\n".join(det),
verifica="revisión humana: o se muda el backend, o el dominio no se muda",
verifica_tipo="humano",
nota="Nadie más puede cazar esto: no lo ve el DNS, no lo ven los procesos, y no lo ve "
"quien decide de a una entrada por vez — que es como se decide.")
# ── 0 bis. RESCATE, antes que nada ──────────────────────────────────────────────────────────
# Va PRIMERO, antes del preflight, porque es el único paso del plan que puede volverse
# IMPOSIBLE mientras se piensa el resto: estos binarios ya no existen en disco y sólo viven en el
# inodo que sostiene su proceso. Un reinicio del origen —o que se caiga ese pid— y se pierden
# para siempre. Y en dos de los cuatro hay AHORA otro fichero en la misma ruta, de distinto
# tamaño: copiar la ruta no falla, muda otra cosa.
borrados = [s for s in censo.get("servicio", [])
if s.get("exec_borrado") and s.get("decision") == "muda"]
if borrados:
lineas, ver = [], []
for s in borrados:
pid = s.get("pid_vivo") or (s.get("pids") or [0])[0]
nom = os.path.basename(s.get("exec") or s["name"])
lineas.append(f"sudo cat /proc/{pid}/exe > rescate/{shlex.quote(nom)} && "
f"chmod +x rescate/{shlex.quote(nom)}")
if s.get("bytes_vivo"):
# La verificación COMPARA contra el tamaño del que CORRE. Un `cat` de un /proc que ya
# no existe crea un fichero VACÍO y devuelve 0: sin comparar, el rescate «pasa».
ver.append(f"[ \"$(stat -c %s rescate/{shlex.quote(nom)} 2>/dev/null)\" = "
f"\"{s['bytes_vivo']}\" ] || {{ echo 'FALTA {nom}'; exit 1; }}")
paso("rescate", f"rescatar {len(borrados)} binario(s) BORRADOS del disco (sólo mientras corran)",
"mkdir -p rescate\n" + "\n".join(lineas),
verifica="\n".join(ver) + "\necho ' ✓ los binarios rescatados coinciden con los que corren'",
nota="⚠ ESTE PASO CADUCA. `/proc/<pid>/exe` deja de existir cuando muere el proceso, y "
"estos ficheros ya no están en su ruta. Si el origen se reinicia antes de correr "
"esto, el binario que estaba andando NO se puede recuperar de ninguna manera.")
# ── 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','?')} → <IP NUEVA>",
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.")
# ⚠ Un directorio que la CONFIG referencia no es un huérfano, diga lo que diga su
# nombre. Sin esto el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda»
# estando servido en la línea 174 del Caddyfile — y ese aviso, aplicado, tira el sitio.
servidos = {}
for st in censo.get("sitio", []):
if st.get("raiz"):
servidos.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
for c in contenido:
ruta = c["path"].rstrip("/")
quien = [d for d in doms_muertos if c in candidatos(d, [c])]
if ruta in servidos:
marca = ("⚠ LO SIRVE la config para: " +
", ".join(" ".join(servidos[ruta]).split()[:3]) +
" — no es huérfano; moverlo tira el sitio")
elif quien:
marca = ("podría ser de: " + ", ".join(quien[:4]) +
(f" (+{len(quien)-4})" if len(quien) > 4 else ""))
else:
marca = "no corresponde a NINGÚN dominio que muera ni lo sirve la config: miralo"
det.append(f"# {ruta: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()