diff --git a/scripts/check_test_wiring.py b/scripts/check_test_wiring.py index c920365a4..20dd48cf6 100755 --- a/scripts/check_test_wiring.py +++ b/scripts/check_test_wiring.py @@ -1873,6 +1873,52 @@ def validate_release_bundle_gate_windows(gate: str, field_windows: dict[str, str require(len(windows) == 1, f"{gate} evidence fields must share one measurement window") +def validate_scanner_heal_release_bundle_gate(bundle_path: Path, source_revision: str, + requirements: dict[str, dict[str, object]], + raw_gates: dict[str, object], gate: str) -> list[str]: + gate_errors: list[str] = [] + requirement = requirements[gate] + gate_evidence = raw_gates.get(gate) + if not isinstance(gate_evidence, dict): + return ["missing gate evidence"] + if gate_evidence.get("status") != "pass": + gate_errors.append("gate status must be pass") + if gate_evidence.get("lane") != requirement["lane"]: + gate_errors.append("gate lane mismatch") + if gate_evidence.get("evidence_type") != "measured": + gate_errors.append("gate evidence type must be measured") + fields = gate_evidence.get("evidence_fields") + if not isinstance(fields, dict): + gate_errors.append("missing gate evidence fields") + fields = {} + required_fields = tuple(SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS[gate]) + missing_fields = [field for field in required_fields if field not in fields] + if missing_fields: + gate_errors.append(f"missing required fields: {', '.join(missing_fields)}") + field_windows: dict[str, str] = {} + for field in required_fields: + if field not in fields: + continue + evidence = fields[field] + if not isinstance(evidence, dict): + gate_errors.append(f"{field} must be an object") + continue + for marker in ("fixture_only", "dry_run", "synthetic"): + if evidence.get(marker) is True: + gate_errors.append(f"{field} is {marker}") + continue + try: + field_windows[field] = validate_release_bundle_artifact(bundle_path, source_revision, gate, field, evidence) + except (OSError, KeyError, TypeError, ValueError) as error: + gate_errors.append(str(error)) + if not gate_errors: + try: + validate_release_bundle_gate_windows(gate, field_windows) + except ValueError as error: + gate_errors.append(str(error)) + return gate_errors + + def scanner_heal_release_bundle_status(root: Path, bundle_path: Path) -> dict[str, object]: """Validate a complete hard-gate evidence bundle without accepting synthetic claims.""" registry = read_json(root / ".config/scanner-heal-required-tests.json") @@ -1894,47 +1940,8 @@ def scanner_heal_release_bundle_status(root: Path, bundle_path: Path) -> dict[st verified: list[str] = [] rejected: dict[str, list[str]] = {} - for gate, requirement in requirements.items(): - gate_errors: list[str] = [] - gate_evidence = raw_gates.get(gate) - if not isinstance(gate_evidence, dict): - rejected[gate] = ["missing gate evidence"] - continue - if gate_evidence.get("status") != "pass": - gate_errors.append("gate status must be pass") - if gate_evidence.get("lane") != requirement["lane"]: - gate_errors.append("gate lane mismatch") - if gate_evidence.get("evidence_type") != "measured": - gate_errors.append("gate evidence type must be measured") - fields = gate_evidence.get("evidence_fields") - if not isinstance(fields, dict): - gate_errors.append("missing gate evidence fields") - fields = {} - required_fields = tuple(SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS[gate]) - missing_fields = [field for field in required_fields if field not in fields] - if missing_fields: - gate_errors.append(f"missing required fields: {', '.join(missing_fields)}") - field_windows: dict[str, str] = {} - for field in required_fields: - if field not in fields: - continue - evidence = fields[field] - if not isinstance(evidence, dict): - gate_errors.append(f"{field} must be an object") - continue - for marker in ("fixture_only", "dry_run", "synthetic"): - if evidence.get(marker) is True: - gate_errors.append(f"{field} is {marker}") - continue - try: - field_windows[field] = validate_release_bundle_artifact(bundle_path, source_revision, gate, field, evidence) - except (OSError, KeyError, TypeError, ValueError) as error: - gate_errors.append(str(error)) - if not gate_errors: - try: - validate_release_bundle_gate_windows(gate, field_windows) - except ValueError as error: - gate_errors.append(str(error)) + for gate in requirements: + gate_errors = validate_scanner_heal_release_bundle_gate(bundle_path, source_revision, requirements, raw_gates, gate) if gate_errors: rejected[gate] = gate_errors else: @@ -1956,6 +1963,43 @@ def scanner_heal_release_bundle_status(root: Path, bundle_path: Path) -> dict[st } +def scanner_heal_release_bundle_gate_status(root: Path, bundle_path: Path, gate: str) -> dict[str, object]: + """Validate one measured release-bundle gate without approving the full release.""" + registry = read_json(root / ".config/scanner-heal-required-tests.json") + requirements, release_schema_capable, pending_lanes = scanner_heal_release_requirements(registry) + require(release_schema_capable, "scanner/heal release bundle gate requires schema 2 registry") + require(gate in requirements, f"unknown scanner/heal release gate: {gate}") + bundle_path = bundle_path.resolve() + bundle = read_json(bundle_path) + for marker in ("fixture_only", "dry_run", "synthetic"): + require(bundle.get(marker) is not True, f"scanner/heal release evidence bundle is {marker}") + require(bundle.get("schema") == 1, "unsupported scanner/heal release evidence bundle schema") + require(bundle.get("evidence") == "measured", "scanner/heal release evidence bundle must be measured") + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + source_revision = bundle.get("source_revision") + require(isinstance(source_revision, str) and re.fullmatch(r"[0-9a-f]{40}", source_revision) is not None and + source_revision == revision, + "scanner/heal release evidence source revision mismatch") + raw_gates = bundle.get("gates") + require(isinstance(raw_gates, dict), "scanner/heal release evidence bundle missing gates") + unknown = sorted(set(raw_gates) - set(requirements)) + require(not unknown, f"scanner/heal release evidence bundle has unknown gates: {', '.join(unknown)}") + + gate_errors = validate_scanner_heal_release_bundle_gate(bundle_path, source_revision, requirements, raw_gates, gate) + verified = not gate_errors + return { + "schema": 1, + "decision": "verified" if verified else "blocked", + "release_approved": False, + "release_schema_capable": release_schema_capable, + "verified_gate": gate if verified else None, + "rejected_gate": None if verified else gate, + "rejected_errors": gate_errors, + "pending_gates": sorted(item for item in requirements if item != gate), + "pending_lanes": pending_lanes, + } + + def release_bundle_descriptor_path(descriptor_path: Path, raw_path: object, gate: str, field: str) -> Path: require(isinstance(raw_path, str) and raw_path.strip(), f"{gate}.{field} missing source artifact") path = Path(raw_path) @@ -2694,6 +2738,44 @@ class SelfTests(unittest.TestCase): self.assertEqual(evidence["sha256"], digest(bundle.parent / evidence["artifact"])) self.assertTrue((bundle.parent / evidence["artifact"]).is_file()) + def test_scanner_heal_release_bundle_gate_accepts_single_measured_gate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp)) + data = read_json(bundle) + data["gates"] = {"G09": data["gates"]["G09"]} + write_json(bundle, data) + with mock.patch("subprocess.check_output", return_value="b" * 40): + status = scanner_heal_release_bundle_gate_status(root, bundle, "G09") + full_status = scanner_heal_release_bundle_status(root, bundle) + self.assertEqual(status["decision"], "verified") + self.assertFalse(status["release_approved"]) + self.assertEqual(status["verified_gate"], "G09") + self.assertIn("G07", status["pending_gates"]) + self.assertEqual(full_status["decision"], "blocked") + self.assertFalse(full_status["release_approved"]) + + def test_scanner_heal_release_bundle_gate_rejects_missing_field(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp)) + data = read_json(bundle) + del data["gates"]["G09"]["evidence_fields"]["rollback_payload_evidence"] + write_json(bundle, data) + with mock.patch("subprocess.check_output", return_value="b" * 40): + status = scanner_heal_release_bundle_gate_status(root, bundle, "G09") + self.assertEqual(status["decision"], "blocked") + self.assertFalse(status["release_approved"]) + self.assertIn("missing required fields: rollback_payload_evidence", status["rejected_errors"]) + + def test_scanner_heal_release_bundle_gate_rejects_unknown_gate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp)) + data = read_json(bundle) + data["gates"] = {"G09": data["gates"]["G09"], "Z99": {"status": "pass"}} + write_json(bundle, data) + with mock.patch("subprocess.check_output", return_value="b" * 40): + with self.assertRaisesRegex(ValueError, "unknown gates: Z99"): + scanner_heal_release_bundle_gate_status(root, bundle, "G09") + def test_scanner_heal_release_bundle_assembler_rejects_fixture_descriptor(self) -> None: with tempfile.TemporaryDirectory() as tmp: root, _ = self.scanner_heal_fixture(Path(tmp)) @@ -4169,6 +4251,7 @@ def main() -> int: return 0 if unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() else 1 if sys.argv[1:2] in (["--begin-scanner-heal"], ["--finish-scanner-heal"], ["--check-scanner-heal"], ["--check-scanner-heal-release"], ["--check-scanner-heal-release-bundle"], + ["--check-scanner-heal-release-bundle-gate"], ["--assemble-scanner-heal-release-bundle"], ["--write-scanner-heal-release-bundle-fixture"]): try: @@ -4203,6 +4286,15 @@ def main() -> int: return 2 print(json.dumps(status, sort_keys=True, separators=(",", ":"))) return 0 if status["release_approved"] else 1 + if len(sys.argv) == 4 and sys.argv[1] == "--check-scanner-heal-release-bundle-gate": + try: + status = scanner_heal_release_bundle_gate_status(ROOT, Path(sys.argv[2]), sys.argv[3]) + except (OSError, KeyError, TypeError, ValueError, ET.ParseError) as error: + print(json.dumps({"schema": 1, "decision": "invalid", "release_approved": False, + "error": str(error)}, sort_keys=True, separators=(",", ":"))) + return 2 + print(json.dumps(status, sort_keys=True, separators=(",", ":"))) + return 0 if status["decision"] == "verified" else 1 if len(sys.argv) == 4 and sys.argv[1] == "--assemble-scanner-heal-release-bundle": try: bundle, status = assemble_scanner_heal_release_bundle(ROOT, Path(sys.argv[2]), Path(sys.argv[3])) @@ -4217,7 +4309,7 @@ def main() -> int: bundle = write_scanner_heal_release_bundle_fixture(ROOT, Path(sys.argv[2])) print(bundle) return 0 - raise ValueError("expected --begin-scanner-heal DIR BINARY TEST_BINARY, --finish-scanner-heal DIR EXIT, --check-scanner-heal DIR CASE|release, --check-scanner-heal-release DIR, --check-scanner-heal-release-bundle FILE, --assemble-scanner-heal-release-bundle DESCRIPTOR DIR, or --write-scanner-heal-release-bundle-fixture DIR") + raise ValueError("expected --begin-scanner-heal DIR BINARY TEST_BINARY, --finish-scanner-heal DIR EXIT, --check-scanner-heal DIR CASE|release, --check-scanner-heal-release DIR, --check-scanner-heal-release-bundle FILE, --check-scanner-heal-release-bundle-gate FILE GATE, --assemble-scanner-heal-release-bundle DESCRIPTOR DIR, or --write-scanner-heal-release-bundle-fixture DIR") except (OSError, KeyError, TypeError, ValueError, subprocess.SubprocessError) as error: print(f"ERROR: {error}", file=sys.stderr) return 1 @@ -4245,6 +4337,7 @@ def main() -> int: print( "usage: check_test_wiring.py [--self-test | --check-core LISTING | --check-profile PROFILE LISTING | " "--update-profile PROFILE LISTING PLATFORM | --check-scanner-heal-release-bundle FILE | " + "--check-scanner-heal-release-bundle-gate FILE GATE | " "--assemble-scanner-heal-release-bundle DESCRIPTOR DIR | --write-scanner-heal-release-bundle-fixture DIR]", file=sys.stderr, ) diff --git a/scripts/run_scanner_heal_g09_upgrade_evidence.sh b/scripts/run_scanner_heal_g09_upgrade_evidence.sh index 08977c1ca..e5c7d4aa7 100755 --- a/scripts/run_scanner_heal_g09_upgrade_evidence.sh +++ b/scripts/run_scanner_heal_g09_upgrade_evidence.sh @@ -25,7 +25,8 @@ usage() { Usage: scripts/run_scanner_heal_g09_upgrade_evidence.sh [OPTIONS] Build the current checkout, run the Scanner/Heal G09 upgrade compatibility -lanes, and validate the raw mixed-version/rollback evidence artifacts. +lanes, validate the raw mixed-version/rollback evidence artifacts, and write a +bundle-ready G09 release-evidence descriptor for full runs. Options: --run-dir DIR New evidence directory (default: target/scanner-heal-g09-evidence/TIMESTAMP) @@ -255,6 +256,10 @@ run_logged() { echo "PASS: $label" } +utc_now() { + date -u +%Y-%m-%dT%H:%M:%SZ +} + validate_artifacts() { local source_revision="$1" "$PYTHON_BIN" - "$RUN_DIR" "$source_revision" "$TEST_SELECTION" <<'PY' @@ -314,6 +319,82 @@ print("PASS: G09 raw evidence artifacts verified") PY } +write_g09_release_descriptor() { + local source_revision="$1" + local started_at="$2" + local finished_at="$3" + "$PYTHON_BIN" - "$ROOT" "$RUN_DIR" "$source_revision" "$started_at" "$finished_at" <<'PY' +import hashlib +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +run_dir = pathlib.Path(sys.argv[2]) +source_revision = sys.argv[3] +started_at = sys.argv[4] +finished_at = sys.argv[5] +descriptor = run_dir / "release-bundle-g09.json" +registry = json.loads((root / ".config/scanner-heal-required-tests.json").read_text()) +requirements = {item["gate"]: item for item in registry["release_requirements"]} +artifacts = { + "mixed_version_reader_evidence": run_dir / "mixed-version-upgrade" / "G09-mixed_version_reader_evidence.json", + "mixed_version_writer_evidence": run_dir / "mixed-version-upgrade" / "G09-mixed_version_writer_evidence.json", + "rollback_payload_evidence": run_dir / "bucket-config-rollback" / "G09-rollback_payload_evidence.json", +} + +def digest(path: pathlib.Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + +def relative_to_descriptor(path: pathlib.Path) -> str: + return path.resolve(strict=True).relative_to(descriptor.parent.resolve()).as_posix() + +fields = {} +for field, artifact in artifacts.items(): + payload = json.loads(artifact.read_text()) + if payload.get("source_revision") != source_revision or payload.get("current_revision") != source_revision: + raise SystemExit(f"{field}: source revision does not match this checkout") + evidence = { + "artifact": relative_to_descriptor(artifact), + "sha256": digest(artifact), + "evidence_type": "measured", + "source_revision": source_revision, + "run_id": payload["run_id"], + "measurement_window_id": payload["measurement_window_id"], + "started_at": started_at, + "finished_at": finished_at, + "command": ["scripts/run_scanner_heal_g09_upgrade_evidence.sh", "--test", "all"], + "artifact_format": "json", + "summary": f"Measured Scanner/Heal G09 {field} from the pinned previous release binary and current PR binary.", + "versions": payload["versions"], + "mixed_version_role": payload["mixed_version_role"], + "mixed_version_cases": payload["mixed_version_cases"], + } + if field == "rollback_payload_evidence": + evidence["rollback_payload_replayed"] = payload.get("rollback_payload_replayed") + fields[field] = evidence + +descriptor.write_text(json.dumps({ + "schema": 1, + "evidence": "measured", + "source_revision": source_revision, + "gates": { + "G09": { + "status": "pass", + "lane": requirements["G09"]["lane"], + "evidence_type": "measured", + "evidence_fields": fields, + }, + }, +}, indent=2, sort_keys=True) + "\n") +print(descriptor) +PY +} + run_self_test() { local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/rustfs-g09-evidence-self-test.XXXXXX")" @@ -342,18 +423,21 @@ run_self_test() { mkdir -p "$tmp/run/mixed-version-upgrade" "$tmp/run/bucket-config-rollback" local current previous - current="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + current="$(git rev-parse HEAD)" previous="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" cat >"$tmp/run/mixed-version-upgrade/G09-mixed_version_reader_evidence.json" <"$tmp/run/mixed-version-upgrade/G09-mixed_version_writer_evidence.json" <"$tmp/run/bucket-config-rollback/G09-rollback_payload_evidence.json" </dev/null + local descriptor + descriptor="$(RUN_DIR="$tmp/run" write_g09_release_descriptor "$current" "$(utc_now)" "$(utc_now)")" + "$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$descriptor" G09 >/dev/null } while [[ $# -gt 0 ]]; do @@ -509,6 +593,7 @@ if [[ -n "${TMPDIR:-}" ]]; then fi ensure_min_free_space "$RUN_DIR" +RUN_STARTED_AT="$(utc_now)" SOURCE_BINARY="$(resolve_source_binary)" export RUSTFS_UPGRADE_SOURCE_BINARY="$SOURCE_BINARY" export RUSTFS_E2E_LOG_DIR="${RUSTFS_E2E_LOG_DIR:-$RUN_DIR/server-logs}" @@ -537,4 +622,10 @@ for case_name in "${CASES[@]}"; do done validate_artifacts "$SOURCE_REVISION" +if [[ "$TEST_SELECTION" == "all" ]]; then + RUN_FINISHED_AT="$(utc_now)" + DESCRIPTOR="$(write_g09_release_descriptor "$SOURCE_REVISION" "$RUN_STARTED_AT" "$RUN_FINISHED_AT")" + "$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$DESCRIPTOR" G09 + echo "Scanner/Heal G09 release descriptor verified: $DESCRIPTOR" +fi echo "Scanner/Heal G09 evidence verified: $RUN_DIR"