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…
⎇ ae6f0b45d94eb275e2464d898b35ee9a64595f43
scripts/mudanza/planear.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
mudanza: decidir SIN teclas unitarias, `/mnt/vvv` entra al censo, y 25 rutas de secretos más
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· **La traducción de la configuración es un PASO, no una advertencia.** Decir «cambiar de servidor
26 web obliga a reescribir la config» es cierto y deja al humano con un párrafo y ninguna herramienta.
27 El plan emite el comando literal de `traducir.py` cuando el par EXISTE —se le pregunta al registro
28 de plugins, no se lista acá— y dice que no existe cuando no. Su verificación es HUMANA: el
29 traductor sale ≠0 si algo quedó sin traducir, así que darlo por bueno por código de salida sería
30 justo al revés de lo que hay que mirar.
31
32Uso:
33 scripts/mudanza/planear.py --censo censo.toml --target root@1.2.3.4 [--decide] --out plan.toml
34"""
35import argparse, os, sys, shlex, time, tomllib
36
37sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38import formatos # noqa: E402
39import censar # noqa: E402
40from censar import candidatos_de as candidatos # noqa: E402
41
42_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
43_AQUI = os.path.join("scripts", "mudanza", "traducir.py")
44
45# ── tamaños ─────────────────────────────────────────────────────────────────────────────────────
46
47_MUL = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}
48
49
50def a_mib(s):
51 """'61.5G' → MiB. Devuelve None si no se entiende: un tamaño ilegible NO se inventa como 0,
52 porque un 0 inventado apaga el preflight justo cuando más hace falta."""
53 s = (s or "").strip()
54 if not s or not s[0].isdigit():
55 return None
56 try:
57 return float(s[:-1]) * _MUL.get(s[-1].upper(), 1) if s[-1].isalpha() else float(s)
58 except ValueError:
59 return None
60
61
62def humano(mib):
63 if mib is None:
64 return "?"
65 return f"{mib/1024:.1f}G" if mib >= 1024 else f"{mib:.0f}M"
66
67
68# ── decisión ────────────────────────────────────────────────────────────────────────────────────
69
70# ── TRES CAMINOS, NO DOS (pedido del usuario, 2026-09-16) ───────────────────────────────────────
71# Con `muda`/`muere` una cosa que no va a la caja nueva sólo podía MORIR, así que todo lo dudoso se
72# marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. El tercer
73# camino es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero
74# perder*. `respalda` lo copia al corral de respaldo y NO lo instala en el destino.
75#
76# ⚠ `respalda` cuenta como «no se muda» para la recomendación derivada de los servicios: un binario
77# que queda archivado no puede arrancar en la caja nueva. Confundirlos daría un servicio marcado
78# `muda` cuyo ejecutable no va a existir allá — y eso se descubre al arrancarlo, no al decidir.
79DECISIONES = ("muda", "respalda", "muere")
80
81
82def entradas(censo):
83 """Todo lo que necesita una decisión, con una etiqueta legible."""
84 out = []
85 for s in censo.get("servicio", []):
86 # `declarado-muerto` no necesita decisión: no corre. Se informa y se deja morir.
87 if s.get("class") != "declarado-muerto":
88 out.append(("servicio", s["name"], s))
89 for d in censo.get("dominio", []):
90 # ⚠ Los `fosil-sin-dns` ENTRAN. Antes se filtraban con el argumento de que un dominio sin DNS
91 # no necesita ningún paso — y es cierto para el DNS, pero no para lo que arrastra: en gioser
92 # son 13 de 29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el caddy. Sacarlos
93 # de la lista los volvía INVISIBLES, así que nadie decidía borrarlos y sus datos viajaban a
94 # la caja nueva por omisión. Es exactamente el «mudar fósiles» que este plan existe para
95 # evitar, y la regla 2 dice que lo que muere se nombra ANTES de borrar nada.
96 out.append(("dominio", d["domain"], d))
97 for d in censo.get("datos", []):
98 out.append(("datos", d["path"], d))
99 return out
100
101
102# ── servicios que NO APLICAN en una caja remota ─────────────────────────────────────────────────
103# Correr en el origen no significa que tenga sentido en el destino. Un servidor en un datacenter no
104# tiene bluetooth, ni módem, ni batería, ni teclado — y arrastrarlos es peor que inútil: algunos
105# PELEAN con el destino. Cada familia con su motivo, porque una recomendación sin motivo no se puede
106# discutir y entonces se acepta a ciegas o se ignora entera.
107NO_APLICA = {
108 "hardware local (una caja remota no tiene ese dispositivo)": {
109 "bluetoothd", "bluetooth", "modemmanager", "upowerd", "upower", "acpid", "adb",
110 "thermald", "iio-sensor-proxy", "fwupd", "alsactl", "pcscd", "cups", "cupsd",
111 },
112 "escritorio o pantalla (un servidor no los tiene)": {
113 "waypipe", "xorg", "x", "wayland", "gdm", "sddm", "lightdm", "pipewire",
114 "wireplumber", "pulseaudio", "carmen-dm",
115 },
116 "la red del destino ya la configura su propio init — pelearían": {
117 "networkmanager", "dhcpcd", "wpa-supplicant", "connman", "systemd-networkd", "dhclient",
118 },
119 "el destino tiene su propio journal (la semilla de arje declara `provides: [\"Spawn\", \"Journal\"]`)": {
120 "metalog", "syslogd", "syslog-ng", "rsyslogd", "rsyslog", "busybox-syslogd", "socklog",
121 },
122 "lo provee el init del destino, no se muda": {
123 "agetty", "getty", "console-getty", "udevd", "udevadm", "systemd-udevd", "eudev",
124 "elogind-daemon", "elogind", "polkitd", "dbus-daemon", "dbus",
125 },
126}
127
128
129def _no_aplica(nombre):
130 n = nombre.lower().replace("_", "-")
131 for motivo, conjunto in NO_APLICA.items():
132 if n in conjunto:
133 return motivo
134 return None
135
136
137# Paquetes que proveen un RUNTIME, no un programa. Que exista `recipes/nodejs.toml` no significa que
138# `openclaw` esté cubierto: el intérprete se resuelve y el programa sigue faltando.
139INTERPRETES = {"python", "python3", "nodejs", "node", "perl", "ruby", "php", "lua", "java", "jre"}
140
141# El paquete del origen y la receta del catálogo no siempre se llaman igual ni siquiera para el mismo
142# intérprete: Artix empaqueta `python` y el catálogo tiene `python3.toml`. Sin esto, tres servicios
143# salían «hace falta una receta takana» cuando el runtime YA está sellado y lo que falta es el
144# programa — dos trabajos muy distintos.
145ALIAS_INTERPRETE = {"python": "python3", "node": "nodejs", "jre": "java"}
146
147
148# ── familias de equivalentes funcionales ────────────────────────────────────────────────────────
149# Agrupa cosas que hacen EL MISMO trabajo, para poder ofrecer alternativas. Es conocimiento de
150# dominio, no gusto: que nginx y caddy sirvan HTTP es un hecho. Lo que NO se decide acá es cuál es
151# mejor — eso sale de datos medibles (¿está en el catálogo? ¿está sellado?) y del costo de cambiar.
152FAMILIAS = {
153 "servidor web / proxy": ["caddy", "nginx", "apache", "httpd", "lighttpd", "traefik", "haproxy"],
154 "base de datos": ["postgresql", "mariadb", "mysql", "sqlite"],
155 "caché en memoria": ["redis", "valkey", "memcached"],
156 "forja git": ["gitea", "forgejo", "gitlab"],
157 "contenedores": ["docker", "podman", "containerd"],
158 "dns": ["unbound", "bind", "dnsmasq", "coredns"],
159 "correo": ["postfix", "exim", "opensmtpd"],
160 "base vectorial": ["qdrant", "milvus", "weaviate"],
161}
162
163
164# ── enganche con el CENTRO DE TRADUCCIÓN ────────────────────────────────────────────────────────
165# El plan ya decía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Eso es
166# cierto y es la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta.
167# Acá ese párrafo se vuelve **un comando literal**, cuando y sólo cuando el par existe de verdad:
168# los pares se PREGUNTAN al registro de plugins, no se listan acá. Una lista propia se
169# desincronizaría del centro y ofrecería una traducción que no existe — el plan mintiendo.
170
171# Dónde vive la declaración del servicio en cada init de origen.
172_RUTA_UNIDAD = {"openrc": "/etc/init.d/{n}",
173 "systemd": "/etc/systemd/system/{n}.service"}
174
175# Dónde vive la configuración de cada servicio. Conocimiento de dominio, igual que `FAMILIAS`: que
176# nginx lea `/etc/nginx/nginx.conf` es un hecho, no una preferencia. Lo que NO está acá no se
177# adivina — se dice que hay que ubicarlo a mano.
178_RUTA_CONFIG = {"nginx": "/etc/nginx/nginx.conf",
179 "apache": "/etc/apache2/apache2.conf",
180 "httpd": "/etc/httpd/conf/httpd.conf",
181 "caddy": "/etc/caddy/Caddyfile"}
182
183
184def traducciones(s):
185 """Comandos de traducción que le corresponden a un servicio que se muda.
186
187 Dos cosas distintas, y confundirlas es un error caro:
188 · la **declaración** (cómo se levanta) — de `openrc`/`systemd` a la tarjeta de arje;
189 · la **configuración** (qué hace) — sólo si se eligió un equivalente, p. ej. nginx → caddy.
190 Un servicio que se muda a sí mismo NO necesita lo segundo: su config sirve tal cual, y ofrecer
191 traducirla sería inventar trabajo."""
192 lectores, escritores = formatos.cargar()
193 out = []
194
195 # 1) la declaración → tarjeta de arje (el init del destino)
196 if "arje" in escritores:
197 for fuente in s.get("declared_in", []):
198 if fuente in lectores and fuente in _RUTA_UNIDAD:
199 ruta = _RUTA_UNIDAD[fuente].format(n=s["name"])
200 out.append({
201 "que": f"declaración de {s['name']} ({fuente} → arje)",
202 "cmd": f"{_AQUI} --from {fuente} --to arje \\\n"
203 f" --in {ruta} --out {s['name']}.card.json",
204 "nota": f"lee la unidad de {fuente} en el ORIGEN y emite la tarjeta. Sale con "
205 "código ≠0 si algo quedó sin traducir: eso NO es un fallo del comando, "
206 "es el acta pidiendo que la leas."})
207 break
208
209 # 2) la configuración → sólo si se cambia de servicio
210 alt = s.get("alternativa")
211 if alt and alt != s["name"]:
212 desde, hacia = s["name"].lower().replace("_", "-"), alt.lower()
213 if desde in lectores and hacia in escritores and \
214 lectores[desde][1] == escritores[hacia][1]:
215 org = _RUTA_CONFIG.get(desde, f"<config de {desde}: ubicala>")
216 dst = _RUTA_CONFIG.get(hacia, f"<config de {hacia}>")
217 out.append({
218 "que": f"configuración de {desde} → {hacia}",
219 "cmd": f"{_AQUI} --from {desde} --to {hacia} \\\n"
220 f" --in {org} --out {os.path.basename(dst)}",
221 "nota": "traduce lo mecánico y DICE con número de línea lo que no pudo. Desplegar "
222 "sin resolver el acta es desplegar un servidor incompleto que además "
223 "parece funcionar."})
224 else:
225 # No hay par: se dice. Ofrecer un comando que no existe es peor que no ofrecer nada.
226 out.append({
227 "que": f"configuración de {desde} → {hacia}",
228 "cmd": f"# NO hay traductor `{desde} → {hacia}` en el centro.\n"
229 f"# Ver los que sí hay: {_AQUI} --list\n"
230 f"# Hay que reescribir la configuración a mano.",
231 "nota": "el centro no tiene ese par; se escribe a mano o se agrega el plugin."})
232 return out
233
234
235def _receta(nombre, repo):
236 """(existe, sellado) para un nombre de receta."""
237 import glob
238 f = os.path.join(repo, "recipes", f"{nombre}.toml")
239 if not os.path.exists(f):
240 return False, False
241 return True, bool(glob.glob(os.path.join(repo, "store", f"*-{nombre}")))
242
243
244def alternativas(nombre, repo):
245 """Equivalentes funcionales del servicio, con una recomendación RAZONADA.
246
247 ── LOS CRITERIOS, Y POR QUÉ ÉSOS ────────────────────────────────────────────────────────────
248 No se recomienda «el mejor» en abstracto: eso es gusto, y un gusto disfrazado de dato es peor que
249 no recomendar. Se recomienda con hechos comprobables y en este orden:
250
251 1. **El que YA CORRE, si takana lo construye.** Cambiar de servidor web no es cambiar un
252 binario: es **reescribir la configuración entera**. Ése es el costo real y casi siempre
253 supera cualquier ventaja teórica del otro.
254 2. Si el que corre NO está en el catálogo pero un equivalente sí, se recomienda el equivalente
255 — porque es el que takana puede construir, firmar y reproducir. Y se dice lo que cuesta.
256 3. Si no hay ninguno, se dice que hay que escribir la receta. No se sugiere «usá otro» sin
257 decir que ese otro tampoco está.
258
259 Devuelve `(lista, recomendado, motivo)` o `(None, None, None)` si el servicio no cae en ninguna
260 familia conocida — que es lo normal y no es un error."""
261 n = nombre.lower().replace("_", "-")
262 fam = next((f for f, miembros in FAMILIAS.items() if n in miembros), None)
263 if not fam:
264 return None, None, None
265 lista = []
266 for cand in FAMILIAS[fam]:
267 existe, sellado = _receta(cand, repo)
268 lista.append({"nombre": cand, "receta": existe, "sellado": sellado, "actual": cand == n})
269 yo = next((x for x in lista if x["actual"]), None)
270 disponibles = [x for x in lista if x["sellado"]]
271
272 if yo and yo["sellado"]:
273 return (lista, n,
274 f"es el que YA corre y takana lo construye (sellado). Cambiar a otro de la familia "
275 f"«{fam}» no es cambiar un binario: obliga a REESCRIBIR la configuración entera")
276 if disponibles:
277 otros = ", ".join(x["nombre"] for x in disponibles)
278 return (lista, disponibles[0]["nombre"],
279 f"el que corre ({n}) no está en el catálogo de takana; de la familia «{fam}» sí "
280 f"están sellados: {otros}. Cambiar implica reescribir la configuración — es una "
281 f"decisión, no un trámite")
282 return (lista, None,
283 f"de la familia «{fam}» NO hay ninguno en el catálogo de takana: ni {n} ni sus "
284 f"equivalentes. Hay que escribir la receta del que se elija")
285
286
287def origen_binario(e, repo=None):
288 """De dónde sale el binario, y por lo tanto QUÉ HAY QUE HACER para tenerlo en el destino.
289
290 «Instalá el paquete» es lo que uno ya sabía. Esto responde otra cosa, cruzando dos hechos: quién
291 posee el fichero en el ORIGEN (se lo preguntó el censo a su gestor de paquetes) y si el corpus de
292 takana tiene una receta con ese nombre. Los tres casos piden trabajo distinto:
293
294 · `receta-takana` — lo mejor: takana ya sabe construirlo. Se instala del repo firmado.
295 · `paquete-ajeno` — lo trae el gestor del origen. En el destino hace falta una receta, o
296 `qorpa` (ADR 0015) si se acepta una jaula con la distro ajena.
297 · `suelto` — NADIE lo provee: un binario puesto a mano. Hay que LLEVARLO con su árbol
298 o escribirle una receta. Es el que se pierde al apagar el origen.
299
300 Medido en gioser: 13 servicios con paquete y **13 binarios sueltos** — `caddy`, `qdrant`,
301 `matilda`, `act_runner`, `pacha-secretos`… en `/usr/local/bin` o en un home. La mitad."""
302 nombre, exe, pkg = e.get("name", ""), e.get("exec", ""), e.get("paquete", "")
303 # scripts/mudanza/planear.py → scripts/mudanza → scripts → RAÍZ. Con un `dirname` de menos
304 # apuntaba a `scripts/`, no encontraba ninguna receta, y TODO salía `suelto` — incluido `caddy`,
305 # que sí tiene `recipes/caddy.toml`. Un clasificador que contesta siempre lo mismo no clasifica.
306 repo = repo or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
307 if not os.path.isdir(os.path.join(repo, "recipes")):
308 # Falla ruidosamente: sin el catálogo no se puede distinguir `receta-takana` de `suelto`, y
309 # contestar `suelto` a todo sería una respuesta falsa con forma de respuesta.
310 raise SystemExit(f"!! no encuentro el catálogo de recetas en {repo}/recipes — "
311 f"pasá --repo o corré desde el repo")
312 # ⚠ El nombre del SERVICIO no es el nombre del paquete: `sshd` lo trae `openssh`, `crond` lo trae
313 # `cronie`. Buscar sólo por el nombre del servicio daba «no hay receta» para cinco que el catálogo
314 # SÍ construye y tiene selladas. El censo ya le preguntó al gestor de paquetes quién posee el
315 # binario, así que ese nombre también se prueba.
316 import glob
317 for cand in (nombre, nombre.replace("_", "-"), os.path.basename(exe or ""),
318 pkg, (pkg or "").removesuffix("-bin"), ALIAS_INTERPRETE.get(pkg or "", "")):
319 if not cand or not os.path.exists(os.path.join(repo, "recipes", f"{cand}.toml")):
320 continue
321 # ⚠⚠ Y la trampa, que es la que haría mentir al perfil: un paquete INTÉRPRETE provee el
322 # runtime, no el programa. `openclaw` lo posee `nodejs` y `fail2ban-server` lo posee
323 # `python`; tener esas recetas NO te da esos servicios. Contarlos como cubiertos haría que el
324 # perfil saliera N/N describiendo un servidor al que le faltan cuatro programas.
325 if pkg in INTERPRETES and cand in (pkg, ALIAS_INTERPRETE.get(pkg or "", "")) and cand != nombre:
326 sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
327 return ("interprete",
328 f"lo corre el intérprete `{pkg}`, que takana SÍ construye (`recipes/{cand}.toml`"
329 + (", sellada)" if sellado else ", sin sellar)")
330 + f" — pero el intérprete NO es el programa: `{nombre}` es código que hay que "
331 f"llevar y declarar aparte. El perfil resuelve el runtime y nada más")
332 sellado = bool(glob.glob(os.path.join(repo, "store", f"*-{cand}")))
333 via = "" if cand in (nombre, nombre.replace("_", "-")) else f" (por su paquete `{pkg}`)" \
334 if cand in (pkg, (pkg or "").removesuffix("-bin")) else f" (por su binario)"
335 return ("receta-takana",
336 f"takana YA lo construye{via}: `recipes/{cand}.toml`"
337 + (" (sellado en el store)" if sellado else " (sin sellar: hay que construirlo)")
338 + f" ⇒ instalarlo del repo firmado, no copiar el binario de {exe or '?'}")
339 if pkg:
340 return ("paquete-ajeno",
341 f"lo provee el gestor del origen: paquete `{pkg}`. En el destino hace falta una "
342 f"receta takana, o `qorpa` si se acepta una jaula con la distro ajena (ADR 0015)")
343 if exe and e.get("exec_borrado"):
344 # Su propia clase, y la más urgente del censo: el fichero YA NO EXISTE. No es «hay que
345 # llevarlo», es «se pierde en el próximo reinicio y el que está en su ruta NO es el mismo».
346 pid = e.get("pid_vivo") or (e.get("pids") or [0])[0]
347 bv, bd = e.get("bytes_vivo", ""), e.get("bytes_disco", "")
348 dif = (f" El que hay AHORA en esa ruta pesa {bd} bytes y el que corre {bv}: NO SON EL MISMO."
349 if bv and bd and bv != bd else
350 (" En esa ruta ya no hay nada." if not bd else ""))
351 return ("borrado",
352 f"⚠ el binario que CORRE fue borrado o reemplazado: `{exe}` ya no existe en disco."
353 f"{dif} Sólo vive mientras viva el pid {pid}; al reiniciar el origen se pierde PARA "
354 f"SIEMPRE. Rescatalo ya: sudo cat /proc/{pid}/exe > {os.path.basename(exe)} "
355 f"&& chmod +x {os.path.basename(exe)}")
356 if exe:
357 return ("suelto",
358 f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
359 f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
360 f"el origen")
361 motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
362 return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
363
364
365def recomendar(tipo, e, datos_que_mueren=(), sitios=(), locales=()):
366 """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
367 que aceptarla sea barato y equivocarse sea caro de no notar.
368
369 Un motivo no es cortesía: una recomendación sin razón no se puede discutir, así que o se acepta a
370 ciegas o se ignora entera. Las dos son malas."""
371 c = e.get("class", "")
372
373 if tipo == "servicio":
374 m = _no_aplica(e.get("name", ""))
375 if m:
376 return "muere", m
377 # DERIVADA, y es la más fuerte: si el binario vive en un árbol que NO se muda, el servicio no
378 # va a poder arrancar allá. Se detecta cruzando su `cmdline` contra las decisiones de datos.
379 exe = (e.get("cmdline") or "").split(" ")[0]
380 for raiz in datos_que_mueren:
381 if exe.startswith(raiz.rstrip("/") + "/"):
382 return "muere", f"su binario vive en {raiz}, que NO se muda ⇒ no podría arrancar allá"
383 if c == "no-declarado":
384 p = e.get("ports") or []
385 return "muda", ("corre y sirve " + ", ".join(str(x) for x in p) + ": alguien lo quiere, "
386 "y NADIE lo declara" if p else
387 "corre sin estar declarado: alguien lo quiso, y nadie sabe levantarlo")
388 if c == "vivo":
389 return "muda", "declarado y corriendo"
390 return "", ""
391
392 if tipo == "dominio":
393 # ⚠ Evidencia más fuerte que el DNS, y sin tocar la red: si el bloque de este dominio en la
394 # config del servidor apunta a un directorio que NO EXISTE, no hay nada que servir — devuelve
395 # 502 resuelva donde resuelva. Medido en gioser: `aura`, `sigma` y `kosmofono` apuntan a
396 # raíces borradas, y son justo los que en su día devolvían los 502 que hicieron que el censo
397 # se baneara a sí mismo al sondearlos por HTTP. Se lee del fichero; no hace falta preguntar.
398 for st in sitios:
399 if e.get("domain") in (st.get("nombres") or "").split() and st.get("raiz_existe") == "no":
400 return "muere", (f"su bloque en {st['config']} apunta a `{st['raiz']}`, que NO "
401 f"EXISTE: no hay nada que servir, devuelve 502 resuelva donde resuelva")
402 if c == "vivo":
403 return "muda", "resuelve a esta máquina y contesta"
404 if c == "ya-mudado":
405 return "muere", f"su DNS ya apunta a {e.get('dns','otra máquina')}: el bloque de acá es fósil"
406 if c == "backend-caido":
407 return "muere", f"resuelve acá pero devuelve {e.get('http','5xx')}: no hay backend"
408 if c == "apunta-aca":
409 # El DNS dice que apunta acá; si SIRVE no se preguntó. Recomendar «muda» sería tratar el
410 # silencio como una respuesta afirmativa, que es el error que este censo evita en todos
411 # los demás lugares. Se recomienda mudar porque el DNS hay que reapuntarlo igual, pero
412 # se DICE lo que no se miró.
413 return "muda", (f"su DNS apunta acá ({e.get('dns','?')}), así que hay que reapuntarlo. "
414 "⚠ NO se sondeó por HTTP: si el backend estaba caído, esto muda un fósil")
415 if c == "fosil-sin-dns":
416 return "muere", ("no resuelve en ningún DNS: nadie puede llegar a él. Revisá si dejó "
417 "contenido en disco o un bloque en el servidor web — eso sí viaja si "
418 "nadie lo borra")
419 if c == "sin-sondear":
420 return "", "sin sondear (censado en local): hace falta mirarlo desde afuera"
421 return "", ""
422
423 if tipo == "datos":
424 # ── LO QUE SIGUE SIN RECOMENDARSE, Y LO QUE SÍ ──────────────────────────────────────────
425 # La regla de fondo no cambió: **qué datos VALEN no se deduce de la máquina** — `terapeuta.ec`
426 # eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso.
427 # Pero hay tres hechos que sí decide la máquina, y callarlos obliga a mirar 169 entradas a
428 # ojo: que el servidor web lo SIRVA, que un proceso vivo lo USE, y que sea recuperable de
429 # otro lado (un repo limpio con remoto) o regenerable (una caché). Se recomienda SÓLO sobre
430 # esos hechos, con el hecho como motivo, y el resto sale en blanco a propósito.
431 if e.get("servido_por"):
432 return "muda", ("lo SIRVE " + ", ".join(e["servido_por"]) +
433 ": si no viaja, ese sitio deja de existir")
434 if e.get("usado_por"):
435 return "muda", ("lo usa un proceso vivo (" + ", ".join(e["usado_por"]) +
436 "): un servicio que se muda sin sus datos arranca vacío")
437 base = os.path.basename(e.get("path", ""))
438 if base in _CACHES or base in ("swap", "lost+found"):
439 return "muere", f"`{base}` es caché o estado transitorio: se regenera solo en el destino"
440 rem = e.get("git_remoto") or ""
441 # ⚠ UN REMOTO QUE APUNTA A ESTA MISMA MÁQUINA NO ES UN RESPALDO, ES EL MISMO DISCO.
442 # Sin esto, «tiene remoto ⇒ se vuelve a clonar» recomienda ABANDONAR un repo cuyo único
443 # ejemplar se borra con la máquina. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es
444 # `ssh://git@127.0.0.1:2345/…` — el gitea de acá. El censo ya lo sabe (`[[repo]] solo_aqui`)
445 # y lo que faltaba era cruzarlo.
446 if rem and any(x and x in rem for x in locales):
447 return "respalda", (f"su remoto ({rem}) es ESTA MISMA MÁQUINA: no es un respaldo, es el "
448 "mismo disco. Clonarlo después de borrarla no se puede")
449 if rem and not e.get("git_sucio"):
450 return "muere", (f"repo git LIMPIO con remoto ({rem}): se vuelve a clonar")
451 if rem and e.get("git_sucio"):
452 return "respalda", ("repo git con remoto pero CON CAMBIOS SIN COMMITEAR: lo commiteado "
453 "se vuelve a clonar, lo de encima sólo está acá")
454 if e.get("git_fecha") and not rem:
455 return "respalda", (f"repo git SIN remoto (último commit {e['git_fecha']}): su historia "
456 "existe únicamente en este disco")
457 return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
458 return "", ""
459
460
461# Nombres cuyo contenido se REGENERA. No es una lista de «cosas grandes»: es de cosas que el destino
462# vuelve a crear solo. Borrar por tamaño sería adivinar; por regenerabilidad es un hecho.
463_CACHES = {
464 ".cache", ".npm", ".npm-global", ".gradle", ".pub-cache", ".dartServer", ".cargo", ".rustup",
465 ".m2", ".gem", ".go", "go", ".yarn", ".bun", ".deno", ".venvs", ".local-cache", "node_modules",
466 "target", "__pycache__", ".mozilla-cache", ".thumbnails", ".ccache", ".sccache",
467}
468
469
470def sugerir(tipo, e, datos_que_mueren=()):
471 return recomendar(tipo, e, datos_que_mueren, sitios)[0]
472
473
474def _etiqueta(tipo, nombre, e):
475 if tipo == "datos":
476 extra = e.get("size", "?")
477 if e.get("size_copy") and e["size_copy"] != e.get("size"):
478 extra += f" (copiar {e['size_copy']})"
479 return extra
480 if tipo == "servicio":
481 p = e.get("ports") or []
482 return ("puertos " + ",".join(str(x) for x in p)) if p else e.get("class", "")
483 return f"{e.get('dns','')} {e.get('http','')}".strip() or e.get("class", "")
484
485
486def revisar(censo, interactivo=True):
487 """Muestra TODO con su recomendación y su motivo, y deja elegir.
488
489 ── EL ORDEN NO ES CAPRICHOSO ────────────────────────────────────────────────────────────────
490 Primero los DATOS, después servicios y dominios. Porque la recomendación más fuerte sobre un
491 servicio se DERIVA de los datos: si su binario vive en un árbol que no se muda, no va a poder
492 arrancar allá. Al revés no se puede calcular.
493 """
494 todos = entradas(censo)
495 datos = [(t, n, e) for t, n, e in todos if t == "datos"]
496 # Con qué se compara un remoto para saber si apunta a esta misma máquina. Sale del censo, no de
497 # una lista cableada: las IPs y el hostname los midió `identidad()`.
498 maq = censo.get("maquina", {})
499 locales = {"127.0.0.1", "localhost", "0.0.0.0"} | set(maq.get("ips", []) or [])
500 if maq.get("hostname"):
501 locales.add(maq["hostname"])
502 resto = [(t, n, e) for t, n, e in todos if t != "datos"]
503
504 # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
505 if datos:
506 print("\n══ 1/2 · DATOS — esto no se recomienda solo ═══════════════════════════════════")
507 print(" Qué datos valen no se deduce de la máquina: sólo vos sabés. Y de esto se deriva")
508 print(" la recomendación de los servicios, así que va primero.\n")
509 for t, n, e in datos:
510 marca = e.get("decision") or "—"
511 rec, motivo = recomendar("datos", e, locales=locales)
512 e["_rec"], e["_motivo"] = rec, motivo
513 print(f" {n:46} {e.get('size','?'):>6} {marca if marca != '—' else (rec or '—'):>8}"
514 f" {e.get('descripcion', '')[:70]}")
515 if interactivo:
516 print("\n [m]uda = va a la caja nueva · [r]espalda = se copia al corral y NO se instala"
517 " · [x] muere con la máquina\n")
518 for t, n, e in datos:
519 if e.get("decision") in DECISIONES:
520 continue
521 rec = e.get("_rec", "")
522 print(f" {n} {e.get('size','?')}")
523 if e.get("descripcion"):
524 print(f" {e['descripcion'][:100]}")
525 if e.get("_motivo"):
526 print(f" ↳ sugerido: {rec} — {e['_motivo'][:90]}")
527 r = input(f" [m]uda/[r]espalda/[x]muere"
528 f"{'/Enter=' + rec if rec else ''} > ").strip().lower()
529 e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(r, "")
530
531 # ⚠ `respalda` entra acá junto a `muere`: las dos significan «su ruta NO va a existir en el
532 # destino», que es lo único que la derivación de servicios necesita saber.
533 mueren = [e["path"] for t, n, e in datos if e.get("decision") in ("muere", "respalda")]
534
535 # ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
536 grupos = {"muda": [], "respalda": [], "muere": [], "": []}
537 for t, n, e in resto:
538 d, motivo = recomendar(t, e, mueren, censo.get("sitio", []))
539 e["_rec"], e["_motivo"] = d, motivo
540 grupos[d].append((t, n, e))
541
542 print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
543 titulos = {"muda": "se recomienda MUDAR", "respalda": "se recomienda RESPALDAR",
544 "muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"}
545 for g in ("muda", "respalda", "muere", ""):
546 if not grupos[g]:
547 continue
548 print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
549 for t, n, e in sorted(grupos[g], key=lambda x: (x[0], x[1])):
550 print(f" {t:8} {n:30} {_etiqueta(t,n,e):>18}")
551 if e.get("_motivo"):
552 print(f" └─ {e['_motivo']}")
553 if t == "servicio" and e.get("decision") != "muere" and g != "muere":
554 oc, od = origen_binario(e)
555 print(f" └─ binario: [{oc}] {od[:96]}")
556 if e.get("usuario") and e["usuario"] != "root":
557 # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
558 # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
559 # corre sin privilegios termina de root en el destino y nadie se entera.
560 print(f" └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
561 f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
562 lista, rec, motivo = alternativas(n, _REPO)
563 if lista:
564 op = " ".join(
565 ("**" + x["nombre"] + "**" if x["nombre"] == rec else x["nombre"])
566 + ("✓" if x["sellado"] else ("·" if x["receta"] else "✗"))
567 for x in lista)
568 print(f" └─ alternativas: {op} (✓ sellado · receta sin sellar · ✗ no está)")
569 print(f" recomendado: {rec or 'ninguno'} — {motivo[:110]}")
570
571 if not interactivo:
572 return censo
573
574 print("\n [a] aceptar todas las recomendaciones · [e] revisar una por una · [q] salir")
575 r = input(" > ").strip().lower()
576 if r == "q":
577 return censo
578 for t, n, e in resto:
579 if e.get("decision") in DECISIONES:
580 continue
581 rec = e.get("_rec", "")
582 if r == "a" and rec:
583 e["decision"] = rec
584 continue
585 sug = f"[{rec}]" if rec else "[sin recomendación]"
586 resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
587 f" [m]uda/[r]espalda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
588 e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(resp, "")
589 if e["decision"] == "muda" and t == "servicio":
590 lista, alt_rec, motivo = alternativas(n, _REPO)
591 if lista and len([x for x in lista if x["sellado"]]) > 1:
592 op = " ".join(f"[{i}]{x['nombre']}" + ("✓" if x["sellado"] else "")
593 for i, x in enumerate(lista))
594 print(f" alternativas: {op}")
595 print(f" recomendado: {alt_rec} — {motivo[:100]}")
596 r2 = input(f" número, o Enter para {alt_rec}: ").strip()
597 if r2.isdigit() and int(r2) < len(lista):
598 e["alternativa"] = lista[int(r2)]["nombre"]
599 else:
600 e["alternativa"] = alt_rec
601 return censo
602
603
604def decidir_desde_fichero(censo, ruta_dec):
605 """Decidir EN LOTE desde un fichero de texto: `<ruta-o-nombre> <decision>` por línea.
606
607 ── POR QUÉ EXISTE (pedido del usuario, 2026-09-16) ─────────────────────────────────────────
608 «no sé cómo activar o desactivar cosas aquí desde shuma llimphi remoto, que las opciones son
609 con teclas unitarias». El modo interactivo de `--decide` lee una tecla por entrada, y hay
610 terminales —la consola remota de shuma, entre otras— donde eso no se puede usar. Una
611 herramienta cuya única forma de decidir exige un tipo de terminal no es una herramienta: es una
612 herramienta *para esa terminal*. El fichero funciona en cualquiera, se revisa antes de
613 aplicarlo, se versiona y se vuelve a correr — que es lo mismo que ya se pide del plan.
614
615 Formato, deliberadamente pobre (`#` comenta, se ignoran líneas vacías):
616
617 /home/sergio/mapas muda
618 terapeuta respalda # basta con que el nombre sea único
619 /var/lib/swap muere
620
621 ⚠ Una clave que no empareja con NADA es un ERROR RUIDOSO, no un aviso: significa que alguien
622 decidió algo que no se aplicó, y el silencio ahí es exactamente la clase de fallo que este
623 programa existe para no tener. Lo mismo si empareja con varias: se nombran todas y no se toca
624 ninguna, porque elegir una por su cuenta sería adivinar.
625 """
626 lineas = []
627 with open(ruta_dec, encoding="utf-8") as fh:
628 for i, l in enumerate(fh, 1):
629 l = l.split("#", 1)[0].strip()
630 if l:
631 lineas.append((i, l))
632 todos = entradas(censo)
633 aplicadas, problemas = 0, []
634 for i, l in lineas:
635 partes = l.rsplit(None, 1)
636 if len(partes) != 2 or partes[1] not in DECISIONES:
637 problemas.append(f"línea {i}: «{l}» — falta la decisión o no es una de "
638 f"{'/'.join(DECISIONES)}")
639 continue
640 clave, dec = partes
641 cands = [(t, n, e) for t, n, e in todos
642 if n == clave or os.path.basename(str(n)) == clave]
643 if not cands:
644 problemas.append(f"línea {i}: «{clave}» no empareja con NADA del censo")
645 continue
646 if len(cands) > 1:
647 problemas.append(f"línea {i}: «{clave}» empareja con {len(cands)}: "
648 + ", ".join(n for _t, n, _e in cands[:5]))
649 continue
650 cands[0][2]["decision"] = dec
651 aplicadas += 1
652 if problemas:
653 print("\n!! decisiones NO aplicadas:", file=sys.stderr)
654 for x in problemas:
655 print(f" {x}", file=sys.stderr)
656 sys.exit("\n No se escribe nada: un lote a medias deja decidido lo que nadie revisó.")
657 return aplicadas
658
659
660def guardar_censo(censo, ruta, respaldar=True):
661 """Vuelve a escribir el censo con las decisiones puestas, usando el MISMO emisor que lo creó.
662
663 Dos detalles que no son de estilo: se descartan las claves internas (`_rec`, `_motivo`, que
664 `revisar()` cuelga de cada entrada y NO son parte del censo), y se escribe con temporal +
665 `rename` — si la herramienta muere a mitad, el fichero anterior sigue entero. Perder el censo a
666 medio decidir es perder el trabajo que más cuesta de toda la mudanza."""
667 limpio = {}
668 for k, v in censo.items():
669 if isinstance(v, list):
670 limpio[k] = [{kk: vv for kk, vv in e.items() if not kk.startswith("_")}
671 if isinstance(e, dict) else e for e in v]
672 else:
673 limpio[k] = v
674 # El emisor de `censar` habla en plural; el TOML, en singular. El puente va acá y no allá para
675 # no tocar el censo, que es quien define el formato.
676 puente = {
677 "fecha": limpio.get("fecha", ""), "identidad": limpio.get("maquina", {}),
678 "servicios": limpio.get("servicio", []), "huerfanos": limpio.get("huerfano", []),
679 "puertos": limpio.get("puerto", []), "dominios": limpio.get("dominio", []),
680 "datos": limpio.get("datos", []), "datos_raiz": limpio.get("datos_raiz", []),
681 "contenido": limpio.get("contenido", []), "sitios": limpio.get("sitio", []),
682 "repos": limpio.get("repo", []), "montajes": limpio.get("montaje", []),
683 "fuera_de_git": limpio.get("fuera_de_git", []),
684 }
685 texto = censar.emitir_toml(puente)
686 # Y se RELEE antes de pisar nada: el mismo guardián que el plan. Un censo que no vuelve a
687 # parsear deja la mudanza sin su fichero central y sin forma de saber cuándo se rompió.
688 try:
689 vuelto = tomllib.loads(texto)
690 except tomllib.TOMLDecodeError as e:
691 sys.exit(f"✗ el censo regenerado NO es TOML válido: {e}\n No se escribe nada.")
692 n_dec = sum(1 for _t, _n, x in entradas(vuelto) if x.get("decision"))
693 n_esp = sum(1 for _t, _n, x in entradas(censo) if x.get("decision"))
694 if n_dec != n_esp:
695 sys.exit(f"✗ el censo regenerado perdió decisiones ({n_esp} → {n_dec}). No se escribe nada.")
696 if respaldar and os.path.exists(ruta):
697 with open(ruta, encoding="utf-8") as fh:
698 open(ruta + ".bak", "w", encoding="utf-8").write(fh.read())
699 tmp = ruta + ".tmp"
700 with open(tmp, "w", encoding="utf-8") as fh:
701 fh.write(texto)
702 fh.flush()
703 os.fsync(fh.fileno())
704 os.replace(tmp, ruta)
705
706
707def validar(censo):
708 """Regla 1: nada sin decidir se ejecuta."""
709 faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
710 if faltan:
711 print("\n!! el plan NO se genera: hay decisiones sin tomar.", file=sys.stderr)
712 print(" El silencio no es consentimiento — sin decisión, ni mudar ni matar es correcto.\n",
713 file=sys.stderr)
714 for t, n in faltan[:40]:
715 print(f" {t:9} {n}", file=sys.stderr)
716 if len(faltan) > 40:
717 print(f" … y {len(faltan)-40} más", file=sys.stderr)
718 print("\n Corré con --decide, o poné `decision` a mano en el censo.", file=sys.stderr)
719 sys.exit(2)
720
721
722# ── plan ────────────────────────────────────────────────────────────────────────────────────────
723
724_TRIPLE_SIMPLE = "'" * 3
725_TRIPLE_DOBLE = '"' * 3
726
727
728def bloque_toml(texto):
729 """Un texto libre como cadena TOML, de modo que el plan se pueda VOLVER A LEER.
730
731 ⚠ Medido, y era un fallo del producto: con la cadena BÁSICA el TOML interpreta las escapes, así
732 que el primer comando con una barra de continuación de línea dejaba el plan ILEGIBLE
733 (`Unescaped '\' in a string`) — y el plan es el producto: uno que no se puede volver a parsear no
734 se puede ni ejecutar ni exportar, que son las dos únicas cosas para las que existe. Apareció
735 recién al meter un comando multilínea, o sea que estuvo latente desde el principio.
736
737 Se usa la cadena LITERAL, que no interpreta nada. Si el texto la contuviera, se cae a la básica
738 con las escapes puestas a mano — el caso raro se maneja, no se supone que no pasa."""
739 if _TRIPLE_SIMPLE not in texto:
740 return _TRIPLE_SIMPLE + "\n" + texto + "\n" + _TRIPLE_SIMPLE
741 escapado = texto.replace("\\", "\\\\").replace(_TRIPLE_DOBLE, '\\"\\"\\"')
742 return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
743
744
745def incoherencias(censo):
746 """Cruza lo que la config DECLARA NECESITAR contra lo que se decidió con cada servicio.
747
748 ── LO QUE ESTO CAZA Y NADA MÁS PODÍA ───────────────────────────────────────────────────────
749 Un dominio que se muda y el servicio que lo sirve marcado `muere`. Las dos decisiones son
750 razonables por separado —el dominio se usa, el servicio parecía prescindible— y juntas dejan el
751 sitio nuevo devolviendo 502. No lo ve el DNS, no lo ve el censo de procesos y no lo ve quien
752 decide de a una entrada por vez, que es como se decide.
753
754 También caza el revés: un `reverse_proxy` a un puerto que NINGÚN servicio censado sirve. Eso ya
755 está roto HOY, en el origen, y mudarlo es mudar un 502."""
756 por_puerto, por_nombre = {}, {}
757 for s in censo.get("servicio", []):
758 por_nombre[s["name"].lower()] = s
759 for p in s.get("ports", []):
760 por_puerto[str(p)] = s
761 dec = {}
762 for d in censo.get("dominio", []):
763 dec[d["domain"]] = d.get("decision", "")
764
765 out = []
766 for st in censo.get("sitio", []):
767 doms = [d for d in (st.get("nombres") or "").split() if dec.get(d) == "muda"]
768 if not doms:
769 continue
770 for destino in (st.get("proxies") or "").split():
771 if destino.startswith("unix/"):
772 # `unix//run/php-fpm/php-fpm.sock` → el servicio se identifica por su nombre.
773 base = destino.rstrip("/").split("/")[-1].removesuffix(".sock")
774 svc = por_nombre.get(base) or por_nombre.get(base.split("-")[0])
775 clave = base
776 else:
777 clave = destino.rsplit(":", 1)[-1]
778 svc = por_puerto.get(clave)
779 if svc is None:
780 out.append((", ".join(doms), destino,
781 "NINGÚN servicio censado sirve ese destino: el sitio ya devuelve 502 "
782 "HOY, en el origen. Mudarlo es mudar un 502"))
783 elif svc.get("decision") == "muere":
784 out.append((", ".join(doms), f"{destino} → {svc['name']}",
785 f"el dominio se MUDA y `{svc['name']}`, que es quien lo sirve, está "
786 f"marcado MUERE. El sitio nuevo va a devolver 502"))
787 elif not svc.get("decision"):
788 out.append((", ".join(doms), f"{destino} → {svc['name']}",
789 f"el dominio se muda y `{svc['name']}`, que lo sirve, NO tiene decisión"))
790 return out
791
792
793def destino_respaldo_default():
794 """El corral de respaldo sale de `scripts/respaldo-storagebox.sh`, NO de una constante nueva acá.
795
796 Dos ficheros con la misma dirección escrita a mano divergen, y el que se queda viejo no falla:
797 respalda a un sitio que nadie mira. Se leen sus defaults (`SB_USER`/`SB_HOST`/`SB_PORT`), que es
798 donde el proyecto ya guarda lo que se guarda por si acaso."""
799 ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "respaldo-storagebox.sh")
800 v = {}
801 try:
802 with open(ruta, encoding="utf-8") as fh:
803 for linea in fh:
804 for k in ("SB_USER", "SB_HOST", "SB_PORT"):
805 if linea.startswith(f'{k}="${{{k}:-'):
806 v[k] = linea.split(":-", 1)[1].split("}", 1)[0]
807 except OSError:
808 return "", ""
809 if not (v.get("SB_USER") and v.get("SB_HOST")):
810 return "", ""
811 return f'{v["SB_USER"]}@{v["SB_HOST"]}:respaldo-mudanza/', v.get("SB_PORT", "22")
812
813
814def pasos(censo, target, key, respaldo=None, respaldo_port=None):
815 ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
816 if key:
817 ssh_opts = f"-i {key} " + ssh_opts
818 ssh = f"ssh {ssh_opts} {target}"
819 P = []
820
821 def paso(clase, titulo, cmd, verifica=None, nota=None, verifica_tipo="cmd"):
822 # `verifica_tipo` no es decoración: el aplicador NO puede tratar igual «corré esto» que «un
823 # humano tiene que mirar esto». Dar por buena una verificación humana porque «no dio error»
824 # es exactamente cómo un aplicador miente.
825 P.append({"clase": clase, "titulo": titulo, "cmd": cmd,
826 "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
827
828 datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
829 datos_resp = [d for d in censo.get("datos", []) if d.get("decision") == "respalda"]
830 svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
831 doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
832 muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
833 contenido = censo.get("contenido", [])
834
835 # ── 0. CÓDIGO QUE SÓLO EXISTE ACÁ ───────────────────────────────────────────────────────────
836 # El primer paso del plan entero, por delante incluso del rescate de binarios: aquello es un
837 # servicio que se pierde, esto es EL CÓDIGO CON EL QUE SE VOLVERÍA A CONSTRUIR. Y no se ve desde
838 # ninguna otra parte del censo —un repo no es un proceso, ni un puerto, ni un dominio—, así que
839 # se descubre cuando ya no hay de dónde sacarlo.
840 solos = [r for r in censo.get("repo", []) if r.get("solo_aqui") == "si"]
841 if solos:
842 det = ["# Estos repositorios NO tienen ningún remoto fuera de esta máquina. Apagarla borra el",
843 "# código, y los clones de trabajo están en el mismo disco que también muere.",
844 "#",
845 "# Por cada uno: agregarle un remoto que viva en otro lado y empujar. El propio takana",
846 "# lo hace así (`scripts/espejo-setup.sh`: pushurl doble a gitea Y a un espejo privado).",
847 "#"]
848 for r in solos:
849 det.append(f"# {r['path']}")
850 det.append(f"# remoto actual (en esta máquina): {r['dentro'][:88]}")
851 paso("codigo", f"{len(solos)} repositorio(s) que sólo existen en la máquina que se va a borrar",
852 "\n".join(det),
853 verifica="revisión humana: cada uno tiene que tener un remoto FUERA antes de apagar nada",
854 verifica_tipo="humano",
855 nota="⚠ Es la dependencia circular de esta mudanza: `tawasuyu` produce 10 de los 13 "
856 "binarios que nadie más provee, y su único remoto es el gitea de esta misma "
857 "máquina. Sin esto, apagar el origen deja servicios que no se pueden reconstruir.")
858
859 # ── 0 bis. LO QUE NO VIAJA CON NINGÚN REPO ──────────────────────────────────────────────────
860 # El paso 1 salva el CÓDIGO; éste salva lo que hace que ese código sirva de algo: las llaves, los
861 # certificados, los tokens y la configuración que no está versionada en ninguna parte. Va acá
862 # arriba por la misma razón —se pierde para siempre— y con una diferencia que importa: un repo
863 # sin copia fuera se nota al intentar clonarlo, y una clave privada borrada no se nota hasta la
864 # próxima vez que alguien firma. La peor de todas es `~/.config/takana/keys/release.ed25519`: el
865 # `.pub` vive EN el repo, la privada sólo en esta máquina.
866 fg = [f for f in censo.get("fuera_de_git", []) if f.get("path") != "crontab"]
867 if fg:
868 ciegos = [f for f in fg if f.get("estado") == "sin_permiso"]
869 det = ["# Config, llaves y credenciales que no están en ningún repo. Se listan por su ruta,",
870 "# su DUEÑO y su tamaño; el censo nunca leyó su contenido.", "#"]
871 for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])):
872 duenio = f" [{f['owner']}]" if f.get("owner") else ""
873 det.append(f"# {f['path']}{duenio} ({f.get('size', '?')})")
874 if f.get("porque"):
875 det.append(f"# {f['porque']}")
876 nota = ("⚠ `~` NO es un home: el censo corre como root y sólo mira `/root` salvo que se le "
877 "pidan los homes de `/etc/passwd`. Lo que se censa por su nombre se decide; lo que "
878 "sólo viaja dentro del bloque `/home` viaja sin que nadie lo haya mirado.")
879 if ciegos:
880 # Un `ls` sin permiso se lee IGUAL que un directorio ausente: decir cuál de las dos es
881 # la única forma de que «no hay nada ahí» sea una afirmación y no un silencio.
882 nota += (" ⚠⚠ " + str(len(ciegos)) + " de estas rutas EXISTEN y el censo NO pudo leerlas "
883 "(" + ", ".join(f["path"] for f in ciegos[:4]) + "): volvé a censar como su "
884 "dueño antes de decidir, o vas a decidir sobre una lista incompleta.")
885 paso("secretos", f"{len(fg)} ruta(s) de config y credenciales fuera de todo repo",
886 "\n".join(det),
887 verifica="revisión humana: por cada una, decidir `muda` o `muere` — y para las que "
888 "mudan, que el destino la tenga ANTES de apagar nada",
889 verifica_tipo="humano", nota=nota)
890
891 for f in fg:
892 if f.get("decision") != "muda":
893 continue
894 origen = f["path"]
895 dst = f.get("destino") or origen
896 # `-aHAX --numeric-ids`: permisos, ACLs y xattrs. Una llave que llega con el modo
897 # cambiado no falla al copiarse, falla al usarse («permissions are too open»), que es
898 # varios pasos más tarde y en otro sitio.
899 paso("secretos", f"copiar {origen} → {dst} ({f.get('size', '?')})",
900 f"rsync -aHAX --numeric-ids --partial "
901 f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(origen)} "
902 f"{target}:{shlex.quote(os.path.dirname(dst.rstrip('/')) or '/')}/",
903 # Verificación POR CONTENIDO sin revelar contenido: el digest de los digests.
904 # Contar ficheros no alcanza — una llave truncada cuenta igual que una entera.
905 verifica=(f"h() {{ find \"$1\" -type f -exec sha256sum {{}} + 2>/dev/null "
906 f"| awk '{{print $1}}' | sort | sha256sum | cut -c1-16; }}; "
907 f"src=$(h {shlex.quote(origen)}); "
908 f"dst=$({ssh} \""
909 f"find {shlex.quote(dst)} -type f -exec sha256sum {{}} + 2>/dev/null "
910 f"| awk '{{print \\$1}}' | sort | sha256sum | cut -c1-16\"); "
911 f'echo "digest: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
912 nota="El digest es de los CONTENIDOS, no de los nombres ni de la cuenta: una llave "
913 "truncada a la mitad cuenta como un fichero igual que la entera.")
914
915 # ── 0 ter. COHERENCIA ───────────────────────────────────────────────────────────────────────
916 incoh = incoherencias(censo)
917 if incoh:
918 det = ["# Cada línea es un dominio que se MUDA cuyo backend no va a estar del otro lado.",
919 "# Las dos decisiones son razonables por separado; juntas dejan un 502.", "#"]
920 for dom, destino, motivo in incoh:
921 det.append(f"# {dom[:44]}")
922 det.append(f"# ↳ {destino}")
923 det.append(f"# {motivo}")
924 paso("coherencia", f"{len(incoh)} dominio(s) que se mudan SIN su backend", "\n".join(det),
925 verifica="revisión humana: o se muda el backend, o el dominio no se muda",
926 verifica_tipo="humano",
927 nota="Nadie más puede cazar esto: no lo ve el DNS, no lo ven los procesos, y no lo ve "
928 "quien decide de a una entrada por vez — que es como se decide.")
929
930 # ── 0 bis. RESCATE, antes que nada ──────────────────────────────────────────────────────────
931 # Va PRIMERO, antes del preflight, porque es el único paso del plan que puede volverse
932 # IMPOSIBLE mientras se piensa el resto: estos binarios ya no existen en disco y sólo viven en el
933 # inodo que sostiene su proceso. Un reinicio del origen —o que se caiga ese pid— y se pierden
934 # para siempre. Y en dos de los cuatro hay AHORA otro fichero en la misma ruta, de distinto
935 # tamaño: copiar la ruta no falla, muda otra cosa.
936 borrados = [s for s in censo.get("servicio", [])
937 if s.get("exec_borrado") and s.get("decision") == "muda"]
938 if borrados:
939 lineas, ver = [], []
940 for s in borrados:
941 pid = s.get("pid_vivo") or (s.get("pids") or [0])[0]
942 nom = os.path.basename(s.get("exec") or s["name"])
943 lineas.append(f"sudo cat /proc/{pid}/exe > rescate/{shlex.quote(nom)} && "
944 f"chmod +x rescate/{shlex.quote(nom)}")
945 if s.get("bytes_vivo"):
946 # La verificación COMPARA contra el tamaño del que CORRE. Un `cat` de un /proc que ya
947 # no existe crea un fichero VACÍO y devuelve 0: sin comparar, el rescate «pasa».
948 ver.append(f"[ \"$(stat -c %s rescate/{shlex.quote(nom)} 2>/dev/null)\" = "
949 f"\"{s['bytes_vivo']}\" ] || {{ echo 'FALTA {nom}'; exit 1; }}")
950 paso("rescate", f"rescatar {len(borrados)} binario(s) BORRADOS del disco (sólo mientras corran)",
951 "mkdir -p rescate\n" + "\n".join(lineas),
952 verifica="\n".join(ver) + "\necho ' ✓ los binarios rescatados coinciden con los que corren'",
953 nota="⚠ ESTE PASO CADUCA. `/proc/<pid>/exe` deja de existir cuando muere el proceso, y "
954 "estos ficheros ya no están en su ruta. Si el origen se reinicia antes de correr "
955 "esto, el binario que estaba andando NO se puede recuperar de ninguna manera.")
956
957 # ── 0. preflight ────────────────────────────────────────────────────────────────────────────
958 # ⚠ El sitio se mide POR SISTEMA DE FICHEROS, no contra `/`. Medido en la caja takana: `/` tiene
959 # 3,5 G libres, `/store` 19 G y `/work` 66 G — un preflight que sume 33,6 G y los compare contra
960 # `/` da un veredicto correcto por casualidad si todo cae en `/`, y COMPLETAMENTE FALSO en cuanto
961 # una ruta va a otro montaje: diría que no entra cuando entra, o al revés. Y además: aquí el
962 # veredicto lo saca el propio comando (exit ≠0 si falta sitio), no un humano leyendo una columna.
963 need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
964 if x is not None)
965 ilegibles = [d["path"] for d in datos_muda
966 if a_mib(d.get("size_copy") or d.get("size")) is None]
967 chks = []
968 for d in datos_muda:
969 mib = a_mib(d.get("size_copy") or d.get("size"))
970 if mib is None:
971 continue
972 dst = (d.get("destino") or "").strip() or d["path"]
973 chks.append(f"chk {shlex.quote(dst)} {int(mib)}")
974 guion = "\n".join([
975 "set -u",
976 "chk() {",
977 ' t="$1"',
978 ' while [ ! -d "$t" ] && [ "$t" != "/" ]; do t=$(dirname "$t"); done',
979 ' df -Pm "$t" | tail -1 | awk -v n="$2" -v r="$1" \'{print $6, $4, n, r}\'',
980 "}",
981 "{",
982 *[" " + c for c in chks],
983 "} | awk '",
984 " { libre[$1]=$2; falta[$1]+=$3; rutas[$1]=rutas[$1] \" \" $4 }",
985 " END { mal=0",
986 ' for (f in falta) {',
987 ' printf " %-16s necesita %8d MiB · libres %8d ⇐%s\\n", f, falta[f], libre[f], rutas[f]',
988 ' if (falta[f] > libre[f]) { mal=1; printf " ✗ NO ENTRA en %s: faltan %d MiB\\n", f, falta[f]-libre[f] }',
989 " }",
990 ' if (mal==0) printf " ✓ entra en todos los sistemas de ficheros\\n"',
991 " exit mal }'",
992 ])
993 paso("preflight", f"el destino responde y tiene sitio ({humano(need)} en total)",
994 f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
995 verifica=f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
996 nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
997 "Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`, y medido "
998 "contra el sistema de ficheros de CADA destino.")
999
1000 # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
1001 for d in datos_muda:
1002 p = d["path"]
1003 dst = (d.get("destino") or "").strip() or p
1004 # Ver la nota del paso de respaldo: con el censo detallado hay entradas que son FICHEROS, y
1005 # a un fichero no se le pone barra (y su verificación no es `find -type f | wc -l` en un
1006 # directorio, sino comparar el fichero mismo).
1007 es_dir = d.get("tipo", "dir") != "fichero"
1008 org, dst_r = (p + "/", dst + "/") if es_dir else (p, dst)
1009 ver = ((f"src=$(find {shlex.quote(p)} -type f | wc -l); "
1010 f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
1011 f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]') if es_dir else
1012 (f"src=$(stat -c%s {shlex.quote(p)}); "
1013 f"dst=$({ssh} 'stat -c%s {shlex.quote(dst)}' 2>/dev/null || echo ausente); "
1014 f'echo "bytes: origen=$src destino=$dst"; [ "$src" = "$dst" ]'))
1015 paso("datos", f"copiar {p} → {dst} ({d.get('size','?')})",
1016 f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
1017 f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(org)} {target}:{shlex.quote(dst_r)}",
1018 verifica=ver,
1019 nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
1020 "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
1021 "FALLA si no coinciden. La versión anterior imprimía el número de directorios "
1022 "vacíos y devolvía 0 igual — un guardián que siempre pasa no es un guardián. "
1023 "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
1024 "así se llenó un disco y quedaron 1367 artefactos vacíos.")
1025
1026 # ── 1 bis. lo que se RESPALDA ───────────────────────────────────────────────────────────────
1027 # Ni se muda ni se pierde. Va al corral de respaldo y NO se instala en el destino: la caja nueva
1028 # no se entera de que esto existe. Por eso es un paso propio y no un `datos` con otro destino —
1029 # su verificación es distinta y su fallo significa otra cosa (no rompe el servidor nuevo, pero
1030 # borra el original sin copia si nadie lo mira).
1031 if datos_resp:
1032 if not respaldo:
1033 # Un corral vacío convertiría cada `respalda` en un `rsync` a ninguna parte que además
1034 # devuelve 0. Abortar acá es la única forma de que no se lea como «respaldado».
1035 sys.exit("✗ hay entradas marcadas `respalda` y no sé a dónde respaldarlas.\n"
1036 " Pasá --respaldo <destino> (host:ruta o ruta local). No invento uno: un\n"
1037 " respaldo a un sitio equivocado se ve igual que un respaldo.")
1038 rsh = f"ssh -p {respaldo_port} {ssh_opts}" if respaldo_port else f"ssh {ssh_opts}"
1039 remoto = ":" in respaldo.split("/", 1)[0]
1040 for d in datos_resp:
1041 ruta = d["path"]
1042 # Aplanada: `/home/sergio/humanoid` → `home-sergio-humanoid`. El corral junta cosas de
1043 # rutas distintas, y conservar el árbol haría que dos orígenes se pisaran en el mismo
1044 # nombre. El nombre aplanado DICE de dónde salió, que es lo que hace falta para volver.
1045 nombre = ruta.strip("/").replace("/", "-")
1046 dst = respaldo.rstrip("/") + "/" + nombre
1047 # ⚠ Un FICHERO no lleva barra final y su destino es el directorio que lo contiene.
1048 # Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y
1049 # `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál
1050 # es cuál (`tipo`), así que no hay que adivinarlo.
1051 es_dir = d.get("tipo", "dir") != "fichero"
1052 org = ruta + "/" if es_dir else ruta
1053 dst_r = dst + "/" if es_dir else dst
1054 # ⚠ `--mkpath` NO es opcional, y lo cazó el guardián de dos sentidos: sin él rsync
1055 # muere con `mkdir "/home/respaldo-mudanza/prueba-guardian" failed` en cuanto el corral
1056 # tiene un nivel que todavía no existe — y el primer respaldo SIEMPRE lo tiene.
1057 base = (f"rsync -aH --mkpath --partial --partial-dir=.rsync-partial --info=stats2 "
1058 + (f"-e {shlex.quote(rsh)} " if remoto else "")
1059 + f"{shlex.quote(org)} {shlex.quote(dst_r)}")
1060 # La verificación se le pregunta a RSYNC, no a `du` ni a `find`: el corral puede ser un
1061 # Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías (medido). Una
1062 # corrida en seco compara tamaño y fecha CONTRA EL DESTINO REAL y dice qué falta.
1063 ver = (f"pend=$({base.replace('rsync -aH ', 'rsync -aHn ').replace('--info=stats2', chr(39)+'--out-format=%n'+chr(39))}"
1064 f" | grep -v '/$' | wc -l); echo \"pendientes en el corral: $pend\"; [ \"$pend\" -eq 0 ]")
1065 paso("respaldo", f"respaldar {ruta} → {dst} ({d.get('size','?')})", base,
1066 verifica=ver,
1067 nota="NO se instala en el destino: esto es archivo, no mudanza. Y `du` en el "
1068 "corral puede dar MENOS que el origen sin que falte nada (un Storage Box "
1069 "comprime: 1,3 G subidos se veían como 576 M). Por eso verifica rsync.")
1070
1071 # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
1072 for s in svc_muda:
1073 nd = s.get("class") == "no-declarado"
1074 # Antes de declarar: TRADUCIR lo que ya está escrito en el origen. Va como paso propio y no
1075 # metido dentro del de arriba porque tiene su propia verificación, y es HUMANA: `traducir.py`
1076 # sale ≠0 cuando algo quedó sin traducir, así que darlo por bueno por código de salida sería
1077 # justo al revés. Lo que verifica este paso es que alguien LEYÓ el acta.
1078 for tr in traducciones(s):
1079 paso("traducir", f"traducir {tr['que']}", tr["cmd"],
1080 verifica=f"# ¿leíste el acta de `{tr['que']}`? Cada línea SIN-TRADUCIR es"
1081 f" trabajo a mano que el destino NO va a hacer solo.",
1082 verifica_tipo="humano", nota=tr["nota"])
1083 paso("servicio", f"declarar y levantar {s['name']}" + (" ⚠ NO-DECLARADO" if nd else ""),
1084 ((f"# ⚠ SE ELIGIÓ UN EQUIVALENTE: {s['alternativa']} en vez de {s['name']}.\n"
1085 f"# Eso NO es cambiar un binario: hay que REESCRIBIR la configuración del servicio.\n"
1086 f"# La del origen no sirve tal cual.\n#\n")
1087 if s.get("alternativa") and s["alternativa"] != s["name"] else "")
1088 + (f"# DE DÓNDE SALE EL BINARIO: {origen_binario(s)[0]}\n"
1089 f"# {origen_binario(s)[1]}\n"
1090 f"#\n"
1091 f"# CÓMO CORRE HOY EN EL ORIGEN (leído de /proc, es lo que hay que reproducir):\n"
1092 f"# cmdline: {s.get('cmdline') or '(no se pudo leer)'}\n"
1093 + (f"# cwd : {s['cwd']}\n" if s.get("cwd") else "")
1094 + (f"# puertos: {', '.join(str(p) for p in s['ports'])}\n" if s.get("ports") else "")
1095 + f"#\n# 1) conseguir el binario según lo de arriba\n"
1096 f"# 2) DECLARARLO en el init del destino con ESA línea de comando\n"
1097 f"# (arje: una tarjeta en /ente/seed.card.json · systemd: una unit)\n"
1098 f"# 3) arrancarlo"),
1099 verifica=f"{ssh} 'ps -eo ppid,comm | awk \"\\$1==1\" | grep -w {shlex.quote(s['name'])}'"
1100 + (f" && {ssh} 'ss -lnt | grep -E \":({'|'.join(str(p) for p in s.get('ports', []))}) \"'"
1101 if s.get("ports") else ""),
1102 nota="⚠ ESTE SERVICIO CORRE SIN ESTAR DECLARADO en el origen: copiar el binario no lo "
1103 "levanta al arrancar. Hay que escribir su declaración — es el trabajo real de la "
1104 "mudanza, y el que se olvida (el caddy de la caja nueva murió así)." if nd else
1105 "Declarado en el origen: traducir su unidad al init del destino.")
1106
1107 # ── 3. dominios ─────────────────────────────────────────────────────────────────────────────
1108 for d in doms_muda:
1109 paso("dominio", f"apuntar {d['domain']} a la máquina nueva",
1110 f"# cambiar el registro A de {d['domain']}: {d.get('dns','?')} → <IP NUEVA>",
1111 verifica=f"getent ahostsv4 {d['domain']} | head -1 && "
1112 f"curl -sk -o /dev/null -w '%{{http_code}}' https://{d['domain']}/",
1113 nota="La mudanza NO está hecha hasta que el dominio contesta 2xx contra la IP NUEVA. "
1114 "Comprobarlo desde AFUERA, nunca desde la máquina: pedirle a un servidor sus "
1115 "propios dominios puede disparar su fail2ban (pasó).")
1116
1117 # ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
1118 if muere:
1119 # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
1120 # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
1121 # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
1122 # supiera dependía de que alguien se acordara.
1123 det = []
1124 for t, n, e in muere:
1125 tam = f" ({e.get('size')})" if e.get("size") else ""
1126 det.append(f"# {t:9} {n}{tam}")
1127
1128 # ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
1129 # cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
1130 # `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
1131 # supiera dependía de que alguien se acordara.
1132 #
1133 # Se lista UN RENGLÓN POR DIRECTORIO, no uno por dominio: siete dominios `*.gioser.net` que
1134 # mueren reclaman el mismo `gioser.bak`, y repetirlo siete veces convierte el aviso en ruido.
1135 # Y se dice «podría», nunca «es»: el directorio se llama `terapeuta` y el dominio
1136 # `terapeuta.ec`. Emparejar «casi» acierta casi siempre, y el resto de las veces manda a
1137 # borrar lo que no era.
1138 if contenido:
1139 doms_muertos = [n for t, n, _ in muere if t == "dominio"]
1140 det.append("#")
1141 det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.")
1142 # ⚠ Un directorio que la CONFIG referencia no es un huérfano, diga lo que diga su
1143 # nombre. Sin esto el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda»
1144 # estando servido en la línea 174 del Caddyfile — y ese aviso, aplicado, tira el sitio.
1145 servidos = {}
1146 for st in censo.get("sitio", []):
1147 if st.get("raiz"):
1148 servidos.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
1149 for c in contenido:
1150 ruta = c["path"].rstrip("/")
1151 quien = [d for d in doms_muertos if c in candidatos(d, [c])]
1152 if ruta in servidos:
1153 marca = ("⚠ LO SIRVE la config para: " +
1154 ", ".join(" ".join(servidos[ruta]).split()[:3]) +
1155 " — no es huérfano; moverlo tira el sitio")
1156 elif quien:
1157 marca = ("podría ser de: " + ", ".join(quien[:4]) +
1158 (f" (+{len(quien)-4})" if len(quien) > 4 else ""))
1159 else:
1160 marca = "no corresponde a NINGÚN dominio que muera ni lo sirve la config: miralo"
1161 det.append(f"# {ruta:34} {c['size']:>6} ← {marca}")
1162
1163 paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
1164 "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
1165 + "\n".join(det),
1166 verifica="revisión humana: leer la lista completa antes de destruir el origen",
1167 verifica_tipo="humano",
1168 nota="El día que se apague la máquina vieja esto no se puede recuperar. Que esté "
1169 "escrito ES el paso.")
1170 return P
1171
1172
1173def emitir(censo, P, target):
1174 L = ["# PLAN DE MUDANZA — generado por scripts/mudanza/planear.py (SDD 29 etapa 2).",
1175 "#",
1176 "# Cada paso lleva su COMANDO LITERAL: este fichero se puede ejecutar a mano, línea por",
1177 "# línea, sin la herramienta y sin el repo. Ésa es la idea — es el registro exportable.",
1178 'schema = "takana-plan/1"',
1179 f'fecha = "{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}"',
1180 f'origen = "{censo.get("maquina", {}).get("hostname", "?")}"',
1181 f'destino = "{target}"',
1182 ""]
1183 for i, p in enumerate(P, 1):
1184 L.append(f"[[paso]]")
1185 L.append(f'n = {i}')
1186 L.append(f'clase = "{p["clase"]}"')
1187 L.append(f'titulo = """{p["titulo"]}"""')
1188 L.append("cmd = " + bloque_toml(p["cmd"]))
1189 if p["verifica"]:
1190 L.append("verifica = " + bloque_toml(p["verifica"]))
1191 L.append(f'verifica_tipo = "{p["verifica_tipo"]}"')
1192 if p["nota"]:
1193 L.append("nota = " + bloque_toml(p["nota"]))
1194 L.append("")
1195 return "\n".join(L) + "\n"
1196
1197
1198def main():
1199 ap = argparse.ArgumentParser(description="Convierte un censo decidido en un plan ejecutable.")
1200 ap.add_argument("--censo", required=True)
1201 ap.add_argument("--target", required=True, help="usuario@host destino")
1202 ap.add_argument("--key", help="clave SSH para el destino")
1203 ap.add_argument("--decide", action="store_true",
1204 help="mostrar TODO con su recomendación y su motivo, y elegir")
1205 ap.add_argument("--revisar", action="store_true",
1206 help="sólo mostrar la revisión con recomendaciones, sin preguntar")
1207 ap.add_argument("--out", help="fichero del plan (default: stdout)")
1208 ap.add_argument("--decidir", metavar="FICHERO",
1209 help="decidir EN LOTE desde un fichero `<ruta-o-nombre> <decision>` por línea. "
1210 "Para terminales donde no se puede pulsar una tecla por entrada")
1211 ap.add_argument("--censo-out", help="dónde escribir el censo con las decisiones tomadas "
1212 "(default: sobre el mismo censo, con copia `.bak`)")
1213 ap.add_argument("--respaldo", help="corral donde va lo marcado `respalda` (host:ruta o ruta "
1214 "local). Default: el Storage Box de respaldo-storagebox.sh")
1215 ap.add_argument("--respaldo-port", help="puerto SSH del corral (el Storage Box usa 23)")
1216 a = ap.parse_args()
1217
1218 with open(a.censo, "rb") as f:
1219 censo = tomllib.load(f)
1220 if a.revisar:
1221 revisar(censo, interactivo=False)
1222 return
1223 if a.decidir:
1224 n = decidir_desde_fichero(censo, a.decidir)
1225 destino_censo = a.censo_out or a.censo
1226 guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
1227 print(f" {n} decisión(es) aplicadas → {destino_censo}")
1228 if a.decide:
1229 censo = revisar(censo, interactivo=True)
1230 # ── LAS DECISIONES SE GUARDAN, O NO EXISTIERON ──────────────────────────────────────────
1231 # Antes `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal
1232 # y las 169 elecciones se perdían. El censo ES la interfaz (SDD 29 §3), así que volver a
1233 # escribirlo no es comodidad, es lo que hace que decidir a medias sea posible — y decidir
1234 # 169 cosas SIEMPRE se hace a medias.
1235 destino_censo = a.censo_out or a.censo
1236 guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
1237 print(f" decisiones guardadas → {destino_censo}")
1238 validar(censo)
1239
1240 resp_dst, resp_port = (a.respaldo, a.respaldo_port)
1241 if not resp_dst:
1242 resp_dst, resp_port = destino_respaldo_default()
1243 P = pasos(censo, a.target, a.key, resp_dst, resp_port)
1244 por_clase = {}
1245 for p in P:
1246 por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1
1247 print(f"\n══ PLAN: {censo.get('maquina',{}).get('hostname','?')} → {a.target} ══")
1248 for c, n in por_clase.items():
1249 print(f" {c:10} {n:3} paso(s)")
1250 txt = emitir(censo, P, a.target)
1251
1252 # ── el plan tiene que VOLVER A LEERSE, y eso se comprueba, no se supone ──────────────────────
1253 # No es celo: emitir TOML inválido ya pasó. Un comando con una barra de continuación de línea
1254 # rompía la cadena y el fichero quedaba ilegible — y el error no aparecía al generarlo sino
1255 # DESPUÉS, cuando alguien intentaba ejecutarlo, que es el peor momento posible. Escribir y
1256 # releer cuesta milisegundos y convierte un fallo diferido en uno inmediato.
1257 try:
1258 tomllib.loads(txt)
1259 except tomllib.TOMLDecodeError as e:
1260 sys.exit(f"✗ el plan generado NO es TOML válido: {e}\n"
1261 f" No se escribe nada. Un plan que no vuelve a parsear no se puede ejecutar ni\n"
1262 f" exportar, que son las dos únicas cosas para las que existe.")
1263
1264 if a.out:
1265 open(a.out, "w").write(txt)
1266 print(f"\n plan → {a.out} ({len(P)} pasos, con su comando literal cada uno)")
1267 else:
1268 print(txt)
1269
1270
1271if __name__ == "__main__":
1272 main()