canon_deploy.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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):
  73. """Boots the DEPLOYED binary (root/usr/bin/clanker-saddle) in a cwd
  74. with no ./canon; returns the process handle (caller terminates)."""
  75. run_dir = os.path.join(root, "run")
  76. os.makedirs(run_dir, exist_ok=True)
  77. config = json.load(open(os.path.join(REPO, "web-config.json")))
  78. with open(os.path.join(run_dir, "web-config.json"), "w") as handle:
  79. json.dump(config, handle)
  80. env = dict(os.environ)
  81. env.update({
  82. "SPRY_DB_PATH": os.path.join(root, "deploy.sqlite"),
  83. "SPRY_SEED_ADMIN_USERNAME": "admin",
  84. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  85. # A registered saddle's bearer token is needed for the canon
  86. # routes; the deployed app shares nothing with the primary
  87. # shard, so point its ingest at the shard's fake daemon — its
  88. # /register handshake delivers a token we can read from the
  89. # primary app's /saddles slot. The SADDLE_* knobs keep the
  90. # poller quiet while the shard tears down.
  91. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  92. "SADDLE_HTTP_TIMEOUT_S": "2",
  93. "SADDLE_DISPATCH_TICK_S": "1",
  94. "LD_LIBRARY_PATH": "",
  95. "XDG_DATA_DIRS": "/usr/share",
  96. })
  97. env.pop("CLANKER_CANON_DIR", None)
  98. log = open(os.path.join(root, "deploy-app.log"), "w")
  99. process = subprocess.Popen(
  100. [os.path.join(root, "usr", "bin", "clanker-saddle"), str(port)],
  101. cwd=run_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
  102. return process
  103. def wait_boot(base, process, timeout=30):
  104. def ready():
  105. if process.poll() is not None:
  106. return "exited-%d" % process.poll()
  107. try:
  108. with urllib.request.urlopen(base + "/login", timeout=3) as response:
  109. return response.status == 200 and "ready" or False
  110. except (urllib.error.URLError, OSError):
  111. return False
  112. return wait_for(ready, timeout=timeout, what="deployed app ready")
  113. def register_saddle(base, auth, name, host):
  114. """Registers a saddle THROUGH the deployed app (the canon routes
  115. need its own bearer token) and returns the token."""
  116. state = slot_public_on(base, auth, "/saddles", "saddles")
  117. act_on(base, auth, state["register"], {"name": name, "host": host})
  118. rows = [s for s in slot_public_on(base, auth, "/saddles", "saddles")["saddles"]
  119. if s["name"] == name]
  120. return rows[0]["token"]
  121. def act_on(base, auth, action, fields):
  122. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  123. if action.get("private"):
  124. headers["X-Statum-Private"] = action["private"]
  125. request = urllib.request.Request(
  126. base + action.get("uri", ""),
  127. data=urllib.parse.urlencode(fields).encode(), method="POST")
  128. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  129. for key, value in headers.items():
  130. request.add_header(key, value)
  131. with urllib.request.urlopen(request, timeout=15) as response:
  132. return json.loads(response.read().decode() or "{}")
  133. def slot_public_on(base, auth, uri, type_name):
  134. request = urllib.request.Request(
  135. base + "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]))
  136. request.add_header("X-Statum-Slot",
  137. "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
  138. with urllib.request.urlopen(request, timeout=15) as response:
  139. frames = json.loads(response.read().decode())
  140. for directive in frames:
  141. if directive["type"] == "set":
  142. content = directive["snapshot_frame"].get("content")
  143. if not content:
  144. continue
  145. content = json.loads(content)
  146. if content["slot"]["type"] == type_name:
  147. return content["public"]
  148. raise SystemExit("no %s slot in deployed entrypoint state for %s"
  149. % (type_name, uri))
  150. def login_on(base, username, password, timeout=20):
  151. """Login against an arbitrary base URL. The deployed app seeds its
  152. admin from a GLib Idle callback, so /login answering 200 does NOT
  153. mean the seed landed — poll until the auth slot arrives."""
  154. deadline = time.monotonic() + timeout
  155. last_body = ""
  156. while time.monotonic() < deadline:
  157. request = urllib.request.Request(base + "/_statum/entrypoint?uri=%2Flogin")
  158. with urllib.request.urlopen(request, timeout=15) as response:
  159. frames = json.loads(response.read().decode())
  160. content = json.loads(frames[0]["snapshot_frame"]["content"])
  161. action = content["public"]["login"]
  162. request = urllib.request.Request(
  163. base + action.get("uri", ""),
  164. data=urllib.parse.urlencode(
  165. {"username": username, "password": password}).encode(),
  166. method="POST")
  167. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  168. try:
  169. with urllib.request.urlopen(request, timeout=15) as response:
  170. body = response.read().decode()
  171. except urllib.error.HTTPError as error:
  172. last_body = error.read().decode()
  173. time.sleep(0.2)
  174. continue
  175. for directive in json.loads(body):
  176. if directive["type"] == "set":
  177. frame = json.loads(directive["snapshot_frame"]["content"])
  178. if frame["slot"]["type"] == "auth":
  179. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  180. raise SystemExit("deployed login response carried no auth slot (last: %s)"
  181. % last_body[:300])
  182. def run(ctx):
  183. auth, fake, fake_token = ctx.auth, ctx.fake, ctx.fake_token
  184. print("== deploy: the manifest versions moved past 1.0 ==")
  185. manifest = json.load(open(os.path.join(REPO, "MANIFEST.usm")))
  186. check(manifest.get("version") != "1.0"
  187. and manifest.get("version") == "1.1",
  188. "MANIFEST.usm carries the bumped version 1.1 (%s)" % manifest.get("version"))
  189. print("== deploy: the packaged archive carries the canon ==")
  190. # `usm manifest package` writes ../<name>-<version>.usmc relative to
  191. # the repo. Building it here keeps the check honest (a stale cached
  192. # package is exactly the miss class this bundle pins).
  193. usm = shutil.which("usm")
  194. archive = None
  195. if usm is None:
  196. print(" (usm not on PATH — asserting the install rule instead)")
  197. else:
  198. done = subprocess.run([usm, "manifest", "package"],
  199. cwd=REPO, capture_output=True, text=True, timeout=600)
  200. if done.returncode != 0:
  201. print(done.stdout[-1000:])
  202. print(done.stderr[-1000:])
  203. check(done.returncode == 0, "usm manifest package succeeds")
  204. archive = os.path.join(os.path.dirname(REPO),
  205. "clanker-saddle-%s.usmc" % manifest["version"])
  206. check(os.path.exists(archive),
  207. "the .usmc lands beside the repo (%s)" % archive)
  208. if os.path.exists(archive):
  209. with tarfile.open(archive) as bundle:
  210. names = bundle.getnames()
  211. canon_files = [n for n in names
  212. if n.startswith("./canon/") and n.endswith(".md")]
  213. check(len(canon_files) >= 15,
  214. "the archive carries the 15 canon .md files (%d)"
  215. % len(canon_files))
  216. check(any(n.endswith("gnu-manifesto.md") for n in canon_files)
  217. and any(n.endswith("magnifica-humanitas-summary.md")
  218. for n in canon_files),
  219. "the archive's canon includes the anchor texts")
  220. # The version INSIDE the archive must be the bumped one
  221. # (the stale-artifact class: an old 1.0 archive would
  222. # satisfy a deploy with no canon at all).
  223. member = bundle.extractfile("./MANIFEST.usm")
  224. inside = json.loads(member.read().decode())
  225. check(inside.get("version") == manifest["version"],
  226. "the archive's MANIFEST.usm carries version %s (%s)"
  227. % (manifest["version"], inside.get("version")))
  228. print("== deploy: the deployed /usr layout serves the canon ==")
  229. root = tempfile.mkdtemp(prefix="canon-deploy-")
  230. deployed_port = free_port()
  231. deployed_base = "http://127.0.0.1:%d" % deployed_port
  232. ok, _ = deploy_tree(root)
  233. check(ok, "meson install --destdir <tmp>/usr with a /usr prefix")
  234. binary = os.path.join(root, "usr", "bin", "clanker-saddle")
  235. share = os.path.join(root, "usr", "share", "clanker-saddle", "canon")
  236. check(os.path.exists(binary), "the installed binary is at <tmp>/usr/bin")
  237. installed_md = [n for n in (os.listdir(share) if os.path.isdir(share) else [])
  238. if n.endswith(".md")]
  239. check(len(installed_md) >= 15,
  240. "the installed share/clanker-saddle/canon carries 15 texts (%d)"
  241. % len(installed_md))
  242. if not ok or not os.path.exists(binary):
  243. shutil.rmtree(root, ignore_errors=True)
  244. return
  245. process = boot_deployed(root, deployed_port)
  246. try:
  247. outcome = wait_boot(deployed_base, process)
  248. check(outcome == "ready",
  249. "the deployed tree boots from a canon-less cwd (%s)" % outcome)
  250. if outcome != "ready":
  251. return
  252. deployed_auth = login_on(deployed_base, "admin", "adminpass123")
  253. token = register_saddle(deployed_base, deployed_auth,
  254. "deploy-probe", "127.0.0.1:%d" % fake.port)
  255. check(bool(token), "a saddle registered through the deployed app")
  256. status, body = http_raw(deployed_base, "GET", "/api/saddle/canon", token)
  257. check(status == 200 and body.get("ok") is True,
  258. "GET /api/saddle/canon answers ok from the deployed tree")
  259. texts = body.get("texts") or []
  260. check(body.get("count") == 15 and len(texts) == 15,
  261. "the deployed canon lists all 15 texts (%s)" % body.get("count"))
  262. check(os.path.abspath(body.get("source_dir") or "") == share,
  263. "the deployed canon resolved the installed share dir (%s)"
  264. % body.get("source_dir"))
  265. status, doc = http_raw(deployed_base, "GET",
  266. "/api/saddle/canon/free-software-definition", token)
  267. check(status == 200 and doc.get("ok") is True
  268. and doc.get("slug") == "free-software-definition"
  269. and "freedom" in (doc.get("body") or "").lower(),
  270. "the deployed canon serves one document by slug")
  271. print("== deploy: the startup health line ==")
  272. log_text = open(os.path.join(root, "deploy-app.log")).read()
  273. health = [line for line in log_text.splitlines()
  274. if "Canon: 15 texts from" in line]
  275. logged = (health[0].split(" from ", 1)[1].strip()
  276. if health else "")
  277. check(bool(health) and os.path.normpath(logged) == share,
  278. "the startup log names the resolved canon dir and count (%s)"
  279. % (health[:1] or ["absent"]))
  280. print("== deploy: the hero carries the canon count ==")
  281. dash = slot_public_on(deployed_base, deployed_auth, "/", "dashboard")
  282. check(int(dash.get("canon_texts", -1)) == 15,
  283. "the dashboard's public data carries canon_texts = 15 (%s)"
  284. % dash.get("canon_texts"))
  285. finally:
  286. process.terminate()
  287. try:
  288. process.wait(timeout=5)
  289. except subprocess.TimeoutExpired:
  290. process.kill()
  291. print("== deploy: the empty shelf is loud ==")
  292. # Boot the deployed binary in a cwd with no ./canon, no override,
  293. # and the installed share moved AWAY — the loud warning naming every
  294. # candidate is the deploying admin's only symptom, so it must be in
  295. # the log. (The absolute /usr/share and /usr/local/share candidates
  296. # do not exist on this machine — no install outside the tmpdir.)
  297. empty_root = tempfile.mkdtemp(prefix="canon-empty-")
  298. shutil.move(share, os.path.join(empty_root, "canon-parked"))
  299. empty_port = free_port()
  300. process = boot_deployed(root, empty_port)
  301. try:
  302. empty_base = "http://127.0.0.1:%d" % empty_port
  303. outcome = wait_boot(empty_base, process)
  304. check(outcome == "ready", "the canon-less deployed tree still boots (%s)"
  305. % outcome)
  306. log_text = open(os.path.join(root, "deploy-app.log")).read()
  307. check("no canon directory resolved" in log_text
  308. and "CLANKER_CANON_DIR" in log_text,
  309. "the empty shelf logs the loud warning naming the override")
  310. check("/usr/share/clanker-saddle/canon" in log_text
  311. and "/usr/local/share/clanker-saddle/canon" in log_text,
  312. "the warning names the absolute system share candidates")
  313. empty_auth = login_on(empty_base, "admin", "adminpass123")
  314. dash = slot_public_on(empty_base, empty_auth, "/", "dashboard")
  315. check(int(dash.get("canon_texts", -1)) == 0,
  316. "the canon-less dashboard reports canon_texts = 0 (%s)"
  317. % dash.get("canon_texts"))
  318. token = register_saddle(empty_base, empty_auth,
  319. "empty-probe", "127.0.0.1:%d" % fake.port)
  320. status, body = http_raw(empty_base, "GET", "/api/saddle/canon", token)
  321. check(status == 200 and body.get("count") == 0,
  322. "the canon-less API answers an empty library, not an error")
  323. finally:
  324. process.terminate()
  325. try:
  326. process.wait(timeout=5)
  327. except subprocess.TimeoutExpired:
  328. process.kill()
  329. shutil.move(os.path.join(empty_root, "canon-parked"), share)
  330. shutil.rmtree(empty_root, ignore_errors=True)
  331. shutil.rmtree(root, ignore_errors=True)