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>
185 lines
8.9 KiB
Python
Executable File
185 lines
8.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""aplicar.py — ejecuta un PLAN de mudanza. Idempotente, reanudable, y sin mentir (SDD 29 etapa 3).
|
|
|
|
── LA REGLA QUE LO DEFINE: UN PASO QUE NO SE EJECUTÓ NO SE MARCA COMO HECHO ────────────────────
|
|
Un plan tiene pasos de dos naturalezas, y confundirlas es la forma más fácil de que una mudanza se dé
|
|
por buena sin estarlo:
|
|
|
|
· **ejecutable** — tiene comandos de verdad (copiar datos, comprobar espacio).
|
|
· **manual** — su `cmd` son COMENTARIOS: «instalá el paquete», «cambiá el registro A». No hay
|
|
nada que correr. Un aplicador que ejecuta un bloque de comentarios obtiene
|
|
exit 0 y lo marca «ok». Eso es una mentira, y es la peor: deja el servicio
|
|
caído con el informe en verde.
|
|
|
|
Acá un paso manual se marca `pendiente-humano`, **la corrida NO se considera completa**, y hay que
|
|
confirmarlo explícitamente (`--hecho N`) tras haberlo hecho.
|
|
|
|
── LAS OTRAS DOS ───────────────────────────────────────────────────────────────────────────────
|
|
· **Reanudable**: el estado vive al lado del plan (`<plan>.estado.json`). Re-correr saltea lo que ya
|
|
salió bien. Cada copia grande de esta mudanza se cortó al menos una vez — reanudar no es un lujo.
|
|
· **La verificación es aparte del comando, y se cree a ella, no al exit code.** El `rsync` que llenó
|
|
el disco devolvió **0** y dejó 1367 artefactos VACÍOS. Un paso sólo pasa a `ok` si su verificación
|
|
pasa; si el comando salió bien y la verificación falla, queda `sospechoso`, que es peor que fallar.
|
|
|
|
Uso:
|
|
scripts/mudanza/aplicar.py --plan plan.toml [--dry-run] [--only datos] [--hecho 7] [--paso 3]
|
|
"""
|
|
import argparse, json, os, subprocess, sys, time, tomllib
|
|
|
|
|
|
def limpio(cmd):
|
|
"""Un `cmd` que después de sacar comentarios y blancos no tiene nada, es MANUAL."""
|
|
return "\n".join(l for l in (cmd or "").splitlines()
|
|
if l.strip() and not l.strip().startswith("#")).strip()
|
|
|
|
|
|
class Estado:
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.d = json.load(open(path)) if os.path.exists(path) else {}
|
|
|
|
def get(self, n):
|
|
return self.d.get(str(n), {})
|
|
|
|
def set(self, n, **kw):
|
|
e = self.d.setdefault(str(n), {})
|
|
e.update(kw)
|
|
e["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
# Escritura durable: temporal + fsync + rename. Un corte a mitad no puede dejar el estado en
|
|
# cero bytes — pasó con el estado de `takana upgrade` y costó un upgrade entero (SDD 28).
|
|
tmp = self.path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump(self.d, f, indent=1, sort_keys=True)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, self.path)
|
|
d = os.open(os.path.dirname(os.path.abspath(self.path)), os.O_RDONLY)
|
|
try:
|
|
os.fsync(d)
|
|
finally:
|
|
os.close(d)
|
|
|
|
|
|
def correr(cmd, timeout=None):
|
|
p = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=timeout)
|
|
return p.returncode, (p.stdout + p.stderr).strip()
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Ejecuta un plan de mudanza. Idempotente y reanudable.")
|
|
ap.add_argument("--plan", required=True)
|
|
ap.add_argument("--dry-run", action="store_true", help="mostrar qué se correría, sin correr")
|
|
ap.add_argument("--only", help="ejecutar sólo una clase de paso (preflight/datos/servicio/…)")
|
|
ap.add_argument("--paso", type=int, action="append", help="ejecutar sólo estos pasos (repetible)")
|
|
ap.add_argument("--hecho", type=int, action="append",
|
|
help="marcar un paso MANUAL como hecho, tras haberlo hecho de verdad (repetible)")
|
|
ap.add_argument("--timeout", type=int, default=7200, help="segundos máx por paso")
|
|
a = ap.parse_args()
|
|
|
|
with open(a.plan, "rb") as f:
|
|
plan = tomllib.load(f)
|
|
pasos = plan.get("paso", [])
|
|
est = Estado(a.plan + ".estado.json")
|
|
|
|
if a.hecho:
|
|
for n in a.hecho:
|
|
p = next((x for x in pasos if x["n"] == n), None)
|
|
if not p:
|
|
print(f"!! no hay paso {n}", file=sys.stderr); sys.exit(2)
|
|
if limpio(p.get("cmd", "")):
|
|
print(f"!! el paso {n} NO es manual: tiene comandos. Corrélo, no lo marques.",
|
|
file=sys.stderr); sys.exit(2)
|
|
est.set(n, estado="ok-manual", nota="confirmado a mano por el operador")
|
|
print(f" paso {n} marcado hecho a mano: {p['titulo'].splitlines()[0]}")
|
|
return
|
|
|
|
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 = mostrados = 0
|
|
for p in pasos:
|
|
n, clase, titulo = p["n"], p["clase"], p["titulo"].strip()
|
|
if a.only and clase != a.only:
|
|
continue
|
|
if a.paso and n not in a.paso:
|
|
continue
|
|
ya = est.get(n).get("estado")
|
|
if ya in ("ok", "ok-manual"):
|
|
saltados += 1
|
|
continue
|
|
|
|
cmd = limpio(p.get("cmd", ""))
|
|
if not cmd:
|
|
manuales += 1
|
|
est.set(n, estado="pendiente-humano")
|
|
print(f" ⏸ {n:3} [{clase}] {titulo}")
|
|
print(" MANUAL — no hay nada que ejecutar. Lo que hay que hacer:")
|
|
for l in (p.get("cmd") or "").strip().splitlines():
|
|
print(f" {l}")
|
|
print(f" cuando esté hecho: --hecho {n}")
|
|
continue
|
|
|
|
print(f" ▶ {n:3} [{clase}] {titulo}")
|
|
mostrados += 1
|
|
if a.dry_run:
|
|
for l in cmd.splitlines():
|
|
print(f" $ {l}")
|
|
continue
|
|
try:
|
|
rc, out = correr(cmd, a.timeout)
|
|
except subprocess.TimeoutExpired:
|
|
rc, out = 124, f"(timeout de {a.timeout}s)"
|
|
if rc != 0:
|
|
fallidos += 1
|
|
est.set(n, estado="falla", rc=rc, salida=out[-2000:])
|
|
print(f" ✗ falló (rc={rc}): {out.splitlines()[-1][:120] if out else ''}")
|
|
print(" se detiene acá: los pasos siguientes pueden depender de éste.")
|
|
break
|
|
|
|
# ── la verificación manda, no el exit code ──────────────────────────────────────────────
|
|
ver, vtipo = (p.get("verifica") or "").strip(), p.get("verifica_tipo", "cmd")
|
|
if not ver:
|
|
est.set(n, estado="ok", rc=0, nota="sin verificación declarada")
|
|
hechos += 1
|
|
print(" ✓ (sin verificación declarada)")
|
|
continue
|
|
if vtipo == "humano":
|
|
sospechosos += 1
|
|
est.set(n, estado="pendiente-humano", rc=0, salida=out[-2000:])
|
|
print(f" ✓ comando ok — pero la verificación es HUMANA, no se da por buena sola:")
|
|
print(f" {ver}")
|
|
print(f" salida: {out.splitlines()[-1][:120] if out else '(vacía)'}")
|
|
print(f" cuando la mires: --hecho {n}")
|
|
continue
|
|
vrc, vout = correr(ver, a.timeout)
|
|
if vrc == 0:
|
|
hechos += 1
|
|
est.set(n, estado="ok", rc=0, verifica_salida=vout[-2000:])
|
|
print(f" ✓ verificado: {vout.splitlines()[-1][:120] if vout else ''}")
|
|
else:
|
|
sospechosos += 1
|
|
est.set(n, estado="sospechoso", rc=0, verifica_rc=vrc, verifica_salida=vout[-2000:])
|
|
print(f" ⚠ el comando salió BIEN y la verificación FALLÓ (rc={vrc}).")
|
|
print(" Eso es peor que un fallo: el rsync que llenó el disco también devolvió 0")
|
|
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:
|
|
print(" ⚠ la mudanza NO está completa: hay pasos sin confirmar. `aplicar.py --plan … ` de")
|
|
print(" nuevo saltea lo ya hecho; los manuales se confirman con `--hecho N`.")
|
|
sys.exit(1)
|
|
print(" ✅ todos los pasos ejecutados y verificados.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|