#!/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 (`.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()