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…
⎇ 98edf488bbffd2dae507a6317a78bb61d5a9c07a
scripts/identidad-compuesta.py
History
Who touched what
raw
fix(medio): el medio declaraba ser `base` mientras llevaba el escritorio entero adentro
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()