mudanza: recomendaciones CON MOTIVO, y la lista completa a la vista para elegir

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 16:27:58 +00:00
co-authored by Claude Opus 5
parent c4641bade8
commit 96dc488842
2 changed files with 199 additions and 25 deletions
+36
View File
@@ -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 <f> --target <host> [--decide] --out plan.toml`
+163 -25
View File
@@ -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)