Files
takana/scripts/mudanza/planear.py
T
SergioandClaude Opus 5 1c237f9da2 mudanza: decidir SIN teclas unitarias, /mnt/vvv entra al censo, y 25 rutas de secretos más
Tres cosas que salieron de usar la herramienta de verdad.

1. `--decidir <fichero>`: decidir EN LOTE desde `<ruta-o-nombre> <decision>` por línea.

   El usuario: «no sé cómo activar o desactivar cosas aquí desde shuma llimphi remoto, que las
   opciones son con teclas unitarias». El modo interactivo lee una tecla por entrada, y hay
   terminales donde eso no se puede usar — una herramienta cuya única forma de decidir exige un
   tipo de terminal no es una herramienta, es una herramienta PARA ESA TERMINAL. El fichero anda en
   cualquiera, se revisa antes de aplicarlo, se versiona y se vuelve a correr.

   Una clave que no empareja con nada, o que empareja con varias, es ERROR RUIDOSO y no se escribe
   NADA: un lote a medias deja decidido lo que nadie revisó.

2. `/mnt/vvv` entra a las raíces del censo. No estaba, y ahí vive el trabajo: los repos de la
   persona, el monorepo, el store, work/sources. El agujero se vio preguntando por `humanoid`: el
   censo sólo conocía `/home/sergio/humanoid` —200 M de `build/` de junio— mientras el proyecto de
   verdad, con su `.git`, estaba en `/mnt/vvv/humanoid`, fuera de toda raíz censada. `repos_git` SÍ
   lo miraba: una parte del censo conocía el árbol y la otra no.

3. 25 rutas más en `rutas-fuera-de-git.txt`, del barrido que hizo el frente tawasuyu sobre su home:
   `~/.wawa/seeds` (sin él ninguna app nueva del génesis nace), `~/.tejido` (la identidad de esta
   máquina en la flota), `~/.local/share/agora`, `~/.config/{wawa,minga,thasnuna,shuma,mirada,hcloud}`,
   `~/keys` y `~/fdroid` (⚠ firma de apps Android), `~/.gnupg`, `~/.pgpass`, `/etc/wireguard`,
   `/etc/{shuma,sandokan,tawasuyu/agente,local.d}`. Son identidades y semillas: no se «vuelven a
   generar», porque generar otras significa ser OTRA máquina para el resto de la flota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:01:16 +00:00

