Files
takana/scripts/mudanza/aplicar.py
T
SergioandClaude Opus 5 d682b9b240 mudanza: el APLICADOR — idempotente, reanudable, y que no marca como hecho lo que no ejecutó
`aplicar.py` ejecuta un plan. La regla que lo define: **un paso que no se ejecutó no se marca como
hecho**. Un plan tiene pasos ejecutables y pasos MANUALES cuyo `cmd` son comentarios («instalá el
paquete», «cambiá el registro A»); un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y
lo marca «ok» — la peor mentira posible, porque deja el servicio caído con el informe en verde.

Acá quedan `pendiente-humano`, la corrida sale con ≠0, y `--hecho N` los confirma — negándose si el
paso sí tenía comandos («corrélo, no lo marques»).

Se le cree a la VERIFICACIÓN, no al exit code: el rsync que llenó el disco devolvió 0 y dejó 1367
artefactos vacíos. Si el comando sale bien y la verificación falla, queda `sospechoso`. Y las
verificaciones van TIPADAS (`cmd`/`humano`): «la columna Available debe ser > X» no es un comando.

Estado reanudable en `<plan>.estado.json`, escrito con temporal + fsync + rename — la lección que
costó un upgrade entero en el SDD 28.

**Y un fallo propio, encontrado en la primera corrida real**: la verificación del paso de datos
imprimía el número de directorios vacíos y devolvía 0 igual. Dio «✓ verificado: 2» donde ese 2 eran
dos vacíos en destino. Un guardián que siempre pasa no es un guardián. Ahora COMPARA los ficheros de
los dos lados y falla si difieren. Probado con rotura a propósito y con el control que debe pasar:
copia no hecha ⇒ origen=5 destino=0 ⇒ FALLA; copia hecha ⇒ 5 y 5 ⇒ PASA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:20:15 +00:00

177 lines
8.4 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 = 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}")
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 ──")
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()