planear: el preflight medía contra / — y los datos de gioser no entran ahí

La cadena `censar → planear → aplicar` corrió entera contra gioser por primera vez: 59 pasos, 20
ejecutables y 39 manuales, bien separados. El ensayo en seco destapó lo que ninguna prueba de juguete
iba a mostrar: **33,6 G de datos contra una raíz con 3,5 G libres** (el sitio está en `/work`, 66 G).

Dos cosas estaban mal a la vez:

· **El plan copiaba ruta → MISMA ruta**, sin forma de decir dónde cae cada árbol en el destino. Ahora
  `[[datos]]` tiene `destino` (vacío = la misma ruta). El fallo que evita es caro: aparecía a mitad
  de un rsync de 22 G.
· **El preflight sumaba todo y lo comparaba contra `/`.** Acierta POR CASUALIDAD mientras todo caiga
  en `/`, y da un veredicto completamente falso en cuanto una ruta va a otro montaje — en las dos
  direcciones. Ahora mide por sistema de ficheros, agrupa los destinos, nombra qué rutas caen en cada
  uno, y el veredicto lo saca el propio comando en vez de un humano leyendo una columna.

Los dos controles, contra la caja de verdad:

    ✗ /     necesita 34386 MiB · libres  3596  ⇐ /var/www /var/lib /srv /opt /home   exit 1
    ✓ /work necesita 34386 MiB · libres 66192  ⇐ /work/var/www … /work/home          exit 0

Además, el paso `muere` ahora cumple la regla 2 ENTERA («por su nombre Y CON SU TAMAÑO»). Un dominio
fósil no pesa nada por sí mismo: pesa lo que dejó en disco, y `terapeuta.ec` —que no resuelve en
ningún DNS— tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se
acordara. Se lista un renglón POR DIRECTORIO (siete dominios `*.gioser.net` reclaman el mismo
`gioser.bak`; repetirlo siete veces convierte el aviso en ruido) y se dice «podría ser de», nunca
«es»: el directorio se llama `terapeuta` y el dominio `terapeuta.ec`, y emparejar «casi» acierta casi
siempre y el resto de las veces manda a borrar lo que no era. Los directorios que no reclama nadie se
listan aparte: son los que nadie recuerda.

