#!/usr/bin/env python3 """censo-piso.py — qué parte de una imagen NO sale de su clausura, medido fichero por fichero. ── POR QUÉ EXISTE ────────────────────────────────────────────────────────────────────────────── Una imagen de perfil se arma poniendo el PISO (`product-rootfs`, la salida de `takana bootstrap product` — **no hay receta que lo produzca**) y fundiendo encima la clausura de cada perfil con `cp -a -l -n`: *gana el primero*, y el primero es el piso. O sea que todo camino que el piso trae y un perfil también, en la imagen sale **del piso**, no de la clausura que el `os-release` declara. Eso no se veía en ningún número. Medido el 2026-10-15 sobre la variante `mirada` (`base` + `escritorio-mirada`, 122 nodos, 19.997 ficheros de perfil): el piso trae 690 ficheros (413 enlaces) y TAPA 418 caminos de la clausura: · 183 por SYMLINK — `bin/ps`, `bin/mount`, `bin/sed`, `bin/tar`, `bin/su`, `bin/login`, `bin/dmesg`… todos a `busybox`, con procps-ng / util-linux / sed / tar / shadow en la clausura, declarados y sin usarse · 235 por FICHERO REAL — un binario del bootstrap ganándole al declarado, sin symlink que lo delate. La primera versión de este censo no los contaba: 235 caminos fuera del número. ⇒ de los 418, **187 son de EJECUCIÓN** (uutils 93 · util-linux 40 · kbd 8 · xz-tools 6 · procps-ng 5 · e2fsprogs 4 …) y 231 de tiempo de build (las cabeceras de musl y sus `crt*.o`, que una imagen de producto no tendría que llevar de ningún lado). y aporta, único suyo: · 230 applets de busybox que ningún perfil provee · 42 ficheros REALES: OpenSSH entero —`escritorio-mirada`+`base` **no declaran openssh**, así que el ssh del medio del escritorio sale del piso— más `arje-zero` (PID 1), `hammerd`, `netup`, `rg`, `ente/attest.json`, `ente/seed.card.json`, `etc/passwd`, `etc/group`, `bin/busybox` y `usr/lib/libc.a` En `servidor` y `full` aparece además `openssh 32` entre las tapadas: esas variantes SÍ lo declaran, y el piso les gana con el binario de agosto. ⇒ **Las dos mitades del trabajo que falta para una imagen definitiva salen de acá**: los 183 son deuda a retirar (el camino lo abrió `takana c51c2bf3`, que liberó sólo los 16 con `[[setuid]]` declarado, con el argumento de que a un symlink no se le puede poner el bit); los 42 son lo que alguna receta y algún perfil tendrán que declarar para que el piso pueda irse. ⚠ **No se tocan 183 caminos de una pasada.** Reemplazar un applet por el binario de upstream cambia banderas y puede romper un script del arranque — está escrito en `docs/plan-botar-busybox.md` y en el propio `compone()`. Esto MIDE; retirar es trabajo con su propia verificación. ## Lo que este censo NO dice No mira contenido: dos ficheros con el mismo camino se cuentan como colisión aunque fueran el mismo byte. Para los symlinks a busybox eso da igual (el destino es otro binario), y es justamente el caso que importa. Uso: censo-piso.py [--json docs/state/piso.json] [--variante mirada] [--piso DIR] (sin `--variante`: todas las que `instalador-variantes.sh` sabe armar) """ import argparse import importlib.util import json import os import sys RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Las variantes y sus perfiles, en el MISMO orden en que `instalador-variantes.sh` los funde — el # orden es lo que decide quién gana, así que una copia desordenada mediría otra imagen. # ⚠ Es una segunda lista y se sabe: la primera está en `perfiles_de()` de ese guion, en shell. Se # comprueba contra ella (`--comprobar-contra`) para que la divergencia se note en vez de mentir. VARIANTES = { 'base': [], 'servidor': ['base', 'servidor'], 'mirada': ['base', 'escritorio-mirada'], 'full': ['base', 'cli', 'escritorio-mirada', 'escritorio-sway', 'escritorio-cosmic', 'escritorio-gnome', 'escritorio-kde', 'servidor'], } def _cargar(nombre, ruta): spec = importlib.util.spec_from_file_location(nombre, ruta) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def piso_por_defecto(): """El MISMO criterio que `instalador-variantes.sh`: el `product-rootfs` más reciente. ⚠ Y ese criterio es por MTIME, no por hash: `ls -dt | head -1`. Dos máquinas con los mismos artefactos pueden elegir pisos distintos, y el censo saldría distinto sin que nada cambie. Se informa el que se usó para que el número sea interpretable. """ cands = [] for d in os.listdir('/store') if os.path.isdir('/store') else []: if d.endswith('-product-rootfs'): p = os.path.join('/store', d) cands.append((os.path.getmtime(p), p)) if not cands: return None return sorted(cands, reverse=True)[0][1] def ficheros_de(raiz): """{ruta relativa: es_enlace}. Los symlinks a directorio cuentan como entrada, no se recorren.""" salida = {} for base, dirs, archivos in os.walk(raiz): if '/.hammer' in base: continue for d in list(dirs): p = os.path.join(base, d) if os.path.islink(p): salida[os.path.relpath(p, raiz)] = True dirs.remove(d) for f in archivos: p = os.path.join(base, f) rel = os.path.relpath(p, raiz) if rel.startswith('.hammer'): continue salida[rel] = os.path.islink(p) return salida def es_de_build(rel): """¿Es un fichero de TIEMPO DE BUILD y no de ejecución? ⚠ La distinción no es cosmética y casi me hizo publicar un titular falso (2026-10-15). El recuento bruto ponía `musl-shared` primero con 231 caminos tapados, lo que se lee como «la libc de la imagen no es la declarada». Mirándolos: **218 son cabeceras `usr/include/*.h` y 13 son `crt1.o`/`libm.a`/`libpthread.a`** — y `libc.so` / `ld-musl-*` NO están en la lista, o sea que el runtime no está tapado en absoluto. Mezclar las dos clases infla el número con lo inocuo y esconde lo que sí importa: `uutils` 93 y `util-linux` 40, que son los comandos que alguien escribe. (Y de paso dice otra cosa: una imagen de PRODUCTO no tiene por qué llevar 218 cabeceras de C ni los `crt*.o`, vengan del piso o del perfil.) """ return (rel.startswith('usr/include/') or rel.startswith('include/') or rel.endswith(('.a', '.o', '.pc', '.h', '.hpp')) or '/pkgconfig/' in rel or '/cmake/' in rel) def censo(variante, perfiles, piso, cp): memb = cp.yupana.membresia_imagen() pedidos = set(perfiles) union = sorted(k for k, v in memb.items() if pedidos & set(v) and k[0] != 'wanted') hashes, _ = cp.mapa_hashes() de_perfil, sin_artefacto = {}, [] for clave in union: h = (hashes.get(clave, ('', ''))[0] or '').replace('b3:', '') d = '/store/%s-%s' % (h, clave[1]) if not h or not os.path.isdir(d): sin_artefacto.append('%s/%s' % clave) continue for rel in ficheros_de(d): de_perfil.setdefault(rel, clave[1]) # ⚠ DOS CLASES DE TAPADO, y la primera versión de este censo sólo contaba una. Un symlink del # piso que tapa un binario declarado se ve enseguida (`bin/ps → busybox`). Pero el piso también # trae FICHEROS REALES en caminos que un perfil declara, y ahí gana un binario del bootstrap # —de otra fecha, de otra cadena— sobre el de la clausura, sin symlink que lo delate. Contar # sólo los enlaces dejaba 235 caminos fuera del número en la variante `mirada`. tapados, tapados_reales, unicos_reales, unicos_enlace = [], [], [], [] if piso and os.path.isdir(piso): for rel, es_enlace in ficheros_de(piso).items(): if rel in de_perfil: if es_enlace: tapados.append({'ruta': rel, 'destino': os.readlink(os.path.join(piso, rel)), 'lo_declara': de_perfil[rel]}) else: tapados_reales.append({'ruta': rel, 'lo_declara': de_perfil[rel]}) elif es_enlace: unicos_enlace.append(rel) else: unicos_reales.append(rel) # Quién pierde más caminos: es el orden de ataque para retirar el piso. # Dos tablas: lo que tapa un comando que alguien ejecuta, y lo que tapa un fichero de build. por_receta, por_receta_build = {}, {} for t in tapados + tapados_reales: destino = por_receta_build if es_de_build(t['ruta']) else por_receta destino[t['lo_declara']] = destino.get(t['lo_declara'], 0) + 1 n_build = sum(por_receta_build.values()) return { 'variante': variante, 'perfiles': perfiles, 'nodos': len(union), 'ficheros_de_perfil': len(de_perfil), 'nodos_sin_artefacto': sin_artefacto, 'piso': os.path.basename(piso) if piso else None, 'tapados': sorted(tapados, key=lambda t: t['ruta']), 'n_tapados': len(tapados), 'tapados_reales': sorted(tapados_reales, key=lambda t: t['ruta']), 'n_tapados_reales': len(tapados_reales), 'tapados_por_receta': dict(sorted(por_receta.items(), key=lambda x: (-x[1], x[0]))), 'tapados_por_receta_build': dict(sorted(por_receta_build.items(), key=lambda x: (-x[1], x[0]))), 'n_tapados_de_build': n_build, 'solo_del_piso_reales': sorted(unicos_reales), 'solo_del_piso_enlaces': len(unicos_enlace), } def perfiles_del_guion(): """Lee `perfiles_de()` de `instalador-variantes.sh` para comprobar que no divergimos.""" ruta = os.path.join(RAIZ, 'scripts', 'instalador-variantes.sh') try: txt = open(ruta, encoding='utf-8').read() except OSError: return None ini = txt.find('perfiles_de()') if ini < 0: return None cuerpo = txt[ini:txt.find('\n}', ini)] # ⚠ Las ramas del `case` son `variante:medio`, y una variante tiene MÁS DE UNA: hoy # `servidor:run` da `base servidor-minimo` y `servidor:*` da `base servidor`. Un parser que se # quedaba con la primera rama comparaba la del medio `run` contra la del medio por defecto y # avisaba de una divergencia que no existía — lo dijo en su primera corrida. Se toma la rama # `:*`, que es la del medio que arma `instalador-variantes.sh` por defecto. vistos = {} for linea in cuerpo.splitlines(): linea = linea.strip() if 'echo' not in linea or ')' not in linea: continue patron = linea.split(')', 1)[0].strip() if ':' not in patron: continue variante, _, medio = patron.partition(':') if medio != '*': continue val = linea.split('echo', 1)[1].strip().strip(';').strip().strip('"') vistos[variante] = val.split() return vistos def main(): ap = argparse.ArgumentParser() ap.add_argument('--json', help='escribir el informe acá') ap.add_argument('--variante', action='append', help='sólo ésta (repetible)') ap.add_argument('--piso', help='el product-rootfs a medir (def: el más reciente)') a = ap.parse_args() cp = _cargar('cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py')) piso = a.piso or piso_por_defecto() if not piso: print('no encuentro ningún product-rootfs en /store', file=sys.stderr) return 1 # La comprobación contra la fuente de verdad, antes de medir nada. del_guion = perfiles_del_guion() if del_guion: for v, p in VARIANTES.items(): suyo = del_guion.get(v) if suyo is not None and suyo != p: print('⚠ divergencia con instalador-variantes.sh en «%s»: acá %s · allá %s' % (v, p, suyo), file=sys.stderr) quiero = a.variante or [v for v in VARIANTES if VARIANTES[v]] informes = [] for v in quiero: if v not in VARIANTES: print('variante desconocida: %s' % v, file=sys.stderr) return 2 if not VARIANTES[v]: continue informes.append(censo(v, VARIANTES[v], piso, cp)) print('== el piso contra la clausura · %s ==' % os.path.basename(piso)) for r in informes: print(' %-10s %4d nodos · %5d ficheros de perfil · TAPA %3d por enlace + %3d por fichero real' ' · aporta %2d propios + %3d applets' % (r['variante'], r['nodos'], r['ficheros_de_perfil'], r['n_tapados'], r['n_tapados_reales'], len(r['solo_del_piso_reales']), r['solo_del_piso_enlaces'])) n_ejec = r['n_tapados'] + r['n_tapados_reales'] - r['n_tapados_de_build'] print(' de ésos, %3d son de EJECUCIÓN (lo que alguien escribe) y %3d de tiempo de build' % (n_ejec, r['n_tapados_de_build'])) peor = list(r['tapados_por_receta'].items())[:6] if peor: print(' recetas más tapadas en ejecución: %s' % ' · '.join('%s %d' % kv for kv in peor)) if r['nodos_sin_artefacto']: print(' ⚠ %d nodo(s) sin artefacto ⇒ sus ficheros NO se contaron: %s' % (len(r['nodos_sin_artefacto']), ', '.join(r['nodos_sin_artefacto'][:5]))) if a.json: with open(a.json, 'w', encoding='utf-8') as fh: json.dump({'schema': 'takana-censo-piso/1', 'piso': os.path.basename(piso), 'variantes': informes}, fh, ensure_ascii=False, indent=1, sort_keys=True) fh.write('\n') print(' → %s' % a.json) return 0 if __name__ == '__main__': sys.exit(main())