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/identidad-compuesta.py Python · 144 líneas · 6.8 KB Historia Quién tocó qué crudo

fix(medio): el medio declaraba ser `base` mientras llevaba el escritorio entero adentro 6b7526cba · Sergio ·

1#!/usr/bin/env python3
2"""identidad-compuesta.py — el `os-release` de una imagen que FUNDE varios perfiles.
3
4── QUÉ PROBLEMA RESUELVE, Y ES UN DEFECTO MEDIDO (2026-10-15) ──────────────────────────────────
5`hydrate-profile.py` escribe la identidad de UN perfil en el árbol que acaba de proyectar, y lo
6hace bien. Pero `instalador-variantes.sh` arma el medio **fundiendo varios perfiles**, cada uno
7hidratado en su propio árbol, con `cp -a -l -n` — *gana el primero*. Y `/etc/os-release` es uno de
8los ficheros que colisionan.
9
10Resultado medido sobre el medio ya armado
11(`/work/sergio/variantes/takana-instalador-usb-mirada.img`, 3,5 GiB): el rootfs que se instala
12declaraba
13
14    TAKANA_PERFIL=base
15    TAKANA_NODOS=74
16    TAKANA_CIERRE=sha256:bb3a92476bd73d50bb57839b4638d5fbb43afa01c09b691c4ebfecf5a8fa1a47
17
18mientras llevaba adentro el escritorio ENTERO: `mirada-compositor`, `mirada-greeter`,
19`pata-llimphi`, `foot`, `mirada-ctl`, las fuentes y **505 binarios en `/usr/bin`**. La variante es
20`base escritorio-mirada`, `base` se hidrata primero, y con `-n` su `os-release` gana: el del
21escritorio se descarta en silencio.
22
23⚠ **Y es el peor tipo de error, no un detalle cosmético.** Ese digest de `base` es HOY un digest
24VÁLIDO, así que `imagen-rancia.py` compara el `TAKANA_CIERRE` del medio contra el cierre vigente de
25`base`, coincide, y da la imagen por **AL DÍA**. Un medio que no es ninguno de los dos perfiles
26pasa el control en verde. El propio `hydrate-profile.py` ya advierte contra esto para el caso de un
27rootfs incompleto —«un número con pinta de identidad sobre una imagen que no es la clausura que
28dice ser»— y se le escapó porque la fusión ocurre FUERA de él.
29
30El comentario de `compone()` ya decía que «un `-n` silencioso es una decisión escondida» y contaba
31los choques. `os-release` es el único choque donde «gana el primero» no es arbitrario sino
32ACTIVAMENTE FALSO, porque ese fichero no es contenido: es la identidad del conjunto.
33
34── EL DIGEST ES EL DE LA UNIÓN, Y SE CALCULA CON LA MISMA FUNCIÓN ──────────────────────────────
35La imagen compuesta es la unión de las clausuras de sus perfiles, así que su identidad es el digest
36de esa unión. Se usa `cierre-perfiles.digest_de` —la misma de `hydrate-profile.py` y de la vigía—
37porque dos implementaciones «equivalentes» divergirían sin que nada lo dijera y el SDD 33 §4 diría
38«rearmá» por una diferencia de código y no de contenido. Los `wanted` quedan fuera del digest y se
39informan al lado, por la misma razón que allá: `hydrate` no los proyecta.
40
41Y si a algún nodo de la unión le falta artefacto vigente, **no se declara digest**: va
42`TAKANA_CIERRE=incompleto` con cuántos faltan. Declarar un número sobre una imagen que no es la
43clausura que dice ser es exactamente el fallo que este guion existe para cerrar.
44
45Nada derivado del reloj entra al fichero: el nombre legible es una etiqueta y va sólo con
46`--version`. Dos corridas seguidas dan un `os-release` byte a byte idéntico.
47
48Uso:  identidad-compuesta.py <arbol> <variante> <perfil>... [--version X]
49"""
50import os
51import sys
52import importlib.util
53
54RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
55
56
57def _cargar_cierre_perfiles():
58    """Se importa por RUTA porque el fichero lleva guion y no es importable como módulo."""
59    ruta = os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py')
60    spec = importlib.util.spec_from_file_location('cierre_perfiles', ruta)
61    mod = importlib.util.module_from_spec(spec)
62    spec.loader.exec_module(mod)
63    return mod
64
65
66def identidad(perfiles, variante, version=''):
67    """Devuelve (lineas, aviso) para la unión de `perfiles`. No escribe nada."""
68    cp = _cargar_cierre_perfiles()
69    membresia = cp.yupana.membresia_imagen()
70    pedidos = set(perfiles)
71
72    # Un perfil que no existe en la vista de imagen es un error del llamador y no un conjunto
73    # vacío: si se dejara pasar, el digest saldría de menos perfiles de los que la imagen lleva,
74    # que es el mismo fallo con otra cara.
75    conocidos = set()
76    for claves in membresia.values():
77        conocidos.update(claves)
78    desconocidos = sorted(pedidos - conocidos)
79    if desconocidos:
80        raise SystemExit('perfil(es) que la vista de imagen no conoce: %s' % ', '.join(desconocidos))
81
82    union = {clave for clave, suyos in membresia.items() if pedidos & set(suyos)}
83    wanted = sorted('%s/%s' % c for c in union if c[0] == 'wanted')
84    resto = [c for c in union if c[0] != 'wanted']
85
86    hashes, _conflictos = cp.mapa_hashes()
87    faltan = sorted('%s/%s' % (c[0], c[1]) for c in resto
88                    if c not in hashes or not hashes[c][0] or hashes[c][1] != 'sealed')
89
90    lineas = ['NAME="takana"', 'ID=takana',
91              'PRETTY_NAME="takana · %s"' % variante,
92              'HOME_URL="https://takana-os.net"',
93              # El `+` dice que es una COMPOSICIÓN y no un perfil con ese nombre. En el orden en
94              # que se funden, que es el que explica quién gana en los otros choques.
95              'TAKANA_PERFIL=%s' % '+'.join(perfiles),
96              'TAKANA_VARIANTE=%s' % variante,
97              'TAKANA_NODOS=%d' % len(resto)]
98    if version:
99        lineas.insert(2, 'VERSION="%s"' % version)
100        lineas.insert(3, 'VERSION_ID=%s' % version)
101    if faltan:
102        lineas.append('TAKANA_CIERRE=incompleto')
103        lineas.append('TAKANA_CIERRE_FALTAN=%d' % len(faltan))
104    else:
105        items = [(c[0], c[1], hashes[c][0]) for c in resto]
106        lineas.append('TAKANA_CIERRE=%s' % cp.digest_de(items))
107    if wanted:
108        lineas.append('TAKANA_WANTED=%s' % ','.join(wanted))
109
110    aviso = faltan
111    return lineas, aviso
112
113
114def main():
115    args = [a for a in sys.argv[1:]]
116    version = ''
117    if '--version' in args:
118        i = args.index('--version')
119        version = args[i + 1] if i + 1 < len(args) else ''
120        del args[i:i + 2]
121    if len(args) < 3:
122        raise SystemExit(__doc__.strip().splitlines()[-1])
123    arbol, variante, perfiles = args[0], args[1], args[2:]
124
125    lineas, faltan = identidad(perfiles, variante, version)
126    destino = os.path.join(arbol, 'etc', 'os-release')
127    os.makedirs(os.path.dirname(destino), exist_ok=True)
128    with open(destino, 'w', encoding='utf-8') as fh:
129        fh.write('\n'.join(lineas) + '\n')
130
131    print('== identidad compuesta → %s' % destino)
132    for l in lineas:
133        if l.startswith('TAKANA_') or l.startswith('VERSION'):
134            print('   %s' % l)
135    if faltan:
136        print('   ⚠ %d nodo(s) de la unión sin artefacto vigente ⇒ NO se declara digest:' % len(faltan))
137        for f in faltan[:8]:
138            print('        %s' % f)
139        if len(faltan) > 8:
140            print('        … y %d más' % (len(faltan) - 8))
141
142
143if __name__ == '__main__':
144    main()