canon_deploy.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. """The canon deploy-hardening bundle (goal 27, after the third review
  2. rejection "the canon never gets installed"): the class of miss was NOT
  3. the install rules or the endpoints — a `meson install --destdir` image
  4. boots and answers the canon API — it was that the running instance had
  5. the canon CODE but no canon DIR (a stale cached package: MANIFEST.usm
  6. 1.0 pre-dated canon/, so `usm manifest deploy` satisfied the deploy from
  7. an old artifact). This bundle pins the deploy-time truths:
  8. - the packaged archive REALLY carries the canon files (a tar inspection
  9. of the .usm package -- skipped when usm is absent)
  10. - the DEPLOYED layout resolves: `meson install --destdir` with a /usr
  11. prefix, then the app booted FROM THAT TREE (binary at
  12. <tmp>/usr/bin/clanker-saddle) in a cwd with NO ./canon and no
  13. CLANKER_CANON_DIR answers /api/saddle/canon with the 15 texts and
  14. serves one document by slug — the exe-relative installed share is the
  15. resolver's first non-override candidate
  16. - the startup health line names the resolved directory and count (and
  17. the empty-shelf shape logs every candidate + the override)
  18. - MANIFEST.usm/meson.build versions moved past 1.0 (the stale-package
  19. class: a re-deploy must build a fresh artifact)
  20. Mind the machine: XDG_DATA_DIRS must include /usr/share (GLib schemas),
  21. web-config.json exists only through `statum-genkeys --out` (a bare
  22. redirect corrupts it), and the deployed app runs OUTSIDE the harness's
  23. WORK dir with its own scratch db and its own port so nothing collides
  24. with the primary shard app.
  25. """
  26. import os
  27. import shutil
  28. import subprocess
  29. import tarfile
  30. import tempfile
  31. import urllib.error
  32. import urllib.request
  33. from harness import * # noqa: F401,F403 — the shared wire helpers
  34. def http_raw(base, method, path, token=None):
  35. """A raw call against an arbitrary base URL (the deployed tree runs
  36. on its own port, not the harness's WEB)."""
  37. request = urllib.request.Request(base + path, method=method)
  38. if token is not None:
  39. request.add_header("Authorization", "Bearer " + token)
  40. try:
  41. with urllib.request.urlopen(request, timeout=15) as response:
  42. return response.status, json.loads(response.read().decode() or "{}")
  43. except urllib.error.HTTPError as error:
  44. body = error.read().decode()
  45. try:
  46. return error.code, json.loads(body)
  47. except ValueError:
  48. return error.code, {"raw": body}
  49. def deploy_tree(target):
  50. """`meson install` the existing build with a /usr prefix into
  51. <target>/usr; returns (ok, log tail). The builddir may carry meson's
  52. default /usr/local prefix (a fresh `meson setup` does) — pin /usr so
  53. the deployed layout is exactly an image-style install: binary at
  54. <target>/usr/bin, canon at <target>/usr/share/clanker-saddle/canon.
  55. The reconfigure regenerates but never rebuilds, and the prefix never
  56. affects the test-run binary's location."""
  57. configure = subprocess.run(
  58. ["meson", "configure", os.path.join(REPO, "builddir"), "-Dprefix=/usr"],
  59. capture_output=True, text=True, timeout=120)
  60. if configure.returncode != 0:
  61. print(configure.stdout[-1000:])
  62. print(configure.stderr[-1000:])
  63. return False, "meson configure -Dprefix=/usr failed"
  64. command = ["meson", "install", "-C", os.path.join(REPO, "builddir"),
  65. "--destdir", target]
  66. done = subprocess.run(command, capture_output=True, text=True, timeout=300)
  67. if done.returncode != 0:
  68. print(done.stdout[-2000:])
  69. print(done.stderr[-2000:])
  70. return False, "meson install failed"
  71. return True, done.stdout[-500:]
  72. def boot_deployed(root, port, binary=None, extra_env=None):
  73. """Boots the DEPLOYED binary (default root/usr/bin/clanker-saddle) in
  74. a cwd with no ./canon; returns the process handle (caller
  75. terminates). `binary` overrides the executed path (the slot-layout
  76. cases boot a copy whose exe-relative share is absent) and
  77. `extra_env` rides the scrubbed environment (USMRAD_APPDIR)."""
  78. run_dir = os.path.join(root, "run")
  79. os.makedirs(run_dir, exist_ok=True)
  80. config = json.load(open(os.path.join(REPO, "web-config.json")))
  81. with open(os.path.join(run_dir, "web-config.json"), "w") as handle:
  82. json.dump(config, handle)
  83. env = dict(os.environ)
  84. env.update({
  85. "SPRY_DB_PATH": os.path.join(root, "deploy.sqlite"),
  86. "SPRY_SEED_ADMIN_USERNAME": "admin",
  87. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  88. # A registered saddle's bearer token is needed for the canon
  89. # routes; the deployed app shares nothing with the primary
  90. # shard, so point its ingest at the shard's fake daemon — its
  91. # /register handshake delivers a token we can read from the
  92. # primary app's /saddles slot. The SADDLE_* knobs keep the
  93. # poller quiet while the shard tears down.
  94. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  95. "SADDLE_HTTP_TIMEOUT_S": "2",
  96. "SADDLE_DISPATCH_TICK_S": "1",
  97. "LD_LIBRARY_PATH": "",
  98. "XDG_DATA_DIRS": "/usr/share",
  99. })
  100. env.pop("CLANKER_CANON_DIR", None)
  101. # a developer machine whose /app carries a clanker canon must not
  102. # leak into the non-slot phases; the slot phases set it explicitly
  103. env.pop("USMRAD_APPDIR", None)
  104. if extra_env:
  105. env.update(extra_env)
  106. log = open(os.path.join(root, "deploy-app.log"), "w")
  107. process = subprocess.Popen(
  108. [binary or os.path.join(root, "usr", "bin", "clanker-saddle"),
  109. str(port)],
  110. cwd=run_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
  111. return process
  112. def wait_boot(base, process, timeout=30):
  113. def ready():
  114. if process.poll() is not None:
  115. return "exited-%d" % process.poll()
  116. try:
  117. with urllib.request.urlopen(base + "/login", timeout=3) as response:
  118. return response.status == 200 and "ready" or False
  119. except (urllib.error.URLError, OSError):
  120. return False
  121. return wait_for(ready, timeout=timeout, what="deployed app ready")
  122. def register_saddle(base, auth, name, host):
  123. """Registers a saddle THROUGH the deployed app (the canon routes
  124. need its own bearer token) and returns the token."""
  125. state = slot_public_on(base, auth, "/saddles", "saddles")
  126. act_on(base, auth, state["register"], {"name": name, "host": host})
  127. rows = [s for s in slot_public_on(base, auth, "/saddles", "saddles")["saddles"]
  128. if s["name"] == name]
  129. return rows[0]["token"]
  130. def act_on(base, auth, action, fields):
  131. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  132. if action.get("private"):
  133. headers["X-Statum-Private"] = action["private"]
  134. request = urllib.request.Request(
  135. base + action.get("uri", ""),
  136. data=urllib.parse.urlencode(fields).encode(), method="POST")
  137. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  138. for key, value in headers.items():
  139. request.add_header(key, value)
  140. with urllib.request.urlopen(request, timeout=15) as response:
  141. return json.loads(response.read().decode() or "{}")
  142. def slot_public_on(base, auth, uri, type_name):
  143. request = urllib.request.Request(
  144. base + "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]))
  145. request.add_header("X-Statum-Slot",
  146. "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
  147. with urllib.request.urlopen(request, timeout=15) as response:
  148. frames = json.loads(response.read().decode())
  149. for directive in frames:
  150. if directive["type"] == "set":
  151. content = directive["snapshot_frame"].get("content")
  152. if not content:
  153. continue
  154. content = json.loads(content)
  155. if content["slot"]["type"] == type_name:
  156. return content["public"]
  157. raise SystemExit("no %s slot in deployed entrypoint state for %s"
  158. % (type_name, uri))
  159. def login_on(base, username, password, timeout=20):
  160. """Login against an arbitrary base URL. The deployed app seeds its
  161. admin from a GLib Idle callback, so /login answering 200 does NOT
  162. mean the seed landed — poll until the auth slot arrives."""
  163. deadline = time.monotonic() + timeout
  164. last_body = ""
  165. while time.monotonic() < deadline:
  166. request = urllib.request.Request(base + "/_statum/entrypoint?uri=%2Flogin")
  167. with urllib.request.urlopen(request, timeout=15) as response:
  168. frames = json.loads(response.read().decode())
  169. content = json.loads(frames[0]["snapshot_frame"]["content"])
  170. action = content["public"]["login"]
  171. request = urllib.request.Request(
  172. base + action.get("uri", ""),
  173. data=urllib.parse.urlencode(
  174. {"username": username, "password": password}).encode(),
  175. method="POST")
  176. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  177. try:
  178. with urllib.request.urlopen(request, timeout=15) as response:
  179. body = response.read().decode()
  180. except urllib.error.HTTPError as error:
  181. last_body = error.read().decode()
  182. time.sleep(0.2)
  183. continue
  184. for directive in json.loads(body):
  185. if directive["type"] == "set":
  186. frame = json.loads(directive["snapshot_frame"]["content"])
  187. if frame["slot"]["type"] == "auth":
  188. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  189. raise SystemExit("deployed login response carried no auth slot (last: %s)"
  190. % last_body[:300])
  191. def run(ctx):
  192. auth, fake, fake_token = ctx.auth, ctx.fake, ctx.fake_token
  193. print("== deploy: the manifest versions moved past 1.0 ==")
  194. manifest = json.load(open(os.path.join(REPO, "MANIFEST.usm")))
  195. check(manifest.get("version") != "1.0"
  196. and manifest.get("version") == "1.1",
  197. "MANIFEST.usm carries the bumped version 1.1 (%s)" % manifest.get("version"))
  198. print("== deploy: the packaged archive carries the canon ==")
  199. # `usm manifest package` writes ../<name>-<version>.usmc relative to
  200. # the repo. Building it here keeps the check honest (a stale cached
  201. # package is exactly the miss class this bundle pins).
  202. usm = shutil.which("usm")
  203. archive = None
  204. if usm is None:
  205. print(" (usm not on PATH — asserting the install rule instead)")
  206. else:
  207. done = subprocess.run([usm, "manifest", "package"],
  208. cwd=REPO, capture_output=True, text=True, timeout=600)
  209. if done.returncode != 0:
  210. print(done.stdout[-1000:])
  211. print(done.stderr[-1000:])
  212. check(done.returncode == 0, "usm manifest package succeeds")
  213. archive = os.path.join(os.path.dirname(REPO),
  214. "clanker-saddle-%s.usmc" % manifest["version"])
  215. check(os.path.exists(archive),
  216. "the .usmc lands beside the repo (%s)" % archive)
  217. if os.path.exists(archive):
  218. with tarfile.open(archive) as bundle:
  219. names = bundle.getnames()
  220. canon_files = [n for n in names
  221. if n.startswith("./canon/") and n.endswith(".md")]
  222. check(len(canon_files) >= 15,
  223. "the archive carries the 15 canon .md files (%d)"
  224. % len(canon_files))
  225. check(any(n.endswith("gnu-manifesto.md") for n in canon_files)
  226. and any(n.endswith("magnifica-humanitas-summary.md")
  227. for n in canon_files),
  228. "the archive's canon includes the anchor texts")
  229. # The version INSIDE the archive must be the bumped one
  230. # (the stale-artifact class: an old 1.0 archive would
  231. # satisfy a deploy with no canon at all).
  232. member = bundle.extractfile("./MANIFEST.usm")
  233. inside = json.loads(member.read().decode())
  234. check(inside.get("version") == manifest["version"],
  235. "the archive's MANIFEST.usm carries version %s (%s)"
  236. % (manifest["version"], inside.get("version")))
  237. print("== deploy: the deployed /usr layout serves the canon ==")
  238. root = tempfile.mkdtemp(prefix="canon-deploy-")
  239. deployed_port = free_port()
  240. deployed_base = "http://127.0.0.1:%d" % deployed_port
  241. ok, _ = deploy_tree(root)
  242. check(ok, "meson install --destdir <tmp>/usr with a /usr prefix")
  243. binary = os.path.join(root, "usr", "bin", "clanker-saddle")
  244. share = os.path.join(root, "usr", "share", "clanker-saddle", "canon")
  245. check(os.path.exists(binary), "the installed binary is at <tmp>/usr/bin")
  246. installed_md = [n for n in (os.listdir(share) if os.path.isdir(share) else [])
  247. if n.endswith(".md")]
  248. check(len(installed_md) >= 15,
  249. "the installed share/clanker-saddle/canon carries 15 texts (%d)"
  250. % len(installed_md))
  251. if not ok or not os.path.exists(binary):
  252. shutil.rmtree(root, ignore_errors=True)
  253. return
  254. process = boot_deployed(root, deployed_port)
  255. try:
  256. outcome = wait_boot(deployed_base, process)
  257. check(outcome == "ready",
  258. "the deployed tree boots from a canon-less cwd (%s)" % outcome)
  259. if outcome != "ready":
  260. return
  261. deployed_auth = login_on(deployed_base, "admin", "adminpass123")
  262. token = register_saddle(deployed_base, deployed_auth,
  263. "deploy-probe", "127.0.0.1:%d" % fake.port)
  264. check(bool(token), "a saddle registered through the deployed app")
  265. status, body = http_raw(deployed_base, "GET", "/api/saddle/canon", token)
  266. check(status == 200 and body.get("ok") is True,
  267. "GET /api/saddle/canon answers ok from the deployed tree")
  268. texts = body.get("texts") or []
  269. check(body.get("count") == 15 and len(texts) == 15,
  270. "the deployed canon lists all 15 texts (%s)" % body.get("count"))
  271. check(os.path.abspath(body.get("source_dir") or "") == share,
  272. "the deployed canon resolved the installed share dir (%s)"
  273. % body.get("source_dir"))
  274. status, doc = http_raw(deployed_base, "GET",
  275. "/api/saddle/canon/free-software-definition", token)
  276. check(status == 200 and doc.get("ok") is True
  277. and doc.get("slug") == "free-software-definition"
  278. and "freedom" in (doc.get("body") or "").lower(),
  279. "the deployed canon serves one document by slug")
  280. print("== deploy: the startup health line ==")
  281. log_text = open(os.path.join(root, "deploy-app.log")).read()
  282. health = [line for line in log_text.splitlines()
  283. if "Canon: 15 texts from" in line]
  284. logged = (health[0].split(" from ", 1)[1].strip()
  285. if health else "")
  286. check(bool(health) and os.path.normpath(logged) == share,
  287. "the startup log names the resolved canon dir and count (%s)"
  288. % (health[:1] or ["absent"]))
  289. print("== deploy: the hero carries the canon count ==")
  290. dash = slot_public_on(deployed_base, deployed_auth, "/", "dashboard")
  291. check(int(dash.get("canon_texts", -1)) == 15,
  292. "the dashboard's public data carries canon_texts = 15 (%s)"
  293. % dash.get("canon_texts"))
  294. finally:
  295. process.terminate()
  296. try:
  297. process.wait(timeout=5)
  298. except subprocess.TimeoutExpired:
  299. process.kill()
  300. print("== deploy: the empty shelf is loud ==")
  301. # Boot the deployed binary in a cwd with no ./canon, no override,
  302. # and the installed share moved AWAY — the loud warning naming every
  303. # candidate is the deploying admin's only symptom, so it must be in
  304. # the log. (The absolute /usr/share and /usr/local/share candidates
  305. # do not exist on this machine — no install outside the tmpdir.)
  306. empty_root = tempfile.mkdtemp(prefix="canon-empty-")
  307. shutil.move(share, os.path.join(empty_root, "canon-parked"))
  308. empty_port = free_port()
  309. process = boot_deployed(root, empty_port)
  310. try:
  311. empty_base = "http://127.0.0.1:%d" % empty_port
  312. outcome = wait_boot(empty_base, process)
  313. check(outcome == "ready", "the canon-less deployed tree still boots (%s)"
  314. % outcome)
  315. log_text = open(os.path.join(root, "deploy-app.log")).read()
  316. check("no canon directory resolved" in log_text
  317. and "CLANKER_CANON_DIR" in log_text,
  318. "the empty shelf logs the loud warning naming the override")
  319. check("/usr/share/clanker-saddle/canon" in log_text
  320. and "/usr/local/share/clanker-saddle/canon" in log_text,
  321. "the warning names the absolute system share candidates")
  322. empty_auth = login_on(empty_base, "admin", "adminpass123")
  323. dash = slot_public_on(empty_base, empty_auth, "/", "dashboard")
  324. check(int(dash.get("canon_texts", -1)) == 0,
  325. "the canon-less dashboard reports canon_texts = 0 (%s)"
  326. % dash.get("canon_texts"))
  327. token = register_saddle(empty_base, empty_auth,
  328. "empty-probe", "127.0.0.1:%d" % fake.port)
  329. status, body = http_raw(empty_base, "GET", "/api/saddle/canon", token)
  330. check(status == 200 and body.get("count") == 0,
  331. "the canon-less API answers an empty library, not an error")
  332. finally:
  333. process.terminate()
  334. try:
  335. process.wait(timeout=5)
  336. except subprocess.TimeoutExpired:
  337. process.kill()
  338. shutil.move(os.path.join(empty_root, "canon-parked"), share)
  339. shutil.rmtree(empty_root, ignore_errors=True)
  340. shutil.rmtree(root, ignore_errors=True)
  341. print("== deploy: the manifest provides the canon as resources ==")
  342. # The slot-fallback miss class: usm's manifest install copies ONLY
  343. # the declared provides resources into the install root, so a
  344. # manifest providing just bin:clanker-saddle deploys a slot with
  345. # the binary and no canon at all — the running instance answers an
  346. # empty library no matter what the resolver tries. Every canon
  347. # file must be declared as a res: resource (the grammar usm's own
  348. # autoprovides emits for share data).
  349. manifest = json.load(open(os.path.join(REPO, "MANIFEST.usm")))
  350. canon_files = sorted(
  351. name for name in os.listdir(os.path.join(REPO, "canon"))
  352. if name.endswith(".md"))
  353. missing = [name for name in canon_files
  354. if "res:clanker-saddle/canon/%s" % name
  355. not in manifest.get("provides", {})]
  356. check(len(canon_files) >= 15,
  357. "the source canon carries the texts (%d)" % len(canon_files))
  358. check(not missing,
  359. "MANIFEST.usm provides every canon .md as a res: resource "
  360. "(missing: %s)" % (missing or "none"))
  361. print("== deploy: the usmrad slot layout serves the canon ==")
  362. # The deployed usmrad slot: <appdir>/<slot>/usr/bin + usr/share,
  363. # with a `current` symlink flipped onto the fresh slot. usmrad
  364. # launches the binary THROUGH that link, so /proc/self/exe sits in
  365. # <appdir>/current/usr/bin and the exe-relative climb finds the
  366. # share. This phase pins that whole layout end-to-end.
  367. slot_root = tempfile.mkdtemp(prefix="canon-slot-")
  368. appdir = os.path.join(slot_root, "app")
  369. ok, _ = deploy_tree(slot_root)
  370. check(ok, "meson install --destdir for the slot layout")
  371. # arrange the usmrad shape: app/a/usr/… + current -> a
  372. os.makedirs(os.path.join(appdir, "a"), exist_ok=True)
  373. shutil.move(os.path.join(slot_root, "usr"), os.path.join(appdir, "a", "usr"))
  374. os.symlink("a", os.path.join(appdir, "current"))
  375. slot_share = os.path.join(appdir, "a", "usr", "share",
  376. "clanker-saddle", "canon")
  377. check(os.path.isdir(slot_share),
  378. "the slot carries usr/share/clanker-saddle/canon")
  379. process = boot_deployed(
  380. slot_root, deployed_port,
  381. binary=os.path.join(appdir, "current", "usr", "bin", "clanker-saddle"),
  382. extra_env={"USMRAD_APPDIR": appdir})
  383. try:
  384. slot_base = "http://127.0.0.1:%d" % deployed_port
  385. outcome = wait_boot(slot_base, process)
  386. check(outcome == "ready", "the slot binary boots through `current` (%s)"
  387. % outcome)
  388. if outcome != "ready":
  389. return
  390. slot_auth = login_on(slot_base, "admin", "adminpass123")
  391. token = register_saddle(slot_base, slot_auth,
  392. "slot-probe", "127.0.0.1:%d" % fake.port)
  393. status, body = http_raw(slot_base, "GET", "/api/saddle/canon", token)
  394. check(status == 200 and body.get("count") == 15,
  395. "the slot layout lists the 15 texts (%s)" % body.get("count"))
  396. status, doc = http_raw(slot_base, "GET",
  397. "/api/saddle/canon/gnu-manifesto", token)
  398. check(status == 200 and doc.get("ok") is True
  399. and "GNU" in (doc.get("title") or ""),
  400. "the slot layout serves a document by slug")
  401. finally:
  402. process.terminate()
  403. try:
  404. process.wait(timeout=5)
  405. except subprocess.TimeoutExpired:
  406. process.kill()
  407. shutil.rmtree(slot_root, ignore_errors=True)
  408. print("== deploy: the slot fallback when the exe share is absent ==")
  409. # The mandate case: the canon exists ONLY in the deployment slot's
  410. # data directory and the system path misses (no /usr/share canon,
  411. # no exe-relative share). The resolver must fall back to
  412. # <appdir>/<slot>/usr/share/… resolved through $USMRAD_APPDIR —
  413. # the exe cannot anchor it once its share neighbours are gone.
  414. bare_root = tempfile.mkdtemp(prefix="canon-bare-")
  415. appdir = os.path.join(bare_root, "app")
  416. ok, _ = deploy_tree(bare_root)
  417. check(ok, "meson install --destdir for the bare-exe layout")
  418. os.makedirs(os.path.join(appdir, "b"), exist_ok=True)
  419. # the slot: share under app/b/usr, the binary copied WITHOUT its
  420. # share neighbours (only the file, no usr tree beside it)
  421. shutil.move(os.path.join(bare_root, "usr", "share"),
  422. os.path.join(appdir, "b", "usr-tmp"))
  423. os.makedirs(os.path.join(appdir, "b", "usr"), exist_ok=True)
  424. shutil.move(os.path.join(appdir, "b", "usr-tmp"),
  425. os.path.join(appdir, "b", "usr", "share"))
  426. os.makedirs(os.path.join(appdir, "bin"), exist_ok=True)
  427. shutil.copy2(os.path.join(bare_root, "usr", "bin", "clanker-saddle"),
  428. os.path.join(appdir, "bin", "clanker-saddle"))
  429. shutil.rmtree(os.path.join(bare_root, "usr"))
  430. os.symlink("b", os.path.join(appdir, "current"))
  431. slot_share = os.path.join(appdir, "b", "usr", "share",
  432. "clanker-saddle", "canon")
  433. check(os.path.isdir(slot_share), "the slot carries the canon data dir")
  434. process = boot_deployed(
  435. bare_root, deployed_port,
  436. binary=os.path.join(appdir, "bin", "clanker-saddle"),
  437. extra_env={"USMRAD_APPDIR": appdir})
  438. try:
  439. bare_base = "http://127.0.0.1:%d" % deployed_port
  440. outcome = wait_boot(bare_base, process)
  441. check(outcome == "ready", "the bare-exe slot binary boots (%s)"
  442. % outcome)
  443. if outcome != "ready":
  444. return
  445. log_text = open(os.path.join(bare_root, "deploy-app.log")).read()
  446. health = [line for line in log_text.splitlines()
  447. if "Canon: 15 texts from" in line]
  448. logged = (health[0].split(" from ", 1)[1].strip()
  449. if health else "")
  450. check(bool(health) and os.path.realpath(logged)
  451. == os.path.realpath(slot_share),
  452. "the bare exe resolves the canon from the slot data dir (%s)"
  453. % (health[:1] or ["absent"]))
  454. bare_auth = login_on(bare_base, "admin", "adminpass123")
  455. token = register_saddle(bare_base, bare_auth,
  456. "bare-probe", "127.0.0.1:%d" % fake.port)
  457. status, body = http_raw(bare_base, "GET", "/api/saddle/canon", token)
  458. check(status == 200 and body.get("count") == 15,
  459. "the slot-only canon lists the 15 texts (%s)" % body.get("count"))
  460. check(os.path.realpath(body.get("source_dir") or "")
  461. == os.path.realpath(slot_share),
  462. "the API reports the slot data dir as the source (%s)"
  463. % body.get("source_dir"))
  464. status, doc = http_raw(bare_base, "GET",
  465. "/api/saddle/canon/free-software-definition",
  466. token)
  467. check(status == 200 and doc.get("ok") is True
  468. and "freedom" in (doc.get("body") or "").lower(),
  469. "the slot-only canon serves one document by slug")
  470. # the warning must NOT fire: the fallback resolved a shelf
  471. check("no canon directory resolved" not in log_text,
  472. "the slot fallback resolves without the empty-shelf warning")
  473. finally:
  474. process.terminate()
  475. try:
  476. process.wait(timeout=5)
  477. except subprocess.TimeoutExpired:
  478. process.kill()
  479. shutil.rmtree(bare_root, ignore_errors=True)
  480. print("== deploy: the higher-precedence share wins over the slot ==")
  481. # The precedence half of the mandate: when the canon exists in a
  482. # HIGHER-precedence location (the system path — represented here
  483. # by the exe-relative share, which outranks the slot in exactly
  484. # the same way the absolute /usr|/usr/local shares do) AND in the
  485. # slot, the higher-precedence shelf is served — the slot fallback
  486. # must never shadow it. A one-text marker shelf distinguishes them.
  487. prec_root = tempfile.mkdtemp(prefix="canon-prec-")
  488. appdir = os.path.join(prec_root, "app")
  489. ok, _ = deploy_tree(prec_root)
  490. check(ok, "meson install --destdir for the precedence layout")
  491. os.makedirs(os.path.join(appdir, "a"), exist_ok=True)
  492. shutil.move(os.path.join(prec_root, "usr", "share"),
  493. os.path.join(appdir, "a", "usr-tmp"))
  494. os.makedirs(os.path.join(appdir, "a", "usr"), exist_ok=True)
  495. shutil.move(os.path.join(appdir, "a", "usr-tmp"),
  496. os.path.join(appdir, "a", "usr", "share"))
  497. os.makedirs(os.path.join(appdir, "bin"), exist_ok=True)
  498. shutil.copy2(os.path.join(prec_root, "usr", "bin", "clanker-saddle"),
  499. os.path.join(appdir, "bin", "clanker-saddle"))
  500. # the higher-precedence shelf: the exe-relative share beside the
  501. # binary, holding ONE marker text
  502. exe_share = os.path.join(appdir, "bin", "..", "share",
  503. "clanker-saddle", "canon")
  504. os.makedirs(exe_share, exist_ok=True)
  505. with open(os.path.join(exe_share, "system-marker.md"), "w") as handle:
  506. handle.write(
  507. "---\nslug: system-marker\ntitle: The System Path\n"
  508. "author: test\nsource: test\nlicence: test\n"
  509. "summary: marks the higher-precedence shelf\n---\n\nsystem\n")
  510. os.symlink("a", os.path.join(appdir, "current"))
  511. process = boot_deployed(
  512. prec_root, deployed_port,
  513. binary=os.path.join(appdir, "bin", "clanker-saddle"),
  514. extra_env={"USMRAD_APPDIR": appdir})
  515. try:
  516. prec_base = "http://127.0.0.1:%d" % deployed_port
  517. outcome = wait_boot(prec_base, process)
  518. check(outcome == "ready", "the precedence binary boots (%s)" % outcome)
  519. if outcome != "ready":
  520. return
  521. prec_auth = login_on(prec_base, "admin", "adminpass123")
  522. token = register_saddle(prec_base, prec_auth,
  523. "prec-probe", "127.0.0.1:%d" % fake.port)
  524. status, body = http_raw(prec_base, "GET", "/api/saddle/canon", token)
  525. check(status == 200
  526. and os.path.abspath(body.get("source_dir") or "")
  527. == os.path.normpath(exe_share),
  528. "the higher-precedence share is served, not the slot (%s)"
  529. % body.get("source_dir"))
  530. check(body.get("count") == 1
  531. and [t.get("slug") for t in (body.get("texts") or [])]
  532. == ["system-marker"],
  533. "the served shelf is the higher-precedence one (%s texts)"
  534. % body.get("count"))
  535. finally:
  536. process.terminate()
  537. try:
  538. process.wait(timeout=5)
  539. except subprocess.TimeoutExpired:
  540. process.kill()
  541. shutil.rmtree(prec_root, ignore_errors=True)