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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user