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 1182Shell 87Rust 63Markdown 541592 Definiciones

⎇ 98edf488bbffd2dae507a6317a78bb61d5a9c07a scripts/censo-piso.py Python · 286 líneas · 13.6 KB Historia Quién tocó qué crudo

feat(medio): qué parte de la imagen NO sale de su clausura, fichero por fichero 7c79b9716 · Sergio ·

1#!/usr/bin/env python3
2"""censo-piso.py — qué parte de una imagen NO sale de su clausura, medido fichero por fichero.
3
4── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
5Una imagen de perfil se arma poniendo el PISO (`product-rootfs`, la salida de `takana bootstrap
6product` — **no hay receta que lo produzca**) y fundiendo encima la clausura de cada perfil con
7`cp -a -l -n`: *gana el primero*, y el primero es el piso. O sea que todo camino que el piso trae
8y un perfil también, en la imagen sale **del piso**, no de la clausura que el `os-release` declara.
9
10Eso no se veía en ningún número. Medido el 2026-10-15 sobre la variante `mirada`
11(`base` + `escritorio-mirada`, 122 nodos, 19.997 ficheros de perfil):
12
13    el piso trae 690 ficheros (413 enlaces) y TAPA 418 caminos de la clausura:
14      · 183 por SYMLINK — `bin/ps`, `bin/mount`, `bin/sed`, `bin/tar`, `bin/su`, `bin/login`,
15        `bin/dmesg`… todos a `busybox`, con procps-ng / util-linux / sed / tar / shadow en la
16        clausura, declarados y sin usarse
17      · 235 por FICHERO REAL — un binario del bootstrap ganándole al declarado, sin symlink que
18        lo delate. La primera versión de este censo no los contaba: 235 caminos fuera del número.
19      ⇒ de los 418, **187 son de EJECUCIÓN** (uutils 93 · util-linux 40 · kbd 8 · xz-tools 6 ·
20        procps-ng 5 · e2fsprogs 4 …) y 231 de tiempo de build (las cabeceras de musl y sus
21        `crt*.o`, que una imagen de producto no tendría que llevar de ningún lado).
22    y aporta, único suyo:
23      · 230 applets de busybox que ningún perfil provee
24      ·  42 ficheros REALES: OpenSSH entero —`escritorio-mirada`+`base` **no declaran openssh**,
25        así que el ssh del medio del escritorio sale del piso— más `arje-zero` (PID 1), `hammerd`,
26        `netup`, `rg`, `ente/attest.json`, `ente/seed.card.json`, `etc/passwd`, `etc/group`,
27        `bin/busybox` y `usr/lib/libc.a`
28
29En `servidor` y `full` aparece además `openssh 32` entre las tapadas: esas variantes SÍ lo declaran,
30y el piso les gana con el binario de agosto.
31
32⇒ **Las dos mitades del trabajo que falta para una imagen definitiva salen de acá**: los 183 son
33deuda a retirar (el camino lo abrió `takana c51c2bf3`, que liberó sólo los 16 con `[[setuid]]`
34declarado, con el argumento de que a un symlink no se le puede poner el bit); los 42 son lo que
35alguna receta y algún perfil tendrán que declarar para que el piso pueda irse.
36
37⚠ **No se tocan 183 caminos de una pasada.** Reemplazar un applet por el binario de upstream cambia
38banderas y puede romper un script del arranque — está escrito en `docs/plan-botar-busybox.md` y en
39el propio `compone()`. Esto MIDE; retirar es trabajo con su propia verificación.
40
41## Lo que este censo NO dice
42
43No mira contenido: dos ficheros con el mismo camino se cuentan como colisión aunque fueran el mismo
44byte. Para los symlinks a busybox eso da igual (el destino es otro binario), y es justamente el caso
45que importa.
46
47Uso:  censo-piso.py [--json docs/state/piso.json] [--variante mirada] [--piso DIR]
48      (sin `--variante`: todas las que `instalador-variantes.sh` sabe armar)
49"""
50import argparse
51import importlib.util
52import json
53import os
54import sys
55
56RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
57
58# Las variantes y sus perfiles, en el MISMO orden en que `instalador-variantes.sh` los funde — el
59# orden es lo que decide quién gana, así que una copia desordenada mediría otra imagen.
60# ⚠ Es una segunda lista y se sabe: la primera está en `perfiles_de()` de ese guion, en shell. Se
61# comprueba contra ella (`--comprobar-contra`) para que la divergencia se note en vez de mentir.
62VARIANTES = {
63    'base': [],
64    'servidor': ['base', 'servidor'],
65    'mirada': ['base', 'escritorio-mirada'],
66    'full': ['base', 'cli', 'escritorio-mirada', 'escritorio-sway', 'escritorio-cosmic',
67             'escritorio-gnome', 'escritorio-kde', 'servidor'],
68}
69
70
71def _cargar(nombre, ruta):
72    spec = importlib.util.spec_from_file_location(nombre, ruta)
73    mod = importlib.util.module_from_spec(spec)
74    spec.loader.exec_module(mod)
75    return mod
76
77
78def piso_por_defecto():
79    """El MISMO criterio que `instalador-variantes.sh`: el `product-rootfs` más reciente.
80
81    ⚠ Y ese criterio es por MTIME, no por hash: `ls -dt | head -1`. Dos máquinas con los mismos
82    artefactos pueden elegir pisos distintos, y el censo saldría distinto sin que nada cambie. Se
83    informa el que se usó para que el número sea interpretable.
84    """
85    cands = []
86    for d in os.listdir('/store') if os.path.isdir('/store') else []:
87        if d.endswith('-product-rootfs'):
88            p = os.path.join('/store', d)
89            cands.append((os.path.getmtime(p), p))
90    if not cands:
91        return None
92    return sorted(cands, reverse=True)[0][1]
93
94
95def ficheros_de(raiz):
96    """{ruta relativa: es_enlace}. Los symlinks a directorio cuentan como entrada, no se recorren."""
97    salida = {}
98    for base, dirs, archivos in os.walk(raiz):
99        if '/.hammer' in base:
100            continue
101        for d in list(dirs):
102            p = os.path.join(base, d)
103            if os.path.islink(p):
104                salida[os.path.relpath(p, raiz)] = True
105                dirs.remove(d)
106        for f in archivos:
107            p = os.path.join(base, f)
108            rel = os.path.relpath(p, raiz)
109            if rel.startswith('.hammer'):
110                continue
111            salida[rel] = os.path.islink(p)
112    return salida
113
114
115def es_de_build(rel):
116    """¿Es un fichero de TIEMPO DE BUILD y no de ejecución?
117
118    ⚠ La distinción no es cosmética y casi me hizo publicar un titular falso (2026-10-15). El
119    recuento bruto ponía `musl-shared` primero con 231 caminos tapados, lo que se lee como «la libc
120    de la imagen no es la declarada». Mirándolos: **218 son cabeceras `usr/include/*.h` y 13 son
121    `crt1.o`/`libm.a`/`libpthread.a`** — y `libc.so` / `ld-musl-*` NO están en la lista, o sea que
122    el runtime no está tapado en absoluto. Mezclar las dos clases infla el número con lo inocuo y
123    esconde lo que sí importa: `uutils` 93 y `util-linux` 40, que son los comandos que alguien
124    escribe. (Y de paso dice otra cosa: una imagen de PRODUCTO no tiene por qué llevar 218
125    cabeceras de C ni los `crt*.o`, vengan del piso o del perfil.)
126    """
127    return (rel.startswith('usr/include/') or rel.startswith('include/')
128            or rel.endswith(('.a', '.o', '.pc', '.h', '.hpp'))
129            or '/pkgconfig/' in rel or '/cmake/' in rel)
130
131
132def censo(variante, perfiles, piso, cp):
133    memb = cp.yupana.membresia_imagen()
134    pedidos = set(perfiles)
135    union = sorted(k for k, v in memb.items() if pedidos & set(v) and k[0] != 'wanted')
136    hashes, _ = cp.mapa_hashes()
137
138    de_perfil, sin_artefacto = {}, []
139    for clave in union:
140        h = (hashes.get(clave, ('', ''))[0] or '').replace('b3:', '')
141        d = '/store/%s-%s' % (h, clave[1])
142        if not h or not os.path.isdir(d):
143            sin_artefacto.append('%s/%s' % clave)
144            continue
145        for rel in ficheros_de(d):
146            de_perfil.setdefault(rel, clave[1])
147
148    # ⚠ DOS CLASES DE TAPADO, y la primera versión de este censo sólo contaba una. Un symlink del
149    # piso que tapa un binario declarado se ve enseguida (`bin/ps → busybox`). Pero el piso también
150    # trae FICHEROS REALES en caminos que un perfil declara, y ahí gana un binario del bootstrap
151    # —de otra fecha, de otra cadena— sobre el de la clausura, sin symlink que lo delate. Contar
152    # sólo los enlaces dejaba 235 caminos fuera del número en la variante `mirada`.
153    tapados, tapados_reales, unicos_reales, unicos_enlace = [], [], [], []
154    if piso and os.path.isdir(piso):
155        for rel, es_enlace in ficheros_de(piso).items():
156            if rel in de_perfil:
157                if es_enlace:
158                    tapados.append({'ruta': rel,
159                                    'destino': os.readlink(os.path.join(piso, rel)),
160                                    'lo_declara': de_perfil[rel]})
161                else:
162                    tapados_reales.append({'ruta': rel, 'lo_declara': de_perfil[rel]})
163            elif es_enlace:
164                unicos_enlace.append(rel)
165            else:
166                unicos_reales.append(rel)
167
168    # Quién pierde más caminos: es el orden de ataque para retirar el piso.
169    # Dos tablas: lo que tapa un comando que alguien ejecuta, y lo que tapa un fichero de build.
170    por_receta, por_receta_build = {}, {}
171    for t in tapados + tapados_reales:
172        destino = por_receta_build if es_de_build(t['ruta']) else por_receta
173        destino[t['lo_declara']] = destino.get(t['lo_declara'], 0) + 1
174    n_build = sum(por_receta_build.values())
175
176    return {
177        'variante': variante,
178        'perfiles': perfiles,
179        'nodos': len(union),
180        'ficheros_de_perfil': len(de_perfil),
181        'nodos_sin_artefacto': sin_artefacto,
182        'piso': os.path.basename(piso) if piso else None,
183        'tapados': sorted(tapados, key=lambda t: t['ruta']),
184        'n_tapados': len(tapados),
185        'tapados_reales': sorted(tapados_reales, key=lambda t: t['ruta']),
186        'n_tapados_reales': len(tapados_reales),
187        'tapados_por_receta': dict(sorted(por_receta.items(), key=lambda x: (-x[1], x[0]))),
188        'tapados_por_receta_build': dict(sorted(por_receta_build.items(), key=lambda x: (-x[1], x[0]))),
189        'n_tapados_de_build': n_build,
190        'solo_del_piso_reales': sorted(unicos_reales),
191        'solo_del_piso_enlaces': len(unicos_enlace),
192    }
193
194
195def perfiles_del_guion():
196    """Lee `perfiles_de()` de `instalador-variantes.sh` para comprobar que no divergimos."""
197    ruta = os.path.join(RAIZ, 'scripts', 'instalador-variantes.sh')
198    try:
199        txt = open(ruta, encoding='utf-8').read()
200    except OSError:
201        return None
202    ini = txt.find('perfiles_de()')
203    if ini < 0:
204        return None
205    cuerpo = txt[ini:txt.find('\n}', ini)]
206    # ⚠ Las ramas del `case` son `variante:medio`, y una variante tiene MÁS DE UNA: hoy
207    # `servidor:run` da `base servidor-minimo` y `servidor:*` da `base servidor`. Un parser que se
208    # quedaba con la primera rama comparaba la del medio `run` contra la del medio por defecto y
209    # avisaba de una divergencia que no existía — lo dijo en su primera corrida. Se toma la rama
210    # `:*`, que es la del medio que arma `instalador-variantes.sh` por defecto.
211    vistos = {}
212    for linea in cuerpo.splitlines():
213        linea = linea.strip()
214        if 'echo' not in linea or ')' not in linea:
215            continue
216        patron = linea.split(')', 1)[0].strip()
217        if ':' not in patron:
218            continue
219        variante, _, medio = patron.partition(':')
220        if medio != '*':
221            continue
222        val = linea.split('echo', 1)[1].strip().strip(';').strip().strip('"')
223        vistos[variante] = val.split()
224    return vistos
225
226
227def main():
228    ap = argparse.ArgumentParser()
229    ap.add_argument('--json', help='escribir el informe acá')
230    ap.add_argument('--variante', action='append', help='sólo ésta (repetible)')
231    ap.add_argument('--piso', help='el product-rootfs a medir (def: el más reciente)')
232    a = ap.parse_args()
233
234    cp = _cargar('cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
235    piso = a.piso or piso_por_defecto()
236    if not piso:
237        print('no encuentro ningún product-rootfs en /store', file=sys.stderr)
238        return 1
239
240    # La comprobación contra la fuente de verdad, antes de medir nada.
241    del_guion = perfiles_del_guion()
242    if del_guion:
243        for v, p in VARIANTES.items():
244            suyo = del_guion.get(v)
245            if suyo is not None and suyo != p:
246                print('⚠ divergencia con instalador-variantes.sh en «%s»: acá %s · allá %s'
247                      % (v, p, suyo), file=sys.stderr)
248
249    quiero = a.variante or [v for v in VARIANTES if VARIANTES[v]]
250    informes = []
251    for v in quiero:
252        if v not in VARIANTES:
253            print('variante desconocida: %s' % v, file=sys.stderr)
254            return 2
255        if not VARIANTES[v]:
256            continue
257        informes.append(censo(v, VARIANTES[v], piso, cp))
258
259    print('== el piso contra la clausura · %s ==' % os.path.basename(piso))
260    for r in informes:
261        print('   %-10s %4d nodos · %5d ficheros de perfil · TAPA %3d por enlace + %3d por fichero real'
262              ' · aporta %2d propios + %3d applets'
263              % (r['variante'], r['nodos'], r['ficheros_de_perfil'], r['n_tapados'],
264                 r['n_tapados_reales'], len(r['solo_del_piso_reales']), r['solo_del_piso_enlaces']))
265        n_ejec = r['n_tapados'] + r['n_tapados_reales'] - r['n_tapados_de_build']
266        print('        de ésos, %3d son de EJECUCIÓN (lo que alguien escribe) y %3d de tiempo de build'
267              % (n_ejec, r['n_tapados_de_build']))
268        peor = list(r['tapados_por_receta'].items())[:6]
269        if peor:
270            print('        recetas más tapadas en ejecución: %s'
271                  % ' · '.join('%s %d' % kv for kv in peor))
272        if r['nodos_sin_artefacto']:
273            print('        ⚠ %d nodo(s) sin artefacto ⇒ sus ficheros NO se contaron: %s'
274                  % (len(r['nodos_sin_artefacto']), ', '.join(r['nodos_sin_artefacto'][:5])))
275
276    if a.json:
277        with open(a.json, 'w', encoding='utf-8') as fh:
278            json.dump({'schema': 'takana-censo-piso/1', 'piso': os.path.basename(piso),
279                       'variantes': informes}, fh, ensure_ascii=False, indent=1, sort_keys=True)
280            fh.write('\n')
281        print('   → %s' % a.json)
282    return 0
283
284
285if __name__ == '__main__':
286    sys.exit(main())