El plan ya advertía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Cierto, y
la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora es un PASO
con su comando literal: `traducir.py --from openrc --to arje --in /etc/init.d/caddy …`.
Dos traducciones distintas por servicio, y confundirlas es caro: la DECLARACIÓN (cómo se levanta,
`openrc`/`systemd` → tarjeta de arje) y la CONFIGURACIÓN (qué hace, sólo si se eligió un
equivalente). Un servicio que se muda a sí mismo no necesita la segunda: ofrecérsela es inventarle
trabajo. Los pares se le PREGUNTAN al registro de plugins, no se listan acá — una lista propia se
desincroniza del centro y el plan ofrecería una traducción que no existe. Sin par, el paso lo dice.
La verificación es HUMANA a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir, así que
dar el paso por bueno por su código de salida sería al revés de lo que hay que mirar. Lo que verifica
es que alguien LEYÓ el acta.
**Y en el camino salió un fallo del producto: el plan no era TOML válido.** Apareció con el primer
comando multilínea, pero estaba latente desde el principio y tiene DOS modos:
· `awk "\$1==1"` —que está en el `verifica` de TODO servicio— con cadena básica da
`Unescaped '\' in a string`: el plan queda ILEGIBLE.
· `cmd --from x \` + salto: la cadena básica PARSEA y se come el salto y la sangría ⇒ el comando
que sale NO es el que se escribió, sin que nada falle. Ése es el peor.
El plan es el producto: se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr. Uno
que no vuelve a parsear no sirve para ninguna de las dos cosas para las que existe. Arreglado con
cadena literal, y con un guardián que ESCRIBE Y RELEE antes de tocar el disco: si el TOML generado no
parsea, no se escribe nada. Convierte un fallo diferido —aparecía cuando alguien iba a EJECUTAR el
plan— en uno inmediato.
Probado de punta a punta: el comando que el plan emite, copiado tal cual, traduce el
`/etc/init.d/caddy` real de esta máquina a una tarjeta con `Restart{initial:3000}` (de su
`respawn_delay=3`) y reporta la única `reload()` que no se puede portar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
679 lines
39 KiB
Python
Executable File
679 lines
39 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
|
|
|
|
_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", []):
|
|
if d.get("class") != "fosil-sin-dns":
|
|
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:
|
|
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")
|
|
return ("desconocido", "no se pudo leer su binario: hace falta averiguarlo a mano")
|
|
|
|
|
|
def recomendar(tipo, e, datos_que_mueren=()):
|
|
"""Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
|
|
que aceptarla sea barato y equivocarse sea caro de no notar.
|
|
|
|
Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
|
|
ciegas o se ignora entera. Las dos son malas."""
|
|
c = e.get("class", "")
|
|
|
|
if tipo == "servicio":
|
|
m = _no_aplica(e.get("name", ""))
|
|
if m:
|
|
return "muere", m
|
|
# DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
|
|
# va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
|
|
exe = (e.get("cmdline") or "").split(" ")[0]
|
|
for raiz in datos_que_mueren:
|
|
if exe.startswith(raiz.rstrip("/") + "/"):
|
|
return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
|
|
if c == "no-declarado":
|
|
p = e.get("ports") or []
|
|
return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
|
|
"y NADIE lo declara" if p else
|
|
"corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
|
|
if c == "vivo":
|
|
return "muda", "declarado y corriendo"
|
|
return "", ""
|
|
|
|
if tipo == "dominio":
|
|
if c == "vivo":
|
|
return "muda", "resuelve a esta máquina y contesta"
|
|
if c == "ya-mudado":
|
|
return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
|
|
if c == "backend-caido":
|
|
return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
|
|
if c == "sin-sondear":
|
|
return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
|
|
return "", ""
|
|
|
|
if tipo == "datos":
|
|
# A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
|
|
# eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
|
|
return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
|
|
return "", ""
|
|
|
|
|
|
def sugerir(tipo, e, datos_que_mueren=()):
|
|
return recomendar(tipo, e, datos_que_mueren)[0]
|
|
|
|
|
|
def _etiqueta(tipo, nombre, e):
|
|
if tipo == "datos":
|
|
extra = e.get("size", "?")
|
|
if e.get("size_copy") and e["size_copy"] != e.get("size"):
|
|
extra += f" (copiar {e['size_copy']})"
|
|
return extra
|
|
if tipo == "servicio":
|
|
p = e.get("ports") or []
|
|
return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
|
|
return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
|
|
|
|
|
|
def revisar(censo, interactivo=True):
|
|
"""Muestra TODO con su recomendación y su motivo, y deja elegir.
|
|
|
|
── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
|
|
Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
|
|
servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
|
|
arrancar allá. Al revés no se puede calcular.
|
|
"""
|
|
todos = entradas(censo)
|
|
datos = [(t, n, e) for t, n, e in todos if t == "datos"]
|
|
resto = [(t, n, e) for t, n, e in todos if t != "datos"]
|
|
|
|
# ── 1. datos ────────────────────────────────────────────────────────────────────────────────
|
|
if datos:
|
|
print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
|
|
print(" Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
|
|
print(" la recomendación de los servicios, así que va primero.\n")
|
|
for t, n, e in datos:
|
|
marca = e.get("decision") or "—"
|
|
print(f" {n:34} {_etiqueta(t,n,e):>22} decisión actual: {marca}")
|
|
if interactivo:
|
|
print()
|
|
for t, n, e in datos:
|
|
if e.get("decision") in DECISIONES:
|
|
continue
|
|
r = input(f" {n:34} [{_etiqueta(t,n,e)}] [m]uda/[x]muere > ").strip().lower()
|
|
e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
|
|
|
|
mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
|
|
|
|
# ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
|
|
grupos = {"muda": [], "muere": [], "": []}
|
|
for t, n, e in resto:
|
|
d, motivo = recomendar(t, e, mueren)
|
|
e["_rec"], e["_motivo"] = d, motivo
|
|
grupos[d].append((t, n, e))
|
|
|
|
print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
|
|
titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
|
|
"": "SIN recomendación — hay que decidir"}
|
|
for g in ("muda", "muere", ""):
|
|
if not grupos[g]:
|
|
continue
|
|
print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
|
|
for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
|
|
print(f" {t:8} {n:30} {_etiqueta(t,n,e):>18}")
|
|
if e.get("_motivo"):
|
|
print(f" └─ {e['_motivo']}")
|
|
if t == "servicio" and e.get("decision") != "muere" and g != "muere":
|
|
oc, od = origen_binario(e)
|
|
print(f" └─ binario: [{oc}] {od[:96]}")
|
|
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"]
|
|
|
|
# ── 0. preflight ────────────────────────────────────────────────────────────────────────────
|
|
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]
|
|
paso("preflight", f"el destino responde y tiene sitio (hacen falta {humano(need)})",
|
|
f"{ssh} 'df -Pm / | tail -1'",
|
|
verifica=f"la columna 'Available' debe ser > {int(need)} MiB",
|
|
verifica_tipo="humano",
|
|
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`.")
|
|
|
|
# ── 1. datos ────────────────────────────────────────────────────────────────────────────────
|
|
for d in datos_muda:
|
|
p = d["path"]
|
|
paso("datos", f"copiar {p} ({d.get('size','?')})",
|
|
f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
|
|
f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(p + '/')}",
|
|
verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); "
|
|
f"dst=$({ssh} 'find {shlex.quote(p)} -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:
|
|
det = []
|
|
for t, n, e in muere:
|
|
tam = f" ({e.get('size')})" if e.get("size") else ""
|
|
det.append(f"# {t:9} {n}{tam}")
|
|
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()
|