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…
mudanza etapa 2: el PLAN — exportable, con el comando literal de cada paso
1#!/usr/bin/env python3
2"""planear.py — convierte un CENSO decidido en un PLAN ejecutable y exportable (SDD 29 etapa 2).
3
4El plan es el producto: un documento que se revisa, se versiona, se lleva a otro proveedor y se
5vuelve a correr. **Cada paso lleva su comando literal**, así que el plan se puede ejecutar con la
6herramienta o a mano, línea por línea, sin la herramienta y sin este repo.
7
8── LAS TRES REGLAS, Y POR QUÉ ──────────────────────────────────────────────────────────────────
91. **Nada sin decidir se ejecuta.** Una entrada con `decision = ""` ABORTA el plan, nombrándola. El
10 silencio no es consentimiento: si nadie dijo qué hacer con un servicio, la respuesta no es
11 «entonces no lo muevo» —eso lo mata en silencio— ni «entonces lo muevo» —eso muda fósiles—.
122. **Lo que muere se dice por su nombre y con su tamaño, ANTES de borrar nada.** En esta mudanza
13 `terapeuta.ec` eran 279 M sin DNS: la diferencia entre «lo decidimos» y «se nos pasó» es una
14 línea escrita.
153. **Cada copia se verifica EN DESTINO.** «Lo copié» no prueba nada: el `rsync` que llenó el disco
16 de la caja nueva devolvió 0 y dejó 1367 artefactos VACÍOS, y sólo se vio contando del otro lado.
17
18── LO QUE EL PLAN SABE QUE UN GUIÓN A MANO OLVIDA ──────────────────────────────────────────────
19· **Dimensiona con el tamaño de COPIA, no con `du`.** Un árbol con hardlinks mide 60 G y ocupa 85 G
20 al copiarlo sin `-H` (medido). El preflight compara contra el número correcto y falla ANTES.
21· **`rsync -aH` y excluyendo cachés.** Sin `-H` los hardlinks se expanden; `.dmerge` son 11 G de
22 caché regenerable que llenó un disco.
23· **Los servicios `no-declarado` no se copian: se DECLARAN.** Copiar un binario no lo levanta al
24 arrancar — el caddy de la caja nueva murió en el primer reinicio por exactamente eso.
25
26Uso:
27 scripts/mudanza/planear.py --censo censo.toml --target root@1.2.3.4 [--decide] --out plan.toml
28"""
29import argparse, os, sys, shlex, time, tomllib
30
31# ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
32
33_MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}
34
35
36def a_mib(s):
37 """'61.5G' → MiB. Devuelve None si no se entiende: un tamaño ilegible NO se inventa como 0,
38 porque un 0 inventado apaga el preflight justo cuando más hace falta."""
39 s = (s or "").strip()
40 if not s or not s[0].isdigit():
41 return None
42 try:
43 return float(s[:-1]) * _MUL.get(s[-1].upper(), 1) if s[-1].isalpha() else float(s)
44 except ValueError:
45 return None
46
47
48def humano(mib):
49 if mib is None:
50 return "?"
51 return f"{mib/1024:.1f}G" if mib >= 1024 else f"{mib:.0f}M"
52
53
54# ── decisión ────────────────────────────────────────────────────────────────────────────────────
55
56DECISIONES = ("muda", "muere")
57
58
59def entradas(censo):
60 """Todo lo que necesita una decisión, con una etiqueta legible."""
61 out = []
62 for s in censo.get("servicio", []):
63 # `declarado-muerto` no necesita decisión: no corre. Se informa y se deja morir.
64 if s.get("class") != "declarado-muerto":
65 out.append(("servicio", s["name"], s))
66 for d in censo.get("dominio", []):
67 if d.get("class") != "fosil-sin-dns":
68 out.append(("dominio", d["domain"], d))
69 for d in censo.get("datos", []):
70 out.append(("datos", d["path"], d))
71 return out
72
73
74def sugerir(tipo, e):
75 """SUGERENCIA, no decisión. Se imprime al lado para que confirmar sea barato y equivocarse caro
76 de notar — pero nunca se aplica sola."""
77 c = e.get("class", "")
78 if c in ("vivo",):
79 return "muda"
80 if c in ("ya-mudado", "backend-caido"):
81 return "muere"
82 if c == "no-declarado":
83 return "muda" # corre: alguien lo quiso. Lo que falta es DECLARARLO.
84 return ""
85
86
87def decidir_interactivo(censo):
88 print("\n══ decidí qué se muda y qué muere ══")
89 print(" [m]uda · [x] muere · [s]altar (queda sin decidir y el plan aborta) · [q] salir\n")
90 for tipo, nombre, e in entradas(censo):
91 if e.get("decision") in DECISIONES:
92 continue
93 sug = sugerir(tipo, e)
94 extra = e.get("class", "")
95 if tipo == "datos":
96 extra = f"{e.get('size','?')}" + (f" (copiar: {e['size_copy']})"
97 if e.get("size_copy") not in (None, e.get("size")) else "")
98 r = input(f" {tipo:9} {nombre:34} [{extra}] sugerido={sug or '—'} > ").strip().lower()
99 if r == "q":
100 break
101 e["decision"] = {"m": "muda", "x": "muere", "": sug}.get(r, "")
102 return censo
103
104
105def validar(censo):
106 """Regla 1: nada sin decidir se ejecuta."""
107 faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
108 if faltan:
109 print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
110 print(" El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
111 file=sys.stderr)
112 for t, n in faltan[:40]:
113 print(f" {t:9} {n}", file=sys.stderr)
114 if len(faltan) > 40:
115 print(f" … y {len(faltan)-40} más", file=sys.stderr)
116 print("\n Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
117 sys.exit(2)
118
119
120# ── plan ────────────────────────────────────────────────────────────────────────────────────────
121
122def pasos(censo, target, key):
123 ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
124 if key:
125 ssh_opts = f"-i {key} " + ssh_opts
126 ssh = f"ssh {ssh_opts} {target}"
127 P = []
128
129 def paso(clase, titulo, cmd, verifica=None, nota=None):
130 P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
131 "verifica": verifica or "", "nota": nota or ""})
132
133 datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
134 svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
135 doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
136 muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
137
138 # ── 0. preflight ────────────────────────────────────────────────────────────────────────────
139 need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
140 if x is not None)
141 ilegibles = [d["path"] for d in datos_muda
142 if a_mib(d.get("size_copy") or d.get("size")) is None]
143 paso("preflight", f"el destino responde y tiene sitio (hacen falta {humano(need)})",
144 f"{ssh} 'df -Pm / | tail -1'",
145 verifica=f"la columna 'Available' debe ser > {int(need)} MiB",
146 nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
147 "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`.")
148
149 # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
150 for d in datos_muda:
151 p = d["path"]
152 paso("datos", f"copiar {p} ({d.get('size','?')})",
153 f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
154 f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(p + '/')}",
155 verifica=f"{ssh} 'du -sh {shlex.quote(p)}; find {shlex.quote(p)} -maxdepth 1 -type d -empty | wc -l'",
156 nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
157 "La verificación cuenta EN DESTINO: un rsync que se corta deja directorios VACÍOS "
158 "y devuelve 0.")
159
160 # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
161 for s in svc_muda:
162 nd = s.get("class") == "no-declarado"
163 paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""),
164 (f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"
165 f"# cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n"
166 + (f"# cwd : {s['cwd']}\n" if s.get("cwd") else "")
167 + (f"# puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "")
168 + f"#\n# 1) instalar el binario/paquete de {s['name']} en el destino\n"
169 f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n"
170 f"# (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n"
171 f"# 3) arrancarlo"),
172 verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'"
173 + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'"
174 if s.get("ports") else ""),
175 nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo "
176 "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la "
177 "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else
178 "Declarado en el origen: traducir su unidad al init del destino.")
179
180 # ── 3. dominios ─────────────────────────────────────────────────────────────────────────────
181 for d in doms_muda:
182 paso("dominio", f"apuntar {d['domain']} a la máquina nueva",
183 f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → <IP NUEVA>",
184 verifica=f"getent ahostsv4 {d['domain']} | head -1 && "
185 f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/",
186 nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. "
187 "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus "
188 "propios dominios puede disparar su fail2ban (pasó).")
189
190 # ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
191 if muere:
192 det = []
193 for t, n, e in muere:
194 tam = f" ({e.get('size')})" if e.get("size") else ""
195 det.append(f"# {t:9} {n}{tam}")
196 paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
197 "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
198 + "\n".join(det),
199 verifica="revisión humana: leer la lista completa antes de destruir el origen",
200 nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté "
201 "escrito ES el paso.")
202 return P
203
204
205def emitir(censo, P, target):
206 L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).",
207 "#",
208 "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por",
209 "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.",
210 'schema = "takana-plan/1"',
211 f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"',
212 f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"',
213 f'destino = "{target}"',
214 ""]
215 for i, p in enumerate(P, 1):
216 L.append(f"[[paso]]")
217 L.append(f'n = {i}')
218 L.append(f'clase = "{p["clase"]}"')
219 L.append(f'titulo = """{p["titulo"]}"""')
220 L.append(f'cmd = """\n{p["cmd"]}\n"""')
221 if p["verifica"]:
222 L.append(f'verifica = """\n{p["verifica"]}\n"""')
223 if p["nota"]:
224 L.append(f'nota = """{p["nota"]}"""')
225 L.append("")
226 return "\n".join(L) + "\n"
227
228
229def main():
230 ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.")
231 ap.add_argument("--censo", required=True)
232 ap.add_argument("--target", required=True, help="usuario@host destino")
233 ap.add_argument("--key", help="clave SSH para el destino")
234 ap.add_argument("--decide", action="store_true", help="preguntar por cada entrada sin decidir")
235 ap.add_argument("--out", help="fichero del plan (default: stdout)")
236 a = ap.parse_args()
237
238 with open(a.censo, "rb") as f:
239 censo = tomllib.load(f)
240 if a.decide:
241 censo = decidir_interactivo(censo)
242 validar(censo)
243
244 P = pasos(censo, a.target, a.key)
245 por_clase = {}
246 for p in P:
247 por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1
248 print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══")
249 for c, n in por_clase.items():
250 print(f" {c:10} {n:3} paso(s)")
251 txt = emitir(censo, P, a.target)
252 if a.out:
253 open(a.out, "w").write(txt)
254 print(f"\n plan → {a.out} ({len(P)} pasos, con su comando literal cada uno)")
255 else:
256 print(txt)
257
258
259if __name__ == "__main__":
260 main()