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 Definitions

feat(estado): nadie comparaba — el bucle del SDD 33 §4 ya se cierra

El §4 son tres pasos y ayer construí dos: el cierre de hoy (`cierre-perfiles.py`) y el que la
imagen declara (`hydrate-profile.py` → `/etc/os-release`). **Faltaba el que los junta.** Dos
mitades que no se encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo
prosa aunque cada pieza por separado funcionara y estuviera probada.

`imagen-rancia.py` lee el `TAKANA_CIERRE` de cada imagen y lo compara con el vigente de su perfil.
Dice CUÁLES rearmar; no rearma — eso son horas de worker y el §4 pone los tres arneses antes de
publicar.

**Cinco caminos, los cinco probados:**

```
AL DÍA        hidratando metal-tigerlake DE VERDAD y comparando  ⇒ sha256:5fed4348…
RANCIA        perturbando el hash de intel-ucode en el grafo     ⇒ lleva 5fed4348… / hoy d6fef70f…
RANCIA        imagen que declara un digest viejo
INCOMPLETA    imagen armada con `--allow-missing` ⇒ rearmar
MUDA          imagen anterior al §4 ⇒ «no se puede saber», NO «al día»
```

⚠ Lo último no es un detalle: **tratar la ausencia de declaración como conformidad es la forma
exacta de que un medio rancio pase por bueno**, la misma trampa que un artefacto vacío en el store.

Y el control de rancidez destapó algo que no estaba buscando: **saltó la guarda de hashes
contradictorios** que `mapa_hashes()` trae, porque perturbé un solo grafo y los otros cuatro
conservaban el hash original. Dijo RANCIA *y* avisó de que la entrada era inconsistente — una
guarda que nunca había disparado haciendo su trabajo sobre una inconsistencia real.

**Puerta diaria, y no por coste** (son milisegundos): para no imprimir «0 imágenes» 48 veces al
día. El registro está vacío a propósito —no hay imágenes publicadas— y el fichero existe para que
el día que se arme una, vigilarla sea añadir una línea en vez de recordar que esto existe. Una
línea diaria mantiene la capacidad viva; 48 la vuelven invisible, que es el modo de fallo que este
mismo guion ya sufrió con el aviso del repo: una línea por hora, 18 días, en un log sin lector.

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

e4580e4c683e86b2f1cd7435a12e428cb5d577ac · by Sergio · (11 d ago) · parent 21d8b7928 · browse the tree at this commit

Semantic delta · 2

scripts/imagen-rancia.py

3 files · +206 −0

diff --git a/docs/state/imagenes.txt b/docs/state/imagenes.txt
new file mode 100644
index 00000000..bd3b1646
--- /dev/null
+++ b/docs/state/imagenes.txt
@@ -0,0 +1,13 @@
+# imagenes.txt — las imágenes que el §4 del SDD 33 vigila, una ruta de rootfs por línea.
+#
+# `scripts/imagen-rancia.py` lee el `TAKANA_CIERRE` que cada una declara en su `etc/os-release` y
+# lo compara con el cierre vigente de su perfil. Dice cuáles rearmar; no rearma: eso son horas de
+# worker y pide los tres arneses antes de publicar.
+#
+# Está VACÍO a propósito, y no por olvido: al 2026-10-10 no hay ninguna imagen publicada (el sitio
+# no ofrece descargas; en `/srv/repo` hay paquetes `.tkn`). El fichero existe para que el día que se
+# arme una, vigilarla sea añadir una línea en vez de recordar que esto existe.
+#
+# Ejemplo:
+#   /opt/takana/work/out/escritorio-kde
+#   /srv/medios/takana-escritorio-sway
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index 9bac60ec..f833cc7d 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -403,6 +403,29 @@ if [ -x "$HAMMER" ]; then
   fi
   rm -f work/.cierres.tmp work/.cierres.salida
 
