#!/usr/bin/env python3 # imagen-rancia.py — ¿esta imagen sigue siendo la clausura que dice ser? # # ══ LA PIEZA QUE HACÍA QUE LAS OTRAS DOS NO SIRVIERAN ═════════════════════════════════════════ # El SDD 33 §4 es un bucle de tres pasos: # # h = hash de la clausura del perfil ← `cierre-perfiles.py`, publica `cierres.json` # el hash de la imagen publicada ← `hydrate-profile.py`, escribe `/etc/os-release` # si h ≠ ese ← ESTO # # Las dos primeras se construyeron el 2026-10-10 y **nadie comparaba**. Dos mitades que no se # encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo prosa, aunque # cada pieza por separado funcionara y estuviera probada. # # No construye, no rearma y no publica: dice CUÁLES rearmar. Rearmar es caro (horas de worker) y # publicar pide los tres arneses antes — 25 comprobaciones del banco, 13 del salto, 13 del USB—, # así que el §4 pone la decisión después del aviso, no antes. # # ══ QUÉ LEE Y QUÉ NO ══════════════════════════════════════════════════════════════════════════ # Lee el `TAKANA_CIERRE` que la imagen declara y lo compara con el cierre de HOY para su perfil. No # recorre el rootfs ni verifica su contenido: eso es `vigia-imagen.py` (¿se puede USAR?) y # `vigia-sonames.py` (¿arranca?), que contestan otras preguntas. Acá la pregunta es una sola — # **¿lo que esta imagen lleva adentro sigue siendo lo vigente?**— y la contesta sin tocar un byte. # # ⚠ Una imagen **sin `TAKANA_CIERRE`** no es «al día»: es una imagen anterior a que las imágenes # declararan nada, y se dice así. Tratar la ausencia como conformidad es la forma exacta de que un # medio rancio pase por bueno — la misma trampa que un artefacto vacío en el store. # # Uso: scripts/imagen-rancia.py … # scripts/imagen-rancia.py --registro docs/state/imagenes.txt # La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano. import argparse, importlib.util, json, os, sys RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(RAIZ, 'scripts')) # Se importa por ruta porque el fichero lleva guion y no es importable como módulo. Se reusa en vez # de reimplementar por lo que ya costó una vez: dos digests «equivalentes» divergieron por el # prefijo `b3:`, con los hashes idénticos uno a uno. _spec = importlib.util.spec_from_file_location( 'cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py')) _cp = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_cp) def leer_os_release(ruta): """{clave: valor} de un `os-release`, o None si no hay. Formato shell KEY=VALUE con comillas opcionales; no se usa un parser de shell porque el fichero lo escribimos nosotros y meter un intérprete para leer siete líneas es superficie de ataque gratis.""" f = os.path.join(ruta, 'etc', 'os-release') if not os.path.isfile(f): return None d = {} with open(f, encoding='utf-8', errors='replace') as fh: for linea in fh: linea = linea.strip() if not linea or linea.startswith('#') or '=' not in linea: continue k, _, v = linea.partition('=') d[k.strip()] = v.strip().strip('"').strip("'") return d def main(): ap = argparse.ArgumentParser() ap.add_argument('rootfs', nargs='*', help='directorios de imagen a comprobar') ap.add_argument('--registro', help='fichero con una ruta de imagen por línea (# = comentario)') ap.add_argument('--json', help='escribir el informe en este fichero') a = ap.parse_args() rutas = list(a.rootfs) if a.registro and os.path.exists(a.registro): with open(a.registro, encoding='utf-8') as fh: for linea in fh: linea = linea.split('#', 1)[0].strip() if linea: rutas.append(linea) # El cierre de HOY, una vez para todas las imágenes. filas_cierre, conflictos = _cp.cierres() hoy = {f['perfil']: f for f in filas_cierre} if not rutas: # ⚠ «0 imágenes» es una RESPUESTA y el silencio no. Si este guion no dijera nada cuando no # hay nada registrado, no se distinguiría de un guion roto — y el día que el registro se # vacíe por accidente, nadie se enteraría de que dejó de vigilar. print('== imagen rancia: 0 imágenes para comprobar ==') print(' (pasá rutas de rootfs, o poné una por línea en --registro)') print(' el cierre de hoy está calculado para %d perfil(es): %s' % (len(hoy), ', '.join(sorted(hoy)))) return 0 informe, rancias, sin_declarar = [], [], [] print('== imagen rancia: %d imagen(es) ==' % len(rutas)) for ruta in rutas: fila = {'rootfs': ruta} osr = leer_os_release(ruta) if osr is None: fila['estado'] = 'sin-os-release' print(' %-44s sin `etc/os-release` — ¿es un rootfs de perfil?' % ruta) informe.append(fila) continue perfil = osr.get('TAKANA_PERFIL') declarado = osr.get('TAKANA_CIERRE') fila['perfil'], fila['declarado'] = perfil, declarado if not perfil or not declarado: fila['estado'] = 'no-declara' sin_declarar.append(ruta) print(' %-44s NO declara su clausura (imagen anterior al §4) ⇒ no se puede saber' % ruta) informe.append(fila) continue if declarado == 'incompleto': fila['estado'] = 'declarada-incompleta' print(' %-44s %s · se armó INCOMPLETA (faltan %s artefactos) ⇒ rearmar' % (ruta, perfil, osr.get('TAKANA_CIERRE_FALTAN', '?'))) rancias.append(ruta) informe.append(fila) continue actual = hoy.get(perfil) if actual is None: fila['estado'] = 'perfil-desconocido' print(' %-44s declara perfil `%s`, que hoy no existe en targets.toml' % (ruta, perfil)) informe.append(fila) continue fila['vigente'] = actual['cierre'] if actual['estado'] != 'ok': # No se puede afirmar nada: el cierre de hoy no se pudo resumir. Decir «al día» sería # inventarlo y decir «rancia» también. fila['estado'] = 'cierre-de-hoy-incompleto' print(' %-44s %s · el cierre de HOY está incompleto ⇒ no se puede comparar' % (ruta, perfil)) informe.append(fila) continue if declarado == actual['cierre']: fila['estado'] = 'al-dia' print(' %-44s %s · AL DÍA %s…' % (ruta, perfil, declarado[:22])) else: fila['estado'] = 'rancia' rancias.append(ruta) print(' %-44s %s · RANCIA' % (ruta, perfil)) print(' lleva %s…' % declarado[:30]) print(' hoy es %s…' % actual['cierre'][:30]) informe.append(fila) if conflictos: print('\n ⚠ %d par(es) (cola,nombre) con hash contradictorio entre grafos — el cierre de ' 'hoy puede no ser fiable' % len(conflictos)) if rancias: print('\n %d imagen(es) a rearmar, probar con los tres arneses y publicar (SDD 33 §4):' % len(rancias)) for r in rancias: print(' %s' % r) if sin_declarar: print('\n %d imagen(es) sin declarar su clausura: rehidratarlas las deja comprobables' % len(sin_declarar)) if a.json: with open(a.json, 'w', encoding='utf-8') as fh: json.dump({'schema': 'takana-imagen-rancia/1', 'imagenes': informe, 'rancias': len(rancias), 'sin_declarar': len(sin_declarar)}, fh, ensure_ascii=False, indent=1) print('\n informe: %s' % a.json) # Sale 0 siempre: esto INFORMA. Una imagen rancia es lo normal cuando se trabaja — lo que no # debe ser normal es no saberlo. return 0 if __name__ == '__main__': sys.exit(main())