feat(staging-check): ORCH-048 B6 reads registry via GET /projects, not local import
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 12s

B6 built the project registry by importing src.projects locally (host-path hack
+ importlib.reload), so it evaluated ORCH_PROJECTS_JSON from the launcher's
process-env. On the deployer's canonical host run that var is unset → built-in
default (ET+ORCH) → false FAIL even when staging isolation is healthy.

- Add read-only additive endpoint GET /projects (src/main.py) returning
  known_plane_project_ids + {plane_project_id, repo, work_item_prefix, name}
  of the live process; no secrets. Existing routes unchanged.
- Rewrite B6 to fetch GET {base}/projects via the same stdlib _get helper as
  A/B4/B5/C; drop the host-path hack and importlib.reload (launch-invariant).
- Isolate the verdict in pure _evaluate_b6(known) -> (passed, detail); contract
  unchanged (PASS iff SANDBOX in known and prod ET/ORCH absent). Endpoint
  degradation (non-200 / missing key / bad body / network) → deterministic FAIL.
- src/projects.py and .env* untouched.

Docs (golden source): API table + staging-gate B6 mechanic in
docs/architecture/README.md; B6 description + isolation row in
docs/operations/STAGING_CHECK.md; CHANGELOG entry.

Tests: tests/test_staging_check_b6.py (TC-01..TC-07), tests/test_projects_endpoint.py.

Refs: ORCH-048

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 05:25:45 +00:00
parent f77825b3c4
commit 2cf873a777
7 changed files with 369 additions and 27 deletions

View File

@@ -214,8 +214,74 @@ SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
PROD_ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
PROD_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
B6_LABEL = "B6 Registry: sandbox present, prod ET/ORCH absent"
def block_b(results: Results):
def _evaluate_b6(known: set[str]) -> tuple[bool, str]:
"""Pure B6 verdict (ORCH-048, 02-trz §9 / TR-2, TR-3).
PASS ⟺ sandbox project is registered AND neither prod project (ET / ORCH) is.
Isolated from any I/O so the registry-isolation contract is unit-testable on
both outcomes (clean → PASS, polluted → FAIL) without a live staging instance
or docker. ``detail`` keeps the human-readable format the suite already emits.
"""
sandbox_present = SANDBOX_PROJECT_ID in known
et_absent = PROD_ET_PROJECT_ID not in known
orch_absent = PROD_ORCH_PROJECT_ID not in known
ok = sandbox_present and et_absent and orch_absent
detail = (
f"sandbox={'YES' if sandbox_present else 'NO'}, "
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
)
return ok, detail
def _check_b6(base: str, results: Results) -> None:
"""B6 — registry isolation, read over HTTP from the LIVE instance (ORCH-048).
Fetches ``GET {base}/projects`` (the read-only endpoint added in ADR-001) and
evaluates ``known_plane_project_ids`` with :func:`_evaluate_b6`. The endpoint
reflects the registry of the very process that serves webhooks (port 8501),
independent of how this suite was launched (host / ``docker exec``).
Replaces the old local-import hack (a hardcoded host-path insert plus a forced
module reload of the registry, TR-6) which evaluated the registry of whoever ran
the script — on a host run with no ``ORCH_PROJECTS_JSON`` that fell back to the
built-in default (ET+ORCH) → false FAIL.
Any source degradation (non-200 / missing or malformed key / network error)
yields a **deterministic FAIL** with a readable detail (TR-4) — never a false
PASS and never an unhandled exception.
"""
try:
status, data = _get(f"{base}/projects")
except Exception as e:
results.add(B6_LABEL, False, f"GET /projects failed: {e}")
return
if status != 200:
results.add(B6_LABEL, False, f"GET /projects → HTTP {status}")
return
if not isinstance(data, dict) or "known_plane_project_ids" not in data:
keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
results.add(B6_LABEL, False,
f"GET /projects → 200 but no 'known_plane_project_ids' (got {keys})")
return
raw = data.get("known_plane_project_ids")
if not isinstance(raw, (list, tuple, set)):
results.add(B6_LABEL, False,
f"GET /projects → 'known_plane_project_ids' not a list "
f"(got {type(raw).__name__})")
return
known = {str(x) for x in raw}
ok, detail = _evaluate_b6(known)
results.add(B6_LABEL, ok, detail)
def block_b(base: str, results: Results):
print(f"\n{_BOLD}[Block B] ACCESS{_RESET}")
plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
@@ -260,28 +326,9 @@ def block_b(results: Results):
except Exception as e:
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", False, str(e))
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs
try:
# Import from inside the container (script runs in /repos/orchestrator context)
sys.path.insert(0, "/repos/orchestrator")
# Force reload to pick up container env
import importlib
if "src.projects" in sys.modules:
importlib.reload(sys.modules["src.projects"])
from src.projects import known_plane_project_ids
known = known_plane_project_ids()
sandbox_present = SANDBOX_PROJECT_ID in known
et_absent = PROD_ET_PROJECT_ID not in known
orch_absent = PROD_ORCH_PROJECT_ID not in known
ok = sandbox_present and et_absent and orch_absent
detail = (
f"sandbox={'YES' if sandbox_present else 'NO'}, "
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
)
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", ok, detail)
except Exception as e:
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", False, str(e))
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs.
# Read over HTTP from the live instance (GET /projects) — ORCH-048 / ADR-001.
_check_b6(base, results)
# ---------------------------------------------------------------------------
@@ -628,7 +675,7 @@ def main():
results = Results()
block_a(base, results)
block_b(results)
block_b(base, results)
block_c(base, results, args.mode)
all_ok = results.summary()