+  # ── ¿ALGUNA IMAGEN QUEDÓ RANCIA? (2026-10-10, SDD 33 §4) ─────────────────────────────────────
+  # Cierra el bucle del §4: `cierre-perfiles.py` dice cuál es el cierre de hoy, `hydrate-profile.py`
+  # hace que la imagen declare el suyo, y esto COMPARA. Sin este paso las otras dos piezas son dos
+  # ficheros que no se encuentran.
+  #
+  # ⚠ PUERTA DIARIA, y no por coste —son milisegundos— sino para no imprimir «0 imágenes» 48 veces
+  # al día. Hoy el registro está vacío a propósito: no hay imágenes publicadas. Una línea diaria
+  # mantiene la capacidad VIVA y visible; 48 la vuelven invisible, que es el modo de fallo que este
+  # guion ya sufrió con el aviso del repo (una línea por hora durante 18 días en un log sin lector).
+  IMG_SELLO="work/.imagen-rancia.sello"
+  if [ ! -f "$IMG_SELLO" ] || [ -n "$(find "$IMG_SELLO" -mtime +0 2>/dev/null)" ]; then
+    touch "$IMG_SELLO"
+    if python3 scripts/imagen-rancia.py --registro docs/state/imagenes.txt \
+         > work/.imagen-rancia.salida 2>&1; then
+      echo "   imagen-rancia ✓ $(sed -n '1s/^== //;1s/ ==$//;1p' work/.imagen-rancia.salida)"
+      sed -n '/RANCIA\|a rearmar\|sin declarar/p' work/.imagen-rancia.salida | sed 's/^/      /' | head -6
+    else
+      echo "   ⚠ imagen-rancia falló"
+      tail -2 work/.imagen-rancia.salida | sed 's/^/      /'
+    fi
+    rm -f work/.imagen-rancia.salida
+  fi
+
   RIO_SELLO="work/.upstream-vigia.sello"
   if [ ! -f "$RIO_SELLO" ] || [ -n "$(find "$RIO_SELLO" -mtime +0 2>/dev/null)" ]; then
     echo "   río arriba: puerta diaria abierta, preguntando a 660 fuentes git (~20 s)…"
