> 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: el tercer camino — mudar, RESPALDAR o abandonar, cosa por cosa y con su descripción
Pedido del usuario: «una lista de cada cosa con una corta descripción y la opción de yo elegir mudar,
mover a un directorio para respaldar ese directorio, o abandonarlo». Eran tres huecos distintos.
1. «CADA COSA». El censo medía RAÍCES: `/home` era UNA entrada de 25 G y UNA decisión, con repos,
SDKs, 4,3 G de caché y trabajo irrepetible adentro. Ahora emite un renglón por cosa —169 en
gioser contra 5—, un nivel hacia adentro de cada raíz y DOS en `/home`, porque su primer nivel
son usuarios y la decisión no es por usuario. `--solo-totales` conserva la vista vieja, que es la
que dimensiona la mudanza; las dos viajan en el censo (`datos` y `datos_raiz`).
⚠ El primer intento mandaba las ~110 sondas en UNA llamada, se pasaba del timeout y devolvía
lista VACÍA: «no hay datos» en vez de «no pude medirlos». Va en tandas, y una tanda que falla se
nombra.
2. «UNA CORTA DESCRIPCIÓN», y son hechos: lo sirve el servidor web · lo usa un proceso vivo · es
repo git y a dónde apunta · tiene cambios sin commitear · adentro hay node_modules/target/venv ·
lo más nuevo que hay dentro. «Sin señales» también es un hecho, y es el que dice dónde mirar.
3. «ELEGIR ENTRE TRES»: DECISIONES pasa de (muda, muere) a (muda, RESPALDA, muere). Faltaba el
camino que se usa de verdad —no lo quiero corriendo allá, tampoco lo quiero perder—; con dos
opciones, todo lo dudoso se marcaba `muda` y la mudanza engordaba.
La regla de fondo no cambió (qué datos VALEN no lo dice la máquina), pero cuatro hechos sí los
sabe: servido/usado ⇒ muda · caché ⇒ muere · repo limpio con remoto ⇒ muere · repo sin remoto o
sucio ⇒ respalda. Y el cruce que evita el peor error: un remoto que apunta a ESTA MISMA máquina
no es un respaldo, es el mismo disco (medido: /mnt/vvv/humanoid → ssh://git@127.0.0.1:2345).
4. LAS DECISIONES SE GUARDAN. `--decide` las usaba sólo para el plan de esa corrida: cerrabas la
terminal y se perdían las 169. Ahora reescribe el censo (temporal+fsync+rename, `.bak`, y releído
antes de pisar nada; aborta si perdió alguna). Para eso hubo que hacer el emisor IDEMPOTENTE:
escribía `decision = ""` fijo, así que re-emitir un censo decidido duplicaba la clave y el TOML
dejaba de parsear — la herramienta no podía reescribir su propio fichero.
5. EL PASO `respaldo`: corral por defecto leído de respaldo-storagebox.sh (no una segunda dirección
a mano), nombre aplanado, y NO se instala en el destino. Sin corral, aborta: un rsync a ninguna
parte devuelve 0 y se lee como un respaldo. Probarlo con rotura a propósito Y con el control que
tiene que pasar destapó dos bugs: `--mkpath` es obligatorio (el primer respaldo siempre estrena
un nivel) y un FICHERO no lleva barra final — `rsync fichero/ dst/` es un error, y el paso de
`datos` tenía el mismo defecto latente. La verificación es rsync en seco contra el destino real,
no `du` ni `find`: el Storage Box no tiene find, no acepta tuberías, y su `du` comprime.
6. De paso: `aplicar.py --dry-run` decía «✅ todos los pasos ejecutados y verificados» sin haber
ejecutado nada. Ahora dice EN SECO: N mostrados, NINGUNO ejecutado y NINGUNO verificado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 68c4e329..e8b64254 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -79,6 +79,109 @@ El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las
3. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió.
4. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
+### 3 bis · El tercer camino: **mudar · respaldar · abandonar** *(pedido del usuario, 2026-09-16)*
+
+> «la herramienta debería mostrarme una lista de cada cosa con una corta descripción y la opción de
+> yo elegir mudar, mover a un directorio para respaldar ese directorio, o abandonarlo»
+
+Las tres partes de esa frase eran tres huecos distintos, y los tres se cerraron.
+
+#### 1. «cada cosa» — el censo medía RAÍCES, no cosas
+
+`/home` era **una** entrada de 25 G y **una** decisión. Adentro hay cosas de dueños distintos: repos
+con remoto, SDKs que se re-bajan, 4,3 G de caché, y trabajo que no está en ningún otro lado. Con una
+sola decisión sólo se puede acertar a lo bruto — mudar 25 G para salvar 200 M, o abandonar 25 G y
+perder ese 200 M. **La granularidad de la lista es la granularidad de lo que se puede decidir.**
+
+`censar.py` ahora emite un renglón por cosa (169 en gioser contra 5), un nivel hacia adentro de cada
+raíz y **dos** en `/home`, porque su primer nivel son usuarios y la decisión no es por usuario.
+Queda `--solo-totales` para la vista vieja, que es la que dimensiona la mudanza y la que mira el
+preflight: las dos vistas viajan en el censo, `datos` y `datos_raiz`.
+
+⚠ Y una lección repetida: el primer intento mandaba las ~110 sondas en **una** llamada, se pasaba
+del timeout, y como el parser no encontraba ninguna línea devolvía **lista vacía** — «no hay datos»
+en vez de «no pude medirlos». Va en tandas de 12 y **una tanda que falla se nombra**.
+
+#### 2. «una corta descripción» — hechos, nunca un juicio
+
+De cada cosa se reporta lo que se puede comprobar, lo más decisivo primero:
+
+```
+/home/sergio/gioser-web 428M LO SIRVE gioser.net, www.gioser.net · repo git → gitea@git.gioser.net:…
+/home/sergio/android-sdk 1019M lo usa adb · lo más nuevo: 2026-06-12
+/home/sergio/accordion 37M repo git → github.com/GotJimmy/accordion · CON CAMBIOS SIN COMMITEAR
+/home/sergio/.cache 4.3G lo más nuevo: 2026-09-16
+/home/sergio/humanoid 200M sin señales: ni git, ni servido, ni usado por nada vivo
+```
+
+Nada de «parece un proyecto viejo». Es repo o no lo es; lo sirve la config del servidor web o no; lo
+usa un proceso vivo o no; adentro hay `node_modules`/`target`/`venv` o no. **«sin señales» también
+es un hecho** — y es el que dice dónde hay que mirar a mano.
+
+#### 3. «yo elegir» entre tres — y qué recomienda la máquina
+
+`DECISIONES` pasa de `("muda","muere")` a `("muda","respalda","muere")`. El camino que faltaba es el
+que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero perder*. Con
+dos opciones, todo lo dudoso se marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que
+nadie iba a usar.
+
+La regla de fondo **no cambió**: qué datos VALEN no se deduce de la máquina, y la mayoría sale sin
+recomendación a propósito. Pero hay cuatro cosas que la máquina sí sabe, y callarlas obliga a mirar
+169 entradas a ojo:
+
+| hecho | recomienda | por qué |
+|---|---|---|
+| lo sirve el servidor web · lo usa un proceso vivo | `muda` | si no viaja, ese sitio o ese servicio deja de existir |
+| es caché o estado transitorio (`.cache`, `.gradle`, `node_modules`…) | `muere` | el destino lo vuelve a crear solo |
+| repo git **limpio** con remoto | `muere` | se vuelve a clonar |
+| repo **sin** remoto, o **con cambios sin commitear** | `respalda` | esa parte existe únicamente en este disco |
+
+⚠ **Y el cruce que evita el peor error**: un remoto que apunta a ESTA MISMA máquina no es un
+respaldo, es el mismo disco. Sin eso, «tiene remoto ⇒ se vuelve a clonar» mandaría a abandonar un
+repo cuyo único ejemplar se borra con el servidor. Medido en gioser: el remoto de
+`/mnt/vvv/humanoid` es `ssh://git@127.0.0.1:2345/…`, el gitea de la propia caja.
+
+#### 4. Y las decisiones ahora se GUARDAN
+
+`--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal y las 169
+elecciones se perdían. El censo **es** la interfaz (§3), así que ahora se vuelve a escribir con las
+decisiones puestas —temporal + `fsync` + `rename`, copia `.bak`, y **releído antes de pisar nada**—
+y se aborta si el fichero regenerado perdió alguna. Decidir 169 cosas siempre se hace a medias; que
+sobreviva a medias es parte del diseño, no una comodidad.
+
+Para que eso fuera posible hubo que hacer el emisor del censo **idempotente**: escribía
+`decision = ""` fijo al final de cada entrada, así que re-emitir un censo decidido producía la clave
+dos veces y el TOML dejaba de parsear. O sea que la herramienta no podía volver a escribir su propio
+fichero.
+
+#### 5. El paso `respaldo`, y los dos bugs que encontró el guardián de dos sentidos
+
+Lo marcado `respalda` va a un **corral** —por defecto el Storage Box, leído de
+`respaldo-storagebox.sh` para que no haya una segunda dirección escrita a mano— con el nombre
+aplanado (`/home/sergio/humanoid` → `home-sergio-humanoid`), y **no se instala en el destino**: la
+caja nueva no se entera de que existe. Sin corral configurado el plan **aborta**: un `rsync` a
+ninguna parte devuelve 0 y se lee igual que un respaldo.
+
+Probar el paso emitido con rotura a propósito **y** con el control que tiene que pasar destapó dos
+cosas que la lectura no veía:
+
+1. **`--mkpath` no es opcional**: sin él rsync muere con `mkdir … failed` en cuanto el corral tiene
+ un nivel que no existe — y el primer respaldo siempre lo tiene.
+2. **Un FICHERO no lleva barra final.** Con el censo detallado entran ficheros sueltos
+ (`mapas.tgz`, `gu.png`, un `.docx`), y `rsync fichero/ destino/` no es «copiá el fichero»: es un
+ error. El censo ya dice cuál es cuál; el paso de `datos` tenía el mismo defecto latente.
+
+La verificación no usa `du` ni `find`, sino **rsync en seco contra el destino real**: el corral
+puede ser un Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías, y cuyo `du`
+además **comprime** (1,3 G subidos se ven como 576 M — un número que parece copia truncada y no lo
+es). Lo que se cuenta son los ficheros que a rsync todavía le faltarían.
+
+#### 6. De paso: el aplicador en seco decía «✅ todos los pasos ejecutados y verificados»
+
+Con `--dry-run` no se ejecuta nada, y el resumen de siempre contaba cero ejecutados igual que cero
+fallidos. Ahora dice **«EN SECO: N pasos mostrados, NINGUNO ejecutado y NINGUNO verificado»**. Es la
+misma regla que el resto del aplicador: *un paso que no se ejecutó no se marca como hecho*.
+
### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
diff --git a/scripts/mudanza/aplicar.py b/scripts/mudanza/aplicar.py
index 8d3eca57..9d3435d9 100755
--- a/scripts/mudanza/aplicar.py
+++ b/scripts/mudanza/aplicar.py
@@ -96,7 +96,7 @@ def main():
print(f"\n══ APLICANDO {plan.get('origen','?')} → {plan.get('destino','?')} ══")
print(f" {len(pasos)} paso(s) · estado en {est.path}\n")
- hechos = fallidos = sospechosos = manuales = saltados = 0
+ hechos = fallidos = sospechosos = manuales = saltados = mostrados = 0
for p in pasos:
n, clase, titulo = p["n"], p["clase"], p["titulo"].strip()
if a.only and clase != a.only:
@@ -120,6 +120,7 @@ def main():
continue
print(f" ▶ {n:3} [{clase}] {titulo}")
+ mostrados += 1
if a.dry_run:
for l in cmd.splitlines():
print(f" $ {l}")
@@ -163,6 +164,13 @@ def main():
print(" y dejó 1367 artefactos vacíos. NO se marca como hecho.")
print(f"\n── resumen ──")
+ if a.dry_run:
+ # ⚠ En seco NO SE EJECUTÓ NADA, así que el resumen de siempre —«✅ todos los pasos
+ # ejecutados y verificados»— sería exactamente la mentira que este aplicador existe para no
+ # decir: cero pasos corridos se contaba igual que cero pasos fallidos.
+ print(f" EN SECO: {mostrados} paso(s) mostrados, NINGUNO ejecutado y NINGUNO verificado.")
+ print(" Quitá --dry-run para correrlo de verdad.")
+ return
print(f" ok {hechos} · ya estaban {saltados} · MANUALES pendientes {manuales} · "
f"sospechosos {sospechosos} · fallidos {fallidos}")
if manuales or sospechosos or fallidos:
diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py
index a8e749d0..ddb5f84c 100755
--- a/scripts/mudanza/censar.py
+++ b/scripts/mudanza/censar.py
@@ -523,6 +523,168 @@ def datos(t, raices):
return res
+def datos_detallados(t, raices, sitios=(), servicios=()):
+ """Un renglón POR COSA, con los hechos que hacen falta para decidir qué hacer con ella.
+
+ ── POR QUÉ NO ALCANZA CON LA RAÍZ (pedido del usuario, 2026-09-16) ─────────────────────────
+ `datos()` mide raíces: `/home` = 25 G, una línea, una decisión. Pero adentro hay 40 cosas de
+ dueños distintos —repos con remoto, SDKs que se re-bajan, caché, y trabajo que no está en
+ ningún lado— y con UNA decisión para todas sólo se puede acertar a lo bruto: mudar 25 G para
+ salvar 200 M, o abandonar 25 G y perder ese 200 M. La granularidad de la lista ES la
+ granularidad de lo que se puede decidir.
+
+ ── Y LA DESCRIPCIÓN SON HECHOS, NO UN JUICIO ──────────────────────────────────────────────
+ De cada entrada se reportan cosas COMPROBADAS: si es un repo git y si tiene remoto (⇒ se vuelve
+ a clonar), la fecha de lo más nuevo que hay dentro, si adentro hay directorios que se regeneran
+ (`node_modules`, `target`, `venv`), si la config del servidor web lo SIRVE y si un proceso vivo
+ lo usa. Nada de «parece un proyecto viejo». El que decide es el humano; esto le pone delante lo
+ que se puede medir, que es justo lo que no tiene a mano cuando mira una lista de nombres.
+
+ Todo en UNA llamada por raíz (un script generado), no una por directorio: son ~110 entradas.
+ """
+ # Un nivel; `/home` va a DOS porque su primer nivel son usuarios y la decisión no es por usuario.
+ objetivos = []
+ for r in raices:
+ out, ok = t.run(f"ls -A {shlex.quote(r)} 2>/dev/null")
+ if not ok or not out.strip():
+ continue
+ for n in out.split():
+ hijo = os.path.join(r, n)
+ if r == "/home":
+ sub, ok2 = t.run(f"ls -A {shlex.quote(hijo)} 2>/dev/null")
+ if ok2 and sub.strip():
+ objetivos += [(os.path.join(hijo, x), r) for x in sub.split()]
+ continue
+ objetivos.append((hijo, r))
+
+ # ── EN TANDAS, Y UN FALLO SE DICE ──────────────────────────────────────────────────────────
+ # `du -sh` sobre 110 directorios (25 G de `/home` entre ellos) no entra en el timeout de una
+ # sola llamada: la primera versión mandaba el programa entero, se pasaba de tiempo, y como el
+ # parser no encontraba ni una línea `P` devolvía **lista vacía** — o sea «no hay datos» en vez
+ # de «no pude medirlos». El mismo modo de fallo que el `-e` del §fuera_de_git, otra vez.
+ prog_de = {}
+ for ruta, _ in objetivos:
+ q = shlex.quote(ruta)
+ prog_de[ruta] = (
+ f'p={q}; '
+ 'printf "P\t%s\n" "$p"; '
+ 'sz=$(timeout 60 du -sh "$p" 2>/dev/null | cut -f1); printf "S\t%s\n" "${sz:-?}"; '
+ 'szc=$(timeout 60 du -sh --count-links "$p" 2>/dev/null | cut -f1); '
+ 'printf "C\t%s\n" "${szc:-?}"; '
+ '[ -d "$p" ] && printf "T\tdir\n" || printf "T\tfichero\n"; '
+ 'if [ -d "$p/.git" ]; then '
+ ' printf "G\t%s\n" "$(git -C "$p" log -1 --format=%cs 2>/dev/null)"; '
+ ' printf "R\t%s\n" "$(git -C "$p" remote get-url origin 2>/dev/null)"; '
+ ' git -C "$p" status --porcelain 2>/dev/null | head -1 | '
+ ' while read -r _l; do printf "D\tsucio\n"; done; '
+ 'fi; '
+ 'for g in node_modules target venv .venv dist build .cache __pycache__; do '
+ ' [ -d "$p/$g" ] && printf "X\t%s\n" "$g"; done; '
+ 'printf "F\t%s\n" "$(timeout 30 find "$p" -maxdepth 3 -type f -printf "%TY-%Tm-%Td\n" '
+ ' 2>/dev/null | sort -r | head -1)"; '
+ 'true'
+ )
+ TANDA = 12
+ rutas = [r for r, _ in objetivos]
+ salida, fallidas = [], []
+ for i in range(0, len(rutas), TANDA):
+ lote = rutas[i:i + TANDA]
+ out, ok = t.run("set -u\n" + "\n".join(prog_de[r] for r in lote))
+ if not ok and "P\t" not in out:
+ fallidas += lote
+ continue
+ salida.append(out)
+ if fallidas:
+ print(f" ⚠ {len(fallidas)} ruta(s) no se pudieron MEDIR (la sonda falló o tardó de más): "
+ f"{', '.join(fallidas[:3])}{' …' if len(fallidas) > 3 else ''}", file=sys.stderr)
+ print(" No es lo mismo que «no hay nada ahí»: no entran al censo y hay que mirarlas.",
+ file=sys.stderr)
+
+ raiz_de = dict(objetivos)
+ por_ruta, actual = {}, None
+ for linea in "\n".join(salida).splitlines():
+ c = linea.split("\t")
+ if len(c) < 2:
+ continue
+ k, v = c[0], c[1]
+ if k == "P":
+ actual = {"path": v, "size": "?", "size_copy": "?", "tipo": "dir", "git_fecha": "",
+ "git_remoto": "", "git_sucio": False, "regenerables": [], "reciente": "",
+ "raiz": raiz_de.get(v, "")}
+ por_ruta[v] = actual
+ elif actual is None:
+ continue
+ elif k == "S": actual["size"] = v or "?"
+ elif k == "C": actual["size_copy"] = v or "?"
+ elif k == "T": actual["tipo"] = v
+ elif k == "G": actual["git_fecha"] = v
+ elif k == "R": actual["git_remoto"] = v
+ elif k == "D": actual["git_sucio"] = True
+ elif k == "X": actual["regenerables"].append(v)
+ elif k == "F": actual["reciente"] = v
+
+ # ── los cruces, en Python: son con lo que YA censamos, no con la máquina ────────────────────
+ raices_servidas = {}
+ for st in sitios:
+ if st.get("raiz"):
+ raices_servidas.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
+
+ res = []
+ for ruta, _ in objetivos:
+ e = por_ruta.get(ruta)
+ if not e:
+ continue
+ # ¿lo sirve la config del servidor web? (la ruta servida puede estar POR DEBAJO de ésta)
+ servido = []
+ for rs, doms in raices_servidas.items():
+ if rs == ruta or rs.startswith(ruta.rstrip("/") + "/"):
+ servido += " ".join(doms).split()
+ e["servido_por"] = sorted(set(servido))[:4]
+ # ¿lo usa un proceso VIVO? (su binario, su cwd o un argumento apuntan adentro)
+ usan = []
+ for s in servicios:
+ if s.get("class") == "declarado-muerto":
+ continue
+ texto = " ".join([s.get("cmdline", ""), s.get("cwd", ""), s.get("exec", "")])
+ if ruta in texto:
+ usan.append(s["name"])
+ e["usado_por"] = sorted(set(usan))[:4]
+ e["descripcion"] = _describir_dato(e)
+ res.append(e)
+ res.sort(key=lambda x: _bytes_aprox(x["size"]), reverse=True)
+ return res
+
+
+def _bytes_aprox(s):
+ """`du -sh` da «1.2G» / «288M». Para ORDENAR alcanza con una conversión aproximada; que no sea
+ exacta no cambia el orden de la lista, y pedir bytes exactos costaría otro `du` sobre 25 G."""
+ try:
+ u = s[-1]
+ n = float(s[:-1].replace(",", "."))
+ return n * {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}.get(u, 1)
+ except Exception:
+ return 0.0
+
+
+def _describir_dato(e):
+ """La línea corta que acompaña a cada cosa en la lista de decisiones. HECHOS separados por `·`,
+ el más decisivo primero. Sin adjetivos: «tiene remoto» se puede discutir, «parece obsoleto» no."""
+ t = []
+ if e["servido_por"]:
+ t.append("LO SIRVE " + ", ".join(e["servido_por"]))
+ if e["usado_por"]:
+ t.append("lo usa " + ", ".join(e["usado_por"]))
+ if e["git_remoto"]:
+ t.append(f"repo git → {e['git_remoto']}" + (" · CON CAMBIOS SIN COMMITEAR" if e["git_sucio"] else ""))
+ elif e["git_fecha"]:
+ t.append(f"repo git SIN remoto (último commit {e['git_fecha']})")
+ if e["regenerables"]:
+ t.append("adentro: " + "/".join(e["regenerables"]) + " (se regenera)")
+ if e["reciente"]:
+ t.append(f"lo más nuevo: {e['reciente']}")
+ return " · ".join(t) if t else "sin señales: ni git, ni servido, ni usado por nada vivo"
+
+
def montajes(t):
out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
res = []
@@ -803,6 +965,21 @@ def toml_escape(s):
return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
+def _campo_decision(e, con_destino=False):
+ """Las líneas `destino`/`decision` de una entrada, respetando lo que YA tenga.
+
+ ⚠ Sin esto, re-emitir un censo DECIDIDO escribe la clave dos veces y el TOML deja de parsear —
+ o sea que la herramienta no podía volver a escribir su propio fichero, que es justo lo que hace
+ falta para que las decisiones tomadas en `--decide` sobrevivan a la corrida."""
+ L = []
+ if con_destino:
+ d = e.get("destino", "")
+ L.append(f'destino = {toml_escape(d)} # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
+ dec = e.get("decision", "")
+ L.append(f'decision = {toml_escape(dec)} # muda | respalda | muere | (vacío = sin decidir)')
+ return L
+
+
def emitir_toml(c):
L = []
L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
@@ -852,7 +1029,7 @@ def emitir_toml(c):
if s.get('exec_motivo'):
L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
- L.append('decision = "" # muda | muere | (vacío = sin decidir)')
+ L += _campo_decision(s)
L.append("")
for x in c.get("repos", []):
L.append("[[repo]]")
@@ -872,20 +1049,28 @@ def emitir_toml(c):
for d in c["dominios"]:
L.append("[[dominio]]")
for k, v in d.items():
+ if k == "decision":
+ continue
L.append(f"{k} = {toml_escape(v)}")
- L.append('decision = "" # muda | muere | (vacío = sin decidir)')
+ L += _campo_decision(d)
L.append("")
for d in c["datos"]:
L.append("[[datos]]")
for k, v in d.items():
- L.append(f"{k} = {toml_escape(v)}")
+ if k in ("decision", "destino"):
+ continue
+ # `regenerables`/`servido_por`/`usado_por` son listas y `git_sucio` un booleano: pasarlos
+ # por `toml_escape` los dejaría como la cadena "[\'x\']", que parsea y MIENTE.
+ if isinstance(v, (list, bool)):
+ L.append(f"{k} = {json.dumps(v)}")
+ else:
+ L.append(f"{k} = {toml_escape(v)}")
# ⚠ `destino` existe porque la ruta de origen NO tiene por qué caber en la misma ruta del
# destino. Medido contra la caja takana: `/` tiene 3,5 G libres y los datos de gioser son
# 33,6 G — el sitio está en `/work` (66 G) y en `/store` (19 G). Sin este campo el plan
# copiaba ruta→misma ruta y la mudanza no entraba, con el detalle de que el fallo aparece a
# mitad de un rsync de 22 G.
- L.append('destino = "" # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
- L.append('decision = "" # muda | muere | (vacío = sin decidir)')
+ L += _campo_decision(d, con_destino=True)
L.append("")
for f in c["fuera_de_git"]:
L.append("[[fuera_de_git]]")
@@ -897,8 +1082,7 @@ def emitir_toml(c):
if f.get("porque"):
L.append(f"porque = {toml_escape(f['porque'])}")
L.append(f"entries = {json.dumps(f['entries'])}")
- L.append('destino = "" # vacío = la MISMA ruta en el destino')
- L.append('decision = "" # muda | muere | (vacío = sin decidir)')
+ L += _campo_decision(f, con_destino=True)
L.append("")
return "\n".join(L) + "\n"
@@ -943,11 +1127,20 @@ def resumen(c):
f"ausentes (corré el censo como su dueño o con privilegio):")
for f in ciegos:
print(f" {f['path']}")
- if c["datos"]:
- print("\n── datos ──")
- for d in c["datos"]:
+ if c.get("datos_raiz"):
+ print("\n── datos, por raíz ──")
+ for d in c["datos_raiz"]:
extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
print(f" {d['path']:28} {d['size']:>7}{extra}")
+ if c["datos"] and c["datos"] is not c.get("datos_raiz"):
+ # ⚠ Se imprimen las 15 más grandes y SE DICE cuántas quedaron fuera. Cortar en silencio es
+ # lo que hace que una lista parezca completa: el fichero las trae todas, y quien decide
+ # tiene que saber que la pantalla no es el censo.
+ print(f"\n── datos, cosa por cosa ({len(c['datos'])} entradas; el TOML las trae todas) ──")
+ for d in c["datos"][:15]:
+ print(f" {d['path']:44} {d['size']:>6} {d.get('descripcion','')[:64]}")
+ if len(c["datos"]) > 15:
+ print(f" … y {len(c['datos']) - 15} más en el fichero")
print()
@@ -964,6 +1157,9 @@ def main():
help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
"contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
+ ap.add_argument("--solo-totales", action="store_true",
+ help="medir los datos POR RAÍZ y no cosa por cosa (más rápido, pero deja "
+ "25 G de `/home` en una sola decisión)")
ap.add_argument("--data-root", action="append", default=[],
help="raíz de datos a medir (repetible). Default: las habituales")
a = ap.parse_args()
@@ -1031,14 +1227,18 @@ def main():
time.sleep(0.5) # no parecer un escaneo ante el propio fail2ban del objetivo
doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
- dat = datos(t, raices)
+ # Dos vistas y no una: el TOTAL por raíz es lo que dimensiona la mudanza (y lo que mira el
+ # preflight), y el DETALLE por cosa es lo que se puede decidir. Dar sólo el total obliga a
+ # decidir 25 G de una; dar sólo el detalle pierde el número que dice si entra en el destino.
+ dat_raiz = datos(t, raices)
+ dat = datos_detallados(t, raices, sitios, svc) if not a.solo_totales else dat_raiz
# Después de `raices`, que es de dónde salen los repos a mirar. (Estaba antes y `raices` todavía
# no existía: habría reventado con NameError en la primera corrida con datos.)
repos = repos_git(t, raices + ["/mnt/vvv"], ident.get("ips", []), nombres)
censo = {
"fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
- "dominios": doms, "datos": dat, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
+ "dominios": doms, "datos": dat, "datos_raiz": dat_raiz, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
"fuera_de_git": fuera_de_git(t),
}
resumen(censo)
diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py
index fbfac008..85e410fc 100755
--- a/scripts/mudanza/planear.py
+++ b/scripts/mudanza/planear.py
@@ -36,6 +36,7 @@ import argparse, os, sys, shlex, time, tomllib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import formatos # noqa: E402
+import censar # noqa: E402
from censar import candidatos_de as candidatos # noqa: E402
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -66,7 +67,16 @@ def humano(mib):
# ── decisión ────────────────────────────────────────────────────────────────────────────────────
-DECISIONES = ("muda", "muere")
+# ── TRES CAMINOS, NO DOS (pedido del usuario, 2026-09-16) ───────────────────────────────────────
+# Con `muda`/`muere` una cosa que no va a la caja nueva sólo podía MORIR, así que todo lo dudoso se
+# marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. El tercer
+# camino es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero
+# perder*. `respalda` lo copia al corral de respaldo y NO lo instala en el destino.
+#
+# ⚠ `respalda` cuenta como «no se muda» para la recomendación derivada de los servicios: un binario
+# que queda archivado no puede arrancar en la caja nueva. Confundirlos daría un servicio marcado
+# `muda` cuyo ejecutable no va a existir allá — y eso se descubre al arrancarlo, no al decidir.
+DECISIONES = ("muda", "respalda", "muere")
def entradas(censo):
@@ -352,7 +362,7 @@ def origen_binario(e, repo=None):
return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
-def recomendar(tipo, e, datos_que_mueren=(), sitios=()):
+def recomendar(tipo, e, datos_que_mueren=(), sitios=(), locales=()):
"""Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
que aceptarla sea barato y equivocarse sea caro de no notar.
@@ -411,12 +421,52 @@ def recomendar(tipo, e, datos_que_mueren=(), sitios=()):
return "", ""
if tipo == "datos":
- # A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
- # eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
+ # ── LO QUE SIGUE SIN RECOMENDARSE, Y LO QUE SÍ ──────────────────────────────────────────
+ # La regla de fondo no cambió: **qué datos VALEN no se deduce de la máquina** — `terapeuta.ec`
+ # eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso.
+ # Pero hay tres hechos que sí decide la máquina, y callarlos obliga a mirar 169 entradas a
+ # ojo: que el servidor web lo SIRVA, que un proceso vivo lo USE, y que sea recuperable de
+ # otro lado (un repo limpio con remoto) o regenerable (una caché). Se recomienda SÓLO sobre
+ # esos hechos, con el hecho como motivo, y el resto sale en blanco a propósito.
+ if e.get("servido_por"):
+ return "muda", ("lo SIRVE " + ", ".join(e["servido_por"]) +
+ ": si no viaja, ese sitio deja de existir")
+ if e.get("usado_por"):
+ return "muda", ("lo usa un proceso vivo (" + ", ".join(e["usado_por"]) +
+ "): un servicio que se muda sin sus datos arranca vacío")
+ base = os.path.basename(e.get("path", ""))
+ if base in _CACHES or base in ("swap", "lost+found"):
+ return "muere", f"`{base}` es caché o estado transitorio: se regenera solo en el destino"
+ rem = e.get("git_remoto") or ""
+ # ⚠ UN REMOTO QUE APUNTA A ESTA MISMA MÁQUINA NO ES UN RESPALDO, ES EL MISMO DISCO.
+ # Sin esto, «tiene remoto ⇒ se vuelve a clonar» recomienda ABANDONAR un repo cuyo único
+ # ejemplar se borra con la máquina. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es
+ # `ssh://git@127.0.0.1:2345/…` — el gitea de acá. El censo ya lo sabe (`[[repo]] solo_aqui`)
+ # y lo que faltaba era cruzarlo.
+ if rem and any(x and x in rem for x in locales):
+ return "respalda", (f"su remoto ({rem}) es ESTA MISMA MÁQUINA: no es un respaldo, es el "
+ "mismo disco. Clonarlo después de borrarla no se puede")
+ if rem and not e.get("git_sucio"):
+ return "muere", (f"repo git LIMPIO con remoto ({rem}): se vuelve a clonar")
+ if rem and e.get("git_sucio"):
+ return "respalda", ("repo git con remoto pero CON CAMBIOS SIN COMMITEAR: lo commiteado "
+ "se vuelve a clonar, lo de encima sólo está acá")
+ if e.get("git_fecha") and not rem:
+ return "respalda", (f"repo git SIN remoto (último commit {e['git_fecha']}): su historia "
+ "existe únicamente en este disco")
return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
return "", ""
+# Nombres cuyo contenido se REGENERA. No es una lista de «cosas grandes»: es de cosas que el destino
+# vuelve a crear solo. Borrar por tamaño sería adivinar; por regenerabilidad es un hecho.
+_CACHES = {
+ ".cache", ".npm", ".npm-global", ".gradle", ".pub-cache", ".dartServer", ".cargo", ".rustup",
+ ".m2", ".gem", ".go", "go", ".yarn", ".bun", ".deno", ".venvs", ".local-cache", "node_modules",
+ "target", "__pycache__", ".mozilla-cache", ".thumbnails", ".ccache", ".sccache",
+}
+
+
def sugerir(tipo, e, datos_que_mueren=()):
return recomendar(tipo, e, datos_que_mueren, sitios)[0]
@@ -443,6 +493,12 @@ def revisar(censo, interactivo=True):
"""
todos = entradas(censo)
datos = [(t, n, e) for t, n, e in todos if t == "datos"]
+ # Con qué se compara un remoto para saber si apunta a esta misma máquina. Sale del censo, no de
+ # una lista cableada: las IPs y el hostname los midió `identidad()`.
+ maq = censo.get("maquina", {})
+ locales = {"127.0.0.1", "localhost", "0.0.0.0"} | set(maq.get("ips", []) or [])
+ if maq.get("hostname"):
+ locales.add(maq["hostname"])
resto = [(t, n, e) for t, n, e in todos if t != "datos"]
# ── 1. datos ────────────────────────────────────────────────────────────────────────────────
@@ -452,28 +508,41 @@ def revisar(censo, interactivo=True):
print(" la recomendación de los servicios, así que va primero.\n")
for t, n, e in datos:
marca = e.get("decision") or "—"
- print(f" {n:34} {_etiqueta(t,n,e):>22} decisión actual: {marca}")
+ rec, motivo = recomendar("datos", e, locales=locales)
+ e["_rec"], e["_motivo"] = rec, motivo
+ print(f" {n:46} {e.get('size','?'):>6} {marca if marca != '—' else (rec or '—'):>8}"
+ f" {e.get('descripcion', '')[:70]}")
if interactivo:
- print()
+ print("\n [m]uda = va a la caja nueva · [r]espalda = se copia al corral y NO se instala"
+ " · [x] muere con la máquina\n")
for t, n, e in datos:
if e.get("decision") in DECISIONES:
continue
- r = input(f" {n:34} [{_etiqueta(t,n,e)}] [m]uda/[x]muere > ").strip().lower()
- e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
-
- mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
+ rec = e.get("_rec", "")
+ print(f" {n} {e.get('size','?')}")
+ if e.get("descripcion"):
+ print(f" {e['descripcion'][:100]}")
+ if e.get("_motivo"):
+ print(f" ↳ sugerido: {rec} — {e['_motivo'][:90]}")
+ r = input(f" [m]uda/[r]espalda/[x]muere"
+ f"{'/Enter=' + rec if rec else ''} > ").strip().lower()
+ e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(r, "")
+
+ # ⚠ `respalda` entra acá junto a `muere`: las dos significan «su ruta NO va a existir en el
+ # destino», que es lo único que la derivación de servicios necesita saber.
+ mueren = [e["path"] for t, n, e in datos if e.get("decision") in ("muere", "respalda")]
# ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
- grupos = {"muda": [], "muere": [], "": []}
+ grupos = {"muda": [], "respalda": [], "muere": [], "": []}
for t, n, e in resto:
d, motivo = recomendar(t, e, mueren, censo.get("sitio", []))
e["_rec"], e["_motivo"] = d, motivo
grupos[d].append((t, n, e))
print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
- titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
- "": "SIN recomendación — hay que decidir"}
- for g in ("muda", "muere", ""):
+ titulos = {"muda": "se recomienda MUDAR", "respalda": "se recomienda RESPALDAR",
+ "muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"}
+ for g in ("muda", "respalda", "muere", ""):
if not grupos[g]:
continue
print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
@@ -515,8 +584,8 @@ def revisar(censo, interactivo=True):
continue
sug = f"[{rec}]" if rec else "[sin recomendación]"
resp = input(f" {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
- f" [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
- e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "")
+ f" [m]uda/[r]espalda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
+ e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(resp, "")
if e["decision"] == "muda" and t == "servicio":
lista, alt_rec, motivo = alternativas(n, _REPO)
if lista and len([x for x in lista if x["sellado"]]) > 1:
@@ -532,6 +601,53 @@ def revisar(censo, interactivo=True):
return censo
+def guardar_censo(censo, ruta, respaldar=True):
+ """Vuelve a escribir el censo con las decisiones puestas, usando el MISMO emisor que lo creó.
+
+ Dos detalles que no son de estilo: se descartan las claves internas (`_rec`, `_motivo`, que
+ `revisar()` cuelga de cada entrada y NO son parte del censo), y se escribe con temporal +
+ `rename` — si la herramienta muere a mitad, el fichero anterior sigue entero. Perder el censo a
+ medio decidir es perder el trabajo que más cuesta de toda la mudanza."""
+ limpio = {}
+ for k, v in censo.items():
+ if isinstance(v, list):
+ limpio[k] = [{kk: vv for kk, vv in e.items() if not kk.startswith("_")}
+ if isinstance(e, dict) else e for e in v]
+ else:
+ limpio[k] = v
+ # El emisor de `censar` habla en plural; el TOML, en singular. El puente va acá y no allá para
+ # no tocar el censo, que es quien define el formato.
+ puente = {
+ "fecha": limpio.get("fecha", ""), "identidad": limpio.get("maquina", {}),
+ "servicios": limpio.get("servicio", []), "huerfanos": limpio.get("huerfano", []),
+ "puertos": limpio.get("puerto", []), "dominios": limpio.get("dominio", []),
+ "datos": limpio.get("datos", []), "datos_raiz": limpio.get("datos_raiz", []),
+ "contenido": limpio.get("contenido", []), "sitios": limpio.get("sitio", []),
+ "repos": limpio.get("repo", []), "montajes": limpio.get("montaje", []),
+ "fuera_de_git": limpio.get("fuera_de_git", []),
+ }
+ texto = censar.emitir_toml(puente)
+ # Y se RELEE antes de pisar nada: el mismo guardián que el plan. Un censo que no vuelve a
+ # parsear deja la mudanza sin su fichero central y sin forma de saber cuándo se rompió.
+ try:
+ vuelto = tomllib.loads(texto)
+ except tomllib.TOMLDecodeError as e:
+ sys.exit(f"✗ el censo regenerado NO es TOML válido: {e}\n No se escribe nada.")
+ n_dec = sum(1 for _t, _n, x in entradas(vuelto) if x.get("decision"))
+ n_esp = sum(1 for _t, _n, x in entradas(censo) if x.get("decision"))
+ if n_dec != n_esp:
+ sys.exit(f"✗ el censo regenerado perdió decisiones ({n_esp} → {n_dec}). No se escribe nada.")
+ if respaldar and os.path.exists(ruta):
+ with open(ruta, encoding="utf-8") as fh:
+ open(ruta + ".bak", "w", encoding="utf-8").write(fh.read())
+ tmp = ruta + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as fh:
+ fh.write(texto)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, ruta)
+
+
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]
@@ -618,7 +734,28 @@ def incoherencias(censo):
return out
-def pasos(censo, target, key):
+def destino_respaldo_default():
+ """El corral de respaldo sale de `scripts/respaldo-storagebox.sh`, NO de una constante nueva acá.
+
+ Dos ficheros con la misma dirección escrita a mano divergen, y el que se queda viejo no falla:
+ respalda a un sitio que nadie mira. Se leen sus defaults (`SB_USER`/`SB_HOST`/`SB_PORT`), que es
+ donde el proyecto ya guarda lo que se guarda por si acaso."""
+ ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "respaldo-storagebox.sh")
+ v = {}
+ try:
+ with open(ruta, encoding="utf-8") as fh:
+ for linea in fh:
+ for k in ("SB_USER", "SB_HOST", "SB_PORT"):
+ if linea.startswith(f'{k}="${{{k}:-'):
+ v[k] = linea.split(":-", 1)[1].split("}", 1)[0]
+ except OSError:
+ return "", ""
+ if not (v.get("SB_USER") and v.get("SB_HOST")):
+ return "", ""
+ return f'{v["SB_USER"]}@{v["SB_HOST"]}:respaldo-mudanza/', v.get("SB_PORT", "22")
+
+
+def pasos(censo, target, key, respaldo=None, respaldo_port=None):
ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
if key:
ssh_opts = f"-i {key} " + ssh_opts
@@ -633,6 +770,7 @@ def pasos(censo, target, key):
"verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
+ datos_resp = [d for d in censo.get("datos", []) if d.get("decision") == "respalda"]
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"]
@@ -807,12 +945,21 @@ def pasos(censo, target, key):
for d in datos_muda:
p = d["path"]
dst = (d.get("destino") or "").strip() or p
+ # Ver la nota del paso de respaldo: con el censo detallado hay entradas que son FICHEROS, y
+ # a un fichero no se le pone barra (y su verificación no es `find -type f | wc -l` en un
+ # directorio, sino comparar el fichero mismo).
+ es_dir = d.get("tipo", "dir") != "fichero"
+ org, dst_r = (p + "/", dst + "/") if es_dir else (p, dst)
+ ver = ((f"src=$(find {shlex.quote(p)} -type f | wc -l); "
+ f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
+ f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]') if es_dir else
+ (f"src=$(stat -c%s {shlex.quote(p)}); "
+ f"dst=$({ssh} 'stat -c%s {shlex.quote(dst)}' 2>/dev/null || echo ausente); "
+ f'echo "bytes: origen=$src destino=$dst"; [ "$src" = "$dst" ]'))
paso("datos", f"copiar {p} → {dst} ({d.get('size','?')})",
f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
- f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(dst + '/')}",
- verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); "
- f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
- f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
+ f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(org)} {target}:{shlex.quote(dst_r)}",
+ verifica=ver,
nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
"Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
"FALLA si no coinciden. La versión anterior imprimía el número de directorios "
@@ -820,6 +967,51 @@ def pasos(censo, target, key):
"Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
"así se llenó un disco y quedaron 1367 artefactos vacíos.")
+ # ── 1 bis. lo que se RESPALDA ───────────────────────────────────────────────────────────────
+ # Ni se muda ni se pierde. Va al corral de respaldo y NO se instala en el destino: la caja nueva
+ # no se entera de que esto existe. Por eso es un paso propio y no un `datos` con otro destino —
+ # su verificación es distinta y su fallo significa otra cosa (no rompe el servidor nuevo, pero
+ # borra el original sin copia si nadie lo mira).
+ if datos_resp:
+ if not respaldo:
+ # Un corral vacío convertiría cada `respalda` en un `rsync` a ninguna parte que además
+ # devuelve 0. Abortar acá es la única forma de que no se lea como «respaldado».
+ sys.exit("✗ hay entradas marcadas `respalda` y no sé a dónde respaldarlas.\n"
+ " Pasá --respaldo <destino> (host:ruta o ruta local). No invento uno: un\n"
+ " respaldo a un sitio equivocado se ve igual que un respaldo.")
+ rsh = f"ssh -p {respaldo_port} {ssh_opts}" if respaldo_port else f"ssh {ssh_opts}"
+ remoto = ":" in respaldo.split("/", 1)[0]
+ for d in datos_resp:
+ ruta = d["path"]
+ # Aplanada: `/home/sergio/humanoid` → `home-sergio-humanoid`. El corral junta cosas de
+ # rutas distintas, y conservar el árbol haría que dos orígenes se pisaran en el mismo
+ # nombre. El nombre aplanado DICE de dónde salió, que es lo que hace falta para volver.
+ nombre = ruta.strip("/").replace("/", "-")
+ dst = respaldo.rstrip("/") + "/" + nombre
+ # ⚠ Un FICHERO no lleva barra final y su destino es el directorio que lo contiene.
+ # Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y
+ # `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál
+ # es cuál (`tipo`), así que no hay que adivinarlo.
+ es_dir = d.get("tipo", "dir") != "fichero"
+ org = ruta + "/" if es_dir else ruta
+ dst_r = dst + "/" if es_dir else dst
+ # ⚠ `--mkpath` NO es opcional, y lo cazó el guardián de dos sentidos: sin él rsync
+ # muere con `mkdir "/home/respaldo-mudanza/prueba-guardian" failed` en cuanto el corral
+ # tiene un nivel que todavía no existe — y el primer respaldo SIEMPRE lo tiene.
+ base = (f"rsync -aH --mkpath --partial --partial-dir=.rsync-partial --info=stats2 "
+ + (f"-e {shlex.quote(rsh)} " if remoto else "")
+ + f"{shlex.quote(org)} {shlex.quote(dst_r)}")
+ # La verificación se le pregunta a RSYNC, no a `du` ni a `find`: el corral puede ser un
+ # Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías (medido). Una
+ # corrida en seco compara tamaño y fecha CONTRA EL DESTINO REAL y dice qué falta.
+ ver = (f"pend=$({base.replace('rsync -aH ', 'rsync -aHn ').replace('--info=stats2', chr(39)+'--out-format=%n'+chr(39))}"
+ f" | grep -v '/$' | wc -l); echo \"pendientes en el corral: $pend\"; [ \"$pend\" -eq 0 ]")
+ paso("respaldo", f"respaldar {ruta} → {dst} ({d.get('size','?')})", base,
+ verifica=ver,
+ nota="NO se instala en el destino: esto es archivo, no mudanza. Y `du` en el "
+ "corral puede dar MENOS que el origen sin que falte nada (un Storage Box "
+ "comprime: 1,3 G subidos se veían como 576 M). Por eso verifica rsync.")
+
# ── 2. servicios ────────────────────────────────────────────────────────────────────────────
for s in svc_muda:
nd = s.get("class") == "no-declarado"
@@ -957,6 +1149,11 @@ def main():
ap.add_argument("--revisar", action="store_true",
help="sólo mostrar la revisión con recomendaciones, sin preguntar")
ap.add_argument("--out", help="fichero del plan (default: stdout)")
+ ap.add_argument("--censo-out", help="dónde escribir el censo con las decisiones tomadas "
+ "(default: sobre el mismo censo, con copia `.bak`)")
+ ap.add_argument("--respaldo", help="corral donde va lo marcado `respalda` (host:ruta o ruta "
+ "local). Default: el Storage Box de respaldo-storagebox.sh")
+ ap.add_argument("--respaldo-port", help="puerto SSH del corral (el Storage Box usa 23)")
a = ap.parse_args()
with open(a.censo, "rb") as f:
@@ -966,9 +1163,20 @@ def main():
return
if a.decide:
censo = revisar(censo, interactivo=True)
+ # ── LAS DECISIONES SE GUARDAN, O NO EXISTIERON ──────────────────────────────────────────
+ # Antes `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal
+ # y las 169 elecciones se perdían. El censo ES la interfaz (SDD 29 §3), así que volver a
+ # escribirlo no es comodidad, es lo que hace que decidir a medias sea posible — y decidir
+ # 169 cosas SIEMPRE se hace a medias.
+ destino_censo = a.censo_out or a.censo
+ guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
+ print(f" decisiones guardadas → {destino_censo}")
validar(censo)
- P = pasos(censo, a.target, a.key)
+ resp_dst, resp_port = (a.respaldo, a.respaldo_port)
+ if not resp_dst:
+ resp_dst, resp_port = destino_respaldo_default()
+ P = pasos(censo, a.target, a.key, resp_dst, resp_port)
por_clase = {}
for p in P:
por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1