tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

⎇ main scripts/mudanza/aplicar.py Python · 184 líneas · 8.9 KB Historia Quién tocó qué crudo

mudanza: el tercer camino — mudar, RESPALDAR o abandonar, cosa por cosa y con su descripción 5ec4e2b21 · Sergio ·

1#!/usr/bin/env python3
2"""aplicar.py — ejecuta un PLAN de mudanza. Idempotente, reanudable, y sin mentir (SDD 29 etapa 3).
3
4── LA REGLA QUE LO DEFINE: UN PASO QUE NO SE EJECUTÓ NO SE MARCA COMO HECHO ────────────────────
5Un plan tiene pasos de dos naturalezas, y confundirlas es la forma más fácil de que una mudanza se dé
6por buena sin estarlo:
7
8  · **ejecutable**  — tiene comandos de verdad (copiar datos, comprobar espacio).
9  · **manual**      — su `cmd` son COMENTARIOS: «instalá el paquete», «cambiá el registro A». No hay
10                      nada que correr. Un aplicador que ejecuta un bloque de comentarios obtiene
11                      exit 0 y lo marca «ok». Eso es una mentira, y es la peor: deja el servicio
12                      caído con el informe en verde.
13
14Acá un paso manual se marca `pendiente-humano`, **la corrida NO se considera completa**, y hay que
15confirmarlo explícitamente (`--hecho N`) tras haberlo hecho.
16
17── LAS OTRAS DOS ───────────────────────────────────────────────────────────────────────────────
18· **Reanudable**: el estado vive al lado del plan (`<plan>.estado.json`). Re-correr saltea lo que ya
19  salió bien. Cada copia grande de esta mudanza se cortó al menos una vez — reanudar no es un lujo.
20· **La verificación es aparte del comando, y se cree a ella, no al exit code.** El `rsync` que llenó
21  el disco devolvió **0** y dejó 1367 artefactos VACÍOS. Un paso sólo pasa a `ok` si su verificación
22  pasa; si el comando salió bien y la verificación falla, queda `sospechoso`, que es peor que fallar.
23
24Uso:
25    scripts/mudanza/aplicar.py --plan plan.toml [--dry-run] [--only datos] [--hecho 7] [--paso 3]
26"""
27import argparse, json, os, subprocess, sys, time, tomllib
28
29
30def limpio(cmd):
31    """Un `cmd` que después de sacar comentarios y blancos no tiene nada, es MANUAL."""
32    return "\n".join(l for l in (cmd or "").splitlines()
33                     if l.strip() and not l.strip().startswith("#")).strip()
34
35
36class Estado:
37    def __init__(self, path):
38        self.path = path
39        self.d = json.load(open(path)) if os.path.exists(path) else {}
40
41    def get(self, n):
42        return self.d.get(str(n), {})
43
44    def set(self, n, **kw):
45        e = self.d.setdefault(str(n), {})
46        e.update(kw)
47        e["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
48        # Escritura durable: temporal + fsync + rename. Un corte a mitad no puede dejar el estado en
49        # cero bytes — pasó con el estado de `takana upgrade` y costó un upgrade entero (SDD 28).
50        tmp = self.path + ".tmp"
51        with open(tmp, "w") as f:
52            json.dump(self.d, f, indent=1, sort_keys=True)
53            f.flush()
54            os.fsync(f.fileno())
55        os.replace(tmp, self.path)
56        d = os.open(os.path.dirname(os.path.abspath(self.path)), os.O_RDONLY)
57        try:
58            os.fsync(d)
59        finally:
60            os.close(d)
61
62
63def correr(cmd, timeout=None):
64    p = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=timeout)
65    return p.returncode, (p.stdout + p.stderr).strip()
66
67
68def main():
69    ap = argparse.ArgumentParser(description="Ejecuta un plan de mudanza. Idempotente y reanudable.")
70    ap.add_argument("--plan", required=True)
71    ap.add_argument("--dry-run", action="store_true", help="mostrar qué se correría, sin correr")
72    ap.add_argument("--only", help="ejecutar sólo una clase de paso (preflight/datos/servicio/…)")
73    ap.add_argument("--paso", type=int, action="append", help="ejecutar sólo estos pasos (repetible)")
74    ap.add_argument("--hecho", type=int, action="append",
75                    help="marcar un paso MANUAL como hecho, tras haberlo hecho de verdad (repetible)")
76    ap.add_argument("--timeout", type=int, default=7200, help="segundos máx por paso")
77    a = ap.parse_args()
78
79    with open(a.plan, "rb") as f:
80        plan = tomllib.load(f)
81    pasos = plan.get("paso", [])
82    est = Estado(a.plan + ".estado.json")
83
84    if a.hecho:
85        for n in a.hecho:
86            p = next((x for x in pasos if x["n"] == n), None)
87            if not p:
88                print(f"!! no hay paso {n}", file=sys.stderr); sys.exit(2)
89            if limpio(p.get("cmd", "")):
90                print(f"!! el paso {n} NO es manual: tiene comandos. Corrélo, no lo marques.",
91                      file=sys.stderr); sys.exit(2)
92            est.set(n, estado="ok-manual", nota="confirmado a mano por el operador")
93            print(f"  paso {n} marcado hecho a mano: {p['titulo'].splitlines()[0]}")
94        return
95
96    print(f"\n══ APLICANDO {plan.get('origen','?')} → {plan.get('destino','?')} ══")
97    print(f"   {len(pasos)} paso(s) · estado en {est.path}\n")
98
99    hechos = fallidos = sospechosos = manuales = saltados = mostrados = 0
100    for p in pasos:
101        n, clase, titulo = p["n"], p["clase"], p["titulo"].strip()
102        if a.only and clase != a.only:
103            continue
104        if a.paso and n not in a.paso:
105            continue
106        ya = est.get(n).get("estado")
107        if ya in ("ok", "ok-manual"):
108            saltados += 1
109            continue
110
111        cmd = limpio(p.get("cmd", ""))
112        if not cmd:
113            manuales += 1
114            est.set(n, estado="pendiente-humano")
115            print(f"  ⏸ {n:3} [{clase}] {titulo}")
116            print("       MANUAL — no hay nada que ejecutar. Lo que hay que hacer:")
117            for l in (p.get("cmd") or "").strip().splitlines():
118                print(f"       {l}")
119            print(f"       cuando esté hecho: --hecho {n}")
120            continue
121
122        print(f"  ▶ {n:3} [{clase}] {titulo}")
123        mostrados += 1
124        if a.dry_run:
125            for l in cmd.splitlines():
126                print(f"       $ {l}")
127            continue
128        try:
129            rc, out = correr(cmd, a.timeout)
130        except subprocess.TimeoutExpired:
131            rc, out = 124, f"(timeout de {a.timeout}s)"
132        if rc != 0:
133            fallidos += 1
134            est.set(n, estado="falla", rc=rc, salida=out[-2000:])
135            print(f"       ✗ falló (rc={rc}): {out.splitlines()[-1][:120] if out else ''}")
136            print("       se detiene acá: los pasos siguientes pueden depender de éste.")
137            break
138
139        # ── la verificación manda, no el exit code ──────────────────────────────────────────────
140        ver, vtipo = (p.get("verifica") or "").strip(), p.get("verifica_tipo", "cmd")
141        if not ver:
142            est.set(n, estado="ok", rc=0, nota="sin verificación declarada")
143            hechos += 1
144            print("       ✓ (sin verificación declarada)")
145            continue
146        if vtipo == "humano":
147            sospechosos += 1
148            est.set(n, estado="pendiente-humano", rc=0, salida=out[-2000:])
149            print(f"       ✓ comando ok — pero la verificación es HUMANA, no se da por buena sola:")
150            print(f"         {ver}")
151            print(f"         salida: {out.splitlines()[-1][:120] if out else '(vacía)'}")
152            print(f"         cuando la mires: --hecho {n}")
153            continue
154        vrc, vout = correr(ver, a.timeout)
155        if vrc == 0:
156            hechos += 1
157            est.set(n, estado="ok", rc=0, verifica_salida=vout[-2000:])
158            print(f"       ✓ verificado: {vout.splitlines()[-1][:120] if vout else ''}")
159        else:
160            sospechosos += 1
161            est.set(n, estado="sospechoso", rc=0, verifica_rc=vrc, verifica_salida=vout[-2000:])
162            print(f"       ⚠ el comando salió BIEN y la verificación FALLÓ (rc={vrc}).")
163            print("         Eso es peor que un fallo: el rsync que llenó el disco también devolvió 0")
164            print("         y dejó 1367 artefactos vacíos. NO se marca como hecho.")
165
166    print(f"\n── resumen ──")
167    if a.dry_run:
168        # ⚠ En seco NO SE EJECUTÓ NADA, así que el resumen de siempre —«✅ todos los pasos
169        # ejecutados y verificados»— sería exactamente la mentira que este aplicador existe para no
170        # decir: cero pasos corridos se contaba igual que cero pasos fallidos.
171        print(f"   EN SECO: {mostrados} paso(s) mostrados, NINGUNO ejecutado y NINGUNO verificado.")
172        print("   Quitá --dry-run para correrlo de verdad.")
173        return
174    print(f"   ok {hechos} · ya estaban {saltados} · MANUALES pendientes {manuales} · "
175          f"sospechosos {sospechosos} · fallidos {fallidos}")
176    if manuales or sospechosos or fallidos:
177        print("   ⚠ la mudanza NO está completa: hay pasos sin confirmar. `aplicar.py --plan … ` de")
178        print("     nuevo saltea lo ya hecho; los manuales se confirman con `--hecho N`.")
179        sys.exit(1)
180    print("   ✅ todos los pasos ejecutados y verificados.")
181
182
183if __name__ == "__main__":
184    main()