diff --git a/scripts/imagen-rancia.py b/scripts/imagen-rancia.py
new file mode 100755
index 00000000..ad5079d2
--- /dev/null
+++ b/scripts/imagen-rancia.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# imagen-rancia.py — ¿esta imagen sigue siendo la clausura que dice ser?
+#
+# ══ LA PIEZA QUE HACÍA QUE LAS OTRAS DOS NO SIRVIERAN ═════════════════════════════════════════
+# El SDD 33 §4 es un bucle de tres pasos:
+#
+#     h = hash de la clausura del perfil        ← `cierre-perfiles.py`, publica `cierres.json`
+#     el hash de la imagen publicada            ← `hydrate-profile.py`, escribe `/etc/os-release`
+#     si h ≠ ese                                ← ESTO
+#
+# Las dos primeras se construyeron el 2026-10-10 y **nadie comparaba**. Dos mitades que no se
+# encuentran son dos ficheros, no un disparador: sin este paso el §4 seguía siendo prosa, aunque
+# cada pieza por separado funcionara y estuviera probada.
+#
+# No construye, no rearma y no publica: dice CUÁLES rearmar. Rearmar es caro (horas de worker) y
+# publicar pide los tres arneses antes — 25 comprobaciones del banco, 13 del salto, 13 del USB—,
+# así que el §4 pone la decisión después del aviso, no antes.
+#
+# ══ QUÉ LEE Y QUÉ NO ══════════════════════════════════════════════════════════════════════════
+# Lee el `TAKANA_CIERRE` que la imagen declara y lo compara con el cierre de HOY para su perfil. No
+# recorre el rootfs ni verifica su contenido: eso es `vigia-imagen.py` (¿se puede USAR?) y
+# `vigia-sonames.py` (¿arranca?), que contestan otras preguntas. Acá la pregunta es una sola —
+# **¿lo que esta imagen lleva adentro sigue siendo lo vigente?**— y la contesta sin tocar un byte.
+#
+# ⚠ Una imagen **sin `TAKANA_CIERRE`** no es «al día»: es una imagen anterior a que las imágenes
+# declararan nada, y se dice así. Tratar la ausencia como conformidad es la forma exacta de que un
+# medio rancio pase por bueno — la misma trampa que un artefacto vacío en el store.
+#
+# Uso:  scripts/imagen-rancia.py <rootfs>…
+#       scripts/imagen-rancia.py --registro docs/state/imagenes.txt
+#       La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
+import argparse, importlib.util, json, os, sys
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
+
+# Se importa por ruta porque el fichero lleva guion y no es importable como módulo. Se reusa en vez
+# de reimplementar por lo que ya costó una vez: dos digests «equivalentes» divergieron por el
+# prefijo `b3:`, con los hashes idénticos uno a uno.
+_spec = importlib.util.spec_from_file_location(
+    'cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
+_cp = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_cp)
+
+
+def leer_os_release(ruta):
+    """{clave: valor} de un `os-release`, o None si no hay. Formato shell KEY=VALUE con comillas
+    opcionales; no se usa un parser de shell porque el fichero lo escribimos nosotros y meter un
+    intérprete para leer siete líneas es superficie de ataque gratis."""
+    f = os.path.join(ruta, 'etc', 'os-release')
+    if not os.path.isfile(f):
+        return None
+    d = {}
+    with open(f, encoding='utf-8', errors='replace') as fh:
+        for linea in fh:
+            linea = linea.strip()
+            if not linea or linea.startswith('#') or '=' not in linea:
+                continue
+            k, _, v = linea.partition('=')
+            d[k.strip()] = v.strip().strip('"').strip("'")
+    return d
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('rootfs', nargs='*', help='directorios de imagen a comprobar')
+    ap.add_argument('--registro', help='fichero con una ruta de imagen por línea (# = comentario)')
+    ap.add_argument('--json', help='escribir el informe en este fichero')
+    a = ap.parse_args()
+
+    rutas = list(a.rootfs)
+    if a.registro and os.path.exists(a.registro):
+        with open(a.registro, encoding='utf-8') as fh:
+            for linea in fh:
+                linea = linea.split('#', 1)[0].strip()
+                if linea:
+                    rutas.append(linea)
+
+    # El cierre de HOY, una vez para todas las imágenes.
+    filas_cierre, conflictos = _cp.cierres()
+    hoy = {f['perfil']: f for f in filas_cierre}
+
+    if not rutas:
+        # ⚠ «0 imágenes» es una RESPUESTA y el silencio no. Si este guion no dijera nada cuando no
+        # hay nada registrado, no se distinguiría de un guion roto — y el día que el registro se
+        # vacíe por accidente, nadie se enteraría de que dejó de vigilar.
+        print('== imagen rancia: 0 imágenes para comprobar ==')
+        print('   (pasá rutas de rootfs, o poné una por línea en --registro)')
+        print('   el cierre de hoy está calculado para %d perfil(es): %s'
+              % (len(hoy), ', '.join(sorted(hoy))))
+        return 0
+
+    informe, rancias, sin_declarar = [], [], []
+    print('== imagen rancia: %d imagen(es) ==' % len(rutas))
+    for ruta in rutas:
+        fila = {'rootfs': ruta}
+        osr = leer_os_release(ruta)
+        if osr is None:
+            fila['estado'] = 'sin-os-release'
+            print('   %-44s sin `etc/os-release` — ¿es un rootfs de perfil?' % ruta)
+            informe.append(fila)
+            continue
+        perfil = osr.get('TAKANA_PERFIL')
+        declarado = osr.get('TAKANA_CIERRE')
+        fila['perfil'], fila['declarado'] = perfil, declarado
+        if not perfil or not declarado:
+            fila['estado'] = 'no-declara'
+            sin_declarar.append(ruta)
+            print('   %-44s NO declara su clausura (imagen anterior al §4) ⇒ no se puede saber'
+                  % ruta)
+            informe.append(fila)
+            continue
+        if declarado == 'incompleto':
+            fila['estado'] = 'declarada-incompleta'
+            print('   %-44s %s · se armó INCOMPLETA (faltan %s artefactos) ⇒ rearmar'
+                  % (ruta, perfil, osr.get('TAKANA_CIERRE_FALTAN', '?')))
+            rancias.append(ruta)
+            informe.append(fila)
+            continue
+        actual = hoy.get(perfil)
+        if actual is None:
+            fila['estado'] = 'perfil-desconocido'
+            print('   %-44s declara perfil `%s`, que hoy no existe en targets.toml' % (ruta, perfil))
+            informe.append(fila)
+            continue
+        fila['vigente'] = actual['cierre']
+        if actual['estado'] != 'ok':
+            # No se puede afirmar nada: el cierre de hoy no se pudo resumir. Decir «al día» sería
+            # inventarlo y decir «rancia» también.
+            fila['estado'] = 'cierre-de-hoy-incompleto'
+            print('   %-44s %s · el cierre de HOY está incompleto ⇒ no se puede comparar'
+                  % (ruta, perfil))
+            informe.append(fila)
+            continue
+        if declarado == actual['cierre']:
+            fila['estado'] = 'al-dia'
+            print('   %-44s %s · AL DÍA  %s…' % (ruta, perfil, declarado[:22]))
+        else:
+            fila['estado'] = 'rancia'
+            rancias.append(ruta)
+            print('   %-44s %s · RANCIA' % (ruta, perfil))
+            print('        lleva  %s…' % declarado[:30])
+            print('        hoy es %s…' % actual['cierre'][:30])
+        informe.append(fila)
+
+    if conflictos:
+        print('\n   ⚠ %d par(es) (cola,nombre) con hash contradictorio entre grafos — el cierre de '
+              'hoy puede no ser fiable' % len(conflictos))
+    if rancias:
+        print('\n   %d imagen(es) a rearmar, probar con los tres arneses y publicar (SDD 33 §4):'
+              % len(rancias))
+        for r in rancias:
+            print('     %s' % r)
+    if sin_declarar:
+        print('\n   %d imagen(es) sin declarar su clausura: rehidratarlas las deja comprobables'
+              % len(sin_declarar))
+
+    if a.json:
+        with open(a.json, 'w', encoding='utf-8') as fh:
+            json.dump({'schema': 'takana-imagen-rancia/1', 'imagenes': informe,
+                       'rancias': len(rancias), 'sin_declarar': len(sin_declarar)}, fh,
+                      ensure_ascii=False, indent=1)
+        print('\n   informe: %s' % a.json)
+    # Sale 0 siempre: esto INFORMA. Una imagen rancia es lo normal cuando se trabaja — lo que no
+    # debe ser normal es no saberlo.
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())