tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1181Shell 82Rust 62Markdown 521515 Definiciones

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

96dc488842c7ef7fc426b2e55e22653150006f3b · por Sergio · (hace 1 mes) · padre c4641bade · ver el árbol en este commit

Delta semántico · 7

scripts/mudanza/planear.py

2 archivos · +201 −27

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 <f> --target <host> [--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 ""
-
-
-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):
+
+    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 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)