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
This commit is contained in:
Sergio
2026-09-11 15:03:33 +00:00
co-authored by Claude Opus 5
parent 5d1120e583
commit 6f12c231fa
3 changed files with 322 additions and 1 deletions
+34 -1
View File
@@ -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:
+28
View File
@@ -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
+260
View File
@@ -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()