瀏覽代碼

canon_deploy bundle: pin the deployment-slot canon, both halves

Four new phases on top of the destdir/empty-shelf ones:

- the manifest check: every canon .md must be a res:clanker-saddle/
  canon/<file> provide. usm's manifest install copies ONLY declared
  provides into the install root, so a bin:-only manifest deploys a
  slot with the binary and no canon - the exact miss this task fixes.
- the usmrad slot layout end-to-end: app/<slot>/usr/bin + usr/share
  with a flipped current symlink, booted THROUGH the link, answers the
  15 texts and a document by slug.
- the slot fallback: the binary copied WITHOUT its share neighbours,
  canon only under <appdir>/<slot>/usr/share - the resolver must find
  it via $USMRAD_APPDIR (the exe cannot anchor it), the health line
  names the slot data dir, and the empty-shelf warning stays silent.
- precedence: with a canon in a higher-ranked candidate AND the slot,
  the higher-ranked shelf is served (one marker text proves which).

boot_deployed() gains binary= and extra_env= so the phases can boot
arbitrary slot binaries with a scrubed USMRAD_APPDIR (a developer
machine's /app must not leak into the non-slot phases).
saddle-Clanker-Saddle 4 周之前
父節點
當前提交
527dffb436
共有 1 個文件被更改,包括 218 次插入 和 4 次删除
  1. 218 4
      test/bundles/canon_deploy.py

+ 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)