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