diff --git a/yuqei-infra/deploy/plan-rollback-rehearsal.py b/yuqei-infra/deploy/plan-rollback-rehearsal.py index d06d70b0..e0660603 100644 --- a/yuqei-infra/deploy/plan-rollback-rehearsal.py +++ b/yuqei-infra/deploy/plan-rollback-rehearsal.py @@ -64,8 +64,35 @@ REHEARSAL_TOOL_INPUTS = ( "yuqei-infra/deploy/release-package.py", "yuqei-infra/deploy/preflight-production.py", "yuqei-infra/deploy/verify-production-compose.py", + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py", "yuqei-infra/scripts/build-installable-release.py", + "yuqei-infra/scripts/build-infrastructure-image-bundle.py", + "yuqei-infra/scripts/build-third-party-materials.py", "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/release-trust/third-party-materials-baseline.json", +) +PACKAGE_SCHEMA_V1 = "yuqei.release-package.v1" +PACKAGE_SCHEMA_V2 = "yuqei.release-package.v2" +V1_BOOTSTRAP_ARTIFACTS = frozenset( + { + "release_manifest", + "checksum_manifest", + "image_identity", + "installable_manifest", + } +) +V2_BOOTSTRAP_ARTIFACTS = frozenset( + { + *V1_BOOTSTRAP_ARTIFACTS, + "infrastructure_source_set", + "infrastructure_image_bundle", + "third_party_materials_input", + "third_party_materials_manifest", + "third_party_materials_verification_receipt", + } +) +OPTIONAL_BOOTSTRAP_ARTIFACTS = frozenset( + {"backup_proof", "compatibility_proof"} ) EXPECTED_APPLICATION_SERVICES = ( "contract-api-migrate", @@ -119,6 +146,15 @@ def _load_release_package_module( "yuqei-infra/deploy/verify-production-compose.py": ( "yuqei_release_package_compose_gate" ), + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py": ( + "yuqei_release_package_snapshot_restore_planner" + ), + "yuqei-infra/scripts/build-infrastructure-image-bundle.py": ( + "yuqei_release_package_infrastructure_bundle" + ), + "yuqei-infra/scripts/build-third-party-materials.py": ( + "yuqei_release_package_third_party_materials" + ), "yuqei-infra/deploy/release-package.py": name, } previous_modules = { @@ -135,6 +171,9 @@ def _load_release_package_module( "yuqei-infra/scripts/build-installable-release.py", "yuqei-infra/deploy/preflight-production.py", "yuqei-infra/deploy/verify-production-compose.py", + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py", + "yuqei-infra/scripts/build-infrastructure-image-bundle.py", + "yuqei-infra/scripts/build-third-party-materials.py", "yuqei-infra/deploy/release-package.py", ): loaded[relative] = _exec_verified_module( @@ -152,6 +191,38 @@ def _load_release_package_module( release_package._COMPOSE_GATE_MODULE = loaded[ "yuqei-infra/deploy/verify-production-compose.py" ] + release_package._SNAPSHOT_RESTORE_PLANNER_MODULE = loaded[ + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py" + ] + release_package._INFRASTRUCTURE_BUNDLE_MODULE = loaded[ + "yuqei-infra/scripts/build-infrastructure-image-bundle.py" + ] + release_package._THIRD_PARTY_MATERIALS_MODULE = loaded[ + "yuqei-infra/scripts/build-third-party-materials.py" + ] + # The snapshot planner normally discovers release-package.py from + # its working tree. Keep the circular validation path on the same + # already verified module bytes instead of permitting an ambient + # file to replace the candidate after bootstrap. + snapshot_planner = release_package._SNAPSHOT_RESTORE_PLANNER_MODULE + snapshot_planner._load_release_package_validator = ( + lambda _repo_root: release_package.validate_release_package + ) + baseline_relative = ( + "yuqei-infra/release-trust/third-party-materials-baseline.json" + ) + baseline_raw, baseline_sha = _read_secure_bytes( + REPO_ROOT / baseline_relative, + label="verified third-party materials trust baseline", + max_bytes=MAX_JSON_BYTES, + ) + if ( + baseline_raw != verified_sources[baseline_relative] + or hashlib.sha256(baseline_raw).hexdigest() != baseline_sha + ): + raise RehearsalProofError( + "Third-party materials trust baseline changed after bootstrap." + ) return release_package except Exception as exc: raise RehearsalProofError( @@ -1522,11 +1593,32 @@ def _bootstrap_verified_tool_sources( current_package_sha256, label="current release-package.json bootstrap", ) - if package.get("schema_version") != "yuqei.release-package.v1": + package_schema = package.get("schema_version") + if package_schema not in {PACKAGE_SCHEMA_V1, PACKAGE_SCHEMA_V2}: raise RehearsalProofError("Current release package bootstrap schema is unsupported.") artifacts = package.get("artifacts") if not isinstance(artifacts, dict): raise RehearsalProofError("Current release package bootstrap has no artifact inventory.") + artifact_names = set(artifacts) + required_artifacts = ( + V2_BOOTSTRAP_ARTIFACTS + if package_schema == PACKAGE_SCHEMA_V2 + else V1_BOOTSTRAP_ARTIFACTS + ) + if ( + not required_artifacts.issubset(artifact_names) + or not artifact_names.issubset( + required_artifacts | OPTIONAL_BOOTSTRAP_ARTIFACTS + ) + ): + if package_schema == PACKAGE_SCHEMA_V2: + raise RehearsalProofError( + "Current release package v2 bootstrap artifact closure is incomplete." + ) + raise RehearsalProofError( + "Legacy release package v1 bootstrap must remain read-only and must not " + "contain v2 or unknown artifacts." + ) release_record = _require_exact_fields( artifacts.get("release_manifest"), {"path", "sha256"}, diff --git a/yuqei-infra/tests/test_rollback_rehearsal_planner.py b/yuqei-infra/tests/test_rollback_rehearsal_planner.py index 66e7c531..15a793b7 100644 --- a/yuqei-infra/tests/test_rollback_rehearsal_planner.py +++ b/yuqei-infra/tests/test_rollback_rehearsal_planner.py @@ -42,6 +42,62 @@ def _write_json(path: Path, payload: dict[str, Any]) -> str: return hashlib.sha256(raw).hexdigest() +def _bootstrap_package_fixture( + tmp_path: Path, + *, + schema: str, + omitted_tool_input: str | None = None, + omitted_artifact: str | None = None, + extra_artifact: str | None = None, + repo_root: Path = REPO_ROOT, +) -> tuple[Path, str, dict[str, bytes]]: + source_items = [] + expected_raw: dict[str, bytes] = {} + for relative in planner.REHEARSAL_TOOL_INPUTS: + source = repo_root / relative + raw = source.read_bytes() + expected_raw[relative] = raw + if relative != omitted_tool_input: + source_items.append( + { + "path": relative, + "sha256": hashlib.sha256(raw).hexdigest(), + } + ) + source_manifest = { + "schema_version": "yuqei.release-source-manifest.v1", + "files": {"items": source_items}, + } + release_dir = tmp_path / "release" + source_path = release_dir / "release-source-manifest.json" + source_sha = _write_json(source_path, source_manifest) + required = ( + planner.V2_BOOTSTRAP_ARTIFACTS + if schema == planner.PACKAGE_SCHEMA_V2 + else planner.V1_BOOTSTRAP_ARTIFACTS + ) + artifacts = { + name: {"path": f"artifacts/{name}.json", "sha256": "0" * 64} + for name in required + if name != omitted_artifact + } + artifacts["release_manifest"] = { + "path": "release-source-manifest.json", + "sha256": source_sha, + } + if extra_artifact is not None: + artifacts[extra_artifact] = { + "path": f"artifacts/{extra_artifact}.json", + "sha256": "1" * 64, + } + package_path = release_dir / "release-package.json" + package_sha = _write_json( + package_path, + {"schema_version": schema, "artifacts": artifacts}, + ) + return package_path, package_sha, expected_raw + + def _with_hash(payload: dict[str, Any], field: str) -> dict[str, Any]: return {**payload, field: planner.canonical_sha256(payload)} @@ -799,33 +855,10 @@ def test_rollback_tool_sources_are_bound_to_current_package(tmp_path: Path) -> N def test_rollback_tool_sources_are_bootstrapped_before_module_execution( tmp_path: Path, ) -> None: - source_items = [] - expected_raw = {} - for relative in planner.REHEARSAL_TOOL_INPUTS: - source = REPO_ROOT / relative - raw = source.read_bytes() - expected_raw[relative] = raw - source_items.append({ - "path": relative, - "sha256": hashlib.sha256(raw).hexdigest(), - }) - source_manifest = { - "schema_version": "yuqei.release-source-manifest.v1", - "files": {"items": source_items}, - } - source_path = tmp_path / "release" / "release-source-manifest.json" - source_sha = _write_json(source_path, source_manifest) - package = { - "schema_version": "yuqei.release-package.v1", - "artifacts": { - "release_manifest": { - "path": "release-source-manifest.json", - "sha256": source_sha, - } - }, - } - package_path = tmp_path / "release" / "release-package.json" - package_sha = _write_json(package_path, package) + package_path, package_sha, expected_raw = _bootstrap_package_fixture( + tmp_path, + schema=planner.PACKAGE_SCHEMA_V2, + ) verified_sources, verified_hashes = planner._bootstrap_verified_tool_sources( current_package_dir=package_path.parent.resolve(), @@ -841,6 +874,15 @@ def test_rollback_tool_sources_are_bootstrapped_before_module_execution( assert loaded._INSTALLABLE_VALIDATOR_MODULE is not None assert loaded._PREFLIGHT_MODULE is not None assert loaded._COMPOSE_GATE_MODULE is not None + assert loaded._INFRASTRUCTURE_BUNDLE_MODULE is not None + assert loaded._THIRD_PARTY_MATERIALS_MODULE is not None + assert loaded._SNAPSHOT_RESTORE_PLANNER_MODULE is not None + assert ( + loaded._SNAPSHOT_RESTORE_PLANNER_MODULE._load_release_package_validator( + REPO_ROOT + ) + is loaded.validate_release_package + ) (package_path.parent / "release-source-manifest.json").write_bytes(b"tampered\n") with pytest.raises(planner.RehearsalProofError, match="externally supplied SHA-256"): @@ -851,6 +893,128 @@ def test_rollback_tool_sources_are_bootstrapped_before_module_execution( ) +@pytest.mark.parametrize( + "relative", + ( + "yuqei-infra/scripts/build-infrastructure-image-bundle.py", + "yuqei-infra/scripts/build-third-party-materials.py", + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py", + "yuqei-infra/release-trust/third-party-materials-baseline.json", + ), +) +def test_v2_bootstrap_rejects_missing_new_source_closure( + tmp_path: Path, + relative: str, +) -> None: + package_path, package_sha, _expected_raw = _bootstrap_package_fixture( + tmp_path, + schema=planner.PACKAGE_SCHEMA_V2, + omitted_tool_input=relative, + ) + with pytest.raises(planner.RehearsalProofError, match="does not bind"): + planner._bootstrap_verified_tool_sources( + current_package_dir=package_path.parent.resolve(), + current_package_sha256=package_sha, + repo_root=REPO_ROOT, + ) + + +@pytest.mark.parametrize( + "relative", + ( + "yuqei-infra/scripts/build-infrastructure-image-bundle.py", + "yuqei-infra/scripts/build-third-party-materials.py", + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py", + "yuqei-infra/release-trust/third-party-materials-baseline.json", + ), +) +def test_v2_bootstrap_rejects_new_source_drift( + tmp_path: Path, + relative: str, +) -> None: + candidate_root = tmp_path / "candidate" + for tool_input in planner.REHEARSAL_TOOL_INPUTS: + target = candidate_root / tool_input + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((REPO_ROOT / tool_input).read_bytes()) + package_path, package_sha, _expected_raw = _bootstrap_package_fixture( + tmp_path / "package", + schema=planner.PACKAGE_SCHEMA_V2, + repo_root=candidate_root, + ) + (candidate_root / relative).write_bytes(b"candidate drift\n") + with pytest.raises(planner.RehearsalProofError, match="differs from package identity"): + planner._bootstrap_verified_tool_sources( + current_package_dir=package_path.parent.resolve(), + current_package_sha256=package_sha, + repo_root=candidate_root.resolve(), + ) + + +def test_verified_v2_dependency_modules_are_injected_from_bootstrapped_bytes( + tmp_path: Path, +) -> None: + _package_path, _package_sha, verified_sources = _bootstrap_package_fixture( + tmp_path, + schema=planner.PACKAGE_SCHEMA_V2, + ) + injected = dict(verified_sources) + module_paths = ( + "yuqei-infra/scripts/build-infrastructure-image-bundle.py", + "yuqei-infra/scripts/build-third-party-materials.py", + "yuqei-infra/deploy/plan-first-release-snapshot-restore.py", + ) + for index, relative in enumerate(module_paths, start=1): + injected[relative] += f"\nVERIFIED_BYTE_SENTINEL = {index}\n".encode() + loaded = planner._load_release_package_module(injected) + assert loaded._INFRASTRUCTURE_BUNDLE_MODULE.VERIFIED_BYTE_SENTINEL == 1 + assert loaded._THIRD_PARTY_MATERIALS_MODULE.VERIFIED_BYTE_SENTINEL == 2 + assert loaded._SNAPSHOT_RESTORE_PLANNER_MODULE.VERIFIED_BYTE_SENTINEL == 3 + + +def test_bootstrap_accepts_complete_legacy_v1_for_read_only_validation( + tmp_path: Path, +) -> None: + package_path, package_sha, expected_raw = _bootstrap_package_fixture( + tmp_path, + schema=planner.PACKAGE_SCHEMA_V1, + ) + verified_sources, _verified_hashes = planner._bootstrap_verified_tool_sources( + current_package_dir=package_path.parent.resolve(), + current_package_sha256=package_sha, + repo_root=REPO_ROOT, + ) + assert verified_sources == expected_raw + + +def test_bootstrap_rejects_incomplete_v2_and_v2_artifact_downgrade( + tmp_path: Path, +) -> None: + package_path, package_sha, _expected_raw = _bootstrap_package_fixture( + tmp_path / "incomplete-v2", + schema=planner.PACKAGE_SCHEMA_V2, + omitted_artifact="third_party_materials_verification_receipt", + ) + with pytest.raises(planner.RehearsalProofError, match="v2 bootstrap artifact closure"): + planner._bootstrap_verified_tool_sources( + current_package_dir=package_path.parent.resolve(), + current_package_sha256=package_sha, + repo_root=REPO_ROOT, + ) + + package_path, package_sha, _expected_raw = _bootstrap_package_fixture( + tmp_path / "downgraded-v1", + schema=planner.PACKAGE_SCHEMA_V1, + extra_artifact="infrastructure_image_bundle", + ) + with pytest.raises(planner.RehearsalProofError, match="v1 bootstrap must remain read-only"): + planner._bootstrap_verified_tool_sources( + current_package_dir=package_path.parent.resolve(), + current_package_sha256=package_sha, + repo_root=REPO_ROOT, + ) + + def test_strict_env_preflight_and_compose_gate_evidence_fail_closed( tmp_path: Path, monkeypatch ) -> None: