Bladeren bron

Merge branch 'goal/advisor' (canon deployment-slot fallback) into web-stack

saddle-Clanker-Saddle 4 weken geleden
bovenliggende
commit
6c2c89be75
3 gewijzigde bestanden met toevoegingen van 308 en 25 verwijderingen
  1. 20 3
      MANIFEST.usm
  2. 70 18
      src/Services/CanonService.vala
  3. 218 4
      test/bundles/canon_deploy.py

+ 20 - 3
MANIFEST.usm

@@ -1,11 +1,28 @@
 {
   "name": "clanker-saddle",
   "version": "1.1",
-  "summary": "ClankerSaddle — LLM task orchestration platform (Statum + Spry)",
+  "summary": "ClankerSaddle \u2014 LLM task orchestration platform (Statum + Spry)",
   "licences": [],
-  "flags": ["ninjaStyleProgress"],
+  "flags": [
+    "ninjaStyleProgress"
+  ],
   "provides": {
-    "bin:clanker-saddle": "as-expected"
+    "bin:clanker-saddle": "as-expected",
+    "res:clanker-saddle/canon/gnu-manifesto.md": "as-expected",
+    "res:clanker-saddle/canon/free-software-definition.md": "as-expected",
+    "res:clanker-saddle/canon/open-source-misses-the-point.md": "as-expected",
+    "res:clanker-saddle/canon/right-to-read.md": "as-expected",
+    "res:clanker-saddle/canon/selling-free-software.md": "as-expected",
+    "res:clanker-saddle/canon/selling-exceptions.md": "as-expected",
+    "res:clanker-saddle/canon/words-to-avoid.md": "as-expected",
+    "res:clanker-saddle/canon/misinterpreting-copyright.md": "as-expected",
+    "res:clanker-saddle/canon/university-and-the-web.md": "as-expected",
+    "res:clanker-saddle/canon/plato-apology.md": "as-expected",
+    "res:clanker-saddle/canon/plato-republic-vi.md": "as-expected",
+    "res:clanker-saddle/canon/aristotle-nicomachean-ethics-i-ii.md": "as-expected",
+    "res:clanker-saddle/canon/aquinas-treatise-on-happiness.md": "as-expected",
+    "res:clanker-saddle/canon/lewis-abolition-of-man-i-ii.md": "as-expected",
+    "res:clanker-saddle/canon/magnifica-humanitas-summary.md": "as-expected"
   },
   "depends": {
     "runtime": [

+ 70 - 18
src/Services/CanonService.vala

@@ -184,9 +184,11 @@ namespace ClankerSaddle.Services {
          * absolute system shares (/usr/share, /usr/local/share — the
          * packaged layout where /proc/self/exe's neighbours are not
          * the install prefix, e.g. a container image with the binary
-         * copied elsewhere), then the source tree (<exe>/../../canon,
-         * <exe>/../canon, ./canon). Empty when nothing resolves — the
-         * endpoints then report an absent canon.
+         * copied elsewhere), then the deployment slot's data
+         * directory (the usmrad layout — see {@link slot_candidates}),
+         * then the source tree (<exe>/../../canon, <exe>/../canon,
+         * ./canon). Empty when nothing resolves — the endpoints then
+         * report an absent canon.
          */
         public string directory() {
             if (directory_cache != null) {
@@ -216,22 +218,72 @@ namespace ClankerSaddle.Services {
          * where the app looked.
          */
         public string[] candidates() {
+            var found = new string[0];
             var exe_dir = exe_directory();
-            return {
-                // installed: <prefix>/bin/clanker-saddle + share/…
-                GLib.Path.build_filename(exe_dir, "..", "share",
-                    "clanker-saddle", "canon"),
-                // packaged layout where the binary's neighbours are not
-                // the install prefix: the absolute system shares
-                "/usr/share/clanker-saddle/canon",
-                "/usr/local/share/clanker-saddle/canon",
-                // build dir run (builddir sits beside src/)
-                GLib.Path.build_filename(exe_dir, "..", "..", "canon"),
-                GLib.Path.build_filename(exe_dir, "..", "canon"),
-                GLib.Path.build_filename(exe_dir, "canon"),
-                // a bare ./canon next to the cwd (tests, dev runs)
-                "canon"
-            };
+            // installed: <prefix>/bin/clanker-saddle + share/…
+            found += GLib.Path.build_filename(exe_dir, "..", "share",
+                "clanker-saddle", "canon");
+            // packaged layout where the binary's neighbours are not
+            // the install prefix: the absolute system shares
+            found += "/usr/share/clanker-saddle/canon";
+            found += "/usr/local/share/clanker-saddle/canon";
+            // the usmrad deployment slot (deduped against the system
+            // shares above so the system path keeps its precedence)
+            foreach (var slot in slot_candidates()) {
+                found += slot;
+            }
+            // build dir run (builddir sits beside src/)
+            found += GLib.Path.build_filename(exe_dir, "..", "..", "canon");
+            found += GLib.Path.build_filename(exe_dir, "..", "canon");
+            found += GLib.Path.build_filename(exe_dir, "canon");
+            // a bare ./canon next to the cwd (tests, dev runs)
+            found += "canon";
+            return found;
+        }
+
+        /**
+         * The canon directories of the usmrad DEPLOYMENT SLOT layout.
+         *
+         * usmrad (the USM radio: a git-tracking deployer) clones the
+         * tracked branch to <appdir>/repo, builds it, and installs
+         * the manifest's provided resources into one of two A/B
+         * slots — <appdir>/<slot>/usr/… — then flips a `current`
+         * symlink onto the fresh slot and launches the binary
+         * through it. The canon rides that install as
+         * <appdir>/<slot>/usr/share/clanker-saddle/canon (the
+         * manifest provides each canon file as a res: resource, so
+         * usm's install phase copies it beside the binary).
+         *
+         * The candidates are built from $USMRAD_APPDIR (default
+         * /app): the `current` link first (the running deployment),
+         * then the concrete a/b slots (a restart racing a flip).
+         * Candidates that duplicate one of the absolute system
+         * shares are dropped — the system path keeps its precedence
+         * and its place in the candidates() order, so a canon
+         * installed at the system path is never shadowed by a slot
+         * copy. The exe's own directory is deliberately NOT an
+         * anchor: /proc/self/exe resolves through the `current`
+         * link, whose `..` climb lands in the appdir, not the slot.
+         */
+        private string[] slot_candidates() {
+            var found = new string[0];
+            var seen = new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
+            seen.add("/usr/share/clanker-saddle/canon");
+            seen.add("/usr/local/share/clanker-saddle/canon");
+            var appdir = (GLib.Environment.get_variable("USMRAD_APPDIR") ?? "")
+                .strip();
+            if (appdir.length == 0) {
+                appdir = "/app";
+            }
+            foreach (var slot in new string[] {"current", "a", "b"}) {
+                var candidate = GLib.Path.build_filename(
+                    appdir, slot, "usr", "share", "clanker-saddle", "canon");
+                if (!seen.contains(candidate)) {
+                    seen.add(candidate);
+                    found += candidate;
+                }
+            }
+            return found;
         }
 
         /**

+ 218 - 4
test/bundles/canon_deploy.py

@@ -79,9 +79,12 @@ def deploy_tree(target):
     return True, done.stdout[-500:]
 
 
-def boot_deployed(root, port):
-    """Boots the DEPLOYED binary (root/usr/bin/clanker-saddle) in a cwd
-    with no ./canon; returns the process handle (caller terminates)."""
+def boot_deployed(root, port, binary=None, extra_env=None):
+    """Boots the DEPLOYED binary (default root/usr/bin/clanker-saddle) in
+    a cwd with no ./canon; returns the process handle (caller
+    terminates). `binary` overrides the executed path (the slot-layout
+    cases boot a copy whose exe-relative share is absent) and
+    `extra_env` rides the scrubbed environment (USMRAD_APPDIR)."""
     run_dir = os.path.join(root, "run")
     os.makedirs(run_dir, exist_ok=True)
     config = json.load(open(os.path.join(REPO, "web-config.json")))
@@ -105,9 +108,15 @@ def boot_deployed(root, port):
         "XDG_DATA_DIRS": "/usr/share",
     })
     env.pop("CLANKER_CANON_DIR", None)
+    # a developer machine whose /app carries a clanker canon must not
+    # leak into the non-slot phases; the slot phases set it explicitly
+    env.pop("USMRAD_APPDIR", None)
+    if extra_env:
+        env.update(extra_env)
     log = open(os.path.join(root, "deploy-app.log"), "w")
     process = subprocess.Popen(
-        [os.path.join(root, "usr", "bin", "clanker-saddle"), str(port)],
+        [binary or os.path.join(root, "usr", "bin", "clanker-saddle"),
+         str(port)],
         cwd=run_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
     return process
 
@@ -359,3 +368,208 @@ def run(ctx):
         shutil.move(os.path.join(empty_root, "canon-parked"), share)
         shutil.rmtree(empty_root, ignore_errors=True)
         shutil.rmtree(root, ignore_errors=True)
+
+    print("== deploy: the manifest provides the canon as resources ==")
+    # The slot-fallback miss class: usm's manifest install copies ONLY
+    # the declared provides resources into the install root, so a
+    # manifest providing just bin:clanker-saddle deploys a slot with
+    # the binary and no canon at all — the running instance answers an
+    # empty library no matter what the resolver tries. Every canon
+    # file must be declared as a res: resource (the grammar usm's own
+    # autoprovides emits for share data).
+    manifest = json.load(open(os.path.join(REPO, "MANIFEST.usm")))
+    canon_files = sorted(
+        name for name in os.listdir(os.path.join(REPO, "canon"))
+        if name.endswith(".md"))
+    missing = [name for name in canon_files
+               if "res:clanker-saddle/canon/%s" % name
+               not in manifest.get("provides", {})]
+    check(len(canon_files) >= 15,
+          "the source canon carries the texts (%d)" % len(canon_files))
+    check(not missing,
+          "MANIFEST.usm provides every canon .md as a res: resource "
+          "(missing: %s)" % (missing or "none"))
+
+    print("== deploy: the usmrad slot layout serves the canon ==")
+    # The deployed usmrad slot: <appdir>/<slot>/usr/bin + usr/share,
+    # with a `current` symlink flipped onto the fresh slot. usmrad
+    # launches the binary THROUGH that link, so /proc/self/exe sits in
+    # <appdir>/current/usr/bin and the exe-relative climb finds the
+    # share. This phase pins that whole layout end-to-end.
+    slot_root = tempfile.mkdtemp(prefix="canon-slot-")
+    appdir = os.path.join(slot_root, "app")
+    ok, _ = deploy_tree(slot_root)
+    check(ok, "meson install --destdir for the slot layout")
+    # arrange the usmrad shape: app/a/usr/… + current -> a
+    os.makedirs(os.path.join(appdir, "a"), exist_ok=True)
+    shutil.move(os.path.join(slot_root, "usr"), os.path.join(appdir, "a", "usr"))
+    os.symlink("a", os.path.join(appdir, "current"))
+    slot_share = os.path.join(appdir, "a", "usr", "share",
+                              "clanker-saddle", "canon")
+    check(os.path.isdir(slot_share),
+          "the slot carries usr/share/clanker-saddle/canon")
+    process = boot_deployed(
+        slot_root, deployed_port,
+        binary=os.path.join(appdir, "current", "usr", "bin", "clanker-saddle"),
+        extra_env={"USMRAD_APPDIR": appdir})
+    try:
+        slot_base = "http://127.0.0.1:%d" % deployed_port
+        outcome = wait_boot(slot_base, process)
+        check(outcome == "ready", "the slot binary boots through `current` (%s)"
+              % outcome)
+        if outcome != "ready":
+            return
+        slot_auth = login_on(slot_base, "admin", "adminpass123")
+        token = register_saddle(slot_base, slot_auth,
+                                "slot-probe", "127.0.0.1:%d" % fake.port)
+        status, body = http_raw(slot_base, "GET", "/api/saddle/canon", token)
+        check(status == 200 and body.get("count") == 15,
+              "the slot layout lists the 15 texts (%s)" % body.get("count"))
+        status, doc = http_raw(slot_base, "GET",
+                               "/api/saddle/canon/gnu-manifesto", token)
+        check(status == 200 and doc.get("ok") is True
+              and "GNU" in (doc.get("title") or ""),
+              "the slot layout serves a document by slug")
+    finally:
+        process.terminate()
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+        shutil.rmtree(slot_root, ignore_errors=True)
+
+    print("== deploy: the slot fallback when the exe share is absent ==")
+    # The mandate case: the canon exists ONLY in the deployment slot's
+    # data directory and the system path misses (no /usr/share canon,
+    # no exe-relative share). The resolver must fall back to
+    # <appdir>/<slot>/usr/share/… resolved through $USMRAD_APPDIR —
+    # the exe cannot anchor it once its share neighbours are gone.
+    bare_root = tempfile.mkdtemp(prefix="canon-bare-")
+    appdir = os.path.join(bare_root, "app")
+    ok, _ = deploy_tree(bare_root)
+    check(ok, "meson install --destdir for the bare-exe layout")
+    os.makedirs(os.path.join(appdir, "b"), exist_ok=True)
+    # the slot: share under app/b/usr, the binary copied WITHOUT its
+    # share neighbours (only the file, no usr tree beside it)
+    shutil.move(os.path.join(bare_root, "usr", "share"),
+                os.path.join(appdir, "b", "usr-tmp"))
+    os.makedirs(os.path.join(appdir, "b", "usr"), exist_ok=True)
+    shutil.move(os.path.join(appdir, "b", "usr-tmp"),
+                os.path.join(appdir, "b", "usr", "share"))
+    os.makedirs(os.path.join(appdir, "bin"), exist_ok=True)
+    shutil.copy2(os.path.join(bare_root, "usr", "bin", "clanker-saddle"),
+                 os.path.join(appdir, "bin", "clanker-saddle"))
+    shutil.rmtree(os.path.join(bare_root, "usr"))
+    os.symlink("b", os.path.join(appdir, "current"))
+    slot_share = os.path.join(appdir, "b", "usr", "share",
+                              "clanker-saddle", "canon")
+    check(os.path.isdir(slot_share), "the slot carries the canon data dir")
+    process = boot_deployed(
+        bare_root, deployed_port,
+        binary=os.path.join(appdir, "bin", "clanker-saddle"),
+        extra_env={"USMRAD_APPDIR": appdir})
+    try:
+        bare_base = "http://127.0.0.1:%d" % deployed_port
+        outcome = wait_boot(bare_base, process)
+        check(outcome == "ready", "the bare-exe slot binary boots (%s)"
+              % outcome)
+        if outcome != "ready":
+            return
+        log_text = open(os.path.join(bare_root, "deploy-app.log")).read()
+        health = [line for line in log_text.splitlines()
+                  if "Canon: 15 texts from" in line]
+        logged = (health[0].split(" from ", 1)[1].strip()
+                  if health else "")
+        check(bool(health) and os.path.realpath(logged)
+              == os.path.realpath(slot_share),
+              "the bare exe resolves the canon from the slot data dir (%s)"
+              % (health[:1] or ["absent"]))
+        bare_auth = login_on(bare_base, "admin", "adminpass123")
+        token = register_saddle(bare_base, bare_auth,
+                                "bare-probe", "127.0.0.1:%d" % fake.port)
+        status, body = http_raw(bare_base, "GET", "/api/saddle/canon", token)
+        check(status == 200 and body.get("count") == 15,
+              "the slot-only canon lists the 15 texts (%s)" % body.get("count"))
+        check(os.path.realpath(body.get("source_dir") or "")
+              == os.path.realpath(slot_share),
+              "the API reports the slot data dir as the source (%s)"
+              % body.get("source_dir"))
+        status, doc = http_raw(bare_base, "GET",
+                               "/api/saddle/canon/free-software-definition",
+                               token)
+        check(status == 200 and doc.get("ok") is True
+              and "freedom" in (doc.get("body") or "").lower(),
+              "the slot-only canon serves one document by slug")
+        # the warning must NOT fire: the fallback resolved a shelf
+        check("no canon directory resolved" not in log_text,
+              "the slot fallback resolves without the empty-shelf warning")
+    finally:
+        process.terminate()
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+        shutil.rmtree(bare_root, ignore_errors=True)
+
+    print("== deploy: the higher-precedence share wins over the slot ==")
+    # The precedence half of the mandate: when the canon exists in a
+    # HIGHER-precedence location (the system path — represented here
+    # by the exe-relative share, which outranks the slot in exactly
+    # the same way the absolute /usr|/usr/local shares do) AND in the
+    # slot, the higher-precedence shelf is served — the slot fallback
+    # must never shadow it. A one-text marker shelf distinguishes them.
+    prec_root = tempfile.mkdtemp(prefix="canon-prec-")
+    appdir = os.path.join(prec_root, "app")
+    ok, _ = deploy_tree(prec_root)
+    check(ok, "meson install --destdir for the precedence layout")
+    os.makedirs(os.path.join(appdir, "a"), exist_ok=True)
+    shutil.move(os.path.join(prec_root, "usr", "share"),
+                os.path.join(appdir, "a", "usr-tmp"))
+    os.makedirs(os.path.join(appdir, "a", "usr"), exist_ok=True)
+    shutil.move(os.path.join(appdir, "a", "usr-tmp"),
+                os.path.join(appdir, "a", "usr", "share"))
+    os.makedirs(os.path.join(appdir, "bin"), exist_ok=True)
+    shutil.copy2(os.path.join(prec_root, "usr", "bin", "clanker-saddle"),
+                 os.path.join(appdir, "bin", "clanker-saddle"))
+    # the higher-precedence shelf: the exe-relative share beside the
+    # binary, holding ONE marker text
+    exe_share = os.path.join(appdir, "bin", "..", "share",
+                             "clanker-saddle", "canon")
+    os.makedirs(exe_share, exist_ok=True)
+    with open(os.path.join(exe_share, "system-marker.md"), "w") as handle:
+        handle.write(
+            "---\nslug: system-marker\ntitle: The System Path\n"
+            "author: test\nsource: test\nlicence: test\n"
+            "summary: marks the higher-precedence shelf\n---\n\nsystem\n")
+    os.symlink("a", os.path.join(appdir, "current"))
+    process = boot_deployed(
+        prec_root, deployed_port,
+        binary=os.path.join(appdir, "bin", "clanker-saddle"),
+        extra_env={"USMRAD_APPDIR": appdir})
+    try:
+        prec_base = "http://127.0.0.1:%d" % deployed_port
+        outcome = wait_boot(prec_base, process)
+        check(outcome == "ready", "the precedence binary boots (%s)" % outcome)
+        if outcome != "ready":
+            return
+        prec_auth = login_on(prec_base, "admin", "adminpass123")
+        token = register_saddle(prec_base, prec_auth,
+                                "prec-probe", "127.0.0.1:%d" % fake.port)
+        status, body = http_raw(prec_base, "GET", "/api/saddle/canon", token)
+        check(status == 200
+              and os.path.abspath(body.get("source_dir") or "")
+              == os.path.normpath(exe_share),
+              "the higher-precedence share is served, not the slot (%s)"
+              % body.get("source_dir"))
+        check(body.get("count") == 1
+              and [t.get("slug") for t in (body.get("texts") or [])]
+              == ["system-marker"],
+              "the served shelf is the higher-precedence one (%s texts)"
+              % body.get("count"))
+    finally:
+        process.terminate()
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            process.kill()
+        shutil.rmtree(prec_root, ignore_errors=True)