1273 lines
79 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
import censar # 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 ────────────────────────────────────────────────────────────────────────────────────
# ── TRES CAMINOS, NO DOS (pedido del usuario, 2026-09-16) ───────────────────────────────────────
# Con `muda`/`muere` una cosa que no va a la caja nueva sólo podía MORIR, así que todo lo dudoso se
# marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. El tercer
# camino es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero
# perder*. `respalda` lo copia al corral de respaldo y NO lo instala en el destino.
#
# ⚠ `respalda` cuenta como «no se muda» para la recomendación derivada de los servicios: un binario
# que queda archivado no puede arrancar en la caja nueva. Confundirlos daría un servicio marcado
# `muda` cuyo ejecutable no va a existir allá — y eso se descubre al arrancarlo, no al decidir.
DECISIONES = ("muda", "respalda", "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",
},
"el destino tiene su propio journal (la semilla de arje declara `provides: [\"Spawn\", \"Journal\"]`)": {
"metalog", "syslogd", "syslog-ng", "rsyslogd", "rsyslog", "busybox-syslogd", "socklog",
},
"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=(), locales=()):
"""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":
# ── LO QUE SIGUE SIN RECOMENDARSE, Y LO QUE SÍ ──────────────────────────────────────────
# La regla de fondo no cambió: **qué datos VALEN no se deduce de la máquina** — `terapeuta.ec`
# eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso.
# Pero hay tres hechos que sí decide la máquina, y callarlos obliga a mirar 169 entradas a
# ojo: que el servidor web lo SIRVA, que un proceso vivo lo USE, y que sea recuperable de
# otro lado (un repo limpio con remoto) o regenerable (una caché). Se recomienda SÓLO sobre
# esos hechos, con el hecho como motivo, y el resto sale en blanco a propósito.
if e.get("servido_por"):
return "muda", ("lo SIRVE " + ", ".join(e["servido_por"]) +
": si no viaja, ese sitio deja de existir")
if e.get("usado_por"):
return "muda", ("lo usa un proceso vivo (" + ", ".join(e["usado_por"]) +
"): un servicio que se muda sin sus datos arranca vacío")
base = os.path.basename(e.get("path", ""))
if base in _CACHES or base in ("swap", "lost+found"):
return "muere", f"`{base}` es caché o estado transitorio: se regenera solo en el destino"
rem = e.get("git_remoto") or ""
# ⚠ UN REMOTO QUE APUNTA A ESTA MISMA MÁQUINA NO ES UN RESPALDO, ES EL MISMO DISCO.
# Sin esto, «tiene remoto ⇒ se vuelve a clonar» recomienda ABANDONAR un repo cuyo único
# ejemplar se borra con la máquina. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es
# `ssh://git@127.0.0.1:2345/…` — el gitea de acá. El censo ya lo sabe (`[[repo]] solo_aqui`)
# y lo que faltaba era cruzarlo.
if rem and any(x and x in rem for x in locales):
return "respalda", (f"su remoto ({rem}) es ESTA MISMA MÁQUINA: no es un respaldo, es el "
"mismo disco. Clonarlo después de borrarla no se puede")
if rem and not e.get("git_sucio"):
return "muere", (f"repo git LIMPIO con remoto ({rem}): se vuelve a clonar")
if rem and e.get("git_sucio"):
return "respalda", ("repo git con remoto pero CON CAMBIOS SIN COMMITEAR: lo commiteado "
"se vuelve a clonar, lo de encima sólo está acá")
if e.get("git_fecha") and not rem:
return "respalda", (f"repo git SIN remoto (último commit {e['git_fecha']}): su historia "
"existe únicamente en este disco")
return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
return "", ""
# Nombres cuyo contenido se REGENERA. No es una lista de «cosas grandes»: es de cosas que el destino
# vuelve a crear solo. Borrar por tamaño sería adivinar; por regenerabilidad es un hecho.
_CACHES = {
".cache", ".npm", ".npm-global", ".gradle", ".pub-cache", ".dartServer", ".cargo", ".rustup",
".m2", ".gem", ".go", "go", ".yarn", ".bun", ".deno", ".venvs", ".local-cache", "node_modules",
"target", "__pycache__", ".mozilla-cache", ".thumbnails", ".ccache", ".sccache",
}
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"]
# Con qué se compara un remoto para saber si apunta a esta misma máquina. Sale del censo, no de
# una lista cableada: las IPs y el hostname los midió `identidad()`.
maq = censo.get("maquina", {})
locales = {"127.0.0.1", "localhost", "0.0.0.0"} | set(maq.get("ips", []) or [])
if maq.get("hostname"):
locales.add(maq["hostname"])
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 "—"
rec, motivo = recomendar("datos", e, locales=locales)
e["_rec"], e["_motivo"] = rec, motivo
print(f" {n:46} {e.get('size','?'):>6} {marca if marca != '—' else (rec or '—'):>8}"
f" {e.get('descripcion', '')[:70]}")
if interactivo:
print("\n [m]uda = va a la caja nueva · [r]espalda = se copia al corral y NO se instala"
" · [x] muere con la máquina\n")
for t, n, e in datos:
if e.get("decision") in DECISIONES:
continue
rec = e.get("_rec", "")
print(f" {n} {e.get('size','?')}")
if e.get("descripcion"):
print(f" {e['descripcion'][:100]}")
if e.get("_motivo"):
print(f" ↳ sugerido: {rec}{e['_motivo'][:90]}")
r = input(f" [m]uda/[r]espalda/[x]muere"
f"{'/Enter=' + rec if rec else ''} > ").strip().lower()
e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(r, "")
# ⚠ `respalda` entra acá junto a `muere`: las dos significan «su ruta NO va a existir en el
# destino», que es lo único que la derivación de servicios necesita saber.
mueren = [e["path"] for t, n, e in datos if e.get("decision") in ("muere", "respalda")]
# ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
grupos = {"muda": [], "respalda": [], "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", "respalda": "se recomienda RESPALDAR",
"muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"}
for g in ("muda", "respalda", "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/[r]espalda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
e["decision"] = {"m": "muda", "r": "respalda", "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 decidir_desde_fichero(censo, ruta_dec):
"""Decidir EN LOTE desde un fichero de texto: `<ruta-o-nombre> <decision>` por línea.
── POR QUÉ EXISTE (pedido del usuario, 2026-09-16) ─────────────────────────────────────────
«no sé cómo activar o desactivar cosas aquí desde shuma llimphi remoto, que las opciones son
con teclas unitarias». El modo interactivo de `--decide` lee una tecla por entrada, y hay
terminales —la consola remota de shuma, entre otras— donde eso no se puede usar. Una
herramienta cuya única forma de decidir exige un tipo de terminal no es una herramienta: es una
herramienta *para esa terminal*. El fichero funciona en cualquiera, se revisa antes de
aplicarlo, se versiona y se vuelve a correr — que es lo mismo que ya se pide del plan.
Formato, deliberadamente pobre (`#` comenta, se ignoran líneas vacías):
/home/sergio/mapas muda
terapeuta respalda # basta con que el nombre sea único
/var/lib/swap muere
⚠ Una clave que no empareja con NADA es un ERROR RUIDOSO, no un aviso: significa que alguien
decidió algo que no se aplicó, y el silencio ahí es exactamente la clase de fallo que este
programa existe para no tener. Lo mismo si empareja con varias: se nombran todas y no se toca
ninguna, porque elegir una por su cuenta sería adivinar.
"""
lineas = []
with open(ruta_dec, encoding="utf-8") as fh:
for i, l in enumerate(fh, 1):
l = l.split("#", 1)[0].strip()
if l:
lineas.append((i, l))
todos = entradas(censo)
aplicadas, problemas = 0, []
for i, l in lineas:
partes = l.rsplit(None, 1)
if len(partes) != 2 or partes[1] not in DECISIONES:
problemas.append(f"línea {i}: «{l}» — falta la decisión o no es una de "
f"{'/'.join(DECISIONES)}")
continue
clave, dec = partes
cands = [(t, n, e) for t, n, e in todos
if n == clave or os.path.basename(str(n)) == clave]
if not cands:
problemas.append(f"línea {i}: «{clave}» no empareja con NADA del censo")
continue
if len(cands) > 1:
problemas.append(f"línea {i}: «{clave}» empareja con {len(cands)}: "
+ ", ".join(n for _t, n, _e in cands[:5]))
continue
cands[0][2]["decision"] = dec
aplicadas += 1
if problemas:
print("\n!! decisiones NO aplicadas:", file=sys.stderr)
for x in problemas:
print(f" {x}", file=sys.stderr)
sys.exit("\n No se escribe nada: un lote a medias deja decidido lo que nadie revisó.")
return aplicadas
def guardar_censo(censo, ruta, respaldar=True):
"""Vuelve a escribir el censo con las decisiones puestas, usando el MISMO emisor que lo creó.
Dos detalles que no son de estilo: se descartan las claves internas (`_rec`, `_motivo`, que
`revisar()` cuelga de cada entrada y NO son parte del censo), y se escribe con temporal +
`rename` — si la herramienta muere a mitad, el fichero anterior sigue entero. Perder el censo a
medio decidir es perder el trabajo que más cuesta de toda la mudanza."""
limpio = {}
for k, v in censo.items():
if isinstance(v, list):
limpio[k] = [{kk: vv for kk, vv in e.items() if not kk.startswith("_")}
if isinstance(e, dict) else e for e in v]
else:
limpio[k] = v
# El emisor de `censar` habla en plural; el TOML, en singular. El puente va acá y no allá para
# no tocar el censo, que es quien define el formato.
puente = {
"fecha": limpio.get("fecha", ""), "identidad": limpio.get("maquina", {}),
"servicios": limpio.get("servicio", []), "huerfanos": limpio.get("huerfano", []),
"puertos": limpio.get("puerto", []), "dominios": limpio.get("dominio", []),
"datos": limpio.get("datos", []), "datos_raiz": limpio.get("datos_raiz", []),
"contenido": limpio.get("contenido", []), "sitios": limpio.get("sitio", []),
"repos": limpio.get("repo", []), "montajes": limpio.get("montaje", []),
"fuera_de_git": limpio.get("fuera_de_git", []),
}
texto = censar.emitir_toml(puente)
# Y se RELEE antes de pisar nada: el mismo guardián que el plan. Un censo que no vuelve a
# parsear deja la mudanza sin su fichero central y sin forma de saber cuándo se rompió.
try:
vuelto = tomllib.loads(texto)
except tomllib.TOMLDecodeError as e:
sys.exit(f"✗ el censo regenerado NO es TOML válido: {e}\n No se escribe nada.")
n_dec = sum(1 for _t, _n, x in entradas(vuelto) if x.get("decision"))
n_esp = sum(1 for _t, _n, x in entradas(censo) if x.get("decision"))
if n_dec != n_esp:
sys.exit(f"✗ el censo regenerado perdió decisiones ({n_esp}{n_dec}). No se escribe nada.")
if respaldar and os.path.exists(ruta):
with open(ruta, encoding="utf-8") as fh:
open(ruta + ".bak", "w", encoding="utf-8").write(fh.read())
tmp = ruta + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(texto)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, ruta)
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 destino_respaldo_default():
"""El corral de respaldo sale de `scripts/respaldo-storagebox.sh`, NO de una constante nueva acá.
Dos ficheros con la misma dirección escrita a mano divergen, y el que se queda viejo no falla:
respalda a un sitio que nadie mira. Se leen sus defaults (`SB_USER`/`SB_HOST`/`SB_PORT`), que es
donde el proyecto ya guarda lo que se guarda por si acaso."""
ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "respaldo-storagebox.sh")
v = {}
try:
with open(ruta, encoding="utf-8") as fh:
for linea in fh:
for k in ("SB_USER", "SB_HOST", "SB_PORT"):
if linea.startswith(f'{k}="${{{k}:-'):
v[k] = linea.split(":-", 1)[1].split("}", 1)[0]
except OSError:
return "", ""
if not (v.get("SB_USER") and v.get("SB_HOST")):
return "", ""
return f'{v["SB_USER"]}@{v["SB_HOST"]}:respaldo-mudanza/', v.get("SB_PORT", "22")
def pasos(censo, target, key, respaldo=None, respaldo_port=None):
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"]
datos_resp = [d for d in censo.get("datos", []) if d.get("decision") == "respalda"]
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. CÓDIGO QUE SÓLO EXISTE ACÁ ───────────────────────────────────────────────────────────
# El primer paso del plan entero, por delante incluso del rescate de binarios: aquello es un
# servicio que se pierde, esto es EL CÓDIGO CON EL QUE SE VOLVERÍA A CONSTRUIR. Y no se ve desde
# ninguna otra parte del censo —un repo no es un proceso, ni un puerto, ni un dominio—, así que
# se descubre cuando ya no hay de dónde sacarlo.
solos = [r for r in censo.get("repo", []) if r.get("solo_aqui") == "si"]
if solos:
det = ["# Estos repositorios NO tienen ningún remoto fuera de esta máquina. Apagarla borra el",
"# código, y los clones de trabajo están en el mismo disco que también muere.",
"#",
"# Por cada uno: agregarle un remoto que viva en otro lado y empujar. El propio takana",
"# lo hace así (`scripts/espejo-setup.sh`: pushurl doble a gitea Y a un espejo privado).",
"#"]
for r in solos:
det.append(f"# {r['path']}")
det.append(f"# remoto actual (en esta máquina): {r['dentro'][:88]}")
paso("codigo", f"{len(solos)} repositorio(s) que sólo existen en la máquina que se va a borrar",
"\n".join(det),
verifica="revisión humana: cada uno tiene que tener un remoto FUERA antes de apagar nada",
verifica_tipo="humano",
nota="⚠ Es la dependencia circular de esta mudanza: `tawasuyu` produce 10 de los 13 "
"binarios que nadie más provee, y su único remoto es el gitea de esta misma "
"máquina. Sin esto, apagar el origen deja servicios que no se pueden reconstruir.")
# ── 0 bis. LO QUE NO VIAJA CON NINGÚN REPO ──────────────────────────────────────────────────
# El paso 1 salva el CÓDIGO; éste salva lo que hace que ese código sirva de algo: las llaves, los
# certificados, los tokens y la configuración que no está versionada en ninguna parte. Va acá
# arriba por la misma razón —se pierde para siempre— y con una diferencia que importa: un repo
# sin copia fuera se nota al intentar clonarlo, y una clave privada borrada no se nota hasta la
# próxima vez que alguien firma. La peor de todas es `~/.config/takana/keys/release.ed25519`: el
# `.pub` vive EN el repo, la privada sólo en esta máquina.
fg = [f for f in censo.get("fuera_de_git", []) if f.get("path") != "crontab"]
if fg:
ciegos = [f for f in fg if f.get("estado") == "sin_permiso"]
det = ["# Config, llaves y credenciales que no están en ningún repo. Se listan por su ruta,",
"# su DUEÑO y su tamaño; el censo nunca leyó su contenido.", "#"]
for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])):
duenio = f" [{f['owner']}]" if f.get("owner") else ""
det.append(f"# {f['path']}{duenio} ({f.get('size', '?')})")
if f.get("porque"):
det.append(f"# {f['porque']}")
nota = ("⚠ `~` NO es un home: el censo corre como root y sólo mira `/root` salvo que se le "
"pidan los homes de `/etc/passwd`. Lo que se censa por su nombre se decide; lo que "
"sólo viaja dentro del bloque `/home` viaja sin que nadie lo haya mirado.")
if ciegos:
# Un `ls` sin permiso se lee IGUAL que un directorio ausente: decir cuál de las dos es
# la única forma de que «no hay nada ahí» sea una afirmación y no un silencio.
nota += (" ⚠⚠ " + str(len(ciegos)) + " de estas rutas EXISTEN y el censo NO pudo leerlas "
"(" + ", ".join(f["path"] for f in ciegos[:4]) + "): volvé a censar como su "
"dueño antes de decidir, o vas a decidir sobre una lista incompleta.")
paso("secretos", f"{len(fg)} ruta(s) de config y credenciales fuera de todo repo",
"\n".join(det),
verifica="revisión humana: por cada una, decidir `muda` o `muere` — y para las que "
"mudan, que el destino la tenga ANTES de apagar nada",
verifica_tipo="humano", nota=nota)
for f in fg:
if f.get("decision") != "muda":
continue
origen = f["path"]
dst = f.get("destino") or origen
# `-aHAX --numeric-ids`: permisos, ACLs y xattrs. Una llave que llega con el modo
# cambiado no falla al copiarse, falla al usarse («permissions are too open»), que es
# varios pasos más tarde y en otro sitio.
paso("secretos", f"copiar {origen}{dst} ({f.get('size', '?')})",
f"rsync -aHAX --numeric-ids --partial "
f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(origen)} "
f"{target}:{shlex.quote(os.path.dirname(dst.rstrip('/')) or '/')}/",
# Verificación POR CONTENIDO sin revelar contenido: el digest de los digests.
# Contar ficheros no alcanza — una llave truncada cuenta igual que una entera.
verifica=(f"h() {{ find \"$1\" -type f -exec sha256sum {{}} + 2>/dev/null "
f"| awk '{{print $1}}' | sort | sha256sum | cut -c1-16; }}; "
f"src=$(h {shlex.quote(origen)}); "
f"dst=$({ssh} \""
f"find {shlex.quote(dst)} -type f -exec sha256sum {{}} + 2>/dev/null "
f"| awk '{{print \\$1}}' | sort | sha256sum | cut -c1-16\"); "
f'echo "digest: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
nota="El digest es de los CONTENIDOS, no de los nombres ni de la cuenta: una llave "
"truncada a la mitad cuenta como un fichero igual que la entera.")
# ── 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
# Ver la nota del paso de respaldo: con el censo detallado hay entradas que son FICHEROS, y
# a un fichero no se le pone barra (y su verificación no es `find -type f | wc -l` en un
# directorio, sino comparar el fichero mismo).
es_dir = d.get("tipo", "dir") != "fichero"
org, dst_r = (p + "/", dst + "/") if es_dir else (p, dst)
ver = ((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" ]') if es_dir else
(f"src=$(stat -c%s {shlex.quote(p)}); "
f"dst=$({ssh} 'stat -c%s {shlex.quote(dst)}' 2>/dev/null || echo ausente); "
f'echo "bytes: origen=$src destino=$dst"; [ "$src" = "$dst" ]'))
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(org)} {target}:{shlex.quote(dst_r)}",
verifica=ver,
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.")
# ── 1 bis. lo que se RESPALDA ───────────────────────────────────────────────────────────────
# Ni se muda ni se pierde. Va al corral de respaldo y NO se instala en el destino: la caja nueva
# no se entera de que esto existe. Por eso es un paso propio y no un `datos` con otro destino —
# su verificación es distinta y su fallo significa otra cosa (no rompe el servidor nuevo, pero
# borra el original sin copia si nadie lo mira).
if datos_resp:
if not respaldo:
# Un corral vacío convertiría cada `respalda` en un `rsync` a ninguna parte que además
# devuelve 0. Abortar acá es la única forma de que no se lea como «respaldado».
sys.exit("✗ hay entradas marcadas `respalda` y no sé a dónde respaldarlas.\n"
" Pasá --respaldo <destino> (host:ruta o ruta local). No invento uno: un\n"
" respaldo a un sitio equivocado se ve igual que un respaldo.")
rsh = f"ssh -p {respaldo_port} {ssh_opts}" if respaldo_port else f"ssh {ssh_opts}"
remoto = ":" in respaldo.split("/", 1)[0]
for d in datos_resp:
ruta = d["path"]
# Aplanada: `/home/sergio/humanoid` → `home-sergio-humanoid`. El corral junta cosas de
# rutas distintas, y conservar el árbol haría que dos orígenes se pisaran en el mismo
# nombre. El nombre aplanado DICE de dónde salió, que es lo que hace falta para volver.
nombre = ruta.strip("/").replace("/", "-")
dst = respaldo.rstrip("/") + "/" + nombre
# ⚠ Un FICHERO no lleva barra final y su destino es el directorio que lo contiene.
# Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y
# `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál
# es cuál (`tipo`), así que no hay que adivinarlo.
es_dir = d.get("tipo", "dir") != "fichero"
org = ruta + "/" if es_dir else ruta
dst_r = dst + "/" if es_dir else dst
# ⚠ `--mkpath` NO es opcional, y lo cazó el guardián de dos sentidos: sin él rsync
# muere con `mkdir "/home/respaldo-mudanza/prueba-guardian" failed` en cuanto el corral
# tiene un nivel que todavía no existe — y el primer respaldo SIEMPRE lo tiene.
base = (f"rsync -aH --mkpath --partial --partial-dir=.rsync-partial --info=stats2 "
+ (f"-e {shlex.quote(rsh)} " if remoto else "")
+ f"{shlex.quote(org)} {shlex.quote(dst_r)}")
# La verificación se le pregunta a RSYNC, no a `du` ni a `find`: el corral puede ser un
# Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías (medido). Una
# corrida en seco compara tamaño y fecha CONTRA EL DESTINO REAL y dice qué falta.
ver = (f"pend=$({base.replace('rsync -aH ', 'rsync -aHn ').replace('--info=stats2', chr(39)+'--out-format=%n'+chr(39))}"
f" | grep -v '/$' | wc -l); echo \"pendientes en el corral: $pend\"; [ \"$pend\" -eq 0 ]")
paso("respaldo", f"respaldar {ruta}{dst} ({d.get('size','?')})", base,
verifica=ver,
nota="NO se instala en el destino: esto es archivo, no mudanza. Y `du` en el "
"corral puede dar MENOS que el origen sin que falte nada (un Storage Box "
"comprime: 1,3 G subidos se veían como 576 M). Por eso verifica rsync.")
# ── 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)")
ap.add_argument("--decidir", metavar="FICHERO",
help="decidir EN LOTE desde un fichero `<ruta-o-nombre> <decision>` por línea. "
"Para terminales donde no se puede pulsar una tecla por entrada")
ap.add_argument("--censo-out", help="dónde escribir el censo con las decisiones tomadas "
"(default: sobre el mismo censo, con copia `.bak`)")
ap.add_argument("--respaldo", help="corral donde va lo marcado `respalda` (host:ruta o ruta "
"local). Default: el Storage Box de respaldo-storagebox.sh")
ap.add_argument("--respaldo-port", help="puerto SSH del corral (el Storage Box usa 23)")
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.decidir:
n = decidir_desde_fichero(censo, a.decidir)
destino_censo = a.censo_out or a.censo
guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
print(f" {n} decisión(es) aplicadas → {destino_censo}")
if a.decide:
censo = revisar(censo, interactivo=True)
# ── LAS DECISIONES SE GUARDAN, O NO EXISTIERON ──────────────────────────────────────────
# Antes `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal
# y las 169 elecciones se perdían. El censo ES la interfaz (SDD 29 §3), así que volver a
# escribirlo no es comodidad, es lo que hace que decidir a medias sea posible — y decidir
# 169 cosas SIEMPRE se hace a medias.
destino_censo = a.censo_out or a.censo
guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
print(f" decisiones guardadas → {destino_censo}")
validar(censo)
resp_dst, resp_port = (a.respaldo, a.respaldo_port)
if not resp_dst:
resp_dst, resp_port = destino_respaldo_default()
P = pasos(censo, a.target, a.key, resp_dst, resp_port)
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()