release: gate-check contract for future release endpoint + ledger anchor ops (prep, no endpoint, no deploy)
This commit is contained in:
1 parent
765cff29ff
commit
cdb481092d
5 files changed
+1288
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"""Anchor script: exit-code contract for tools/ledger_anchor_check.py.
|
||||
|
||||
Runs the script as a subprocess against throwaway SQLite chains
|
||||
in a TemporaryDirectory (deleted with the test). Pins only what
|
||||
the operator depends on: 0 pass, 1 verification failure with the
|
||||
failure named, 2 usage error, and --write-anchor refused on a
|
||||
failing chain. The script itself never writes the chain; the
|
||||
tamper/tail cases simulate privileged tampering by dropping the
|
||||
SQLite append-only triggers first (a normal UPDATE is already
|
||||
blocked by the trigger, which is itself asserted here).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = REPO_ROOT / "tools" / "ledger_anchor_check.py"
|
||||
|
||||
try:
|
||||
from cloud.audit_wiring import record_audit_verdict
|
||||
from cloud.ledger import LedgerStore
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
from cloud.audit_wiring import record_audit_verdict
|
||||
from cloud.ledger import LedgerStore
|
||||
|
||||
|
||||
def _run(*args):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
try:
|
||||
report = json.loads(proc.stdout) if proc.stdout.strip() else None
|
||||
except ValueError:
|
||||
report = None
|
||||
return proc.returncode, report, proc.stderr
|
||||
|
||||
|
||||
class LedgerAnchorCheckTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
store = LedgerStore(self.db_path)
|
||||
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
|
||||
"fail", "audit", failures=["ci_green"])
|
||||
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
|
||||
"pass", "audit")
|
||||
head = store.export()["head_hash"]
|
||||
store.close()
|
||||
self.anchor_path = os.path.join(self.tmp.name, "anchor.json")
|
||||
with open(self.anchor_path, "w", encoding="utf-8") as handle:
|
||||
json.dump({"version": 1, "head_hash": head, "count": 2},
|
||||
handle)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _copy_db(self, name):
|
||||
import shutil
|
||||
path = os.path.join(self.tmp.name, name)
|
||||
shutil.copyfile(self.db_path, path)
|
||||
return path
|
||||
|
||||
def test_pass_without_and_with_anchor(self):
|
||||
code, report, _ = _run("--db", self.db_path)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(report["ok"])
|
||||
code, report, _ = _run("--db", self.db_path,
|
||||
"--anchor", self.anchor_path)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual(report["head_advanced_by"], 0)
|
||||
|
||||
def test_normal_update_blocked_by_trigger_then_privileged_tamper_fails(self):
|
||||
path = self._copy_db("tamper.db")
|
||||
conn = sqlite3.connect(path)
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"UPDATE audit_ledger SET detail='x' WHERE id=1")
|
||||
conn.rollback()
|
||||
conn.execute("DROP TRIGGER audit_ledger_no_update")
|
||||
conn.execute("UPDATE audit_ledger SET detail='tampered'"
|
||||
" WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
code, report, _ = _run("--db", path,
|
||||
"--anchor", self.anchor_path)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(report["ok"])
|
||||
self.assertTrue(any("entry_hash mismatch" in f
|
||||
for f in report["failures"]))
|
||||
|
||||
def test_tail_deletion_fails_against_old_anchor(self):
|
||||
path = self._copy_db("tail.db")
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("DROP TRIGGER audit_ledger_no_delete")
|
||||
conn.execute("DELETE FROM audit_ledger WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
code, report, _ = _run("--db", path,
|
||||
"--anchor", self.anchor_path)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(report["ok"])
|
||||
self.assertTrue(any("shorter than anchor" in f
|
||||
for f in report["failures"]))
|
||||
|
||||
def test_extended_chain_still_passes_anchor(self):
|
||||
store = LedgerStore(self.db_path)
|
||||
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
|
||||
"pass", "audit")
|
||||
store.close()
|
||||
code, report, _ = _run("--db", self.db_path,
|
||||
"--anchor", self.anchor_path)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual(report["head_advanced_by"], 1)
|
||||
|
||||
def test_write_anchor_refused_on_failing_export(self):
|
||||
path = self._copy_db("tail2.db")
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("DROP TRIGGER audit_ledger_no_delete")
|
||||
conn.execute("DELETE FROM audit_ledger WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
store = LedgerStore(path)
|
||||
export_path = os.path.join(self.tmp.name, "export.json")
|
||||
with open(export_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(store.export(), handle)
|
||||
store.close()
|
||||
out_path = os.path.join(self.tmp.name, "new-anchor.json")
|
||||
code, _report, _ = _run("--export", export_path,
|
||||
"--anchor", self.anchor_path,
|
||||
"--write-anchor", out_path)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(os.path.exists(out_path))
|
||||
|
||||
def test_usage_errors_exit_2(self):
|
||||
code, _report, _ = _run(
|
||||
"--db", os.path.join(self.tmp.name, "nope.db"))
|
||||
self.assertEqual(code, 2)
|
||||
code, _report, _ = _run("--db", "postgres://host/db")
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Future release endpoint: contract tests for cloud/release_gate.
|
||||
|
||||
These tests pin the CONTRACT, not an endpoint: there is no live
|
||||
release endpoint in this codebase (building one here would be
|
||||
the "new live endpoint touching production" this track forbids),
|
||||
so every test calls ``evaluate_release`` directly with a local
|
||||
SQLite chain. Nothing here deploys, publishes, or touches a
|
||||
production flow; the chain lives in a TemporaryDirectory and is
|
||||
deleted with the test.
|
||||
|
||||
What is pinned (and nowhere else):
|
||||
|
||||
- A blocked audit pushback blocks release (409, audit code),
|
||||
even when the fresh hard gate passes.
|
||||
- A passing re-audit unblocks release -- but only with a FRESH
|
||||
passing hard gate supplied by the caller; a merge-time 200 is
|
||||
never an input, and a missing hard gate blocks fail-closed.
|
||||
- A chain-read failure blocks fail-closed (state unavailable),
|
||||
distinguishable from an audit block and from a hard-gate block.
|
||||
- A hard-gate failure blocks release even when the chain is
|
||||
clear, and both sources are listed when both block.
|
||||
- CORRECTED-to-pass does NOT unblock: a meta-review correction
|
||||
to ``pass`` with no new passing conclusion stays blocked; only
|
||||
a fresh passing verdict unblocks (the #247/#244 semantic,
|
||||
re-checked at release time through the same reader).
|
||||
- A fix alone does not unblock.
|
||||
- A merge-time pass is not reused: a chain that was clear at
|
||||
"merge time" and is blocked by the time release is evaluated
|
||||
blocks release (the evaluator re-reads the chain every call).
|
||||
- The evaluator is read-only: evaluating never moves the chain
|
||||
head (no row is appended by a check).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.audit_pushback import GATE_CHECKS # noqa: F401
|
||||
from cloud.audit_wiring import pushback_package_from_gate
|
||||
from cloud.audit_wiring import record_audit_verdict
|
||||
from cloud.audit_wiring import record_fix
|
||||
from cloud.audit_wiring import record_meta_review
|
||||
from cloud.audit_wiring import record_pushback
|
||||
from cloud.audit_wiring import record_reaudit
|
||||
from cloud.ledger import LedgerStore
|
||||
from cloud.merge_gate_hard import evaluate_hard_merge_gate
|
||||
from cloud.release_gate import evaluate_release
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.audit_pushback import GATE_CHECKS # noqa: F401
|
||||
from cloud.audit_wiring import pushback_package_from_gate
|
||||
from cloud.audit_wiring import record_audit_verdict
|
||||
from cloud.audit_wiring import record_fix
|
||||
from cloud.audit_wiring import record_meta_review
|
||||
from cloud.audit_wiring import record_pushback
|
||||
from cloud.audit_wiring import record_reaudit
|
||||
from cloud.ledger import LedgerStore
|
||||
from cloud.merge_gate_hard import evaluate_hard_merge_gate
|
||||
from cloud.release_gate import evaluate_release
|
||||
|
||||
MODULE = "apps/web"
|
||||
CONTRACT = "v2"
|
||||
|
||||
|
||||
def _passing_gate():
|
||||
return evaluate_hard_merge_gate(
|
||||
files_changed=["apps/web/a.py"], owned_dir="apps/web",
|
||||
protection="protected", ci_status="success",
|
||||
ci_checks=[{"name": "ci", "status": "success"}],
|
||||
required_checks=["ci"], contract_version="v2",
|
||||
contract_frozen=True, coordinator_approved=True,
|
||||
unresolved_review_comments=0)
|
||||
|
||||
|
||||
def _failing_gate():
|
||||
return evaluate_hard_merge_gate(
|
||||
files_changed=["apps/web/a.py"], owned_dir="apps/web",
|
||||
protection="protected", ci_status="pending",
|
||||
ci_checks=[{"name": "ci", "status": "pending"}],
|
||||
required_checks=["ci"], contract_version="v2",
|
||||
contract_frozen=True, coordinator_approved=True,
|
||||
unresolved_review_comments=0)
|
||||
|
||||
|
||||
class ReleaseGateTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.store = LedgerStore(self.db_path)
|
||||
self.task = "task-1"
|
||||
self.project = "proj-1"
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.store.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _conn(self):
|
||||
return sqlite3.connect(self.db_path)
|
||||
|
||||
def _decide(self, hard_gate=None, task_status="merged"):
|
||||
conn = self._conn()
|
||||
try:
|
||||
return evaluate_release(
|
||||
self.task, conn, hard_gate=hard_gate,
|
||||
task_status=task_status)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _block_task(self):
|
||||
record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"fail", "audit", failures=["ci_green"])
|
||||
package = pushback_package_from_gate(
|
||||
_failing_gate(), self.task, 0)
|
||||
record_pushback(self.store, self.task, self.project, MODULE,
|
||||
CONTRACT, 1, package["failed_checks"], package,
|
||||
failure_source="audit")
|
||||
return package
|
||||
|
||||
def _head(self):
|
||||
return self.store.verify()["head_hash"]
|
||||
|
||||
def test_audit_pushback_blocks_release_even_with_passing_gate(self):
|
||||
self._block_task()
|
||||
decision = self._decide(hard_gate=_passing_gate())
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["http_status"], 409)
|
||||
self.assertEqual(decision["code"],
|
||||
"release_blocked_audit_pushback")
|
||||
self.assertEqual(decision["blocked_by"], "audit_pushback")
|
||||
self.assertIn("release_blocked_audit_pushback",
|
||||
decision["blocked_sources"])
|
||||
self.assertIn("ci_green", decision["failed_checks"])
|
||||
self.assertEqual(decision["round"], 1)
|
||||
|
||||
def test_reaudit_pass_allows_release_with_fresh_passing_gate(self):
|
||||
package = self._block_task()
|
||||
record_reaudit(self.store, self.task, self.project, MODULE,
|
||||
CONTRACT, 1, {"verdict": "pass"},
|
||||
package=package)
|
||||
decision = self._decide(hard_gate=_passing_gate())
|
||||
self.assertTrue(decision["allowed"])
|
||||
self.assertEqual(decision["http_status"], 200)
|
||||
self.assertEqual(decision["code"], "release_allowed")
|
||||
self.assertIsNone(decision["blocked_by"])
|
||||
self.assertEqual(decision["blocked_sources"], [])
|
||||
|
||||
def test_corrected_to_pass_still_blocks_until_fresh_pass(self):
|
||||
# One failing verdict is the ONLY block here, so the
|
||||
# correction below is the only thing that could unblock
|
||||
# -- if it did, this test would catch it. (A second fail
|
||||
# or an open pushback would mask a wrong unblock.)
|
||||
verdict = record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"fail", "audit", failures=["ci_green"])
|
||||
self.assertFalse(
|
||||
self._decide(hard_gate=_passing_gate())["allowed"])
|
||||
# Audit-of-audit correction: the original fail was wrong,
|
||||
# corrected to pass. That schedules a re-audit; it does
|
||||
# NOT release the gate by itself.
|
||||
record_meta_review(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
verdict["id"], "fail", "pass", True, reviewer="auditor-b")
|
||||
decision = self._decide(hard_gate=_passing_gate())
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["code"],
|
||||
"release_blocked_audit_pushback")
|
||||
# Only a fresh passing verdict unblocks.
|
||||
record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"pass", "audit")
|
||||
decision = self._decide(hard_gate=_passing_gate())
|
||||
self.assertTrue(decision["allowed"], decision)
|
||||
|
||||
def test_hard_gate_blocks_release_when_chain_is_clear(self):
|
||||
record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"pass", "audit")
|
||||
decision = self._decide(hard_gate=_failing_gate())
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["http_status"], 409)
|
||||
self.assertEqual(decision["code"], "release_blocked_hard_gate")
|
||||
self.assertEqual(decision["blocked_by"], "hard_gate")
|
||||
self.assertTrue(decision["blocks"])
|
||||
self.assertNotIn("release_blocked_audit_pushback",
|
||||
decision["blocked_sources"])
|
||||
|
||||
def test_both_sources_listed_when_both_block(self):
|
||||
self._block_task()
|
||||
decision = self._decide(hard_gate=_failing_gate())
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertIn("release_blocked_hard_gate",
|
||||
decision["blocked_sources"])
|
||||
self.assertIn("release_blocked_audit_pushback",
|
||||
decision["blocked_sources"])
|
||||
self.assertTrue(decision["blocks"])
|
||||
self.assertTrue(decision["failed_checks"])
|
||||
|
||||
def test_chain_read_failure_fails_closed(self):
|
||||
conn = self._conn()
|
||||
conn.close()
|
||||
decision = evaluate_release(
|
||||
self.task, conn, hard_gate=_passing_gate(),
|
||||
task_status="merged")
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["http_status"], 409)
|
||||
self.assertEqual(decision["code"],
|
||||
"release_blocked_state_unavailable")
|
||||
self.assertEqual(
|
||||
decision["blocked_by"],
|
||||
"audit_pushback_state_unavailable")
|
||||
|
||||
def test_missing_hard_gate_evidence_blocks(self):
|
||||
record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"pass", "audit")
|
||||
decision = self._decide(hard_gate=None)
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["code"],
|
||||
"release_hard_gate_evidence_missing")
|
||||
|
||||
def test_unmerged_task_cannot_release(self):
|
||||
record_audit_verdict(
|
||||
self.store, self.task, self.project, MODULE, CONTRACT,
|
||||
"pass", "audit")
|
||||
decision = self._decide(hard_gate=_passing_gate(),
|
||||
task_status="completed")
|
||||
self.assertFalse(decision["allowed"])
|
||||
self.assertEqual(decision["code"], "release_requires_merged")
|
||||
|
||||
def test_merge_time_pass_is_not_reused_at_release(self):
|
||||
# "Merge time": chain clear, fresh gate passes.
|
||||
first = self._decide(hard_gate=_passing_gate())
|
||||
self.assertTrue(first["allowed"], first)
|
||||
# Between merge and release, a pushback lands on the chain.
|
||||
self._block_task()
|
||||
second = self._decide(hard_gate=_passing_gate())
|
||||
self.assertFalse(second["allowed"])
|
||||
self.assertEqual(second["code"],
|
||||
"release_blocked_audit_pushback")
|
||||
|
||||
def test_fix_alone_does_not_unblock_release(self):
|
||||
package = self._block_task()
|
||||
record_fix(self.store, self.task, self.project, MODULE,
|
||||
CONTRACT, 1,
|
||||
{"summary": "fix ci", "diff_sha256": "b" * 64,
|
||||
"files_changed": ["apps/web/a.py"]}, package)
|
||||
decision = self._decide(hard_gate=_passing_gate())
|
||||
self.assertFalse(decision["allowed"])
|
||||
|
||||
def test_evaluator_is_read_only(self):
|
||||
self._block_task()
|
||||
before = self._head()
|
||||
self._decide(hard_gate=_passing_gate())
|
||||
self._decide(hard_gate=_failing_gate())
|
||||
self.assertEqual(self._head(), before)
|
||||
self.assertTrue(self.store.verify()["ok"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user