`/proc/<pid>/exe` termina en « (deleted)» para cuatro servicios de gioser: el fichero ya no está en
disco, sólo vive el inodo que sostiene su proceso. Y en tres de los cuatro hay AHORA otro fichero en
la misma ruta, de distinto tamaño:
tejido corre 12750368 B · en su ruta hay 13815864 B
shuma-gateway corre 8431896 B · en su ruta hay 10363504 B
pacha-secretos corre 8634240 B · en su ruta hay 8647456 B
puerta-f6e393ff corre 197262440 B · en su ruta NO HAY NADA
Copiar la ruta NO FALLA: muda otra cosa, y el servicio nuevo no es el que estaba andando. Todo verde,
todo distinto — el modo de fallo más caro que hay en este frente.
Se recuperan leyendo `/proc/<pid>/exe`, y SÓLO mientras el proceso viva. En una mudanza que termina
BORRANDO el origen, un reinicio de gioser antes de este paso los pierde para siempre. Por eso:
· clase propia en `origen_binario` (`borrado`), no una coletilla dentro del texto de `suelto`: no es
«hay que llevarlo», es «se pierde en el próximo reinicio y el que está en su ruta no es el mismo».
El motivo trae el comando literal de rescate con su pid.
· paso `rescate` en el plan, ANTES del preflight, porque es el único paso que puede volverse
IMPOSIBLE mientras se piensa el resto.
· su verificación COMPARA TAMAÑOS contra el que corre. No es celo: un `cat` de un `/proc` que ya no
existe crea un fichero VACÍO y devuelve 0, así que sin comparar el rescate «pasa». Probado en los
dos sentidos — el paso real sale 0, y con un fichero vacío a propósito dice `FALTA tejido` y sale 1.
Los cuatro ya están rescatados en `work/mudanza/rescate/` (gitignored), byte a byte iguales a los que
corren, con su SHA256SUMS.
Y el empalme se hizo comprobando que el marcador fuera ÚNICO antes de cortar, que es la lección del
`def pasos` duplicado de ayer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
825 lines
49 KiB
Python
Executable File
825 lines
49 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
|
|
|
|
|
|
# ── 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")
|
|
for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or "")):
|
|
if cand and os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
|
|
import glob
|
|
sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
|
|
return ("receta-takana",
|
|
f"takana YA lo construye: `recipes/{cand}.toml`"
|
|
+ (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)")
|
|
+ f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}")
|
|
if pkg:
|
|
return ("paquete-ajeno",
|
|
f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "
|
|
f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)")
|
|
if exe 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 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 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()
|