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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user