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…

TOML 815Rust 56Shell 37Markdown 291229 Sutichaykuna

fix(medio): el medio declaraba ser `base` mientras llevaba el escritorio entero adentro

Medido sobre el `.img` ya armado (`takana-instalador-usb-mirada.img`, 3,5 GiB, montado por loop):
el rootfs que se instala declaraba

    TAKANA_PERFIL=base · TAKANA_NODOS=74
    TAKANA_CIERRE=sha256:bb3a92476bd73d50bb57839b4638d5fbb43afa01c09b691c4ebfecf5a8fa1a47

y adentro tenía `mirada-compositor`, `mirada-greeter`, `pata-llimphi`, `foot`, `mirada-ctl`, las
fuentes y **505 binarios en `/usr/bin`**.

LA CAUSA: `hydrate-profile.py` escribe un `os-release` por perfil, en SU árbol, y `compone()` los
funde con `cp -a -l -n` —gana el primero—. La variante `mirada` es `base escritorio-mirada`, `base`
entra primero, y su `os-release` gana: el del escritorio se descarta en silencio. El comentario de
`compone()` ya advertía que «un `-n` silencioso es una decisión escondida» y contaba los choques;
`os-release` es el único donde «gana el primero» no es arbitrario sino ACTIVAMENTE FALSO, porque
ese fichero no es contenido, es la identidad del conjunto.

⚠ Y NO ERA COSMÉTICO: el digest de `base` es HOY un digest VÁLIDO, así que `imagen-rancia.py` lo
comparaba contra el cierre vigente de `base`, coincidía, y daba por **AL DÍA** un medio que no es
ninguno de sus dos perfiles. Un control en verde sobre una imagen que no es la clausura que dice ser
es peor que no tener control — y es la misma trampa que `hydrate-profile.py` ya documenta para el
rootfs incompleto («un número con pinta de identidad…»). Se le escapó porque la fusión ocurre FUERA
de él.

`identidad-compuesta.py` escribe la identidad de la UNIÓN de las clausuras, con la MISMA `digest_de`
que la vigía y que `hydrate-profile.py` (tres llamadores, una implementación — dos «equivalentes»
divergirían y el §4 diría «rearmá» por una diferencia de código). Para la variante `mirada`:

    TAKANA_PERFIL=base+escritorio-mirada · TAKANA_VARIANTE=mirada · TAKANA_NODOS=122
    TAKANA_CIERRE=sha256:a9234db6d36c748de5279ad2838a0340c1dd341661c970c38e2df8601574d37b
    TAKANA_WANTED=wanted/churay

122 y no 132 porque diez nodos están en los dos perfiles. Y el `TAKANA_WANTED` destapa de paso que
`escritorio-mirada` declara `churay` sin receta en el catálogo: hoy `recipes/churay.toml` existe
sólo como fichero sin commitear en el clon de la granja.

Cinco controles: unión correcta (122 nodos, digest distinto de los dos perfiles) · dos corridas
byte a byte idénticas (nada del reloj entra) · un perfil inexistente se RECHAZA por nombre en vez de
dar una unión incompleta · un nodo de la unión sin artefacto vigente ⇒ `TAKANA_CIERRE=incompleto`
con cuántos y cuáles, nunca un número · y un fallo del generador aborta el armado (no se tuberea a
`sed`: con `set -eu` sin `pipefail` el estado sería el de `sed` y el medio quedaría sin identidad en
verde).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

6b7526cba1c69ecc4966ea57fa1d53a732d34fa4 · ruwaq Sergio · (6 p'unchaw ñawpaq) · tayta f69b12689 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 3

scripts/identidad-compuesta.py

2 qillqakuna · +178 −0

diff --git a/scripts/identidad-compuesta.py b/scripts/identidad-compuesta.py
new file mode 100755
index 00000000..2d0d8185
--- /dev/null
+++ b/scripts/identidad-compuesta.py
@@ -0,0 +1,144 @@
+#!/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 <arbol> <variante> <perfil>... [--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()
diff --git a/scripts/instalador-variantes.sh b/scripts/instalador-variantes.sh
index 26251a68..13fa81f8 100755
--- a/scripts/instalador-variantes.sh
+++ b/scripts/instalador-variantes.sh
@@ -206,6 +206,40 @@ except Exception:
         rm -rf "$H"
         echo "     $(grep -oE '[0-9]+/[0-9]+' "work/.variante-$_cv-$p.log" | tail -1) artefactos · acumulado $(du -sm "$C" | cut -f1) MiB"
     done
+
+    # ── LA IDENTIDAD DEL CONJUNTO, QUE LA FUSIÓN DESCARTABA ──────────────────────────────────
+    # `hydrate-profile.py` escribe un `/etc/os-release` por perfil, en SU árbol. Acá se funden con
+    # `-n` —gana el primero— y ese fichero es uno de los que colisionan: con `base` entrando
+    # primero, el medio quedaba declarando `TAKANA_PERFIL=base`, 74 nodos y el cierre de `base`
+    # mientras llevaba el escritorio entero adentro (medido el 2026-10-15 sobre el `.img` ya
+    # armado: 505 binarios en /usr/bin, compositor, greeter, pata, fuentes).
+    #
+    # ⚠ Y no era cosmético: el digest de `base` es HOY un digest VÁLIDO, así que
+    # `imagen-rancia.py` lo comparaba contra el cierre vigente de `base`, coincidía, y daba por
+    # **AL DÍA** un medio que no es ninguno de sus dos perfiles. Un control en verde sobre una
+    # imagen que no es la clausura que dice ser es peor que no tener control.
+    #
+    # La identidad de una imagen compuesta es el digest de la UNIÓN de las clausuras de sus
+    # perfiles, y lo calcula `identidad-compuesta.py` con la MISMA `digest_de` que la vigía y que
+    # `hydrate-profile.py` — tres llamadores, una implementación.
+    # ⚠ NO se tuberea a `sed` directamente: este guion va con `set -eu` y SIN `pipefail`, así que
+    # el estado de `python3 … | sed` es el de `sed` y un fallo del generador saldría 0 — el medio
+    # quedaría sin identidad y el armado en verde. Se captura, se mira el estado, y después se
+    # imprime.
+    if [ -n "$(perfiles_de "$_cv" "$_cm")" ]; then
+        if python3 scripts/identidad-compuesta.py "$C" "$_cv" $(perfiles_de "$_cv" "$_cm") \
+               > "work/.identidad-$_cv-$_cm.log" 2>&1; then
+            sed 's/^/     /' "work/.identidad-$_cv-$_cm.log"
+        else
+            echo "     ✗ no pude escribir la identidad compuesta de $_cv:" >&2
+            sed 's/^/       /' "work/.identidad-$_cv-$_cm.log" >&2
+            exit 1
+        fi
+    else
+        # `base` no compone ningún perfil (es el piso solo) ⇒ no hay unión que resumir. Se dice,
+        # porque la AUSENCIA de declaración no debe leerse como conformidad.
+        echo "     · variante sin perfiles ⇒ sin identidad declarada (es el piso de arranque solo)"
+    fi
     # ── el árbol tiene que quedar en el MISMO montaje que el stage del ensamblado ─────────────
     # ⚠ **Algunos artefactos traen ficheros que el usuario normal no puede LEER** —ModemManager
     # instala sus `fcc-unlock.available.d/*` en 0500, que es como vienen de upstream— así que copiar