gioser

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/imagen-rancia.py Python · 170 líneas · 8.2 KB Historia Quién tocó qué crudo

feat(estado): nadie comparaba — el bucle del SDD 33 §4 ya se cierra e4580e4c6 · Sergio ·

1#!/usr/bin/env python3
2# imagen-rancia.py — ¿esta imagen sigue siendo la clausura que dice ser?
3#
4# ══ LA PIEZA QUE HACÍA QUE LAS OTRAS DOS NO SIRVIERAN ═════════════════════════════════════════
5# El SDD 33 §4 es un bucle de tres pasos:
6#
7#     h = hash de la clausura del perfil        ← `cierre-perfiles.py`, publica `cierres.json`
8#     el hash de la imagen publicada            ← `hydrate-profile.py`, escribe `/etc/os-release`
9#     si h ≠ ese                                ← ESTO
10#
11# Las dos primeras se construyeron el 2026-10-10 y **nadie comparaba**. Dos mitades que no se
12# encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo prosa, aunque
13# cada pieza por separado funcionara y estuviera probada.
14#
15# No construye, no rearma y no publica: dice CUÁLES rearmar. Rearmar es caro (horas de worker) y
16# publicar pide los tres arneses antes — 25 comprobaciones del banco, 13 del salto, 13 del USB—,
17# así que el §4 pone la decisión después del aviso, no antes.
18#
19# ══ QUÉ LEE Y QUÉ NO ══════════════════════════════════════════════════════════════════════════
20# Lee el `TAKANA_CIERRE` que la imagen declara y lo compara con el cierre de HOY para su perfil. No
21# recorre el rootfs ni verifica su contenido: eso es `vigia-imagen.py` (¿se puede USAR?) y
22# `vigia-sonames.py` (¿arranca?), que contestan otras preguntas. Acá la pregunta es una sola —
23# **¿lo que esta imagen lleva adentro sigue siendo lo vigente?**— y la contesta sin tocar un byte.
24#
25# ⚠ Una imagen **sin `TAKANA_CIERRE`** no es «al día»: es una imagen anterior a que las imágenes
26# declararan nada, y se dice así. Tratar la ausencia como conformidad es la forma exacta de que un
27# medio rancio pase por bueno — la misma trampa que un artefacto vacío en el store.
28#
29# Uso:  scripts/imagen-rancia.py <rootfs>…
30#       scripts/imagen-rancia.py --registro docs/state/imagenes.txt
31#       La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
32import argparse, importlib.util, json, os, sys
33
34RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
36
37# Se importa por ruta porque el fichero lleva guion y no es importable como módulo. Se reusa en vez
38# de reimplementar por lo que ya costó una vez: dos digests «equivalentes» divergieron por el
39# prefijo `b3:`, con los hashes idénticos uno a uno.
40_spec = importlib.util.spec_from_file_location(
41    'cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
42_cp = importlib.util.module_from_spec(_spec)
43_spec.loader.exec_module(_cp)
44
45
46def leer_os_release(ruta):
47    """{clave: valor} de un `os-release`, o None si no hay. Formato shell KEY=VALUE con comillas
48    opcionales; no se usa un parser de shell porque el fichero lo escribimos nosotros y meter un
49    intérprete para leer siete líneas es superficie de ataque gratis."""
50    f = os.path.join(ruta, 'etc', 'os-release')
51    if not os.path.isfile(f):
52        return None
53    d = {}
54    with open(f, encoding='utf-8', errors='replace') as fh:
55        for linea in fh:
56            linea = linea.strip()
57            if not linea or linea.startswith('#') or '=' not in linea:
58                continue
59            k, _, v = linea.partition('=')
60            d[k.strip()] = v.strip().strip('"').strip("'")
61    return d
62
63
64def main():
65    ap = argparse.ArgumentParser()
66    ap.add_argument('rootfs', nargs='*', help='directorios de imagen a comprobar')
67    ap.add_argument('--registro', help='fichero con una ruta de imagen por línea (# = comentario)')
68    ap.add_argument('--json', help='escribir el informe en este fichero')
69    a = ap.parse_args()
70
71    rutas = list(a.rootfs)
72    if a.registro and os.path.exists(a.registro):
73        with open(a.registro, encoding='utf-8') as fh:
74            for linea in fh:
75                linea = linea.split('#', 1)[0].strip()
76                if linea:
77                    rutas.append(linea)
78
79    # El cierre de HOY, una vez para todas las imágenes.
80    filas_cierre, conflictos = _cp.cierres()
81    hoy = {f['perfil']: f for f in filas_cierre}
82
83    if not rutas:
84        # ⚠ «0 imágenes» es una RESPUESTA y el silencio no. Si este guion no dijera nada cuando no
85        # hay nada registrado, no se distinguiría de un guion roto — y el día que el registro se
86        # vacíe por accidente, nadie se enteraría de que dejó de vigilar.
87        print('== imagen rancia: 0 imágenes para comprobar ==')
88        print('   (pasá rutas de rootfs, o poné una por línea en --registro)')
89        print('   el cierre de hoy está calculado para %d perfil(es): %s'
90              % (len(hoy), ', '.join(sorted(hoy))))
91        return 0
92
93    informe, rancias, sin_declarar = [], [], []
94    print('== imagen rancia: %d imagen(es) ==' % len(rutas))
95    for ruta in rutas:
96        fila = {'rootfs': ruta}
97        osr = leer_os_release(ruta)
98        if osr is None:
99            fila['estado'] = 'sin-os-release'
100            print('   %-44s sin `etc/os-release` — ¿es un rootfs de perfil?' % ruta)
101            informe.append(fila)
102            continue
103        perfil = osr.get('TAKANA_PERFIL')
104        declarado = osr.get('TAKANA_CIERRE')
105        fila['perfil'], fila['declarado'] = perfil, declarado
106        if not perfil or not declarado:
107            fila['estado'] = 'no-declara'
108            sin_declarar.append(ruta)
109            print('   %-44s NO declara su clausura (imagen anterior al §4) ⇒ no se puede saber'
110                  % ruta)
111            informe.append(fila)
112            continue
113        if declarado == 'incompleto':
114            fila['estado'] = 'declarada-incompleta'
115            print('   %-44s %s · se armó INCOMPLETA (faltan %s artefactos) ⇒ rearmar'
116                  % (ruta, perfil, osr.get('TAKANA_CIERRE_FALTAN', '?')))
117            rancias.append(ruta)
118            informe.append(fila)
119            continue
120        actual = hoy.get(perfil)
121        if actual is None:
122            fila['estado'] = 'perfil-desconocido'
123            print('   %-44s declara perfil `%s`, que hoy no existe en targets.toml' % (ruta, perfil))
124            informe.append(fila)
125            continue
126        fila['vigente'] = actual['cierre']
127        if actual['estado'] != 'ok':
128            # No se puede afirmar nada: el cierre de hoy no se pudo resumir. Decir «al día» sería
129            # inventarlo y decir «rancia» también.
130            fila['estado'] = 'cierre-de-hoy-incompleto'
131            print('   %-44s %s · el cierre de HOY está incompleto ⇒ no se puede comparar'
132                  % (ruta, perfil))
133            informe.append(fila)
134            continue
135        if declarado == actual['cierre']:
136            fila['estado'] = 'al-dia'
137            print('   %-44s %s · AL DÍA  %s…' % (ruta, perfil, declarado[:22]))
138        else:
139            fila['estado'] = 'rancia'
140            rancias.append(ruta)
141            print('   %-44s %s · RANCIA' % (ruta, perfil))
142            print('        lleva  %s…' % declarado[:30])
143            print('        hoy es %s…' % actual['cierre'][:30])
144        informe.append(fila)
145
146    if conflictos:
147        print('\n   ⚠ %d par(es) (cola,nombre) con hash contradictorio entre grafos — el cierre de '
148              'hoy puede no ser fiable' % len(conflictos))
149    if rancias:
150        print('\n   %d imagen(es) a rearmar, probar con los tres arneses y publicar (SDD 33 §4):'
151              % len(rancias))
152        for r in rancias:
153            print('     %s' % r)
154    if sin_declarar:
155        print('\n   %d imagen(es) sin declarar su clausura: rehidratarlas las deja comprobables'
156              % len(sin_declarar))
157
158    if a.json:
159        with open(a.json, 'w', encoding='utf-8') as fh:
160            json.dump({'schema': 'takana-imagen-rancia/1', 'imagenes': informe,
161                       'rancias': len(rancias), 'sin_declarar': len(sin_declarar)}, fh,
162                      ensure_ascii=False, indent=1)
163        print('\n   informe: %s' % a.json)
164    # Sale 0 siempre: esto INFORMA. Una imagen rancia es lo normal cuando se trabaja — lo que no
165    # debe ser normal es no saberlo.
166    return 0
167
168
169if __name__ == '__main__':
170    sys.exit(main())