> 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…
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
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()