From 5ec4e2b213414991d83690993727855d4e329895 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 16 Sep 2026 16:20:32 +0000 Subject: [PATCH] =?UTF-8?q?mudanza:=20el=20tercer=20camino=20=E2=80=94=20m?= =?UTF-8?q?udar,=20RESPALDAR=20o=20abandonar,=20cosa=20por=20cosa=20y=20co?= =?UTF-8?q?n=20su=20descripci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/29-mudanza.md | 103 +++++++++++++++ scripts/mudanza/aplicar.py | 10 +- scripts/mudanza/censar.py | 224 +++++++++++++++++++++++++++++++-- scripts/mudanza/planear.py | 250 +++++++++++++++++++++++++++++++++---- 4 files changed, 553 insertions(+), 34 deletions(-) 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, "") + 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 (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