⚠ Y un bug que me hice yo y que el control cazó: empalmar por `str.index('# ── 1. datos ───')` cuando
ese marcador existe en DOS funciones. Tomó el corte al revés (j < i) y DUPLICÓ `def pasos` entero;
Python se queda con la última definición, así que el fichero importaba bien y generaba planes con el
preflight viejo. Se vio porque el comando del plan no era el que yo acababa de escribir. Un marcador
de empalme que no es único no es un marcador.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-12 00:45:27 +00:00
co-authored by Claude Opus 5
parent 2b87ecb50f
commit 0f58df4795
3 changed files with 154 additions and 9 deletions
+52 -1
View File
@@ -408,6 +408,45 @@ def fuera_de_git(t):
return res
def contenido_web(t, raices=("/var/www", "/srv")):
"""Qué hay en las raíces de contenido web, con su tamaño. Un nivel, sin juicio.
── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────
La regla 2 del plan dice que lo que muere se nombra «por su nombre Y CON SU TAMAÑO». Los
servicios y los datos ya lo cumplían; los DOMINIOS no — se listaban pelados, y un dominio fósil
no pesa nada por sí mismo: pesa lo que dejó en disco. `terapeuta.ec` no resuelve en ningún DNS y
tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se acordara.
⚠ **No se afirma la correspondencia, se OFRECE.** El directorio se llama `terapeuta` y el
dominio `terapeuta.ec`: no coinciden. Una regla difusa que empareje «casi» acierta la mayoría de
las veces y el resto de las veces manda a borrar lo que no era. Se listan los candidatos con su
tamaño y decide el humano — y se listan TAMBIÉN los directorios que no se parecen a ningún
dominio, que son los que nadie recuerda (`gioser.bak.20260501`, `ogisoer`)."""
res = []
for raiz in raices:
out, ok = t.run(f"du -sh {raiz}/* 2>/dev/null | sort -rh | head -40")
if not ok or not out.strip():
continue
for l in out.splitlines():
p = l.split(None, 1)
if len(p) == 2 and p[1].startswith("/"):
res.append({"path": p[1], "size": p[0], "raiz": raiz})
return res
def candidatos_de(dominio, contenido):
"""Directorios que PODRÍAN ser de este dominio. Por etiqueta principal, no por nombre completo."""
d = dominio.lower()
etiquetas = [x for x in d.split(".") if x not in ("www", "com", "net", "org", "ec", "io", "dev")]
principal = etiquetas[-1] if etiquetas else d
out = []
for c in contenido:
base = os.path.basename(c["path"]).lower()
if principal and (principal in base or base.split(".")[0] in etiquetas):
out.append(c)
return out
# ── clasificación ───────────────────────────────────────────────────────────────────────────────
# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
@@ -539,6 +578,11 @@ def emitir_toml(c):
L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
L.append("")
for x in c.get("contenido", []):
L.append("[[contenido]]")
for k, v in x.items():
L.append(f"{k} = {toml_escape(v)}")
L.append("")
for d in c["dominios"]:
L.append("[[dominio]]")
for k, v in d.items():
@@ -549,6 +593,12 @@ def emitir_toml(c):
L.append("[[datos]]")
for k, v in d.items():
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.append("")
for f in c["fuera_de_git"]:
@@ -649,6 +699,7 @@ def main():
"el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
pts = puertos(t)
nombres = dominios(t)
cont = contenido_web(t)
sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
http_ok = not a.probe_dns
if nombres and not sondear:
@@ -670,7 +721,7 @@ def main():
censo = {
"fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
"dominios": doms, "datos": dat, "montajes": montajes(t),
"dominios": doms, "datos": dat, "contenido": cont, "montajes": montajes(t),
"fuera_de_git": fuera_de_git(t),
}
resumen(censo)
+68 -8
View File
@@ -36,6 +36,7 @@ import argparse, os, sys, shlex, time, tomllib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import formatos # noqa: E402
from censar import candidatos_de as candidatos # noqa: E402
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_AQUI = os.path.join("scripts", "mudanza", "traducir.py")
@@ -532,27 +533,60 @@ def pasos(censo, target, key):
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"]
contenido = censo.get("contenido", [])
# ── 0. preflight ────────────────────────────────────────────────────────────────────────────
# ⚠ El sitio se mide POR SISTEMA DE FICHEROS, no contra `/`. Medido en la caja takana: `/` tiene
# 3,5 G libres, `/store` 19 G y `/work` 66 G — un preflight que sume 33,6 G y los compare contra
# `/` da un veredicto correcto por casualidad si todo cae en `/`, y COMPLETAMENTE FALSO en cuanto
# una ruta va a otro montaje: diría que no entra cuando entra, o al revés. Y además: aquí el
# veredicto lo saca el propio comando (exit ≠0 si falta sitio), no un humano leyendo una columna.
need = sum(x for x in (a_mib(d.get("size_copy") or d.get("size")) for d in datos_muda)
if x is not None)
ilegibles = [d["path"] for d in datos_muda
if a_mib(d.get("size_copy") or d.get("size")) is None]
paso("preflight", f"el destino responde y tiene sitio (hacen falta {humano(need)})",
f"{ssh} 'df -Pm / | tail -1'",
verifica=f"la columna 'Available' debe ser > {int(need)} MiB",
verifica_tipo="humano",
chks = []
for d in datos_muda:
mib = a_mib(d.get("size_copy") or d.get("size"))
if mib is None:
continue
dst = (d.get("destino") or "").strip() or d["path"]
chks.append(f"chk {shlex.quote(dst)} {int(mib)}")
guion = "\n".join([
"set -u",
"chk() {",
' t="$1"',
' while [ ! -d "$t" ] && [ "$t" != "/" ]; do t=$(dirname "$t"); done',
' df -Pm "$t" | tail -1 | awk -v n="$2" -v r="$1" \'{print $6, $4, n, r}\'',
"}",
"{",
*[" " + c for c in chks],
"} | awk '",
" { libre[$1]=$2; falta[$1]+=$3; rutas[$1]=rutas[$1] \" \" $4 }",
" END { mal=0",
' for (f in falta) {',
' printf " %-16s necesita %8d MiB · libres %8d%s\\n", f, falta[f], libre[f], rutas[f]',
' if (falta[f] > libre[f]) { mal=1; printf " ✗ NO ENTRA en %s: faltan %d MiB\\n", f, falta[f]-libre[f] }',
" }",
' if (mal==0) printf " ✓ entra en todos los sistemas de ficheros\\n"',
" exit mal }'",
])
paso("preflight", f"el destino responde y tiene sitio ({humano(need)} en total)",
f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
verifica=f"{ssh} 'sh -s' <<'FIN'\n{guion}\nFIN",
nota=("⚠ tamaños ilegibles, NO contados en el total: " + ", ".join(ilegibles)) if ilegibles else
"Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`.")
"Dimensionado con el tamaño de COPIA (hardlinks expandidos), no con `du`, y medido "
"contra el sistema de ficheros de CADA destino.")
# ── 1. datos ────────────────────────────────────────────────────────────────────────────────
for d in datos_muda:
p = d["path"]
paso("datos", f"copiar {p} ({d.get('size','?')})",
dst = (d.get("destino") or "").strip() or p
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(p + '/')}",
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(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" ]'),
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 "
@@ -609,10 +643,36 @@ def pasos(censo, target, key):
# ── 4. lo que muere ─────────────────────────────────────────────────────────────────────────
if muere:
# ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
# cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
# `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
# supiera dependía de que alguien se acordara.
det = []
for t, n, e in muere:
tam = f" ({e.get('size')})" if e.get("size") else ""
det.append(f"# {t:9} {n}{tam}")
# ⚠ Un dominio fósil no pesa nada POR SÍ MISMO: pesa lo que dejó en disco. Listarlo pelado
# cumple la mitad de la regla 2 («por su nombre Y CON SU TAMAÑO») y deja lo caro invisible:
# `terapeuta.ec` no resuelve en ningún DNS y tiene 279 M en `/var/www/terapeuta`. Que eso se
# supiera dependía de que alguien se acordara.
#
# Se lista UN RENGLÓN POR DIRECTORIO, no uno por dominio: siete dominios `*.gioser.net` que
# mueren reclaman el mismo `gioser.bak`, y repetirlo siete veces convierte el aviso en ruido.
# Y se dice «podría», nunca «es»: el directorio se llama `terapeuta` y el dominio
# `terapeuta.ec`. Emparejar «casi» acierta casi siempre, y el resto de las veces manda a
# borrar lo que no era.
if contenido:
doms_muertos = [n for t, n, _ in muere if t == "dominio"]
det.append("#")
det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.")
for c in contenido:
quien = [d for d in doms_muertos if c in candidatos(d, [c])]
marca = ("podría ser de: " + ", ".join(quien[:4]) +
(f" (+{len(quien)-4})" if len(quien) > 4 else "")) if quien else \
"no corresponde a NINGÚN dominio que muera: nadie lo recuerda, miralo"
det.append(f"# {c['path']:34} {c['size']:>6}{marca}")
paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja",
"# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"
+ "\n".join(det),