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 188Rust 55Shell 30Markdown 281211 Definiciones

mudanza etapa 2: el PLAN — exportable, con el comando literal de cada paso

`planear.py` convierte un censo decidido en un plan ejecutable. Cada paso lleva su COMANDO LITERAL y
su verificación, así que el fichero se ejecuta a mano, línea por línea, sin la herramienta y sin este
repo. Eso es lo que el usuario pidió como «pasos exportables».

Las tres reglas, cada una pagada en el SDD 28:

1. **Nada sin decidir se ejecuta**: aborta con código 2 listando qué falta. El silencio no es
   consentimiento — sin decisión, ni mudar ni matar es correcto. Probado en los dos sentidos.
2. **Lo que muere se dice por su nombre y con su tamaño ANTES de borrar**, y el paso no borra nada:
   es una lista para leer antes de apagar el origen.
3. **Cada copia se verifica EN DESTINO**: el `rsync` que llenó el disco devolvió 0 y dejó 1367
   artefactos vacíos; sólo se vio contando del otro lado.

Y el preflight dimensiona con el tamaño de COPIA (hardlinks expandidos, 60 G → 85 G medidos), no con
`du`; si un tamaño resulta ilegible lo dice en vez de contarlo como 0.

**El añadido que cambia el valor: la invocación real.** Decir «este servicio no está declarado»
nombra el problema; lo que hace falta para resolverlo es cómo corre AHORA. El censo lo lee de
`/proc` (`cmdline` + `cwd`, nunca `environ`: el entorno trae tokens) y el plan lo emite:

    #   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
    #   cwd    : /mnt/vvv/tawasuyu/shared/tejido
    #   puertos: 34221, 44961

Ese servicio de gioser es un binario de `target/debug/deps/` corriendo en producción, con dos
puertos, que ninguna declaración conoce. Apagada la máquina vieja, eso no se reconstruye de memoria.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

6f12c231fa259ea86ca12945316cf0a773e23cd0 · por Sergio · (hace 1 mes) · padre 5d1120e58 · ver el árbol en este commit

Delta semántico · 13

scripts/mudanza/censar.py

scripts/mudanza/planear.py

3 archivos · +322 −1

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index d8f56779..2cc16e74 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -69,7 +69,7 @@ La primera versión daba 28 `no-declarado` con ruido; tras normalizar nombres da
 (`matilda`, `pacha`, `puerta-…`, `adb`, `ModemManager`). Afinarlo no es cosmética: es la diferencia
 entre que alguien lo lea o no.
 
-## 3. Etapa 2 — el plan *(pendiente)*
+## 3. Etapa 2 — el plan *(implementado: `scripts/mudanza/planear.py`)*
 
 El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las decisiones puestas
 (`muda` / `muere`), más el orden y las dependencias. Requisitos:
@@ -79,6 +79,39 @@ 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.
 
+### Lo implementado
+
+`planear.py --censo <f> --target <host> [--decide] --out plan.toml`
+
+- **Aborta con código 2** si queda algo sin decidir, listando qué. Probado.
+- `--decide` recorre lo pendiente y propone (`vivo`→muda, `ya-mudado`/`backend-caido`→muere,
+  `no-declarado`→muda). **La sugerencia se imprime, nunca se aplica sola.**
+- **Cada paso lleva su comando literal y su verificación**, así que el plan se ejecuta a mano, línea
+  por línea, sin la herramienta y sin este repo. Eso es «los pasos exportables».
+- El preflight dimensiona con el **tamaño de copia** (hardlinks expandidos), no con `du`; y si algún
+  tamaño resultó ilegible lo dice en vez de contarlo como 0 — un 0 inventado apaga el chequeo justo
+  cuando hace falta.
+- El paso «muere» **no borra nada**: lista con nombre y tamaño lo que se pierde al apagar el origen.
+
+### ⚠ El dato que vuelve accionable a un `no-declarado`: su invocación
+
+Decir «este servicio no está declarado» es nombrar el problema. Lo que hace falta para resolverlo es
+**cómo se está ejecutando ahora**, y eso se lee de `/proc`. El censo captura `cmdline` y `cwd` de
+cada servicio vivo, y el plan los emite en el paso:
+
+```
+#   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
+#   cwd    : /mnt/vvv/tawasuyu/shared/tejido
+#   puertos: 34221, 44961
+```
+
+Ese servicio de gioser es **un binario de `target/debug/deps/`** corriendo en producción, con dos
+puertos, que ninguna declaración conoce. Una vez apagada la máquina vieja eso no se reconstruye de
+memoria. Ahora está escrito.
+
+⚠ Se lee `cmdline` y `cwd`, **nunca `environ`**: el entorno suele traer tokens y contraseñas, y un
+censo que se guarda en un fichero y se comparte no puede llevarlos.
+
 ## 4. Etapa 3 — la aplicación *(pendiente)*
 
 Ejecuta el plan contra la máquina nueva. Requisitos, todos pagados en el SDD 28:
diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py
index e5a9ebe5..bd7a8393 100755
--- a/scripts/mudanza/censar.py
+++ b/scripts/mudanza/censar.py
@@ -75,6 +75,27 @@ def identidad(t):
     }
 
 
+def invocaciones(t, pids):
+    """La LÍNEA DE COMANDO y el `cwd` de cada servicio vivo, leídos de `/proc`.
+
+    Es el dato que convierte `no-declarado` de «acá hay un problema» en «acá está la solución»: si
+    nadie sabe cómo levantar un servicio, lo que hace falta para declararlo es exactamente cómo se
+    está ejecutando ahora. Sin esto el plan sólo puede decir «declaralo», que es lo que uno ya sabía.
+
+    ⚠ Se lee `cmdline` y `cwd`, NO el entorno: `/proc/<pid>/environ` suele traer tokens y
+    contraseñas, y un censo que se guarda en un fichero y se manda por ahí no puede llevarlos."""
+    out = {}
+    for nombre, ps in pids.items():
+        pid = ps[0]
+        r, ok = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null; echo; "
+                      f"readlink /proc/{pid}/cwd 2>/dev/null")
+        if ok:
+            ls = r.split("\n")
+            out[nombre] = {"cmdline": (ls[0] if ls else "").strip(),
+                           "cwd": (ls[1] if len(ls) > 1 else "").strip()}
+    return out
+
+
 def procesos_vivos(t):
     """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
     out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'")
@@ -358,6 +379,8 @@ def emitir_toml(c):
         L.append(f"declared_in = {json.dumps(s['declared_in'])}")
         L.append(f"pids = {json.dumps(s['pids'])}")
         L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}")
+        L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
+        L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
         L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
         L.append("")
     for d in c["dominios"]:
@@ -438,6 +461,11 @@ def main():
     vivos = procesos_vivos(t)
     decl = declarados(t)
     svc, huerfanos = clasificar(vivos, decl)
+    inv = invocaciones(t, vivos)
+    for e in svc:
+        i = inv.get(e['name'], {})
+        e['cmdline'] = i.get('cmdline', '')
+        e['cwd'] = i.get('cwd', '')
     pts = puertos(t)
     nombres = dominios(t)
     sondear = (a.probe or bool(a.host)) and not a.no_probe
diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py
new file mode 100755
index 00000000..b60c2774
--- /dev/null
+++ b/scripts/mudanza/planear.py
@@ -0,0 +1,260 @@
+#!/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.
+
+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
+
+# ── 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", []):
+        if d.get("class") != "fosil-sin-dns":
+            out.append(("dominio", d["domain"], d))
+    for d in censo.get("datos", []):
+        out.append(("datos", d["path"], d))
+    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."""
+    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 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, "")
+    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 ────────────────────────────────────────────────────────────────────────────────────────
+
+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):
+        P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
+                  "verifica": verifica or "", "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",
+         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"{ssh} 'du -sh {shlex.quote(p)}; find {shlex.quote(p)} -maxdepth 1 -type d -empty | wc -l'",
+             nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
+                  "La verificación cuenta EN DESTINO: un rsync que se corta deja directorios VACÍOS "
+                  "y devuelve 0.")
+
+    # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
+    for s in svc_muda:
+        nd = s.get("class") == "no-declarado"
+        paso("servicio", f"declarar y levantar {s['name']}" + ("  ⚠ NO-DECLARADO" if nd else ""),
+             (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) instalar el binario/paquete de {s['name']} en el destino\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",
+             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(f'cmd = """\n{p["cmd"]}\n"""')
+        if p["verifica"]:
+            L.append(f'verifica = """\n{p["verifica"]}\n"""')
+        if p["nota"]:
+            L.append(f'nota = """{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="preguntar por cada entrada sin decidir")
+    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.decide:
+        censo = decidir_interactivo(censo)
+    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)
+    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()