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:
@@ -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
|
||||
|
||||
Executable
+260
@@ -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()
|
||||
Reference in New Issue
Block a user