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…
⎇ b0085e7cf6978a7f9e27aa863f756a463fc1407e
scripts/test-atuq-instalacion.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
atuq: dos cosas que el README daba por ciertas eran falsas, y las dos se midieron
1#!/usr/bin/env python3
2"""¿QUIÉN instala de verdad las extensiones de `atuq`: la política o la carpeta? (SDD 26 §7.bis)
3
4 python3 scripts/test-atuq-instalacion.py # los tres escenarios y el veredicto
5 python3 scripts/test-atuq-instalacion.py --scenario as-is # uno solo, para diagnosticar
6
7── POR QUÉ EXISTE ESTA PRUEBA ────────────────────────────────────────────────────────────────
8Dos documentos nuestros se contradecían sobre el mecanismo del que dependen TODAS las extensiones
9de atuq —hoy `inicio` y `proxy`, mañana la de `sct`—:
10
11 · `recipes/atuq/README.md` decía que el *sideloading* desde `distribution/extensions/` «ya no
12 funciona» y que lo que instala es `ExtensionSettings.install_url` de la política;
13 · el §7.bis del SDD 26 midió lo contrario: que `install_url` con `file://` no instala nada y que
14 quien instala es el escaneo de la carpeta.
15
16Los dos mecanismos APUNTAN A LOS MISMOS FICHEROS, así que ninguna observación del artefacto los
17distingue: hay que romper uno por vez y mirar qué pasa. Eso es lo que hace este guión.
18
19── LOS TRES ESCENARIOS ───────────────────────────────────────────────────────────────────────
20 as-is el artefacto tal cual ⇒ las extensiones tienen que estar
21 no-policy `policies.json` SIN `ExtensionSettings` ⇒ si siguen: instala LA CARPETA
22 policy-only los XPI mudados fuera de la carpeta y la
23 política apuntando a la ruta nueva ⇒ si no están: la política NO instala
24
25En los tres se mira lo mismo, y por dos vías independientes:
26
27 el MOTOR una sonda con permiso `browserSettings` pregunta el `homepageOverride`; si el
28 override de `inicio` agarró, es `moz-extension://…/inicio.html`
29 el PERFIL `extensions.json` dice si el complemento ESTÁ y si está `active`
30
31⚠ El `location` de `extensions.json` NO sirve para distinguir el mecanismo, y esto es medido: los
32dos XPI de atuq salen `app-profile` **también cuando los instala el escaneo de la carpeta** y no hay
33ninguna política que los nombre. Se lo imprime igual porque es barato, pero la única señal que
34discrimina es la de arriba: romper un mecanismo por vez y ver si la extensión sigue.
35
36La sonda es además el CONTROL de que la corrida sirvió: vive en `distribution/extensions/` y NO está
37declarada en ninguna política, así que si habla, el escaneo de la carpeta está vivo en esa corrida.
38Una corrida muda no se interpreta como «no instaló»: se declara rota.
39
40REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado.
41"""
42import json
43import os
44import shutil
45import subprocess
46import sys
47import tempfile
48import zipfile
49
50ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
51RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
52ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
53HAMMER = os.path.join(ROOT, "target/release/takana")
54STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
55
56APPDIR = "/usr/lib/atuq"
57EXTDIR = f"{APPDIR}/distribution/extensions"
58ALTDIR = f"{APPDIR}/xpi-politica"
59IDS = ("inicio@atuq.tawasuyu", "proxy@atuq.tawasuyu")
60
61SONDA = "sonda-instalacion@atuq.tawasuyu"
62MANIFIESTO = {
63 "manifest_version": 2,
64 "name": "sonda-instalacion",
65 "version": "1.0",
66 "browser_specific_settings": {"gecko": {"id": SONDA}},
67 "permissions": ["browserSettings"],
68 "background": {"scripts": ["fondo.js"]},
69}
70FONDO = """
71function di(s) { try { dump("INSTALACION-TEST " + s + "\\n"); } catch (e) {} }
72(async () => {
73 try {
74 const h = await browser.browserSettings.homepageOverride.get({});
75 di("HOMEPAGE " + h.value);
76 } catch (e) {
77 di("EXCEPCIÓN " + e.message);
78 }
79})();
80"""
81
82ESCENARIOS = ("as-is", "no-policy", "policy-only")
83
84
85def fatal(msg):
86 print(f"✗ {msg}", file=sys.stderr)
87 raise SystemExit(1)
88
89
90def artefacto():
91 """El artefacto VIGENTE de atuq, por `takana hash` y nunca por glob.
92
93 ⚠ ESCOTILLA `ATUQ_DIR` con su aviso puesto: el corpus es compartido y otro frente puede
94 re-hashear `firefox` dejando el atuq vigente sin sellar durante las horas del build. Con la
95 escotilla se puede medir contra uno viejo A SABIENDAS, y por eso grita.
96 """
97 forzado = os.environ.get("ATUQ_DIR")
98 if forzado:
99 if not os.path.isdir(forzado):
100 fatal(f"ATUQ_DIR={forzado} no existe")
101 print(f"⚠ ATUQ_DIR: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
102 return forzado
103 r = subprocess.run([HAMMER, "--store", STORE, "hash", "recipes/atuq.toml"],
104 cwd=ROOT, capture_output=True, text=True)
105 if r.returncode != 0:
106 fatal(f"no pude hashear atuq: {r.stderr.strip()}")
107 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
108 d = os.path.join(STORE, f"{h}-atuq")
109 if not os.path.isdir(d):
110 fatal(f"atuq b3:{h[:8]} no está sellado — construilo antes")
111 return d
112
113
114def politica_sin_extensionsettings(base):
115 p = json.load(open(base))
116 p["policies"].pop("ExtensionSettings", None)
117 return p
118
119
120def politica_apuntando_a(base, directorio):
121 p = json.load(open(base))
122 for ident, cfg in p["policies"]["ExtensionSettings"].items():
123 cfg["install_url"] = f"file://{directorio}/{ident}.xpi"
124 return p
125
126
127def correr(escenario, atuq):
128 """Una corrida de atuq headless con perfil nuevo. Devuelve (homepage, locations, log)."""
129 tmp = tempfile.mkdtemp(prefix=f"atuq-inst-{escenario}-")
130 salida = os.path.join(tmp, "salida")
131 capa_ext = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution", "extensions")
132 perfil = os.path.join(salida, "perfil")
133 os.makedirs(perfil)
134 os.makedirs(capa_ext)
135 try:
136 with zipfile.ZipFile(os.path.join(capa_ext, f"{SONDA}.xpi"), "w") as z:
137 z.writestr("manifest.json", json.dumps(MANIFIESTO, indent=1))
138 z.writestr("fondo.js", FONDO)
139
140 with open(os.path.join(perfil, "user.js"), "w") as fh:
141 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
142 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
143
144 base = os.path.join(atuq, APPDIR.lstrip("/"), "distribution", "policies.json")
145 preparar = "true"
146 if escenario == "no-policy":
147 nueva = politica_sin_extensionsettings(base)
148 elif escenario == "policy-only":
149 nueva = politica_apuntando_a(base, ALTDIR)
150 preparar = (f"mkdir -p {ALTDIR} && "
151 + " && ".join(f"mv {EXTDIR}/{i}.xpi {ALTDIR}/{i}.xpi" for i in IDS))
152 else:
153 nueva = None
154 if nueva is not None:
155 capa_pol = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution")
156 with open(os.path.join(capa_pol, "policies.json"), "w") as fh:
157 json.dump(nueva, fh, indent=2)
158
159 guion = f"""
160 mkdir -p /root
161 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
162 {preparar}
163 timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless about:blank \
164 > /salida/consola.log 2>&1
165 cp /salida/perfil/extensions.json /salida/extensions.json 2>/dev/null || true
166 exit 0
167 """
168 subprocess.run([
169 "bwrap",
170 "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
171 "--overlay-src", os.path.join(tmp, "capa"), "--tmp-overlay", "/",
172 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
173 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
174 "--bind", salida, "/salida",
175 "--uid", "0", "--gid", "0",
176 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
177 "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "HOME", "/root",
178 "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
179 "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
180 "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
181 "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
182 "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
183 "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
184 "sh", "-c", guion,
185 ], check=False)
186
187 log = open(os.path.join(salida, "consola.log"), errors="replace").read()
188 homepage = None
189 for l in log.splitlines():
190 if l.startswith("INSTALACION-TEST HOMEPAGE "):
191 homepage = l.split(" ", 2)[2]
192 locations = {}
193 ej = os.path.join(salida, "extensions.json")
194 if os.path.exists(ej):
195 try:
196 for a in json.load(open(ej)).get("addons", []):
197 if a.get("id") in IDS or a.get("id") == SONDA:
198 locations[a["id"]] = (a.get("location"), bool(a.get("active")))
199 except (ValueError, KeyError) as e:
200 print(f" ⚠ no pude leer extensions.json: {e}")
201 return homepage, locations, log
202 finally:
203 shutil.rmtree(tmp, ignore_errors=True)
204
205
206def main():
207 argv = sys.argv[1:]
208 pedido = None
209 if "--scenario" in argv:
210 pedido = argv[argv.index("--scenario") + 1]
211 if pedido not in ESCENARIOS:
212 fatal(f"escenario desconocido: {pedido} (hay {', '.join(ESCENARIOS)})")
213 if not os.path.isdir(RFS):
214 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
215
216 atuq = artefacto()
217 print(f"artefacto: {os.path.basename(atuq)}")
218 res = {}
219 for e in (ESCENARIOS if pedido is None else (pedido,)):
220 print(f"\n== escenario {e}")
221 homepage, locations, log = correr(e, atuq)
222 nuestra = bool(homepage and "inicio.html" in homepage)
223 print(f" MOTOR homepage = {homepage}"
224 f" ⇒ {'la de atuq' if nuestra else 'la de Firefox'}")
225 if not homepage:
226 destino = os.path.join(ROOT, "work", f"atuq-instalacion-{e}.log")
227 os.makedirs(os.path.dirname(destino), exist_ok=True)
228 open(destino, "w").write(log)
229 fatal(f"la sonda no dijo NADA en «{e}» — corrida rota, no resultado. Log en {destino}")
230 for ident, (loc, activo) in sorted(locations.items()):
231 print(f" PERFIL {ident:34s} location={loc} activo={activo}")
232 for ident in IDS:
233 if ident not in locations:
234 print(f" PERFIL {ident:34s} NO ESTÁ en extensions.json")
235 res[e] = (nuestra, locations)
236
237 if pedido is not None:
238 return 0
239
240 print()
241 fallos = []
242 if not res["as-is"][0]:
243 fallos.append("tal cual, la página de inicio NO es la de atuq — atuq está roto, "
244 "no es un problema del mecanismo")
245 if not res["no-policy"][0]:
246 fallos.append("sin `ExtensionSettings` las extensiones NO se instalan ⇒ el escaneo de "
247 "`distribution/extensions/` YA NO alcanza: hay que revisar el §7.bis y el "
248 "README, porque todas las extensiones de atuq cuelgan de ahí")
249 if res["policy-only"][0]:
250 fallos.append("con los XPI FUERA de la carpeta y sólo la política apuntándolos, las "
251 "extensiones SÍ se instalaron ⇒ `install_url` con `file://` ahora funciona "
252 "y el §7.bis quedó viejo")
253 if fallos:
254 for f in fallos:
255 print(f"✗ {f}", file=sys.stderr)
256 return 1
257 print("✓ quien instala es el ESCANEO de `distribution/extensions/`: sin la política siguen "
258 "puestas, y con la política sola no aparecen.")
259 print(" La política las FIJA y las CONFIGURA; instalarlas, no. (SDD 26 §7.bis)")
260 return 0
261
262
263sys.exit(main())