Files
takana/scripts/mudanza/planear.py
T
SergioandClaude Opus 5 6f12c231fa 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
2026-09-11 15:03:33 +00:00

261 lines
14 KiB
Python
Executable File

#!/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()