fix(staging_check): B6 reads registry from running staging instance env
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 16s

B6 false-FAILed because it built the project registry from the
launcher process-env via a host-path hack (sys.path.insert +
importlib.reload), not from the running staging instance. Run from the
host, ORCH_PROJECTS_JSON is unset -> default ET+ORCH registry -> false
FAIL -> spurious deploy-staging -> development rollback.

Variant (v) per ADR-001: remove the host-path hack; canonically run the
suite INSIDE orchestrator-staging via docker exec so src.projects
resolves from /app (PYTHONPATH) with .env.staging. Verdict logic
extracted into pure _evaluate_b6(known) -> (passed, detail) +
_known_project_ids_from_registry() / _run_b6() with deterministic FAIL on
source unavailability. deployer.md and STAGING_CHECK.md updated to the
docker exec command. src/projects.py, .env* and checks A/B4/B5/C
untouched.

Refs: ORCH-048

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:03:31 +00:00
parent d6744c3c05
commit 28d019a1e2
4 changed files with 230 additions and 26 deletions

View File

@@ -0,0 +1,151 @@
"""ORCH-048: unit tests for the B6 registry-isolation verdict in staging_check.py.
B6 «Registry: sandbox present, prod ET/ORCH absent» is the staging-isolation
safety check. Its verdict logic is isolated into the pure function
``_evaluate_b6(known) -> (passed, detail)`` so both outcomes (clean staging
registry → PASS, polluted registry → FAIL) can be tested without standing up a
live staging instance or docker (02-trz §9, ADR-001).
These tests target that pure function plus the deterministic-degradation path
(``_run_b6``) and statically assert the host-path hack is gone (TR-6 / TC-06).
"""
import importlib.util
import pathlib
import pytest
# ---------------------------------------------------------------------------
# Load scripts/staging_check.py by path (scripts/ is not an importable package).
# ---------------------------------------------------------------------------
_SCRIPT_PATH = (
pathlib.Path(__file__).resolve().parent.parent / "scripts" / "staging_check.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("staging_check", _SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
sc = _load_module()
SANDBOX = sc.SANDBOX_PROJECT_ID
PROD_ET = sc.PROD_ET_PROJECT_ID
PROD_ORCH = sc.PROD_ORCH_PROJECT_ID
# ---------------------------------------------------------------------------
# TC-01 — clean staging registry → PASS
# ---------------------------------------------------------------------------
def test_tc01_clean_registry_passes():
passed, detail = sc._evaluate_b6({SANDBOX})
assert passed is True
assert "sandbox=YES" in detail
assert "prod-ET=NO(good)" in detail
assert "prod-ORCH=NO(good)" in detail
# ---------------------------------------------------------------------------
# TC-02 — prod-ET leaked into registry → FAIL
# ---------------------------------------------------------------------------
def test_tc02_prod_et_present_fails():
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ET})
assert passed is False
assert "sandbox=YES" in detail
assert "prod-ET=YES(BAD!)" in detail
assert "prod-ORCH=NO(good)" in detail
# ---------------------------------------------------------------------------
# TC-03 — prod-ORCH leaked into registry → FAIL
# ---------------------------------------------------------------------------
def test_tc03_prod_orch_present_fails():
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ORCH})
assert passed is False
assert "sandbox=YES" in detail
assert "prod-ET=NO(good)" in detail
assert "prod-ORCH=YES(BAD!)" in detail
# ---------------------------------------------------------------------------
# TC-04 — sandbox absent (empty registry) → deterministic FAIL, no exception
# ---------------------------------------------------------------------------
def test_tc04_empty_registry_fails_without_sandbox():
passed, detail = sc._evaluate_b6(set())
assert passed is False
assert "sandbox=NO" in detail
# ---------------------------------------------------------------------------
# TC-05 — both prod projects leaked → FAIL
# ---------------------------------------------------------------------------
def test_tc05_both_prod_present_fails():
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ET, PROD_ORCH})
assert passed is False
assert "prod-ET=YES(BAD!)" in detail
assert "prod-ORCH=YES(BAD!)" in detail
# ---------------------------------------------------------------------------
# TC-06 — registry source no longer depends on the host-path hack (TR-6)
# ---------------------------------------------------------------------------
def test_tc06_no_host_path_hack_in_source():
source = _SCRIPT_PATH.read_text(encoding="utf-8")
# The host-worktree path injection and the env-of-the-launcher reload that
# caused the false FAIL must be gone from the B6 mechanics.
assert 'sys.path.insert(0, "/repos/orchestrator")' not in source
assert "importlib.reload" not in source
def test_tc06_registry_loader_uses_src_projects():
# The verdict input is built from src.projects.known_plane_project_ids()
# resolved via the running instance's own PYTHONPATH/env — not from a
# host-path-injected import. We verify the loader delegates to that function.
import src.projects as projects_mod
sentinel = {"sentinel-id"}
original = projects_mod.known_plane_project_ids
projects_mod.known_plane_project_ids = lambda: sentinel
try:
known = sc._known_project_ids_from_registry()
finally:
projects_mod.known_plane_project_ids = original
assert known == sentinel
# ---------------------------------------------------------------------------
# TC-07 — degraded registry source → deterministic FAIL (not false PASS, not raise)
# ---------------------------------------------------------------------------
def test_tc07_source_failure_is_deterministic_fail(monkeypatch):
def _boom():
raise RuntimeError("registry import blew up")
monkeypatch.setattr(sc, "_known_project_ids_from_registry", _boom)
results = sc.Results()
# Must not raise.
sc._run_b6(results)
assert len(results._items) == 1
label, passed, detail = results._items[0]
assert passed is False
assert "registry source unavailable" in detail
assert "registry import blew up" in detail
# ---------------------------------------------------------------------------
# _run_b6 happy path wiring (clean registry → PASS result recorded)
# ---------------------------------------------------------------------------
def test_run_b6_records_pass_for_clean_registry(monkeypatch):
monkeypatch.setattr(
sc, "_known_project_ids_from_registry", lambda: {SANDBOX}
)
results = sc.Results()
sc._run_b6(results)
assert len(results._items) == 1
_label, passed, detail = results._items[0]
assert passed is True
assert "sandbox=YES" in detail