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>
This commit is contained in:
Sergio
2026-09-16 16:20:32 +00:00
co-authored by Claude Opus 5
parent 628abdbb51
commit 5ec4e2b213
4 changed files with 553 additions and 34 deletions
+9 -1
View File
@@ -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:
+212 -12
View File
@@ -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)
+229 -21
View File
@@ -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, "")
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, "")
mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
# ⚠ `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