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…
⎇ 32627251b1adcc995958bd60e72226040bff6125
scripts/censo-piso.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(medio): qué parte de la imagen NO sale de su clausura, fichero por fichero
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())