Follow-up ORCH-040: legacy root:root files in /repos broke worktree creation under uid 1000 with a raw "Permission denied" (agent never started, no diagnosis). Three additive, kill-switch-reversible layers; STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys / DB schema are byte-for-byte unchanged. - D1: ensure_worktree classifies the permission class and raises an actionable RuntimeError (cause + chown command + INFRA.md ref); non-permission errors keep the prior raw-stderr contract; kill-switch off -> contract 1:1 as before ORCH-057. - D2: new never-raise leaf src/fs_normalize.py — scan_ownership (TTL-cached, early-exit per root), applies()-first scope (empty CSV -> self-hosting only), opt-in normalize() that chowns ONLY when privileged (no-op under uid 1000). - D3: best-effort startup detect in main.lifespan (WARNING + Telegram on mismatch, never-fatal); read-only fs_ownership block in GET /queue; POST /fs-normalize/check. Claim is NOT blocked — the clear early outcome is delivered by D1 at launch. - Docs/config: .env.example flags + CHANGELOG (architecture README / adr-0031 / INFRA.md procedure already landed on the branch). - Tests: test_fs_normalize.py, test_git_worktree_perm.py, test_fs_normalize_startup.py, test_api_queue.py (TC-01..TC-12). Full suite green. Refs: ORCH-057 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
9.4 KiB
Python
215 lines
9.4 KiB
Python
"""ORCH-057 D2/D4/D6: ownership-detect leaf (src/fs_normalize.py) unit tests.
|
|
|
|
TC-03..TC-09 (04-test-plan.yaml). All FS-dependent tests use ``tmp_path`` and vary
|
|
``target_uid`` (a uid no tmp file actually has -> mismatch; the runner's own uid ->
|
|
clean) so NO real chown / privilege is needed. ``os.geteuid`` is monkeypatched for
|
|
the privilege-gated normalize test (TC-08). Never touches /repos.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_fsn.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
|
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
|
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
|
|
|
from src import fs_normalize
|
|
|
|
|
|
_NONEXISTENT_UID = 999999 # no tmp file is owned by this uid -> deterministic mismatch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(monkeypatch):
|
|
fs_normalize.reset_cache()
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", True)
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", False)
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_scan_cache_ttl_s", 300)
|
|
yield
|
|
fs_normalize.reset_cache()
|
|
|
|
|
|
@pytest.fixture
|
|
def tree(tmp_path):
|
|
"""A small dir tree with a file, owned by the test runner's own uid."""
|
|
d = tmp_path / "root"
|
|
(d / "sub").mkdir(parents=True)
|
|
(d / "a.txt").write_text("a")
|
|
(d / "sub" / "b.txt").write_text("b")
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-03 / TC-04 — scan verdict
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc03_scan_detects_mismatch(tree):
|
|
"""TC-03: a tree whose files are not owned by target_uid -> mismatch=True with the
|
|
affected root listed and a sample path set."""
|
|
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert scan.mismatch is True
|
|
assert str(tree) in scan.roots_mismatch
|
|
assert scan.sample_path is not None
|
|
assert scan.target_uid == _NONEXISTENT_UID
|
|
|
|
|
|
def test_tc04_clean_tree_no_mismatch(tree):
|
|
"""TC-04: a clean tree (all files owned by target_uid == the runner) -> idempotent
|
|
mismatch=False no-op."""
|
|
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid())
|
|
assert scan.mismatch is False
|
|
assert scan.roots_mismatch == []
|
|
assert scan.sample_path is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-05 — never-raise on bad/missing root
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc05_never_raise_on_missing_root(tmp_path):
|
|
"""TC-05: a non-existent root degrades to mismatch=False, never raises."""
|
|
missing = str(tmp_path / "does-not-exist")
|
|
scan = fs_normalize.scan_ownership(roots=[missing], target_uid=_NONEXISTENT_UID)
|
|
assert scan.mismatch is False
|
|
assert scan.roots_checked == [] # the missing root is skipped
|
|
|
|
|
|
def test_tc05_never_raise_on_walk_error(tree, monkeypatch):
|
|
"""TC-05: an os.walk explosion mid-scan degrades to a conservative verdict."""
|
|
def boom(*a, **k):
|
|
raise OSError("simulated walk failure")
|
|
|
|
monkeypatch.setattr(fs_normalize.os, "walk", boom)
|
|
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
# The root dir itself is owned by the runner (not _NONEXISTENT_UID was checked via
|
|
# lstat which still works) -> walk error swallowed, no exception escapes.
|
|
assert isinstance(scan, fs_normalize.OwnershipScan)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-06 — applies() scope
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc06_applies_empty_csv_self_hosting_only(monkeypatch):
|
|
"""TC-06: empty ORCH_FS_NORMALIZE_REPOS -> True only for the self-hosting repo
|
|
(orchestrator), False for enduro-trails."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
|
|
assert fs_normalize.applies("orchestrator") is True
|
|
assert fs_normalize.applies("enduro-trails") is False
|
|
|
|
|
|
def test_tc06_applies_explicit_csv(monkeypatch):
|
|
"""TC-06: a non-empty CSV scopes by list (case-insensitive)."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "enduro-trails")
|
|
assert fs_normalize.applies("enduro-trails") is True
|
|
assert fs_normalize.applies("orchestrator") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-07 — kill-switch
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc07_killswitch_off_scan_inert(tree, monkeypatch):
|
|
"""TC-07: fs_normalize_enabled=False -> scan is inert (mismatch=False, enabled
|
|
flag exposes the off state); applies() False for everyone."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
|
|
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert scan.mismatch is False
|
|
assert scan.enabled is False
|
|
assert fs_normalize.applies("orchestrator") is False
|
|
|
|
|
|
def test_tc07_killswitch_off_normalize_inert(tree, monkeypatch):
|
|
"""TC-07: normalize is a documented no-op when the kill-switch is off."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
|
|
res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert res["attempted"] is False
|
|
assert res["changed"] == 0
|
|
assert "disabled" in res["note"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-08 — normalize without privilege
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc08_normalize_without_rights_is_noop_not_error(tree, monkeypatch):
|
|
"""TC-08: under a non-root euid with auto=True and foreign files, normalize is a
|
|
no-op + honest log ('operator procedure required'), NOT an exception."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", True)
|
|
monkeypatch.setattr(fs_normalize.os, "geteuid", lambda: 1000) # non-root
|
|
res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert res["privileged"] is False
|
|
assert res["attempted"] is False
|
|
assert res["changed"] == 0
|
|
assert "INFRA.md" in res["note"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-09 — TTL cache
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc09_ttl_cache_avoids_rescan(tree, monkeypatch):
|
|
"""TC-09: a repeat call inside the TTL window does NOT re-walk; force/reset
|
|
invalidates (mirrors preflight._cache)."""
|
|
calls = {"n": 0}
|
|
real_scan = fs_normalize._scan
|
|
|
|
def counting_scan(roots, target_uid):
|
|
calls["n"] += 1
|
|
return real_scan(roots, target_uid)
|
|
|
|
monkeypatch.setattr(fs_normalize, "_scan", counting_scan)
|
|
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert calls["n"] == 1 # second call served from cache
|
|
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID, force=True)
|
|
assert calls["n"] == 2 # force bypasses the cache
|
|
|
|
fs_normalize.reset_cache()
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
assert calls["n"] == 3 # reset invalidates
|
|
|
|
|
|
def test_tc09_cache_keyed_by_roots_and_uid(tree, monkeypatch):
|
|
"""A different (roots, target_uid) key is not served from another key's cache."""
|
|
calls = {"n": 0}
|
|
real_scan = fs_normalize._scan
|
|
|
|
def counting_scan(roots, target_uid):
|
|
calls["n"] += 1
|
|
return real_scan(roots, target_uid)
|
|
|
|
monkeypatch.setattr(fs_normalize, "_scan", counting_scan)
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
|
|
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid()) # different uid
|
|
assert calls["n"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# classifier (pure) + snapshot
|
|
# ---------------------------------------------------------------------------
|
|
def test_classify_worktree_error_markers():
|
|
assert fs_normalize.classify_worktree_error("fatal: ...: Permission denied") is True
|
|
assert fs_normalize.classify_worktree_error("could not create leading directories") is True
|
|
assert fs_normalize.classify_worktree_error("insufficient permission for adding an object") is True
|
|
assert fs_normalize.classify_worktree_error("fatal: branch already checked out") is False
|
|
assert fs_normalize.classify_worktree_error("") is False
|
|
assert fs_normalize.classify_worktree_error(None) is False
|
|
|
|
|
|
def test_is_permission_failure_from_exc():
|
|
assert fs_normalize.is_permission_failure(exc=PermissionError(13, "denied")) is True
|
|
import errno as _errno
|
|
assert fs_normalize.is_permission_failure(exc=OSError(_errno.EACCES, "x")) is True
|
|
assert fs_normalize.is_permission_failure(exc=OSError(_errno.ENOENT, "x")) is False
|
|
|
|
|
|
def test_snapshot_shape(tree, monkeypatch):
|
|
"""snapshot() returns the additive fs_ownership block and never raises."""
|
|
monkeypatch.setattr(fs_normalize.settings, "fs_scan_roots", str(tree))
|
|
snap = fs_normalize.snapshot()
|
|
for k in ("enabled", "auto", "repos", "target_uid", "mismatch",
|
|
"roots_checked", "roots_mismatch", "sample_path", "checked_at"):
|
|
assert k in snap
|
|
assert snap["enabled"] is True
|