#!/usr/bin/env python3 # cierre-perfiles.py — el HASH DE LA CLAUSURA DE IMAGEN de cada perfil, sin construir nada. # # ══ QUÉ CONTESTA, Y POR QUÉ FALTABA ═══════════════════════════════════════════════════════════ # El SDD 33 §4 decide **cuándo rearmar los medios: por el CIERRE, no por el calendario.** # # por cada perfil publicado: # h = hash de la clausura del perfil # si h ≠ el hash de la imagen publicada: # rearmar, probar con los tres arneses, publicar # # Un cron semanal que rearma imágenes gasta horas de worker para producir, casi siempre, los mismos # bytes. El disparador correcto es que la clausura haya cambiado — pero **ese `h` no existía**: el # grafo sabe el hash de cada ARTEFACTO y nadie resumía el conjunto. Esto lo calcula. No construye, # no descarga y no publica. # # ══ ⚠ DE QUÉ CONJUNTO, QUE ES LA PARTE QUE SE PUEDE ERRAR SIN ENTERARSE ═══════════════════════ # **Del cierre de IMAGEN (`yupana.membresia_imagen`), no del de build.** La primera versión de este # guion (2026-10-10, mañana) usó el campo `perfiles` de `build-state.json`, y estaba mal por DOS # motivos independientes, los dos medidos el mismo día: # # 1. **`build ∪ runtime` ≠ lo que va en la imagen.** El cierre de un perfil se calcula sobre las # dos aristas porque para CONSTRUIR hacen falta las dos, pero **la imagen no se construye a sí # misma**: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`… son `[solo-build]`. Son el # **44 % de los bytes de `servidor`** (medido el 2026-09-22, ver `yupana.membresia_imagen`). # Con el conjunto equivocado, tocar una herramienta de build movería el digest y diría «la # imagen quedó rancia» cuando sus bytes no cambian. Un disparador de rearmado que se enciende # por algo que no entra en la imagen es exactamente el calendario que el §4 quiere evitar. # base 86 → 69 · cli 153 → 133 · servidor 186 → 166 · escritorio-kde 445 → 411 # # 2. **El campo `perfiles` NO es la fuente de verdad, y `yupana` lo dice en su propio docstring** # («esto es lo que arregla la mentira de `perfiles`»). Se calcula por grafo, así que para los # escritorios queda truncado: `escritorio-kde` son **256** en `build-state.json`, **431** en # `build-state-kde.json` y **445** de verdad. Leer «el grafo de su cola» mitiga el síntoma y no # lo arregla: la membresía hay que resolverla sobre el grafo entero, respetando sibling-first, # y eso es justo lo que `yupana.membresia()` hace. # # ⇒ La membresía sale de `yupana`; los hashes, de los grafos, indexados por **(cola, nombre)** y no # por nombre a secas — porque `openssl` es una receta distinta en `corpus` y en `incoming-kde`. # Comprobado antes de confiar en el mapa: 1.288 pares y **cero hashes contradictorios** entre los # cinco grafos. # # ⚠ **Lo que sigue faltando del §4: que la imagen publicada declare de qué clausura salió.** Hoy # ninguna imagen declara nada —no se escribe ni un `/etc/os-release`— y además no hay imágenes # publicadas. Lo que esto deja listo es el lado nuestro: el `git log` de `docs/state/cierres.json` # ES la señal «este perfil se movió ⇒ su imagen quedó rancia». # # Uso: scripts/cierre-perfiles.py [--json docs/state/cierres.json] [--contra ] # La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano. import argparse, collections, glob, hashlib, json, os, sys RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(RAIZ, 'scripts')) import yupana # noqa: E402 ESTADO = os.path.join(RAIZ, 'docs', 'state') def mapa_hashes(): """{(cola, nombre): (hash, estado)} desde los cinco grafos. Por (cola, nombre) y no por nombre: `openssl` es una receta distinta en `corpus` y en `incoming-kde`, y colapsarlas daría el hash de la que apareciera primero. Se comprobó que los grafos no se contradicen —1.288 pares, 0 conflictos— y si algún día se contradicen hay que enterarse, así que el conflicto se devuelve en vez de resolverse a dedo. """ m, conflictos = {}, [] for f in sorted(glob.glob(os.path.join(ESTADO, 'build-state*.json'))): if not f.endswith('.json'): continue with open(f, encoding='utf-8') as fh: g = json.load(fh) for n, v in g['nodes'].items(): k = (v.get('queue'), n) par = (v.get('hash'), v.get('state')) if k in m and m[k] != par: conflictos.append((k, m[k], par, os.path.basename(f))) m[k] = par return m, conflictos # ══ LA FUNCIÓN COMPARTIDA, Y POR QUÉ ES TAN CORTA ═════════════════════════════════════════════ # `hydrate-profile.py` la importa para escribir el digest DENTRO de la imagen que acaba de armar. # Si hubiera dos implementaciones, el número de la imagen y el que esta vigía predice divergirían # sin que nada lo dijera — y entonces la comparación del §4 («h ≠ el de la imagen publicada») # diría «rearmá» por una diferencia de código y no de contenido. Por eso es UNA función, de tres # líneas, en vez de dos «equivalentes». def digest_de(items): """sha256 sobre `cola/nombre hash`, ORDENADO. `items` = iterable de (cola, nombre, hash). Ordenado porque el orden de un dict no es contrato y sin ordenar el mismo cierre daría dos números. Por (cola, nombre) y no por nombre porque `openssl` es una receta distinta en `corpus` y en `incoming-kde`. Y `sha256:` y no `b3:` porque esto resume un conjunto y no direcciona nada en el store: reusar el prefijo del ArtifactHash invitaría a buscarlo ahí. """ # ⚠ EL PREFIJO `b3:` SE NORMALIZA ACÁ, Y ES UN BUG MEDIDO, NO UNA PRECAUCIÓN. La vigía lee el # hash del grafo, que lo guarda como `b3:7373…`; `hydrate-profile.py` lo saca de `takana hash` # y le quita el prefijo. **Mismo hash, cadena distinta, digest distinto** — los dos números # salieron diferentes la primera vez que se hidrató de verdad para compararlos, con los cinco # hashes IDÉNTICOS uno a uno. Que la función normalice es lo único que impide que un llamador # futuro vuelva a pisarlo: una función compartida que exige un formato convenido de palabra ya # son dos implementaciones otra vez. h = hashlib.sha256() for cola, nombre, hh in sorted((c, n, (x or '').replace('b3:', '')) for c, n, x in items): h.update(('%s/%s b3:%s\n' % (cola, nombre, hh)).encode()) return 'sha256:' + h.hexdigest() def cierres(): por_perfil = collections.defaultdict(set) for clave, perfiles in yupana.membresia_imagen().items(): for p in perfiles: por_perfil[p].add(clave) hashes, conflictos = mapa_hashes() filas = [] for perfil in sorted(por_perfil): miembros = sorted(por_perfil[perfil]) det = {'perfil': perfil, 'nodos': len(miembros), 'vista': 'imagen'} # ── `wanted` FUERA DEL DIGEST, y esto es alineación con quien manda ──────────────────── # `wanted` = declarado por el perfil y sin receta que lo provea (`escritorio-kde` declara # `xwayland`). **`hydrate-profile.py` los excluye explícitamente** (`n[0] != "wanted"`), así # que no van en la imagen — y un digest que los incluyera NUNCA coincidiría con el que la # imagen declara, que es justo para lo que existe este número. Se informan al lado, no # adentro: la información no se pierde y la comparación no se rompe. # # Medido el 2026-10-10: en los diez perfiles, el ÚNICO miembro no construible de cualquier # cierre de imagen es `wanted/xwayland`. Ni un `ajeno`, ni un desconocido. Por eso cualquier # otro caso que no se pueda hashear se trata como clausura INCOMPLETA y sin digest — que es # también lo que `hydrate` hace: aborta sin `--allow-missing`. wanted = sorted('%s/%s' % k for k in miembros if k[0] == 'wanted') resto = [k for k in miembros if k[0] != 'wanted'] det['wanted'] = wanted det['nodos_en_digest'] = len(resto) impedidos = sorted('%s/%s (%s)' % (k[0], k[1], 'sin entrada en ningún grafo' if k not in hashes else 'sin hash' if not hashes[k][0] else 'estado=%s' % hashes[k][1]) for k in resto if k not in hashes or not hashes[k][0] or hashes[k][1] != 'sealed') if impedidos: det['estado'] = 'incompleto' det['impedidos'] = impedidos[:10] det['n_impedidos'] = len(impedidos) det['cierre'] = None filas.append(det) continue det['estado'] = 'ok' det['cierre'] = digest_de((k[0], k[1], hashes[k][0]) for k in resto) filas.append(det) return filas, conflictos def main(): ap = argparse.ArgumentParser() ap.add_argument('--json', help='escribir el informe en este fichero') ap.add_argument('--contra', help='comparar contra un informe anterior y decir qué se movió') a = ap.parse_args() filas, conflictos = cierres() previo = {} ruta_previa = a.contra or a.json if ruta_previa and os.path.exists(ruta_previa): try: with open(ruta_previa, encoding='utf-8') as f: prev = json.load(f) # Sólo se compara contra un informe de la MISMA vista: el digest de `build ∪ runtime` # y el de la imagen son números distintos del mismo perfil, y compararlos diría «se # movió» en el primer ciclo tras el cambio de vista, que es una alarma falsa. if prev.get('vista') == 'imagen': previo = {r['perfil']: r.get('cierre') for r in prev['perfiles']} except Exception: previo = {} ok = [f for f in filas if f['estado'] == 'ok'] print('== cierre de IMAGEN por perfil: %d de %d con clausura completa ==' % (len(ok), len(filas))) if conflictos: print(' ⚠ %d par(es) (cola,nombre) con HASH CONTRADICTORIO entre grafos — mirarlo:' % len(conflictos)) for k, viejo, nuevo, f in conflictos[:5]: print(' %s/%s: %s vs %s (%s)' % (k[0], k[1], viejo[0], nuevo[0], f)) movidos = [] for f in filas: marca = '' if f['estado'] == 'ok' and previo.get(f['perfil']): if previo[f['perfil']] != f['cierre']: marca = ' ← SE MOVIÓ (su imagen quedó rancia)' movidos.append(f['perfil']) else: marca = ' (igual que la vez anterior)' if f['estado'] == 'ok': extra = '' if f.get('wanted'): # Se dice en el informe, no sólo en el json: un perfil que declara algo que nadie # provee es un dato, y esconderlo detrás de un digest «completo» lo perdería. extra = ' · declara sin receta: %s' % ', '.join(f['wanted']) print(' %-20s %5d nodos %s…%s%s' % (f['perfil'], f['nodos_en_digest'], f['cierre'][:22], marca, extra)) else: print(' %-20s %5d incompleto: %d nodo(s) sin hashear; p.ej. %s' % (f['perfil'], f['nodos'], f['n_impedidos'], f['impedidos'][0])) if movidos: print('\n %d perfil(es) con la clausura movida ⇒ rearmar su imagen, probar con los tres ' 'arneses y publicar (SDD 33 §4):' % len(movidos)) for m in movidos: print(' %s' % m) if a.json: with open(a.json, 'w', encoding='utf-8') as f: json.dump({'schema': 'takana-cierres/2', 'vista': 'imagen', 'perfiles': filas, 'completos': len(ok), 'total': len(filas), 'conflictos_de_hash': len(conflictos), 'movidos_vs_anterior': movidos}, f, ensure_ascii=False, indent=1) print('\n informe: %s' % a.json) # Sale 0 siempre: esto INFORMA. Que una clausura se mueva es lo normal cuando se trabaja. return 0 if __name__ == '__main__': sys.exit(main())