El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<n>` y el
que va de `label` en la tarjeta de arje.
· `comm` está capado a 15 caracteres: `willay-crosscheck` llegaba como `willay-crossche` y con ese
nombre no casaba contra su declaración ⇒ figuraba como `no-declarado` TENIENDO su tarjeta en la
semilla de arje. La línea de comando trae el nombre entero.
· `supervise-daemon` no es un servicio, es un ENVOLTORIO. Los cinco de gioser se fundían en una
entrada `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon`
salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar,
entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token
antes del `--` suelto (comprobado contra los cinco).
· `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a
`descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
**Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** Muchos demonios reescriben su
`argv[0]` (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
comando no dice cuál es el binario — `/proc/<pid>/exe` sí, y necesita ser dueño o root. Medido:
uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy
distintos, y confundirlos manda a alguien a investigar un permiso.
Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era
`/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado
era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que
ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de
arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a
la vista, un servicio que acá corre sin privilegios termina de root en el destino.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
702 lines
41 KiB
Python
Executable File
702 lines
41 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", []):
|
|
# ⚠ 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:
|
|
return ("suelto",
|
|
f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
|
|
f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
|
|
f"el origen")
|
|
motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
|
|
return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
|
|
|
|
|
|
def recomendar(tipo, e, datos_que_mueren=()):
|
|
"""Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
|
|
que aceptarla sea barato y equivocarse sea caro de no notar.
|
|
|
|
Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
|
|
ciegas o se ignora entera. Las dos son malas."""
|
|
c = e.get("class", "")
|
|
|
|
if tipo == "servicio":
|
|
m = _no_aplica(e.get("name", ""))
|
|
if m:
|
|
return "muere", m
|
|
# DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
|
|
# va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
|
|
exe = (e.get("cmdline") or "").split(" ")[0]
|
|
for raiz in datos_que_mueren:
|
|
if exe.startswith(raiz.rstrip("/") + "/"):
|
|
return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
|
|
if c == "no-declarado":
|
|
p = e.get("ports") or []
|
|
return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
|
|
"y NADIE lo declara" if p else
|
|
"corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
|
|
if c == "vivo":
|
|
return "muda", "declarado y corriendo"
|
|
return "", ""
|
|
|
|
if tipo == "dominio":
|
|
if c == "vivo":
|
|
return "muda", "resuelve a esta máquina y contesta"
|
|
if c == "ya-mudado":
|
|
return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
|
|
if c == "backend-caido":
|
|
return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
|
|
if c == "apunta-aca":
|
|
# El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el
|
|
# silencio como una respuesta afirmativa, que es el error que este censo evita en todos
|
|
# los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero
|
|
# se DICE lo que no se miró.
|
|
return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. "
|
|
"⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil")
|
|
if c == "fosil-sin-dns":
|
|
return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó "
|
|
"contenido en disco o un bloque en el servidor web — eso sí viaja si "
|
|
"nadie lo borra")
|
|
if c == "sin-sondear":
|
|
return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
|
|
return "", ""
|
|
|
|
if tipo == "datos":
|
|
# A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
|
|
# eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
|
|
return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
|
|
return "", ""
|
|
|
|
|
|
def sugerir(tipo, e, datos_que_mueren=()):
|
|
return recomendar(tipo, e, datos_que_mueren)[0]
|
|
|
|
|
|
def _etiqueta(tipo, nombre, e):
|
|
if tipo == "datos":
|
|
extra = e.get("size", "?")
|
|
if e.get("size_copy") and e["size_copy"] != e.get("size"):
|
|
extra += f" (copiar {e['size_copy']})"
|
|
return extra
|
|
if tipo == "servicio":
|
|
p = e.get("ports") or []
|
|
return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
|
|
return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
|
|
|
|
|
|
def revisar(censo, interactivo=True):
|
|
"""Muestra TODO con su recomendación y su motivo, y deja elegir.
|
|
|
|
── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
|
|
Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
|
|
servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
|
|
arrancar allá. Al revés no se puede calcular.
|
|
"""
|
|
todos = entradas(censo)
|
|
datos = [(t, n, e) for t, n, e in todos if t == "datos"]
|
|
resto = [(t, n, e) for t, n, e in todos if t != "datos"]
|
|
|
|
# ── 1. datos ────────────────────────────────────────────────────────────────────────────────
|
|
if datos:
|
|
print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
|
|
print(" Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
|
|
print(" la recomendación de los servicios, así que va primero.\n")
|
|
for t, n, e in datos:
|
|
marca = e.get("decision") or "—"
|
|
print(f" {n:34} {_etiqueta(t,n,e):>22} decisión actual: {marca}")
|
|
if interactivo:
|
|
print()
|
|
for t, n, e in datos:
|
|
if e.get("decision") in DECISIONES:
|
|
continue
|
|
r = input(f" {n:34} [{_etiqueta(t,n,e)}] [m]uda/[x]muere > ").strip().lower()
|
|
e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
|
|
|
|
mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
|
|
|
|
# ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
|
|
grupos = {"muda": [], "muere": [], "": []}
|
|
for t, n, e in resto:
|
|
d, motivo = recomendar(t, e, mueren)
|
|
e["_rec"], e["_motivo"] = d, motivo
|
|
grupos[d].append((t, n, e))
|
|
|
|
print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
|
|
titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
|
|
"": "SIN recomendación — hay que decidir"}
|
|
for g in ("muda", "muere", ""):
|
|
if not grupos[g]:
|
|
continue
|
|
print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
|
|
for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
|
|
print(f" {t:8} {n:30} {_etiqueta(t,n,e):>18}")
|
|
if e.get("_motivo"):
|
|
print(f" └─ {e['_motivo']}")
|
|
if t == "servicio" and e.get("decision") != "muere" and g != "muere":
|
|
oc, od = origen_binario(e)
|
|
print(f" └─ binario: [{oc}] {od[:96]}")
|
|
if e.get("usuario") and e["usuario"] != "root":
|
|
# No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
|
|
# escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
|
|
# corre sin privilegios termina de root en el destino y nadie se entera.
|
|
print(f" └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
|
|
f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
|
|
lista, rec, motivo = alternativas(n, _REPO)
|
|
if lista:
|
|
op = " ".join(
|
|
("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"])
|
|
+ ("✓" if x["sellado"] else ("·" if x["receta"] else "✗"))
|
|
for x in lista)
|
|
print(f" └─ alternativas: {op} (✓ sellado · receta sin sellar · ✗ no está)")
|
|
print(f" recomendado: {rec or 'ninguno'} — {motivo[:110]}")
|
|
|
|
if not interactivo:
|
|
return censo
|
|
|
|
print("\n [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir")
|
|
r = input(" > ").strip().lower()
|
|
if r == "q":
|
|
return censo
|
|
for t, n, e in resto:
|
|
if e.get("decision") in DECISIONES:
|
|
continue
|
|
rec = e.get("_rec", "")
|
|
if r == "a" and rec:
|
|
e["decision"] = rec
|
|
continue
|
|
sug = f"[{rec}]" if rec else "[sin recomendación]"
|
|
resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
|
|
f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
|
|
e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "")
|
|
if e["decision"] == "muda" and t == "servicio":
|
|
lista, alt_rec, motivo = alternativas(n, _REPO)
|
|
if lista and len([x for x in lista if x["sellado"]]) > 1:
|
|
op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "")
|
|
for i, x in enumerate(lista))
|
|
print(f" alternativas: {op}")
|
|
print(f" recomendado: {alt_rec} — {motivo[:100]}")
|
|
r2 = input(f" número, o Enter para {alt_rec}: ").strip()
|
|
if r2.isdigit() and int(r2) < len(lista):
|
|
e["alternativa"] = lista[int(r2)]["nombre"]
|
|
else:
|
|
e["alternativa"] = alt_rec
|
|
return censo
|
|
|
|
|
|
def validar(censo):
|
|
"""Regla 1: nada sin decidir se ejecuta."""
|
|
faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
|
|
if faltan:
|
|
print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
|
|
print(" El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
|
|
file=sys.stderr)
|
|
for t, n in faltan[:40]:
|
|
print(f" {t:9} {n}", file=sys.stderr)
|
|
if len(faltan) > 40:
|
|
print(f" … y {len(faltan)-40} más", file=sys.stderr)
|
|
print("\n Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
# ── plan ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
_TRIPLE_SIMPLE = "'" * 3
|
|
_TRIPLE_DOBLE = '"' * 3
|
|
|
|
|
|
def bloque_toml(texto):
|
|
"""Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
|
|
|
|
⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
|
|
que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
|
|
(`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
|
|
se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
|
|
recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
|
|
|
|
Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
|
|
con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
|
|
if _TRIPLE_SIMPLE not in texto:
|
|
return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
|
|
escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
|
|
return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
|
|
|
|
|
|
def pasos(censo, target, key):
|
|
ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
|
|
if key:
|
|
ssh_opts = f"-i {key} " + ssh_opts
|
|
ssh = f"ssh {ssh_opts} {target}"
|
|
P = []
|
|
|
|
def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"):
|
|
# `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un
|
|
# humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error»
|
|
# es exactamente cómo un aplicador miente.
|
|
P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
|
|
"verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
|
|
|
|
datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
|
|
svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
|
|
doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
|
|
muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
|
|
|
|
# ── 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()
|