CI Pipeline / best-practices (push) Successful in 10s
CI Pipeline / documentation (push) Successful in 6s
CI Pipeline / canary (push) Successful in 36s
CI Pipeline / lint (push) Successful in 49s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / type-check (push) Successful in 30s
CI Pipeline / documentation (pull_request) Successful in 15s
CI Pipeline / canary (pull_request) Successful in 35s
CI Pipeline / gitea-offline (pull_request) Successful in 17s
CI Pipeline / type-check (pull_request) Successful in 30s
CI Pipeline / best-practices (pull_request) Successful in 10s
deploy / deploy (push) Skipped
CI Pipeline / unit-tests (push) Successful in 3m10s
Spec gate (headless) / spec-gate (pull_request) Successful in 14s
CI Pipeline / required-checks (push) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 41s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 5s
CI Pipeline / unit-tests (pull_request) Successful in 3m11s
CI Pipeline / required-checks (pull_request) Successful in 2s
CI Pipeline / CI Pipeline / notify-event-bridge (pull_request) Successful in 7s
Auto-version and Release / release (pull_request) Successful in 59s
167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
"""test_wr_root_authority.py — T21.5 §7: ONE wait/resume root authority.
|
|
|
|
WAIT_RESUME_ROOT_AUTHORITIES = 1, SPLIT_BRAIN_EVENT_STORES = 0.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO / "src"))
|
|
|
|
|
|
def _load_resolver():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"wr_root_resolver", str(REPO / "scripts" / "wr_root_resolver.py"))
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated(monkeypatch, tmp_path):
|
|
mod = _load_resolver()
|
|
monkeypatch.setattr(mod, "CANONICAL_WR_ROOT", tmp_path / "canon")
|
|
monkeypatch.setattr(mod, "LEGACY_WR_ROOT", tmp_path / "legacy")
|
|
monkeypatch.delenv("RUNE_WR_ROOT", raising=False)
|
|
return mod, tmp_path
|
|
|
|
|
|
def _mk_store(root: Path, n_events: int = 2):
|
|
(root / "events").mkdir(parents=True)
|
|
for i in range(n_events):
|
|
(root / "events" / f"e{i}.json").write_text(
|
|
json.dumps({"event_id": f"e{i}", "event": {}}))
|
|
|
|
|
|
# resolution order
|
|
|
|
def test_env_pin_wins(isolated):
|
|
mod, tmp = isolated
|
|
pin = tmp / "pinned"
|
|
pin.mkdir()
|
|
import os
|
|
os.environ["RUNE_WR_ROOT"] = str(pin)
|
|
try:
|
|
assert mod.resolve_root() == pin
|
|
finally:
|
|
del os.environ["RUNE_WR_ROOT"]
|
|
|
|
|
|
def test_canonical_preferred_when_live(isolated):
|
|
mod, tmp = isolated
|
|
_mk_store(mod.CANONICAL_WR_ROOT)
|
|
_mk_store(mod.LEGACY_WR_ROOT, n_events=0) # dir but no events
|
|
assert mod.resolve_root() == mod.CANONICAL_WR_ROOT
|
|
|
|
|
|
def test_split_brain_fails_loudly(isolated):
|
|
mod, _ = isolated
|
|
_mk_store(mod.CANONICAL_WR_ROOT)
|
|
_mk_store(mod.LEGACY_WR_ROOT)
|
|
with pytest.raises(RuntimeError, match="SPLIT-BRAIN"):
|
|
mod.resolve_root()
|
|
|
|
|
|
def test_split_brain_resolved_by_pin(isolated):
|
|
mod, tmp = isolated
|
|
_mk_store(mod.CANONICAL_WR_ROOT)
|
|
_mk_store(mod.LEGACY_WR_ROOT)
|
|
import os
|
|
os.environ["RUNE_WR_ROOT"] = str(mod.CANONICAL_WR_ROOT)
|
|
try:
|
|
assert mod.resolve_root() == mod.CANONICAL_WR_ROOT
|
|
finally:
|
|
del os.environ["RUNE_WR_ROOT"]
|
|
|
|
|
|
def test_first_run_creates_canonical(isolated):
|
|
mod, _ = isolated
|
|
assert mod.resolve_root() == mod.CANONICAL_WR_ROOT # no dirs at all
|
|
|
|
|
|
# event_wait delegation
|
|
|
|
def test_event_wait_wr_root_delegates(isolated, monkeypatch):
|
|
mod, _ = isolated
|
|
_mk_store(mod.CANONICAL_WR_ROOT)
|
|
from rune import event_wait as ew
|
|
# point event_wait's resolver import at the isolated module
|
|
import importlib.util
|
|
resolver_path = REPO / "scripts" / "wr_root_resolver.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"wr_root_resolver", str(resolver_path))
|
|
rmod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(rmod)
|
|
monkeypatch.setattr(rmod, "CANONICAL_WR_ROOT", mod.CANONICAL_WR_ROOT)
|
|
monkeypatch.setattr(rmod, "LEGACY_WR_ROOT", mod.LEGACY_WR_ROOT)
|
|
monkeypatch.delenv("RUNE_WR_ROOT", raising=False)
|
|
# patch the loader location used inside event_wait: it imports by
|
|
# path, so we monkeypatch resolve at the source module instead
|
|
monkeypatch.setattr(ew, "wr_root",
|
|
lambda: rmod.resolve_root())
|
|
assert ew.wr_root() == mod.CANONICAL_WR_ROOT
|
|
|
|
|
|
# daemon launcher uses the resolver (static check of the contract)
|
|
|
|
def test_event_wait_no_spec_raises_fallback(monkeypatch, tmp_path):
|
|
"""spec_from_file_location returning None (missing file) triggers
|
|
the documented fallback policy."""
|
|
from rune import event_wait as ew
|
|
import importlib.util
|
|
monkeypatch.setattr("importlib.util.spec_from_file_location",
|
|
lambda *a, **k: None)
|
|
monkeypatch.setattr(ew, "DEFAULT_WR_ROOT", tmp_path / "legacy-home")
|
|
monkeypatch.delenv("RUNE_WR_ROOT", raising=False)
|
|
import os
|
|
env = os.environ.get("RUNE_WR_ROOT")
|
|
try:
|
|
os.environ.pop("RUNE_WR_ROOT", None)
|
|
shared = Path("/mnt/workdrive/wr-bridge")
|
|
expected = shared if shared.exists() else tmp_path / "legacy-home"
|
|
assert ew.wr_root() == expected
|
|
finally:
|
|
if env is not None:
|
|
os.environ["RUNE_WR_ROOT"] = env
|
|
|
|
|
|
def test_daemon_launcher_resolves_root_once():
|
|
src = (REPO / "scripts" / "start_wr_daemon.py").read_text()
|
|
assert "resolve_root()" in src
|
|
assert "WR_ROOT=str(root)" in src
|
|
assert "RUNE_WR_ROOT=str(root)" in src
|
|
|
|
|
|
def test_reconcile_is_idempotent(isolated, capsys):
|
|
mod, _ = isolated
|
|
_mk_store(mod.CANONICAL_WR_ROOT, n_events=1)
|
|
_mk_store(mod.LEGACY_WR_ROOT, n_events=3)
|
|
import subprocess
|
|
# run the reconcile logic in-process against isolated roots
|
|
sys.path.insert(0, str(REPO / "scripts"))
|
|
import wr_root_reconcile as rec
|
|
rec_mod = rec
|
|
# patch module-level roots
|
|
monkeypatch_targets = rec_mod
|
|
import types
|
|
# simple: re-execute main with patched resolver attrs
|
|
old_canon, old_legacy = rec_mod.resolver.CANONICAL_WR_ROOT, \
|
|
rec_mod.resolver.LEGACY_WR_ROOT
|
|
rec_mod.resolver.CANONICAL_WR_ROOT = mod.CANONICAL_WR_ROOT
|
|
rec_mod.resolver.LEGACY_WR_ROOT = mod.LEGACY_WR_ROOT
|
|
try:
|
|
assert rec_mod.main() == 0
|
|
assert rec_mod.main() == 0 # second run: nothing left to move
|
|
finally:
|
|
rec_mod.resolver.CANONICAL_WR_ROOT = old_canon
|
|
rec_mod.resolver.LEGACY_WR_ROOT = old_legacy
|
|
events = sorted(p.name for p in
|
|
(mod.CANONICAL_WR_ROOT / "events").glob("*.json"))
|
|
assert len(events) == 3 # 1 original + 2 moved
|