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…
feat(estado): nadie comparaba — el bucle del SDD 33 §4 ya se cierra
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())