diff --git a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py index 14940011..259690e6 100644 --- a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py +++ b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py @@ -35,7 +35,10 @@ RESTORE_TEST_SCHEMA = "yuqei.first-release-restore-test.v1" PLAN_SCHEMA = "yuqei.first-release-snapshot-restore-plan.v1" READINESS_RECEIPT_SCHEMA = "yuqei.first-release-recovery-readiness-receipt.v1" IMAGE_IDENTITY_SCHEMA = "yuqei.release-image-identity.v2" -PACKAGE_SCHEMA = "yuqei.release-package.v1" +PACKAGE_SCHEMA = "yuqei.release-package.v2" +READ_ONLY_PACKAGE_SCHEMAS = frozenset( + {"yuqei.release-package.v1", PACKAGE_SCHEMA} +) SOURCE_MANIFEST_SCHEMA = "yuqei.release-source-manifest.v1" CHECKSUM_MANIFEST_SCHEMA = "yuqei.release-package-checksums.v1" INSTALLABLE_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1" @@ -416,6 +419,7 @@ def _validate_package( *, repo_root: Path, package_validator: Callable[..., Any] | None = None, + require_installable_package_v2: bool = False, ) -> dict[str, Any]: root, package, actual, bootstrapped_release, release_sha = _bootstrap_release_package( package_dir, @@ -438,7 +442,12 @@ def _validate_package( if getattr(validated, "package_sha256", None) != actual: raise SnapshotRestorePlanError("official validator package hash drifted") validated_package = getattr(validated, "package", None) - if validated_package != package or package.get("schema_version") != PACKAGE_SCHEMA: + package_schema = package.get("schema_version") + if ( + validated_package != package + or package_schema not in READ_ONLY_PACKAGE_SCHEMAS + or (require_installable_package_v2 and package_schema != PACKAGE_SCHEMA) + ): raise SnapshotRestorePlanError("official validator package payload/schema drifted") release_manifest = getattr(validated, "release_manifest", None) if release_manifest != bootstrapped_release: @@ -581,6 +590,7 @@ def _validate_package( raise SnapshotRestorePlanError("official validator trusted build receipt drifted") return { "release_id": release_id, + "package_schema_version": package_schema, "package_sha256": actual, "source_commit": source_commit, "release_manifest_sha256": release_sha, @@ -1400,12 +1410,14 @@ def _verify_raw_inputs( trusted_root_public_key_file: Path, now: datetime, package_validator_for_tests: Callable[..., Any] | None = None, + require_installable_package_v2: bool = False, ) -> tuple[dict[str, Any], dict[str, Any], str]: package_binding = _validate_package( package_dir, expected_package_sha256, repo_root=repo_root, package_validator=package_validator_for_tests, + require_installable_package_v2=require_installable_package_v2, ) evidence = load_json(evidence_file, label="first-release recovery evidence") keyring = load_json(keyring_file, label="recovery keyring") @@ -1458,6 +1470,7 @@ def verify_and_build_output( trusted_root_public_key_file=trusted_root_public_key_file, now=now, package_validator_for_tests=package_validator_for_tests, + require_installable_package_v2=(output_kind == "install-readiness-receipt"), ) if output_kind == "plan": return build_plan( @@ -1527,6 +1540,7 @@ def validate_install_readiness( keyring_file=keyring_file, trusted_root_public_key_file=trusted_root_public_key_file, now=now, + require_installable_package_v2=True, ) rebuilt = build_install_readiness_receipt( package_binding=package_binding, diff --git a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py index c4e4aa5e..61bfebfc 100644 --- a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py +++ b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py @@ -594,6 +594,19 @@ def _build_output(case: dict[str, Any], output_kind: str) -> dict[str, Any]: ) +def _rewrite_package_schema(case: dict[str, Any], schema: str) -> None: + package = json.loads(case["package_path"].read_text(encoding="utf-8")) + package["schema_version"] = schema + package = _embedded( + {key: value for key, value in package.items() if key != "package_payload_sha256"}, + "package_payload_sha256", + ) + _write_json(case["package_path"], package) + case["package_sha256"] = _sha(case["package_path"]) + case["validated_package"].package = package + case["validated_package"].package_sha256 = case["package_sha256"] + + def test_valid_first_release_evidence_emits_only_a_non_writing_plan( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -674,6 +687,29 @@ def test_install_readiness_receipt_binds_every_gate_input_without_allowing_resto ) +def test_v1_package_remains_read_only_and_cannot_mint_install_readiness( + tmp_path: Path, +) -> None: + case = _case(tmp_path) + _rewrite_package_schema(case, "yuqei.release-package.v1") + binding = planner._validate_package( + case["package_dir"], + case["package_sha256"], + repo_root=case["repo_root"], + package_validator=case["package_validator"], + ) + assert binding["package_schema_version"] == "yuqei.release-package.v1" + + with pytest.raises(planner.SnapshotRestorePlanError, match="payload/schema drifted"): + planner._validate_package( + case["package_dir"], + case["package_sha256"], + repo_root=case["repo_root"], + package_validator=case["package_validator"], + require_installable_package_v2=True, + ) + + @pytest.mark.parametrize("which", ("application", "infrastructure")) def test_official_package_result_must_cover_all_eleven_images( tmp_path: Path,