#!/usr/bin/env python3 """identidad-compuesta.py — el `os-release` de una imagen que FUNDE varios perfiles. ── QUÉ PROBLEMA RESUELVE, Y ES UN DEFECTO MEDIDO (2026-10-15) ────────────────────────────────── `hydrate-profile.py` escribe la identidad de UN perfil en el árbol que acaba de proyectar, y lo hace bien. Pero `instalador-variantes.sh` arma el medio **fundiendo varios perfiles**, cada uno hidratado en su propio árbol, con `cp -a -l -n` — *gana el primero*. Y `/etc/os-release` es uno de los ficheros que colisionan. Resultado medido sobre el medio ya armado (`/work/sergio/variantes/takana-instalador-usb-mirada.img`, 3,5 GiB): el rootfs que se instala declaraba TAKANA_PERFIL=base TAKANA_NODOS=74 TAKANA_CIERRE=sha256:bb3a92476bd73d50bb57839b4638d5fbb43afa01c09b691c4ebfecf5a8fa1a47 mientras llevaba adentro el escritorio ENTERO: `mirada-compositor`, `mirada-greeter`, `pata-llimphi`, `foot`, `mirada-ctl`, las fuentes y **505 binarios en `/usr/bin`**. La variante es `base escritorio-mirada`, `base` se hidrata primero, y con `-n` su `os-release` gana: el del escritorio se descarta en silencio. ⚠ **Y es el peor tipo de error, no un detalle cosmético.** Ese digest de `base` es HOY un digest VÁLIDO, así que `imagen-rancia.py` compara el `TAKANA_CIERRE` del medio contra el cierre vigente de `base`, coincide, y da la imagen por **AL DÍA**. Un medio que no es ninguno de los dos perfiles pasa el control en verde. El propio `hydrate-profile.py` ya advierte contra esto para el caso de un rootfs incompleto —«un número con pinta de identidad sobre una imagen que no es la clausura que dice ser»— y se le escapó porque la fusión ocurre FUERA de él. El comentario de `compone()` ya decía que «un `-n` silencioso es una decisión escondida» y contaba los choques. `os-release` es el único choque donde «gana el primero» no es arbitrario sino ACTIVAMENTE FALSO, porque ese fichero no es contenido: es la identidad del conjunto. ── EL DIGEST ES EL DE LA UNIÓN, Y SE CALCULA CON LA MISMA FUNCIÓN ────────────────────────────── La imagen compuesta es la unión de las clausuras de sus perfiles, así que su identidad es el digest de esa unión. Se usa `cierre-perfiles.digest_de` —la misma de `hydrate-profile.py` y de la vigía— porque dos implementaciones «equivalentes» divergirían sin que nada lo dijera y el SDD 33 §4 diría «rearmá» por una diferencia de código y no de contenido. Los `wanted` quedan fuera del digest y se informan al lado, por la misma razón que allá: `hydrate` no los proyecta. Y si a algún nodo de la unión le falta artefacto vigente, **no se declara digest**: va `TAKANA_CIERRE=incompleto` con cuántos faltan. Declarar un número sobre una imagen que no es la clausura que dice ser es exactamente el fallo que este guion existe para cerrar. Nada derivado del reloj entra al fichero: el nombre legible es una etiqueta y va sólo con `--version`. Dos corridas seguidas dan un `os-release` byte a byte idéntico. Uso: identidad-compuesta.py ... [--version X] """ import os import sys import importlib.util RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _cargar_cierre_perfiles(): """Se importa por RUTA porque el fichero lleva guion y no es importable como módulo.""" ruta = os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py') spec = importlib.util.spec_from_file_location('cierre_perfiles', ruta) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def identidad(perfiles, variante, version=''): """Devuelve (lineas, aviso) para la unión de `perfiles`. No escribe nada.""" cp = _cargar_cierre_perfiles() membresia = cp.yupana.membresia_imagen() pedidos = set(perfiles) # Un perfil que no existe en la vista de imagen es un error del llamador y no un conjunto # vacío: si se dejara pasar, el digest saldría de menos perfiles de los que la imagen lleva, # que es el mismo fallo con otra cara. conocidos = set() for claves in membresia.values(): conocidos.update(claves) desconocidos = sorted(pedidos - conocidos) if desconocidos: raise SystemExit('perfil(es) que la vista de imagen no conoce: %s' % ', '.join(desconocidos)) union = {clave for clave, suyos in membresia.items() if pedidos & set(suyos)} wanted = sorted('%s/%s' % c for c in union if c[0] == 'wanted') resto = [c for c in union if c[0] != 'wanted'] hashes, _conflictos = cp.mapa_hashes() faltan = sorted('%s/%s' % (c[0], c[1]) for c in resto if c not in hashes or not hashes[c][0] or hashes[c][1] != 'sealed') lineas = ['NAME="takana"', 'ID=takana', 'PRETTY_NAME="takana · %s"' % variante, 'HOME_URL="https://takana-os.net"', # El `+` dice que es una COMPOSICIÓN y no un perfil con ese nombre. En el orden en # que se funden, que es el que explica quién gana en los otros choques. 'TAKANA_PERFIL=%s' % '+'.join(perfiles), 'TAKANA_VARIANTE=%s' % variante, 'TAKANA_NODOS=%d' % len(resto)] if version: lineas.insert(2, 'VERSION="%s"' % version) lineas.insert(3, 'VERSION_ID=%s' % version) if faltan: lineas.append('TAKANA_CIERRE=incompleto') lineas.append('TAKANA_CIERRE_FALTAN=%d' % len(faltan)) else: items = [(c[0], c[1], hashes[c][0]) for c in resto] lineas.append('TAKANA_CIERRE=%s' % cp.digest_de(items)) if wanted: lineas.append('TAKANA_WANTED=%s' % ','.join(wanted)) aviso = faltan return lineas, aviso def main(): args = [a for a in sys.argv[1:]] version = '' if '--version' in args: i = args.index('--version') version = args[i + 1] if i + 1 < len(args) else '' del args[i:i + 2] if len(args) < 3: raise SystemExit(__doc__.strip().splitlines()[-1]) arbol, variante, perfiles = args[0], args[1], args[2:] lineas, faltan = identidad(perfiles, variante, version) destino = os.path.join(arbol, 'etc', 'os-release') os.makedirs(os.path.dirname(destino), exist_ok=True) with open(destino, 'w', encoding='utf-8') as fh: fh.write('\n'.join(lineas) + '\n') print('== identidad compuesta → %s' % destino) for l in lineas: if l.startswith('TAKANA_') or l.startswith('VERSION'): print(' %s' % l) if faltan: print(' ⚠ %d nodo(s) de la unión sin artefacto vigente ⇒ NO se declara digest:' % len(faltan)) for f in faltan[:8]: print(' %s' % f) if len(faltan) > 8: print(' … y %d más' % (len(faltan) - 8)) if __name__ == '__main__': main()