From 96dc488842c7ef7fc426b2e55e22653150006f3b Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 16:27:58 +0000 Subject: [PATCH] mudanza: recomendaciones CON MOTIVO, y la lista completa a la vista para elegir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correr en el origen no significa que aplique en el destino. `planear.py --revisar` muestra todo agrupado con su recomendación **y su razón**, y `--decide` deja aceptarlas en bloque o revisarlas una por una. Cuatro familias que no aplican en una caja remota, cada una con su motivo: · hardware local → bluetoothd, ModemManager, upowerd, adb · escritorio o pantalla → waypipe · la red del destino → NetworkManager, dhcpcd: **pelearían** con el init de allá · lo provee el init destino → udevd, dbus-daemon, elogind, polkitd, agetty 11 de 37 servicios de gioser caen ahí. El 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. **Y la recomendación DERIVADA, que es la más fuerte**: si el binario vive en un árbol que no se muda, el servicio no podría arrancar allá. Se calcula cruzando el `cmdline` leído de `/proc` contra las decisiones de datos. Probado en los dos sentidos: con `/mnt/vvv` muriendo, `puerta-f6e393ff` sale «no mudar — su binario vive en /mnt/vvv»; con `/mnt/vvv` mudándose, sale «mudar». Por eso la revisión decide los DATOS PRIMERO: de ellos se deriva la recomendación de los servicios, y al revés no se puede calcular. Y los datos salen SIN recomendación a propósito — qué datos valen no se deduce de la máquina; `terapeuta.ec` eran 279 M sin DNS y sólo el usuario podía decidirlo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 36 +++++++ scripts/mudanza/planear.py | 188 ++++++++++++++++++++++++++++++++----- 2 files changed, 199 insertions(+), 25 deletions(-) diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 88a5b261..130bf4b3 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -79,6 +79,42 @@ El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las 3. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió. 4. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad. +### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo + +Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene +bluetooth, ni módem, ni batería, ni pantalla — y algunos servicios no sólo sobran: **pelean** con el +destino. `planear.py --revisar` muestra todo agrupado, con su recomendación **y su razón**: + +| familia | motivo | ejemplos en gioser | +|---|---|---| +| hardware local | una caja remota no tiene ese dispositivo | `bluetoothd` `ModemManager` `upowerd` `adb` | +| escritorio o pantalla | un servidor no los tiene | `waypipe` | +| red del destino | **pelearían**: el destino la configura con su propio init | `NetworkManager` `dhcpcd` | +| lo provee el init del destino | no se muda | `udevd` `dbus-daemon` `elogind` `polkitd` `agetty` | + +**11 de 37 servicios de gioser caen ahí.** Y el 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. + +#### La recomendación DERIVADA, que es la más fuerte + +Si el binario de un servicio vive en un árbol que **no se muda**, el servicio no va a poder arrancar +allá. Eso no hace falta saberlo: se calcula, cruzando el `cmdline` que el censo leyó de `/proc` +contra las decisiones de datos. Probado en los dos sentidos: + +``` +/mnt/vvv → muere : puerta-f6e393ff ⇒ NO mudar — «su binario vive en /mnt/vvv, que NO se muda» +/mnt/vvv → muda : puerta-f6e393ff ⇒ mudar — «corre y sirve 34221, y NADIE lo declara» +``` + +**Por eso los DATOS se deciden primero**: de ellos se deriva la recomendación de los servicios, y al +revés no se puede calcular. La revisión lo hace en ese orden y lo explica. + +#### Y lo que NO se recomienda solo + +Los **datos** salen sin recomendación, a propósito: qué datos valen no se deduce de la máquina. +`terapeuta.ec` eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía +decidir eso por él. + ### Lo implementado `planear.py --censo --target [--decide] --out plan.toml` diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index ec6d5a96..dc87405d 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -71,34 +71,166 @@ def entradas(censo): return out -def sugerir(tipo, e): - """SUGERENCIA, no decisión. Se imprime al lado para que confirmar sea barato y equivocarse caro - de notar — pero nunca se aplica sola.""" +# ── 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 + + +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 c in ("vivo",): - return "muda" - if c in ("ya-mudado", "backend-caido"): - return "muere" - if c == "no-declarado": - return "muda" # corre: alguien lo quiso. Lo que falta es DECLARARLO. - return "" + + 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 decidir_interactivo(censo): - print("\n══ decidí qué se muda y qué muere ══") - print(" [m]uda · [x] muere · [s]altar (queda sin decidir y el plan aborta) · [q] salir\n") - for tipo, nombre, e in entradas(censo): +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 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 - sug = sugerir(tipo, e) - extra = e.get("class", "") - if tipo == "datos": - extra = f"{e.get('size','?')}" + (f" (copiar: {e['size_copy']})" - if e.get("size_copy") not in (None, e.get("size")) else "") - r = input(f" {tipo:9} {nombre:34} [{extra}] sugerido={sug or '—'} > ").strip().lower() - if r == "q": - break - e["decision"] = {"m": "muda", "x": "muere", "": sug}.get(r, "") + 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, "") return censo @@ -242,14 +374,20 @@ def main(): 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="preguntar por cada entrada sin decidir") + 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 = decidir_interactivo(censo) + censo = revisar(censo, interactivo=True) validar(censo) P = pasos(censo, a.target, a.key)