Files
orchestrator/tests/test_fs_normalize_startup.py
claude-bot a98d605477 feat(fs): legacy root-owned ownership detect + actionable worktree error (ORCH-057)
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>
2026-06-10 03:03:34 +03:00

137 lines
5.5 KiB
Python

"""ORCH-057 D3: startup-hook observability + the clear pre-launch outcome.
TC-10 / TC-11 (04-test-plan.yaml):
* TC-10 — the lifespan startup hook, on a detected mismatch, emits a WARNING and a
Telegram message; a detect error never crashes the start (never-fatal).
* TC-11 — the "clear, early" outcome on a permission failure is delivered by the
actionable ensure_worktree error (ADR-001 D3: claim is NOT blocked), i.e. the
launch surfaces an actionable diagnosis, never a raw git-fatal.
Background daemons are disabled via env so the lifespan is cheap and deterministic.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_fsn_startup.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"
os.environ["ORCH_PLANE_WEBHOOK_SECRET"] = ""
os.environ["ORCH_GITEA_WEBHOOK_SECRET"] = ""
# Keep the lifespan light: no background daemons during the test.
os.environ["ORCH_RECONCILE_ENABLED"] = "false"
os.environ["ORCH_REAPER_ENABLED"] = "false"
os.environ["ORCH_DISK_MONITOR_ENABLED"] = "false"
os.environ["ORCH_BUILD_CACHE_PRUNE_ENABLED"] = "false"
os.environ["ORCH_FS_NORMALIZE_ENABLED"] = "true"
from fastapi.testclient import TestClient
from src import fs_normalize, git_worktree
from src.main import app
from src.db import init_db
@pytest.fixture(autouse=True)
def _db():
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
fs_normalize.reset_cache()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
# ---------------------------------------------------------------------------
# TC-10 — startup observability
# ---------------------------------------------------------------------------
def test_tc10_startup_mismatch_warns_and_telegrams(monkeypatch, caplog):
"""TC-10: on a detected mismatch the startup hook logs a WARNING and sends a
Telegram message (mocked)."""
sent = []
monkeypatch.setattr(
"src.notifications.send_telegram", lambda *a, **k: sent.append(a[0] if a else "")
)
scan = fs_normalize.OwnershipScan(
mismatch=True, target_uid=1000, roots_checked=["/repos/_wt"],
roots_mismatch=["/repos/_wt"], sample_path="/repos/_wt/x", checked_at=1.0,
)
monkeypatch.setattr("src.fs_normalize.scan_ownership", lambda *a, **k: scan)
with caplog.at_level("WARNING"):
with TestClient(app):
pass
assert any("FS-ownership mismatch" in r.message for r in caplog.records)
# Filter for the fs-ownership message (the shared startup may emit other,
# unrelated Telegram traffic — e.g. a leftover task's tracker card).
fs_msgs = [m for m in sent if "legacy root-owned" in m.lower() or "chown" in m.lower()]
assert fs_msgs, "expected a Telegram message on mismatch"
def test_tc10_startup_detect_error_never_fatal(monkeypatch):
"""TC-10: a detect error must NOT crash the start (never-fatal)."""
def boom(*a, **k):
raise RuntimeError("simulated detect failure")
monkeypatch.setattr("src.fs_normalize.scan_ownership", boom)
# Entering/exiting the lifespan must not raise.
with TestClient(app):
pass
def test_tc10_startup_clean_no_telegram(monkeypatch):
"""A clean environment (no mismatch) sends no Telegram and does not warn."""
sent = []
monkeypatch.setattr(
"src.notifications.send_telegram", lambda *a, **k: sent.append(a[0] if a else "")
)
clean = fs_normalize.OwnershipScan(mismatch=False, target_uid=1000, checked_at=1.0)
monkeypatch.setattr("src.fs_normalize.scan_ownership", lambda *a, **k: clean)
with TestClient(app):
pass
# No fs-ownership message on a clean environment (unrelated startup Telegram
# traffic from a shared-DB leftover task is ignored).
fs_msgs = [m for m in sent if "legacy root-owned" in m.lower() or "обнаружены legacy" in m.lower()]
assert fs_msgs == []
# ---------------------------------------------------------------------------
# TC-11 — clear pre-launch outcome (D1, not a claim gate)
# ---------------------------------------------------------------------------
def test_tc11_launch_permission_failure_is_actionable_not_raw(tmp_path, monkeypatch):
"""TC-11: the launch-time worktree creation surfaces an actionable error (clear,
before the agent spends a token), not a raw git-fatal — the ADR-001 D3 "внятно и
заранее" outcome that replaces a blocking claim gate."""
repo = "orchestrator"
repos_dir = tmp_path / "repos"
(repos_dir / repo).mkdir(parents=True)
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(repos_dir / "_wt"))
monkeypatch.setattr(git_worktree.settings, "fs_normalize_enabled", True)
class _R:
def __init__(self, rc, err=""):
self.returncode = rc
self.stderr = err
self.stdout = ""
def fake_run(cmd, *a, **k):
if "fetch" in cmd:
return _R(0)
if "worktree" in cmd and "add" in cmd:
return _R(128, "fatal: ...: Permission denied")
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as ei:
git_worktree.ensure_worktree(repo, "feature/x")
msg = str(ei.value)
assert "INFRA.md" in msg and "chown" in msg.lower()
assert "git worktree add failed" not in msg # not the raw passthrough