diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8efd7a5b..6f1608c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ on: - feat/ar1-metadata-fabric-active-metadata-scheduler-delivery - feat/ar1-metadata-fabric-active-metadata-projection-execution - feat/ar1-metadata-fabric-active-metadata-binding-reconciliation + - feat/ar1-metadata-fabric-durable-active-metadata-promotion env: PYTHON_VERSION: "3.13" @@ -192,6 +193,9 @@ jobs: - name: Validate metadata fabric durable Active Metadata promotion evidence run: python -m data_agent.metadata_fabric_durable_active_metadata_promotion validate + - name: Validate metadata fabric object-store Active Metadata promotion evidence + run: python -m data_agent.metadata_fabric_object_store_active_metadata_promotion validate + - name: Validate Active Metadata consumer deployment boundary run: python -m data_agent.active_metadata_consumer_deployment validate @@ -281,6 +285,7 @@ jobs: data_agent/test_metadata_fabric_active_metadata_projection_execution.py \ data_agent/test_metadata_fabric_active_metadata_binding_reconciliation.py \ data_agent/test_metadata_fabric_durable_active_metadata_promotion.py \ + data_agent/test_metadata_fabric_object_store_active_metadata_promotion.py \ data_agent/test_metadata_fabric_lineage_delivery.py \ data_agent/test_metadata_fabric_provider_identity.py \ data_agent/test_metadata_fabric_gravitino_identity.py \ diff --git a/config/metadata-fabric-object-store-active-metadata-promotion.local.yaml b/config/metadata-fabric-object-store-active-metadata-promotion.local.yaml new file mode 100644 index 00000000..f0ad4f07 --- /dev/null +++ b/config/metadata-fabric-object-store-active-metadata-promotion.local.yaml @@ -0,0 +1,52 @@ +schema: gda.object_store_active_metadata_promotion_profile.v1 +environment: local_docker_desktop + +dependencies: + m320_profile_path: config/metadata-fabric-durable-active-metadata-promotion.local.yaml + m320_evidence_path: docs/evidence/metadata-fabric-durable-active-metadata-promotion-2026-07-30.json + m320_evidence_sha256: 53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b + m310_profile_path: config/metadata-fabric-spark-object-store-interoperability.local.yaml + m310_evidence_path: docs/evidence/metadata-fabric-spark-object-store-interoperability-2026-07-29.json + m310_evidence_fingerprint: 05844457efb378581fb7fc2e7ed3c706819b2d8fa5a52b2f82577051d38c2cd1 + +target: + metalake: gda_chongqing_m3_21 + catalog: lakehouse + schema: cultural_heritage + table: cultural_districts + catalog_type: RELATIONAL + catalog_provider: lakehouse-iceberg + catalog_backend: jdbc + uri: jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg + warehouse: s3://gda-metadata-warehouse/warehouse + jdbc_driver: org.postgresql.Driver + io_impl: org.apache.iceberg.aws.s3.S3FileIO + s3_endpoint: http://metadata-object-store:9000 + s3_region: us-east-1 + s3_path_style_access: true + bucket: gda-metadata-warehouse + object_prefix: warehouse/cultural_heritage/cultural_districts/ + +identity: + service_admin: gda-object-store-admin + user: gda-object-store-active-metadata-promoter + role: gda-object-store-cultural-district-projector + material_delivery: runtime_generated_ephemeral_kubernetes_object + +authorization: + policy_version_ref: gda://metadata-authorization-local/policy/object-store-metadata-promotion-v1 + evaluator_subject: workload:object-store-metadata-policy-evaluator + approver_subject: human:metadata-platform-owner + approval_reason: approved local object-store Active Metadata promotion rehearsal + +claims: + predecessor_history_changed: false + promotion_persisted_to_gda_control: false + protected_workload_identity_verified: false + provider_minimum_privilege_verified: false + durable_catalog_verified: false + production_object_store_verified: false + oidc_verified: false + tls_verified: false + production_ingestion_verified: false + production_ready: false diff --git a/data_agent/metadata_fabric_object_store_active_metadata_promotion.py b/data_agent/metadata_fabric_object_store_active_metadata_promotion.py new file mode 100644 index 00000000..625e1b8f --- /dev/null +++ b/data_agent/metadata_fabric_object_store_active_metadata_promotion.py @@ -0,0 +1,1768 @@ +"""Promote the real Chongqing Active Metadata target into a JDBC/S3 runtime. + +M3-21 consumes the checked M3-20 runtime-bound promotion and M3-10 cross-node +object-store evidence. It creates a new Gravitino projection in a JDBC catalog +whose Iceberg warehouse is MinIO rather than a shared filesystem. The provider +projection and the direct S3 metadata must remain unchanged across ordered +PostgreSQL and Gravitino restarts. + +The result is local evidence only. It does not change predecessor history, +persist a promotion to GDA Control, ingest source feature rows, or establish +production object-store durability, protected identity, or readiness. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import secrets +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Literal +from urllib.parse import quote +from uuid import UUID, uuid5 + +import yaml +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator + +from . import metadata_fabric_active_metadata_projection_execution as execution +from . import metadata_fabric_bridge as bridge +from . import metadata_fabric_durable_active_metadata_promotion as durable +from . import metadata_fabric_gravitino_identity as identity +from . import metadata_fabric_ingestion_replay as replay +from . import metadata_fabric_provider_metrics as provider_metrics +from . import metadata_fabric_spark_object_store_interoperability as m310 +from .platform_authorization import ( + build_approval_artifact, + build_policy_decision_artifact, + validate_run_authorization_evidence, +) +from .platform_contracts import ( + ApprovalRecord, + Artifact, + ArtifactRole, + PlatformRun, + PolicyDecision, + RunPolicyReferences, + SubjectContext, + canonical_json_bytes, + canonical_json_fingerprint, +) +from .spatial_dataset_bundle import validate_shapefile_bundle_inventory + +PROFILE_SCHEMA = "gda.object_store_active_metadata_promotion_profile.v1" +PLAN_SCHEMA = "gda.object_store_active_metadata_projection_plan.v1" +PROMOTION_SCHEMA = "gda.object_store_runtime_bound_metadata_promotion_candidate.v1" +CONTRACT_SCHEMA = "gda.object_store_active_metadata_promotion_contract.v1" +OBSERVATION_SCHEMA = "gda.object_store_active_metadata_promotion_observation.v1" +EVIDENCE_SCHEMA = "gda.object_store_active_metadata_promotion_evidence.v1" +VALIDATION_SCHEMA = "gda.object_store_active_metadata_promotion_validation.v1" +RUNTIME_BINDING_SCHEMA = "gda.object_store_provider_runtime_binding.v1" +ACTION = "metadata_fabric.promote_object_store_projection" +TENANT = durable.TENANT +RESOURCE_VERSION_ID = durable.RESOURCE_VERSION_ID +DEFINITION_ID = UUID("a9000000-0000-4000-8000-000000000006") +RUN_ID = UUID("a9000000-0000-4000-8000-000000000007") +WORKLOAD = "workload:object-store-active-metadata-promoter" +M320_EVIDENCE_SHA256 = "53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b" +M310_EVIDENCE_FINGERPRINT = "05844457efb378581fb7fc2e7ed3c706819b2d8fa5a52b2f82577051d38c2cd1" + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PROFILE_PATH = ( + REPO_ROOT / "config/metadata-fabric-object-store-active-metadata-promotion.local.yaml" +) +DEFAULT_EVIDENCE_PATH = ( + REPO_ROOT + / "docs/evidence/metadata-fabric-object-store-active-metadata-promotion-2026-07-31.json" +) +DEFAULT_WRAPPER_PATH = ( + REPO_ROOT / "scripts/metadata-fabric-object-store-active-metadata-promotion.sh" +) +FALSE_CLAIMS = ( + "predecessor_history_changed", + "promotion_persisted_to_gda_control", + "dataset_source_committed", + "dataset_absolute_path_committed", + "dataset_required_in_ci", + "deployment_applied", + "source_feature_rows_ingested", + "protected_workload_identity_verified", + "provider_minimum_privilege_verified", + "durable_catalog_verified", + "production_object_store_verified", + "oidc_verified", + "tls_verified", + "production_scheduler_submission_verified", + "production_ingestion_verified", + "spark_conformance_verified", + "flink_conformance_verified", + "platform_run_succeeded", + "production_ready", +) + + +class ObjectStoreActiveMetadataPromotionError(RuntimeError): + """The JDBC/S3 Active Metadata promotion failed closed.""" + + +class _FrozenModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class DependencyProfile(_FrozenModel): + m320_profile_path: str + m320_evidence_path: str + m320_evidence_sha256: Literal[M320_EVIDENCE_SHA256] + m310_profile_path: str + m310_evidence_path: str + m310_evidence_fingerprint: Literal[M310_EVIDENCE_FINGERPRINT] + + +class ObjectStoreTarget(_FrozenModel): + metalake: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + catalog: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + schema_name: str = Field(alias="schema", pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + table: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + catalog_type: Literal["RELATIONAL"] + catalog_provider: Literal["lakehouse-iceberg"] + catalog_backend: Literal["jdbc"] + uri: Literal["jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg"] + warehouse: Literal["s3://gda-metadata-warehouse/warehouse"] + jdbc_driver: Literal["org.postgresql.Driver"] + io_impl: Literal["org.apache.iceberg.aws.s3.S3FileIO"] + s3_endpoint: Literal["http://metadata-object-store:9000"] + s3_region: Literal["us-east-1"] + s3_path_style_access: Literal[True] + bucket: Literal["gda-metadata-warehouse"] + object_prefix: Literal["warehouse/cultural_heritage/cultural_districts/"] + + @property + def identity(self) -> str: + return f"{self.metalake}/{self.catalog}/{self.schema_name}/{self.table}" + + @property + def table_location(self) -> str: + return f"s3://{self.bucket}/{self.object_prefix.rstrip('/')}" + + +class IdentityProfile(_FrozenModel): + service_admin: Literal["gda-object-store-admin"] + user: Literal["gda-object-store-active-metadata-promoter"] + role: Literal["gda-object-store-cultural-district-projector"] + material_delivery: Literal["runtime_generated_ephemeral_kubernetes_object"] + + +class AuthorizationProfile(_FrozenModel): + policy_version_ref: str + evaluator_subject: Literal["workload:object-store-metadata-policy-evaluator"] + approver_subject: Literal["human:metadata-platform-owner"] + approval_reason: str + + +class ClaimProfile(_FrozenModel): + predecessor_history_changed: Literal[False] + promotion_persisted_to_gda_control: Literal[False] + protected_workload_identity_verified: Literal[False] + provider_minimum_privilege_verified: Literal[False] + durable_catalog_verified: Literal[False] + production_object_store_verified: Literal[False] + oidc_verified: Literal[False] + tls_verified: Literal[False] + production_ingestion_verified: Literal[False] + production_ready: Literal[False] + + +class ObjectStorePromotionProfile(_FrozenModel): + profile_schema: Literal[PROFILE_SCHEMA] = Field(alias="schema") + environment: Literal["local_docker_desktop"] + dependencies: DependencyProfile + target: ObjectStoreTarget + identity: IdentityProfile + authorization: AuthorizationProfile + claims: ClaimProfile + + +def _promotion_candidate_payload( + *, + tenant_id: str, + resource_urn: str, + resource_version_id: UUID, + content_sha256: str, + predecessor_promotion_candidate_sha256: str, + logical_binding_sha256: str, + runtime_binding_sha256: str, + openmetadata_ref: bridge.OpenMetadataTableRef, + gravitino_ref: bridge.GravitinoTableRef, +) -> dict[str, Any]: + return { + "schema": PROMOTION_SCHEMA, + "tenant_id": tenant_id, + "resource_urn": resource_urn, + "resource_version_id": str(resource_version_id), + "content_sha256": content_sha256, + "predecessor_promotion_candidate_sha256": (predecessor_promotion_candidate_sha256), + "logical_binding_sha256": logical_binding_sha256, + "runtime_binding_sha256": runtime_binding_sha256, + "openmetadata_ref": openmetadata_ref.model_dump(mode="json"), + "gravitino_ref": gravitino_ref.model_dump(mode="json"), + "predecessor_history_changed": False, + "persisted_to_gda_control": False, + } + + +class ObjectStoreProjectionPlan(_FrozenModel): + plan_schema: Literal[PLAN_SCHEMA] = Field(default=PLAN_SCHEMA, alias="schema") + tenant_id: str + run_id: UUID + definition_version_id: UUID + resource_urn: str + resource_version_id: UUID + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + predecessor_promotion_candidate_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + openmetadata_ref: bridge.OpenMetadataTableRef + openmetadata_snapshot_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + gravitino_ref: bridge.GravitinoTableRef + target: ObjectStoreTarget + runtime_binding: dict[str, Any] + runtime_binding_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + logical_binding_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + promotion_candidate_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + writes_to_gda_control: Literal[False] = False + writes_to_legacy: Literal[False] = False + apply_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + @model_validator(mode="after") + def _fingerprints_match(self) -> ObjectStoreProjectionPlan: + if self.target.identity != self.gravitino_ref.identity: + raise ValueError("object-store target does not match Gravitino ref") + if self.runtime_binding_sha256 != canonical_json_fingerprint(self.runtime_binding): + raise ValueError("object-store runtime binding fingerprint does not match") + expected_logical = bridge.metadata_fabric_binding_fingerprint( + tenant_id=self.tenant_id, + resource_urn=self.resource_urn, + resource_version_id=self.resource_version_id, + content_sha256=self.content_sha256, + openmetadata=self.openmetadata_ref, + gravitino=(self.gravitino_ref,), + ) + if self.logical_binding_sha256 != expected_logical: + raise ValueError("object-store logical binding fingerprint does not match") + candidate = _promotion_candidate_payload( + tenant_id=self.tenant_id, + resource_urn=self.resource_urn, + resource_version_id=self.resource_version_id, + content_sha256=self.content_sha256, + predecessor_promotion_candidate_sha256=(self.predecessor_promotion_candidate_sha256), + logical_binding_sha256=self.logical_binding_sha256, + runtime_binding_sha256=self.runtime_binding_sha256, + openmetadata_ref=self.openmetadata_ref, + gravitino_ref=self.gravitino_ref, + ) + if self.promotion_candidate_sha256 != canonical_json_fingerprint(candidate): + raise ValueError("object-store promotion candidate fingerprint does not match") + stable = self.model_dump(mode="json", by_alias=True, exclude={"apply_plan_sha256"}) + if self.apply_plan_sha256 != canonical_json_fingerprint(stable): + raise ValueError("object-store apply plan fingerprint does not match") + return self + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _load_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ObjectStoreActiveMetadataPromotionError(f"{path.name} is not valid JSON") from exc + if not isinstance(value, dict): + raise ObjectStoreActiveMetadataPromotionError(f"{path.name} must be an object") + return value + + +def _resolve_repo_path(value: str) -> Path: + path = (REPO_ROOT / value).resolve() + try: + path.relative_to(REPO_ROOT) + except ValueError as exc: + raise ObjectStoreActiveMetadataPromotionError( + "profile dependency path leaves repository" + ) from exc + return path + + +def load_profile(path: Path = DEFAULT_PROFILE_PATH) -> ObjectStorePromotionProfile: + try: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise TypeError("profile must be an object") + replay._reject_sensitive_fields(value) + return ObjectStorePromotionProfile.model_validate(value) + except (OSError, TypeError, ValueError, yaml.YAMLError) as exc: + raise ObjectStoreActiveMetadataPromotionError( + "object-store promotion profile is invalid" + ) from exc + + +def _load_dependencies( + profile: ObjectStorePromotionProfile, +) -> tuple[dict[str, Any], m310.SparkObjectStoreInteroperabilityProfile]: + dependencies = profile.dependencies + m320_path = _resolve_repo_path(dependencies.m320_evidence_path) + m320_evidence = _load_json_object(m320_path) + m320_validation = durable.build_validation_report( + profile_path=_resolve_repo_path(dependencies.m320_profile_path), + evidence_path=m320_path, + ) + if ( + m320_validation.get("errors") + or durable.verify_evidence_integrity(m320_evidence) + or m320_evidence.get("evidence_sha256") != M320_EVIDENCE_SHA256 + ): + raise ObjectStoreActiveMetadataPromotionError( + "M3-20 durable promotion evidence does not match" + ) + m310_path = _resolve_repo_path(dependencies.m310_evidence_path) + m310_evidence = _load_json_object(m310_path) + m310_validation = m310.build_validation_report( + profile_path=_resolve_repo_path(dependencies.m310_profile_path), + evidence_path=m310_path, + ) + if ( + m310_validation.get("errors") + or m310.verify_evidence_integrity(m310_evidence) + or m310_evidence.get("evidence_fingerprint") != M310_EVIDENCE_FINGERPRINT + ): + raise ObjectStoreActiveMetadataPromotionError( + "M3-10 object-store interoperability evidence does not match" + ) + return m320_evidence, m310.load_profile(_resolve_repo_path(dependencies.m310_profile_path)) + + +def _file_record(path: Path) -> dict[str, str | None]: + resolved = path.resolve() + return { + "path": resolved.relative_to(REPO_ROOT).as_posix(), + "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest() if resolved.is_file() else None, + } + + +def _provider_runtime_binding( + snapshot: Mapping[str, Any], + *, + cluster_uid: str, + target: ObjectStoreTarget, +) -> dict[str, Any]: + namespace = _mapping(snapshot.get("namespace")) + service = _mapping(snapshot.get("service")) + object_service = _mapping(snapshot.get("object_store_service")) + postgresql = _mapping(snapshot.get("postgresql")) + gravitino = _mapping(snapshot.get("gravitino")) + object_store = _mapping(snapshot.get("object_store")) + postgresql_pvc = _mapping(postgresql.get("pvc")) + object_store_pvc = _mapping(object_store.get("pvc")) + gravitino_claims = gravitino.get("persistent_volume_claims") + if gravitino.get("pvc") is not None or gravitino_claims != []: + raise ObjectStoreActiveMetadataPromotionError( + "Gravitino may not mount an object-store warehouse PVC" + ) + binding = { + "schema": RUNTIME_BINDING_SCHEMA, + "context": snapshot.get("context"), + "cluster_uid": cluster_uid, + "namespace": { + "name": namespace.get("name"), + "uid": namespace.get("uid"), + }, + "services": { + "gravitino": {"name": service.get("name"), "uid": service.get("uid")}, + "object_store": { + "name": object_service.get("name"), + "uid": object_service.get("uid"), + }, + }, + "workloads": { + "postgresql_statefulset_uid": postgresql.get("statefulset_uid"), + "gravitino_statefulset_uid": gravitino.get("statefulset_uid"), + "object_store_statefulset_uid": object_store.get("statefulset_uid"), + }, + "storage": { + "postgresql_pvc_uid": postgresql_pvc.get("uid"), + "postgresql_volume_name": postgresql_pvc.get("volume_name"), + "object_store_pvc_uid": object_store_pvc.get("uid"), + "object_store_volume_name": object_store_pvc.get("volume_name"), + "gravitino_persistent_volume_claims": gravitino_claims, + }, + "topology": { + "object_store_node": object_store.get("node_name"), + "provider_node": gravitino.get("node_name"), + }, + "images": { + "postgresql_image_id": postgresql.get("image_id"), + "gravitino_image_id": gravitino.get("image_id"), + "object_store_image_id": object_store.get("image_id"), + }, + "catalog": { + "backend": target.catalog_backend, + "uri": target.uri, + "warehouse": target.warehouse, + "io_impl": target.io_impl, + "s3_endpoint": target.s3_endpoint, + "s3_region": target.s3_region, + "s3_path_style_access": target.s3_path_style_access, + "bucket": target.bucket, + }, + } + serialized = json.dumps(binding, ensure_ascii=True, sort_keys=True) + if ( + not cluster_uid + or "null" in serialized + or '"uid": ""' in serialized + or binding["topology"]["object_store_node"] == binding["topology"]["provider_node"] + ): + raise ObjectStoreActiveMetadataPromotionError( + "object-store provider runtime identity is incomplete" + ) + return binding + + +def _verify_openmetadata( + payload: dict[str, Any], + source: Mapping[str, Any], + *, + observed_at: datetime, +) -> bridge.OpenMetadataObservation: + expected = _mapping(_mapping(source.get("observation")).get("openmetadata")) + return durable._verify_openmetadata( + payload, + {"first_readback": {"openmetadata": dict(expected)}}, + observed_at=observed_at, + ) + + +def build_projection_plan( + profile: ObjectStorePromotionProfile, + source: Mapping[str, Any], + openmetadata: bridge.OpenMetadataObservation, + runtime_binding: dict[str, Any], +) -> ObjectStoreProjectionPlan: + provider_revision = f"shapefile-bundle-{openmetadata.content_sha256[:16]}" + gravitino_ref = bridge.GravitinoTableRef( + metalake=profile.target.metalake, + catalog=profile.target.catalog, + schema_name=profile.target.schema_name, + table_name=profile.target.table, + provider_revision=provider_revision, + server_version="1.3.0", + ) + logical_binding_sha256 = bridge.metadata_fabric_binding_fingerprint( + tenant_id=TENANT, + resource_urn=openmetadata.resource_urn, + resource_version_id=openmetadata.resource_version_id, + content_sha256=openmetadata.content_sha256, + openmetadata=openmetadata.ref, + gravitino=(gravitino_ref,), + ) + runtime_binding_sha256 = canonical_json_fingerprint(runtime_binding) + predecessor = str(source["promotion_candidate_sha256"]) + candidate = _promotion_candidate_payload( + tenant_id=TENANT, + resource_urn=openmetadata.resource_urn, + resource_version_id=openmetadata.resource_version_id, + content_sha256=openmetadata.content_sha256, + predecessor_promotion_candidate_sha256=predecessor, + logical_binding_sha256=logical_binding_sha256, + runtime_binding_sha256=runtime_binding_sha256, + openmetadata_ref=openmetadata.ref, + gravitino_ref=gravitino_ref, + ) + values = { + "tenant_id": TENANT, + "run_id": RUN_ID, + "definition_version_id": DEFINITION_ID, + "resource_urn": openmetadata.resource_urn, + "resource_version_id": openmetadata.resource_version_id, + "content_sha256": openmetadata.content_sha256, + "predecessor_promotion_candidate_sha256": predecessor, + "openmetadata_ref": openmetadata.ref, + "openmetadata_snapshot_sha256": openmetadata.snapshot_sha256, + "gravitino_ref": gravitino_ref, + "target": profile.target, + "runtime_binding": runtime_binding, + "runtime_binding_sha256": runtime_binding_sha256, + "logical_binding_sha256": logical_binding_sha256, + "promotion_candidate_sha256": canonical_json_fingerprint(candidate), + } + stable = { + "schema": PLAN_SCHEMA, + **{ + key: value.model_dump(mode="json", by_alias=True) + if isinstance(value, BaseModel) + else str(value) + if isinstance(value, UUID) + else value + for key, value in values.items() + }, + "writes_to_gda_control": False, + "writes_to_legacy": False, + } + return ObjectStoreProjectionPlan( + **values, + apply_plan_sha256=canonical_json_fingerprint(stable), + ) + + +def _build_execution_plan_artifact( + plan: ObjectStoreProjectionPlan, *, created_at: datetime +) -> Artifact: + manifest = { + "schema": "gda.object_store_active_metadata_execution_plan.v1", + "plan": plan.model_dump(mode="json", by_alias=True), + } + artifact_id = uuid5(RUN_ID, f"object-store-promotion:{plan.apply_plan_sha256}") + content = canonical_json_bytes(manifest) + return Artifact( + tenant_id=TENANT, + artifact_id=artifact_id, + artifact_key=f"object-store-active-metadata:{artifact_id}", + artifact_role=ArtifactRole.EXECUTION_PLAN, + storage_uri=f"postgresql://gda-control/execution-plans/{TENANT}/{artifact_id}", + media_type="application/vnd.gda.object-store-active-metadata-plan+json", + content_sha256=canonical_json_fingerprint(manifest), + size_bytes=len(content), + run_id=None, + resource_version_id=DEFINITION_ID, + manifest=manifest, + created_by=WORKLOAD, + created_at=created_at, + ) + + +def build_apply_authorization( + plan: ObjectStoreProjectionPlan, + profile: ObjectStorePromotionProfile, + *, + authorized_at: datetime, +) -> tuple[PlatformRun, Artifact, Artifact, Artifact, str]: + if authorized_at.tzinfo is None or authorized_at.utcoffset() is None: + raise ObjectStoreActiveMetadataPromotionError( + "authorization timestamp must include a timezone" + ) + subject = SubjectContext( + tenant_id=TENANT, + subject_id=WORKLOAD.removeprefix("workload:"), + subject_type="workload", + roles=("metadata_projector",), + purpose="promote a content-bound projection into a local S3 warehouse", + ) + execution_plan = _build_execution_plan_artifact( + plan, created_at=authorized_at - timedelta(seconds=3) + ) + decision = PolicyDecision( + tenant_id=TENANT, + run_id=RUN_ID, + subject_context=subject, + action=ACTION, + definition_version_id=DEFINITION_ID, + resource_version_ids=(DEFINITION_ID, RESOURCE_VERSION_ID), + execution_plan_artifact_id=execution_plan.artifact_id, + effect="allow", + policy_version_ref=profile.authorization.policy_version_ref, + evaluator_subject=profile.authorization.evaluator_subject, + requires_approval=True, + obligations=(), + decided_at=authorized_at - timedelta(seconds=3), + expires_at=authorized_at + timedelta(days=365), + ) + policy_artifact = build_policy_decision_artifact(decision) + approval = ApprovalRecord( + tenant_id=TENANT, + run_id=RUN_ID, + definition_version_id=DEFINITION_ID, + policy_decision_artifact_id=policy_artifact.artifact_id, + policy_decision_sha256=policy_artifact.content_sha256, + verdict="approved", + approver_subject=profile.authorization.approver_subject, + reason=profile.authorization.approval_reason, + decided_at=authorized_at - timedelta(seconds=2), + expires_at=authorized_at + timedelta(days=180), + ) + approval_artifact = build_approval_artifact(approval) + run = PlatformRun( + tenant_id=TENANT, + run_id=RUN_ID, + definition_version_id=DEFINITION_ID, + orchestration_class="dataops", + subject_context=subject, + input_bindings=( + { + "binding_name": "metadata_change", + "resource_version_id": RESOURCE_VERSION_ID, + "semantic_type": "gis.cultural_districts", + }, + ), + idempotency_key=(f"object-store-active-metadata:{plan.promotion_candidate_sha256}"), + policy_refs=RunPolicyReferences( + policy_decision_artifact_id=policy_artifact.artifact_id, + approval_artifact_id=approval_artifact.artifact_id, + ), + submitted_at=authorized_at - timedelta(seconds=1), + ) + validate_run_authorization_evidence( + run, + policy_artifact, + approval_artifact, + execution_plan, + at=authorized_at, + expected_action=ACTION, + ) + stable = { + "run": run.model_dump(mode="json"), + "execution_plan": execution_plan.model_dump(mode="json"), + "policy_decision": policy_artifact.model_dump(mode="json"), + "approval": approval_artifact.model_dump(mode="json"), + } + return ( + run, + execution_plan, + policy_artifact, + approval_artifact, + canonical_json_fingerprint(stable), + ) + + +def validate_apply_authorization( + plan: ObjectStoreProjectionPlan, + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str], + *, + at: datetime, +) -> None: + run, execution_plan, policy_artifact, approval_artifact, fingerprint = authorization + if _mapping(execution_plan.manifest.get("plan")) != plan.model_dump(mode="json", by_alias=True): + raise ObjectStoreActiveMetadataPromotionError( + "execution plan artifact does not contain the exact object-store plan" + ) + validate_run_authorization_evidence( + run, + policy_artifact, + approval_artifact, + execution_plan, + at=at, + expected_action=ACTION, + ) + stable = { + "run": run.model_dump(mode="json"), + "execution_plan": execution_plan.model_dump(mode="json"), + "policy_decision": policy_artifact.model_dump(mode="json"), + "approval": approval_artifact.model_dump(mode="json"), + } + if fingerprint != canonical_json_fingerprint(stable): + raise ObjectStoreActiveMetadataPromotionError( + "object-store apply authorization fingerprint does not match" + ) + + +def build_contract_report( + *, + profile_path: Path = DEFAULT_PROFILE_PATH, + wrapper_path: Path = DEFAULT_WRAPPER_PATH, +) -> dict[str, Any]: + errors: list[str] = [] + profile: ObjectStorePromotionProfile | None = None + source_sha: str | None = None + object_store_fingerprint: str | None = None + try: + profile = load_profile(profile_path) + source, _runtime_profile = _load_dependencies(profile) + source_sha = str(source.get("evidence_sha256")) + m310_evidence = _load_json_object( + _resolve_repo_path(profile.dependencies.m310_evidence_path) + ) + object_store_fingerprint = str(m310_evidence.get("evidence_fingerprint")) + except ObjectStoreActiveMetadataPromotionError as exc: + errors.append(f"M3-21 dependency contract is invalid: {type(exc).__name__}") + try: + wrapper = wrapper_path.read_text(encoding="utf-8") + for marker in ( + "set -euo pipefail", + "metadata_fabric_object_store_active_metadata_promotion", + '"$@"', + ): + if marker not in wrapper: + errors.append(f"M3-21 wrapper is missing marker: {marker}") + except OSError as exc: + errors.append(f"M3-21 wrapper is invalid: {type(exc).__name__}") + files = { + "implementation": _file_record(Path(__file__)), + "profile": _file_record(profile_path), + "wrapper": _file_record(wrapper_path), + } + stable = { + "schema": CONTRACT_SCHEMA, + "source_m320_evidence_sha256": source_sha, + "m310_evidence_fingerprint": object_store_fingerprint, + "target_identity": profile.target.identity if profile else None, + "target_warehouse": profile.target.warehouse if profile else None, + "target_object_prefix": profile.target.object_prefix if profile else None, + "runtime_binding_schema": RUNTIME_BINDING_SCHEMA, + "authorization_action": ACTION, + "files": files, + "predecessor_history_changed": False, + "promotion_persisted_to_gda_control": False, + "production_object_store_verified": False, + "production_ready": False, + } + return { + **stable, + "contract_sha256": canonical_json_fingerprint(stable), + "local_static_contract_verified": not errors, + "status": "valid" if not errors else "invalid", + "errors": errors, + } + + +class ObjectStoreProjectionRehearsal(durable.DurableProjectionRehearsal): + """Bootstrap a JDBC/S3 catalog and apply through one bounded principal.""" + + def bootstrap( + self, + profile: ObjectStorePromotionProfile, + *, + database_material: SecretStr, + user_material: SecretStr, + object_store_user: SecretStr, + object_store_material: SecretStr, + ) -> dict[str, Any]: + admin_status, version_payload = self.admin.request( + "GET", "version", label="object-store promotion admin authentication" + ) + version = _mapping(_mapping(version_payload).get("version")).get("version") + if version is None: + version = _mapping(version_payload).get("version") + _, metalake_payload = self.admin.request( + "POST", + "metalakes", + json_body={ + "name": profile.target.metalake, + "comment": "Real Chongqing object-store Active Metadata promotion", + "properties": {"gda.environment": "local_object_store_promotion"}, + }, + label="object-store promotion metalake create", + ) + metalake = identity._response_entity( + metalake_payload, "metalake", "object-store promotion metalake" + ) + _, catalog_payload = self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/catalogs", + json_body={ + "name": profile.target.catalog, + "type": profile.target.catalog_type, + "provider": profile.target.catalog_provider, + "comment": "JDBC catalog with S3-compatible Active Metadata warehouse", + "properties": { + "catalog-backend": profile.target.catalog_backend, + "uri": profile.target.uri, + "warehouse": profile.target.warehouse, + "jdbc-user": "gravitino", + "jdbc-password": database_material.get_secret_value(), + "gravitino.bypass.jdbc-driver": profile.target.jdbc_driver, + "gravitino.bypass.jdbc-initialize": "true", + "io-impl": profile.target.io_impl, + "s3-access-key-id": object_store_user.get_secret_value(), + "s3-secret-access-key": object_store_material.get_secret_value(), + "s3-endpoint": profile.target.s3_endpoint, + "s3-region": profile.target.s3_region, + "s3-path-style-access": "true", + }, + }, + label="object-store promotion JDBC catalog create", + ) + catalog = identity._response_entity( + catalog_payload, "catalog", "object-store promotion catalog" + ) + _, schema_payload = self.admin.request( + "POST", + f"{self._catalog_path(profile.target)}/schemas", + json_body={ + "name": profile.target.schema_name, + "comment": "Content-bound cultural heritage projection schema", + "properties": {}, + }, + label="object-store promotion schema create", + ) + schema = identity._response_entity( + schema_payload, "schema", "object-store promotion schema" + ) + self.admin.request( + "POST", + "idp/users", + json_body={ + "user": profile.identity.user, + "password": user_material.get_secret_value(), + }, + label="object-store promotion IdP user create", + ) + self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/users", + json_body={"name": profile.identity.user}, + label="object-store promotion metalake user register", + ) + self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/roles", + json_body={ + "name": profile.identity.role, + "properties": {"gda.scope": "cultural_district_table_projection"}, + "securableObjects": durable._expected_role(profile), + }, + label="object-store promotion bounded role create", + ) + self.admin.request( + "PUT", + ( + f"metalakes/{quote(profile.target.metalake)}/permissions/users/" + f"{quote(profile.identity.user)}/grant" + ), + json_body={"roleNames": [profile.identity.role]}, + label="object-store promotion bounded role grant", + ) + _, role_payload = self.admin.request( + "GET", + (f"metalakes/{quote(profile.target.metalake)}/roles/{quote(profile.identity.role)}"), + label="object-store promotion role readback", + ) + role = identity._response_entity( + role_payload, "role", "object-store promotion role readback" + ) + bounded = self._bounded_api(profile, user_material) + bounded_status, _ = bounded.request( + "GET", "version", label="object-store promotion bounded authentication" + ) + denied_status, _ = bounded.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/catalogs", + json_body={ + "name": "unauthorized_catalog", + "type": "RELATIONAL", + "provider": "lakehouse-iceberg", + "comment": "must remain denied", + "properties": { + "catalog-backend": "memory", + "uri": "file:///tmp/gda-denied", + "warehouse": "file:///tmp/gda-denied", + }, + }, + expected=frozenset({200, 403}), + label="object-store promotion catalog administration denial", + ) + return { + "admin_authentication_status": admin_status, + "bounded_authentication_status": bounded_status, + "server_version": version, + "metalake": metalake.get("name"), + "catalog": catalog.get("name"), + "schema": schema.get("name"), + "catalog_backend": profile.target.catalog_backend, + "catalog_uri": profile.target.uri, + "warehouse": profile.target.warehouse, + "io_impl": profile.target.io_impl, + "s3_endpoint": profile.target.s3_endpoint, + "s3_region": profile.target.s3_region, + "s3_path_style_access": profile.target.s3_path_style_access, + "bucket": profile.target.bucket, + "role": { + "name": role.get("name"), + "securable_objects": identity._normalize_securable_objects( + role.get("securableObjects") + ), + }, + "denied_catalog_create_status": denied_status, + "material_recorded": False, + } + + def apply_once( + self, + plan: ObjectStoreProjectionPlan, + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str], + *, + at: datetime, + create: bool = False, + ) -> dict[str, Any]: + validate_apply_authorization(plan, authorization, at=at) + if self.bounded is None: + raise ObjectStoreActiveMetadataPromotionError( + "bounded Gravitino principal is unavailable" + ) + path = self._table_path(plan.target) + mutations: list[str] = [] + if create: + self.bounded.request( + "POST", + f"{self._schema_path(plan.target)}/tables", + json_body={ + "name": plan.target.table, + "comment": "Real Chongqing cultural district object-store projection", + "columns": [ + { + "name": "BSM", + "type": "string", + "nullable": False, + "comment": "Cultural district identifier", + }, + { + "name": "geometry", + "type": "binary", + "nullable": False, + "comment": "PolygonZ geometry bytes", + }, + ], + "properties": { + "gda.resource_urn": plan.resource_urn, + "gda.resource_version_id": str(plan.resource_version_id), + "gda.content_sha256": plan.content_sha256, + "gda.provider_revision": plan.gravitino_ref.provider_revision, + }, + }, + label="bounded object-store projection table create", + ) + mutations.append("gravitino.table.create") + else: + self.bounded.request("GET", path, label="object-store projection table lookup") + _, read_payload = self.bounded.request( + "GET", path, label="object-store projection exact readback" + ) + assert read_payload is not None + observation = bridge.parse_gravitino_table_observation( + plan.gravitino_ref, read_payload, observed_at=at + ) + if ( + observation.resource_urn != plan.resource_urn + or observation.resource_version_id != plan.resource_version_id + or observation.content_sha256 != plan.content_sha256 + or observation.provider_revision != plan.gravitino_ref.provider_revision + ): + raise ObjectStoreActiveMetadataPromotionError( + "object-store Gravitino projection does not match the ResourceVersion" + ) + projection = durable._table_projection(read_payload) + expected_projection = { + "name": plan.target.table, + "columns": [ + {"name": "BSM", "type": "string", "nullable": False}, + {"name": "geometry", "type": "binary", "nullable": False}, + ], + "resource_urn": plan.resource_urn, + "resource_version_id": str(plan.resource_version_id), + "content_sha256": plan.content_sha256, + "provider_revision": plan.gravitino_ref.provider_revision, + } + if projection != expected_projection: + raise ObjectStoreActiveMetadataPromotionError( + "object-store Gravitino table projection drifted" + ) + return { + "status": "created" if mutations else "no_op", + "mutations": mutations, + "mutation_count": len(mutations), + "gravitino": { + "identity": observation.ref.identity, + "resource_urn": observation.resource_urn, + "resource_version_id": str(observation.resource_version_id), + "content_sha256": observation.content_sha256, + "provider_revision": observation.provider_revision, + "snapshot_sha256": observation.snapshot_sha256, + }, + "table_projection": projection, + "table_projection_sha256": canonical_json_fingerprint(projection), + "logical_binding_sha256": plan.logical_binding_sha256, + "promotion_candidate_sha256": plan.promotion_candidate_sha256, + } + + +def observe_table_object_store( + runtime: m310.IsolatedSparkObjectStoreRuntime, + profile: ObjectStorePromotionProfile, + *, + endpoint_url: str, + object_store_user: SecretStr, + object_store_material: SecretStr, +) -> dict[str, Any]: + client = runtime._s3_client( + endpoint_url=endpoint_url, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + try: + objects: list[dict[str, Any]] = [] + continuation: str | None = None + while True: + request: dict[str, Any] = { + "Bucket": profile.target.bucket, + "Prefix": profile.target.object_prefix, + } + if continuation: + request["ContinuationToken"] = continuation + response = client.list_objects_v2(**request) + for item in response.get("Contents") or []: + objects.append( + { + "key": item.get("Key"), + "size": item.get("Size"), + "etag": str(item.get("ETag") or "").strip('"'), + } + ) + if response.get("IsTruncated") is not True: + break + continuation = response.get("NextContinuationToken") + if not isinstance(continuation, str) or not continuation: + raise ObjectStoreActiveMetadataPromotionError( + "object-store listing continuation is invalid" + ) + objects.sort(key=lambda item: str(item.get("key"))) + data_keys = sorted( + str(item["key"]) for item in objects if str(item.get("key") or "").endswith(".parquet") + ) + metadata_keys = sorted( + str(item["key"]) + for item in objects + if str(item.get("key") or "").endswith(".metadata.json") + ) + manifest_keys = sorted( + str(item["key"]) for item in objects if str(item.get("key") or "").endswith(".avro") + ) + if not metadata_keys: + raise ObjectStoreActiveMetadataPromotionError( + "object-store Active Metadata table has no Iceberg metadata" + ) + latest_key = metadata_keys[-1] + metadata_response = client.get_object( + Bucket=profile.target.bucket, + Key=latest_key, + ) + body = metadata_response["Body"].read() + metadata = json.loads(body) + if not isinstance(metadata, dict): + raise TypeError("Iceberg metadata object must be an object") + current_schema_id = metadata.get("current-schema-id") + schemas = metadata.get("schemas") + schema_items = schemas if isinstance(schemas, list) else [] + current_schema = next( + ( + _mapping(item) + for item in schema_items + if _mapping(item).get("schema-id") == current_schema_id + ), + {}, + ) + fields = current_schema.get("fields") + field_items = fields if isinstance(fields, list) else [] + latest = { + "key": latest_key, + "body_sha256": hashlib.sha256(body).hexdigest(), + "location": metadata.get("location"), + "current_snapshot_id": metadata.get("current-snapshot-id"), + "current_schema_id": current_schema_id, + "fields": [ + { + "name": _mapping(item).get("name"), + "required": _mapping(item).get("required"), + "type": _mapping(item).get("type"), + } + for item in field_items + ], + } + expected_fields = [ + {"name": "BSM", "required": True, "type": "string"}, + {"name": "geometry", "required": True, "type": "binary"}, + ] + if ( + latest["location"] != profile.target.table_location + or latest["fields"] != expected_fields + or data_keys + or manifest_keys + or any( + not str(item.get("key") or "").startswith(profile.target.object_prefix) + for item in objects + ) + ): + raise ObjectStoreActiveMetadataPromotionError( + "direct object-store table metadata does not match the projection" + ) + return { + "bucket": profile.target.bucket, + "prefix": profile.target.object_prefix, + "object_count": len(objects), + "objects": objects, + "data_keys": data_keys, + "metadata_keys": metadata_keys, + "manifest_keys": manifest_keys, + "latest_metadata": latest, + "source_feature_rows_present": False, + "material_recorded": False, + } + finally: + client.close() + + +def restart_provider_runtime( + runtime: m310.IsolatedSparkObjectStoreRuntime, +) -> dict[str, Any]: + before = runtime.observe_runtime() + namespace = runtime.profile.cluster.rehearsal_namespace + for workload in ( + "statefulset/gravitino-persistence-postgresql", + "statefulset/gravitino-persistence", + ): + runtime.kubectl.run( + ["-n", namespace, "rollout", "restart", workload], + label=f"object-store promotion {workload} restart", + ) + runtime.kubectl.run( + ["-n", namespace, "rollout", "status", workload, "--timeout=10m"], + timeout=660, + label=f"object-store promotion {workload} restart rollout", + ) + return {"before": before, "after": runtime.observe_runtime()} + + +def _runtime_continuity_errors( + restart: Mapping[str, Any], + runtime_binding: Mapping[str, Any], + *, + cluster_uid: str, + target: ObjectStoreTarget, +) -> list[str]: + errors: list[str] = [] + before = _mapping(restart.get("before")) + after = _mapping(restart.get("after")) + errors.extend(m310._runtime_errors(before)) + errors.extend(m310._runtime_errors(after)) + try: + before_binding = _provider_runtime_binding(before, cluster_uid=cluster_uid, target=target) + after_binding = _provider_runtime_binding(after, cluster_uid=cluster_uid, target=target) + except ObjectStoreActiveMetadataPromotionError: + errors.append("object-store provider runtime identity is incomplete") + return errors + if before_binding != after_binding or dict(runtime_binding) != before_binding: + errors.append("object-store provider runtime identity changed across restart") + for workload_name in ("postgresql", "gravitino"): + old = _mapping(before.get(workload_name)) + new = _mapping(after.get(workload_name)) + if old.get("statefulset_uid") != new.get("statefulset_uid"): + errors.append(f"{workload_name} StatefulSet identity changed") + if not old.get("pod_uid") or old.get("pod_uid") == new.get("pod_uid"): + errors.append(f"{workload_name} pod did not restart") + if old.get("ready_replicas") != 1 or new.get("ready_replicas") != 1: + errors.append(f"{workload_name} was not ready around restart") + old_object_store = _mapping(before.get("object_store")) + new_object_store = _mapping(after.get("object_store")) + for key in ("statefulset_uid", "pod_uid", "pvc", "image_id"): + if old_object_store.get(key) != new_object_store.get(key): + errors.append(f"object-store runtime changed across provider restart: {key}") + return errors + + +def build_evidence( + observation: Mapping[str, Any], + *, + profile: ObjectStorePromotionProfile, +) -> dict[str, Any]: + errors: list[str] = [] + try: + replay._reject_sensitive_fields(observation) + except ValueError: + errors.append("object-store promotion observation contains sensitive material") + if observation.get("schema") != OBSERVATION_SCHEMA: + errors.append("object-store promotion observation schema does not match") + contract = build_contract_report() + observed_contract = _mapping(observation.get("contract")) + if ( + observed_contract.get("contract_sha256") != contract.get("contract_sha256") + or observed_contract.get("source_m320_evidence_sha256") != M320_EVIDENCE_SHA256 + or observed_contract.get("m310_evidence_fingerprint") != M310_EVIDENCE_FINGERPRINT + ): + errors.append("object-store promotion contract binding does not match") + dataset = _mapping(observation.get("dataset_bundle")) + errors.extend(validate_shapefile_bundle_inventory(dict(dataset))) + plan = _mapping(observation.get("plan")) + target = _mapping(plan.get("target")) + if ( + plan.get("resource_version_id") != str(RESOURCE_VERSION_ID) + or plan.get("content_sha256") != dataset.get("content_sha256") + or plan.get("predecessor_promotion_candidate_sha256") + != observation.get("predecessor_promotion_candidate_sha256") + or target.get("metalake") != profile.target.metalake + or target.get("catalog") != profile.target.catalog + or target.get("schema") != profile.target.schema_name + or target.get("table") != profile.target.table + or target.get("warehouse") != profile.target.warehouse + or target.get("object_prefix") != profile.target.object_prefix + ): + errors.append("object-store plan does not bind the real ResourceVersion") + runtime_binding = _mapping(observation.get("runtime_binding")) + if observation.get("runtime_binding_sha256") != canonical_json_fingerprint(runtime_binding): + errors.append("object-store runtime-bound promotion fingerprint does not match") + errors.extend( + _runtime_continuity_errors( + _mapping(observation.get("restart")), + runtime_binding, + cluster_uid=str(observation.get("cluster_uid") or ""), + target=profile.target, + ) + ) + openmetadata = _mapping(observation.get("openmetadata")) + source_openmetadata = _mapping(observation.get("source_m320_openmetadata")) + if dict(openmetadata) != dict(source_openmetadata): + errors.append("retained OpenMetadata readback does not match M3-20") + if observation.get("openmetadata_mutation_count") != 0: + errors.append("object-store promotion may not mutate retained OpenMetadata") + bootstrap = _mapping(observation.get("bootstrap")) + post_security = _mapping(observation.get("post_restart_security")) + expected_role = identity._normalize_securable_objects(durable._expected_role(profile)) + if ( + bootstrap.get("admin_authentication_status") != 200 + or bootstrap.get("bounded_authentication_status") != 200 + or bootstrap.get("server_version") != "1.3.0" + or bootstrap.get("catalog_backend") != "jdbc" + or bootstrap.get("warehouse") != profile.target.warehouse + or bootstrap.get("io_impl") != profile.target.io_impl + or bootstrap.get("s3_endpoint") != profile.target.s3_endpoint + or bootstrap.get("bucket") != profile.target.bucket + or bootstrap.get("denied_catalog_create_status") != 403 + or _mapping(bootstrap.get("role")).get("securable_objects") != expected_role + or bootstrap.get("material_recorded") is not False + or post_security.get("bounded_authentication_status") != 200 + or post_security.get("denied_catalog_create_status") != 403 + or _mapping(post_security.get("role")).get("securable_objects") != expected_role + or post_security.get("material_recorded") is not False + ): + errors.append("bounded object-store Gravitino identity did not survive restart") + first = _mapping(observation.get("first_apply")) + immediate = _mapping(observation.get("immediate_replay")) + post = _mapping(observation.get("post_restart_first_replay")) + if ( + first.get("status") != "created" + or first.get("mutation_count") != 1 + or first.get("mutations") != ["gravitino.table.create"] + ): + errors.append("first object-store projection apply was not one bounded create") + for label, result in (("immediate", immediate), ("post-restart", post)): + if result.get("status") != "no_op" or result.get("mutation_count") != 0: + errors.append(f"{label} object-store projection replay was not no-op") + for key in ( + "gravitino", + "table_projection", + "table_projection_sha256", + "logical_binding_sha256", + "promotion_candidate_sha256", + ): + if first.get(key) != immediate.get(key) or first.get(key) != post.get(key): + errors.append(f"object-store provider projection drifted: {key}") + if ( + first.get("logical_binding_sha256") != plan.get("logical_binding_sha256") + or first.get("promotion_candidate_sha256") != plan.get("promotion_candidate_sha256") + or plan.get("predecessor_promotion_candidate_sha256") + == plan.get("promotion_candidate_sha256") + ): + errors.append("object-store promotion candidate does not match plan") + authorization = _mapping(observation.get("authorization")) + if ( + authorization.get("action") != ACTION + or authorization.get("provider_apply_authorized") is not True + or not authorization.get("authorization_sha256") + ): + errors.append("object-store provider apply authorization is not bound") + before_objects = _mapping(observation.get("object_store_before_restart")) + after_objects = _mapping(observation.get("object_store_after_restart")) + if ( + dict(before_objects) != dict(after_objects) + or before_objects.get("bucket") != profile.target.bucket + or before_objects.get("prefix") != profile.target.object_prefix + or not before_objects.get("metadata_keys") + or before_objects.get("data_keys") != [] + or before_objects.get("manifest_keys") != [] + or _mapping(before_objects.get("latest_metadata")).get("location") + != profile.target.table_location + or before_objects.get("source_feature_rows_present") is not False + or before_objects.get("material_recorded") is not False + ): + errors.append("direct S3 Iceberg metadata did not survive provider restart") + checks = _mapping(observation.get("runtime_checks")) + if ( + checks.get("openmetadata_port_forward_stopped") is not True + or checks.get("all_runtime_port_forwards_stopped") is not True + or checks.get("namespace_delete_completed") is not True + or checks.get("namespace_absent") is not True + or checks.get("persistent_volumes_absent") is not True + or checks.get("provider_objects_retained") is not False + or checks.get("object_store_objects_retained") is not False + or checks.get("material_recorded") is not False + ): + errors.append("object-store promotion runtime cleanup is incomplete") + verified = not errors + stable = { + "schema": EVIDENCE_SCHEMA, + "environment": "local_docker_desktop", + "status": ( + "local_object_store_active_metadata_promotion_verified" if verified else "blocked" + ), + "contract_sha256": contract.get("contract_sha256"), + "source_m320_evidence_sha256": M320_EVIDENCE_SHA256, + "m310_evidence_fingerprint": M310_EVIDENCE_FINGERPRINT, + "dataset_bundle": dict(dataset), + "resource_version_id": plan.get("resource_version_id"), + "resource_version_content_sha256": plan.get("content_sha256"), + "predecessor_promotion_candidate_sha256": plan.get( + "predecessor_promotion_candidate_sha256" + ), + "logical_binding_sha256": plan.get("logical_binding_sha256"), + "runtime_binding_sha256": observation.get("runtime_binding_sha256"), + "promotion_candidate_sha256": plan.get("promotion_candidate_sha256"), + "local_object_store_active_metadata_promotion_verified": verified, + "real_dataset_resource_version_bound": verified, + "openmetadata_read_only_verified": verified, + "bounded_gravitino_projection_verified": verified, + "local_jdbc_s3_catalog_restart_continuity_verified": verified, + "local_cross_node_object_store_runtime_bound": verified, + "direct_object_store_metadata_verified": verified, + "pre_restart_replay_no_op_verified": verified, + "post_restart_first_replay_no_op_verified": verified, + "m320_history_untouched": verified, + "predecessor_history_changed": False, + "promotion_persisted_to_gda_control": False, + "dataset_source_committed": False, + "dataset_absolute_path_committed": False, + "dataset_required_in_ci": False, + "deployment_applied": False, + "source_feature_rows_ingested": False, + "protected_workload_identity_verified": False, + "provider_minimum_privilege_verified": False, + "durable_catalog_verified": False, + "production_object_store_verified": False, + "oidc_verified": False, + "tls_verified": False, + "production_scheduler_submission_verified": False, + "production_ingestion_verified": False, + "spark_conformance_verified": False, + "flink_conformance_verified": False, + "platform_run_succeeded": False, + "production_ready": False, + "observation": dict(observation), + "errors": errors, + } + return {**stable, "evidence_sha256": canonical_json_fingerprint(stable)} + + +def verify_evidence_integrity(evidence: Mapping[str, Any]) -> list[str]: + errors: list[str] = [] + try: + replay._reject_sensitive_fields(evidence) + except ValueError: + errors.append("object-store promotion evidence contains sensitive material") + stable = {key: value for key, value in evidence.items() if key != "evidence_sha256"} + if evidence.get("evidence_sha256") != canonical_json_fingerprint(stable): + errors.append("object-store promotion evidence SHA-256 does not match") + if evidence.get("schema") != EVIDENCE_SCHEMA or evidence.get("errors") != []: + errors.append("object-store promotion evidence is not verified") + for claim in ( + "local_object_store_active_metadata_promotion_verified", + "real_dataset_resource_version_bound", + "openmetadata_read_only_verified", + "bounded_gravitino_projection_verified", + "local_jdbc_s3_catalog_restart_continuity_verified", + "local_cross_node_object_store_runtime_bound", + "direct_object_store_metadata_verified", + "pre_restart_replay_no_op_verified", + "post_restart_first_replay_no_op_verified", + "m320_history_untouched", + ): + if evidence.get(claim) is not True: + errors.append(f"object-store promotion evidence claim is false: {claim}") + for claim in FALSE_CLAIMS: + if evidence.get(claim) is not False: + errors.append(f"object-store promotion evidence may not claim {claim}") + serialized = json.dumps(evidence, ensure_ascii=True, sort_keys=True) + for forbidden in ( + "/Users/", + "/home/", + "Downloads/", + ".tmp/", + "host.docker.internal", + '"password"', + '"secret"', + '"token"', + '"session"', + '"access_key"', + '"access-key"', + ): + if forbidden in serialized: + errors.append("object-store promotion evidence contains local or secret material") + break + return errors + + +def run_live_rehearsal( + profile_path: Path = DEFAULT_PROFILE_PATH, +) -> dict[str, Any]: + profile = load_profile(profile_path) + source, runtime_profile = _load_dependencies(profile) + contract = build_contract_report(profile_path=profile_path) + if contract.get("status") != "valid": + raise ObjectStoreActiveMetadataPromotionError( + "object-store promotion static contract is invalid" + ) + provider_profile = execution.build_projection_profile(datetime.now(UTC)) + try: + openmetadata_username = os.environ[provider_profile.providers.openmetadata.username_env] + openmetadata_password = SecretStr( + os.environ[provider_profile.providers.openmetadata.password_env] + ) + except KeyError as exc: + raise ObjectStoreActiveMetadataPromotionError( + "OpenMetadata local bootstrap credential environment is missing" + ) from exc + admin_material = SecretStr(secrets.token_urlsafe(24)) + database_material = SecretStr(secrets.token_urlsafe(24)) + user_material = SecretStr(secrets.token_urlsafe(24)) + object_store_user = SecretStr("gda" + secrets.token_hex(8)) + object_store_material = SecretStr(secrets.token_urlsafe(32)) + runtime = m310.IsolatedSparkObjectStoreRuntime(runtime_profile) + om_forward: provider_metrics._PortForward | None = None + object_forward: provider_metrics._PortForward | None = None + before_forward: provider_metrics._PortForward | None = None + after_forward: provider_metrics._PortForward | None = None + om_client: replay.OpenMetadataApplyClient | None = None + rehearsal: ObjectStoreProjectionRehearsal | None = None + initial_runtime: dict[str, Any] | None = None + restart: dict[str, Any] | None = None + object_store_prepared: dict[str, Any] | None = None + openmetadata_observation: bridge.OpenMetadataObservation | None = None + plan: ObjectStoreProjectionPlan | None = None + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str] | None = None + bootstrap: dict[str, Any] | None = None + first_apply: dict[str, Any] | None = None + immediate_replay: dict[str, Any] | None = None + object_store_before: dict[str, Any] | None = None + post_restart_security: dict[str, Any] | None = None + post_restart_replay: dict[str, Any] | None = None + object_store_after: dict[str, Any] | None = None + cluster_uid: str | None = None + runtime_binding: dict[str, Any] | None = None + openmetadata_mutation_count = -1 + om_forward_stopped = False + object_forward_stopped = False + before_forward_stopped = False + after_forward_stopped = False + cleanup = { + "namespace_delete_completed": False, + "namespace_absent": False, + "persistent_volumes_absent": False, + "provider_objects_retained": True, + "object_store_objects_retained": True, + } + try: + initial_runtime = runtime.start( + admin_material=admin_material, + database_material=database_material, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + cluster = runtime.kubectl.get_json( + ["get", "namespace", "kube-system"], + label="object-store promotion cluster identity", + ) + cluster_uid = str(_mapping(_mapping(cluster).get("metadata")).get("uid")) + runtime_binding = _provider_runtime_binding( + initial_runtime, + cluster_uid=cluster_uid, + target=profile.target, + ) + + object_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=runtime_profile.cluster.context, + namespace=runtime_profile.cluster.rehearsal_namespace, + service=runtime_profile.runtime.object_store_service, + target_port=runtime_profile.runtime.object_store_service_port, + ) + object_forward.start() + object_endpoint = f"http://127.0.0.1:{object_forward.local_port}" + object_store_prepared = runtime.prepare_object_store( + endpoint_url=object_endpoint, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + + om_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=provider_profile.cluster.context, + namespace=provider_profile.cluster.namespace, + service=provider_profile.providers.openmetadata.service, + target_port=provider_profile.providers.openmetadata.service_port, + ) + om_forward.start() + om_client = replay.OpenMetadataApplyClient( + base_url=f"http://127.0.0.1:{om_forward.local_port}/api/v1", + username=openmetadata_username, + password=openmetadata_password, + ) + om_payload = om_client.get_table(provider_profile.targets.openmetadata.table_fqn) + if om_payload is None: + raise ObjectStoreActiveMetadataPromotionError( + "retained OpenMetadata projection is missing" + ) + openmetadata_observation = _verify_openmetadata( + om_payload, source, observed_at=datetime.now(UTC) + ) + openmetadata_mutation_count = len(om_client.mutations) + om_client.close() + om_client = None + om_forward_stopped = om_forward.stop() + om_forward = None + + plan = build_projection_plan(profile, source, openmetadata_observation, runtime_binding) + authorized_at = datetime.now(UTC) + authorization = build_apply_authorization(plan, profile, authorized_at=authorized_at) + before_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=runtime_profile.cluster.context, + namespace=runtime_profile.cluster.rehearsal_namespace, + service=runtime_profile.runtime.service, + target_port=runtime_profile.runtime.gravitino_service_port, + ) + before_forward.start() + rehearsal = ObjectStoreProjectionRehearsal( + base_url=f"http://127.0.0.1:{before_forward.local_port}/api", + admin_name=profile.identity.service_admin, + admin_material=admin_material, + ) + bootstrap = rehearsal.bootstrap( + profile, + database_material=database_material, + user_material=user_material, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + first_apply = rehearsal.apply_once(plan, authorization, at=authorized_at, create=True) + immediate_replay = rehearsal.apply_once( + plan, authorization, at=authorized_at + timedelta(seconds=1) + ) + object_store_before = observe_table_object_store( + runtime, + profile, + endpoint_url=object_endpoint, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + rehearsal.close() + rehearsal = None + before_forward_stopped = before_forward.stop() + before_forward = None + + restart = restart_provider_runtime(runtime) + + after_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=runtime_profile.cluster.context, + namespace=runtime_profile.cluster.rehearsal_namespace, + service=runtime_profile.runtime.service, + target_port=runtime_profile.runtime.gravitino_service_port, + ) + after_forward.start() + rehearsal = ObjectStoreProjectionRehearsal( + base_url=f"http://127.0.0.1:{after_forward.local_port}/api", + admin_name=profile.identity.service_admin, + admin_material=admin_material, + ) + post_restart_security = rehearsal.reconnect_bounded(profile, user_material) + post_restart_replay = rehearsal.apply_once( + plan, authorization, at=authorized_at + timedelta(seconds=2) + ) + object_store_after = observe_table_object_store( + runtime, + profile, + endpoint_url=object_endpoint, + object_store_user=object_store_user, + object_store_material=object_store_material, + ) + finally: + if om_client is not None: + om_client.close() + if rehearsal is not None: + rehearsal.close() + if om_forward is not None: + om_forward_stopped = om_forward.stop() + if before_forward is not None: + before_forward_stopped = before_forward.stop() + if after_forward is not None: + after_forward_stopped = after_forward.stop() + if object_forward is not None: + object_forward_stopped = object_forward.stop() + cleanup = runtime.cleanup() + required = ( + initial_runtime, + restart, + object_store_prepared, + openmetadata_observation, + plan, + authorization, + bootstrap, + first_apply, + immediate_replay, + object_store_before, + post_restart_security, + post_restart_replay, + object_store_after, + cluster_uid, + runtime_binding, + ) + if any(value is None for value in required): + raise ObjectStoreActiveMetadataPromotionError( + "object-store promotion rehearsal did not produce a complete outcome" + ) + assert isinstance(openmetadata_observation, bridge.OpenMetadataObservation) + assert isinstance(plan, ObjectStoreProjectionPlan) + run, execution_plan, policy_artifact, approval_artifact, auth_sha = authorization + source_openmetadata = _mapping(_mapping(source.get("observation")).get("openmetadata")) + observation = { + "schema": OBSERVATION_SCHEMA, + "observed_at": datetime.now(UTC).isoformat(), + "contract": { + "contract_sha256": contract["contract_sha256"], + "source_m320_evidence_sha256": M320_EVIDENCE_SHA256, + "m310_evidence_fingerprint": M310_EVIDENCE_FINGERPRINT, + }, + "dataset_bundle": source["dataset_bundle"], + "predecessor_promotion_candidate_sha256": source["promotion_candidate_sha256"], + "source_m320_openmetadata": dict(source_openmetadata), + "openmetadata": { + **dict(source_openmetadata), + "owner_refs": sorted(openmetadata_observation.owner_refs), + "domain_refs": sorted(openmetadata_observation.domain_refs), + "tag_refs": sorted(openmetadata_observation.tag_refs), + }, + "openmetadata_mutation_count": openmetadata_mutation_count, + "cluster_uid": cluster_uid, + "runtime_binding": runtime_binding, + "runtime_binding_sha256": canonical_json_fingerprint(runtime_binding), + "initial_runtime": initial_runtime, + "object_store_prepared": object_store_prepared, + "restart": restart, + "plan": plan.model_dump(mode="json", by_alias=True), + "authorization": { + "action": ACTION, + "provider_apply_authorized": True, + "authorization_sha256": auth_sha, + "run_id": str(run.run_id), + "execution_plan_artifact_id": str(execution_plan.artifact_id), + "policy_decision_artifact_id": str(policy_artifact.artifact_id), + "approval_artifact_id": str(approval_artifact.artifact_id), + }, + "bootstrap": bootstrap, + "first_apply": first_apply, + "immediate_replay": immediate_replay, + "object_store_before_restart": object_store_before, + "post_restart_security": post_restart_security, + "post_restart_first_replay": post_restart_replay, + "object_store_after_restart": object_store_after, + "runtime_checks": { + **cleanup, + "openmetadata_port_forward_stopped": om_forward_stopped, + "all_runtime_port_forwards_stopped": ( + object_forward_stopped and before_forward_stopped and after_forward_stopped + ), + "material_recorded": False, + }, + } + return build_evidence(observation, profile=profile) + + +def build_validation_report( + *, + profile_path: Path = DEFAULT_PROFILE_PATH, + evidence_path: Path = DEFAULT_EVIDENCE_PATH, +) -> dict[str, Any]: + contract = build_contract_report(profile_path=profile_path) + errors = list(contract["errors"]) + evidence: dict[str, Any] | None = None + try: + evidence = _load_json_object(evidence_path) + errors.extend(verify_evidence_integrity(evidence)) + observed_contract = _mapping(_mapping(evidence.get("observation")).get("contract")).get( + "contract_sha256" + ) + if observed_contract != contract.get("contract_sha256"): + errors.append("object-store promotion evidence contract SHA drift") + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + errors.append(f"object-store promotion evidence is invalid: {type(exc).__name__}") + verified = not errors + return { + "schema": VALIDATION_SCHEMA, + "status": "valid" if verified else "invalid", + "local_static_contract_verified": contract["local_static_contract_verified"], + "local_object_store_active_metadata_promotion_verified": ( + verified + and evidence is not None + and evidence.get("local_object_store_active_metadata_promotion_verified") is True + ), + "local_cross_node_object_store_runtime_bound": ( + verified + and evidence is not None + and evidence.get("local_cross_node_object_store_runtime_bound") is True + ), + "direct_object_store_metadata_verified": ( + verified + and evidence is not None + and evidence.get("direct_object_store_metadata_verified") is True + ), + "promotion_persisted_to_gda_control": False, + "durable_catalog_verified": False, + "production_object_store_verified": False, + "production_ingestion_verified": False, + "production_ready": False, + "contract_sha256": contract["contract_sha256"], + "evidence_sha256": evidence.get("evidence_sha256") if evidence else None, + "errors": errors, + } + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate or run the object-store Active Metadata promotion" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + validate_parser = subparsers.add_parser("validate") + validate_parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE_PATH) + validate_parser.add_argument("--evidence", type=Path, default=DEFAULT_EVIDENCE_PATH) + contract_parser = subparsers.add_parser("contract") + contract_parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE_PATH) + live_parser = subparsers.add_parser("live") + live_parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE_PATH) + live_parser.add_argument("--output", type=Path, default=DEFAULT_EVIDENCE_PATH) + args = parser.parse_args(argv) + if args.command == "contract": + report = build_contract_report(profile_path=args.profile) + elif args.command == "live": + report = run_live_rehearsal(args.profile) + _write_json(args.output, report) + else: + report = build_validation_report(profile_path=args.profile, evidence_path=args.evidence) + print(json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True)) + return 0 if not report.get("errors") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data_agent/platform_truth.py b/data_agent/platform_truth.py index 127cd6eb..a89e1c45 100644 --- a/data_agent/platform_truth.py +++ b/data_agent/platform_truth.py @@ -978,6 +978,26 @@ def _config( ), "Object-store-backed durable provider, protected identity and production binding promotion", ), + RuntimeSpec( + "metadata_object_store_active_metadata_promotion_rehearsal", + "object_store_active_metadata_promotion_rehearsal", + "governed", + "evidence_durable", + "temporary JDBC/S3 provider runtime + committed local evidence", + "metadata-platform", + "local_verification_only", + ( + "data_agent/metadata_fabric_object_store_active_metadata_promotion.py", + "scripts/metadata-fabric-object-store-active-metadata-promotion.sh", + ), + ( + ( + "data_agent/metadata_fabric_object_store_active_metadata_promotion.py", + "def run_live_rehearsal", + ), + ), + "Protected object-store identity/TLS/KMS, real row ingestion and versioned promotion ledger", + ), RuntimeSpec( "datalake_monitor", "monitor_loop", diff --git a/data_agent/test_metadata_fabric_object_store_active_metadata_promotion.py b/data_agent/test_metadata_fabric_object_store_active_metadata_promotion.py new file mode 100644 index 00000000..9ecbe653 --- /dev/null +++ b/data_agent/test_metadata_fabric_object_store_active_metadata_promotion.py @@ -0,0 +1,411 @@ +import inspect +import json +from copy import deepcopy +from datetime import UTC, datetime + +import pytest +from pydantic import SecretStr, ValidationError + +from data_agent import metadata_fabric_durable_active_metadata_promotion as durable +from data_agent import metadata_fabric_object_store_active_metadata_promotion as object_promotion +from data_agent import metadata_fabric_spark_object_store_interoperability as m310 +from data_agent.platform_contracts import canonical_json_fingerprint + +AT = datetime(2026, 7, 31, 10, 0, tzinfo=UTC) +EXPECTED_EVIDENCE_SHA256 = "d73754c53cf16d888aa345baa5d079cc7fd98d8b84db747f52188c1a69bf1628" + + +def _profile(): + return object_promotion.load_profile() + + +def _source(): + profile = _profile() + return object_promotion._load_json_object( + object_promotion._resolve_repo_path(profile.dependencies.m320_evidence_path) + ) + + +def _openmetadata(): + expected = _source()["observation"]["openmetadata"] + ref = object_promotion.bridge.OpenMetadataTableRef( + entity_id=expected["entity_id"], + fully_qualified_name=expected["fully_qualified_name"], + entity_version=expected["entity_version"], + server_version="1.13.1", + ) + return object_promotion.bridge.OpenMetadataObservation( + ref=ref, + resource_urn=expected["resource_urn"], + resource_version_id=expected["resource_version_id"], + content_sha256=expected["content_sha256"], + owner_refs=expected["owner_refs"], + domain_refs=expected["domain_refs"], + tag_refs=expected["tag_refs"], + snapshot_sha256=expected["snapshot_sha256"], + observed_at=AT, + ) + + +def _workload(uid: str, pod_uid: str, *, node: str, pvc=None): + return { + "statefulset_uid": uid, + "pod_uid": pod_uid, + "pod_name": "pod-0", + "node_name": node, + "ready_replicas": 1, + "service_account": "bounded", + "service_account_automount_disabled": True, + "image": "example/image:pinned", + "image_id": f"sha256:{uid.replace('-', '')[:32].ljust(64, '0')}", + "persistent_volume_claims": [pvc["name"]] if pvc else [], + "pvc": pvc, + } + + +def _runtime_snapshot(): + postgresql_pvc = { + "name": "data-gravitino-persistence-postgresql-0", + "uid": "55555555-5555-4555-8555-555555555555", + "storage_class": "standard", + "volume_name": "pvc-postgresql", + "phase": "Bound", + } + object_store_pvc = { + "name": "data-metadata-object-store-0", + "uid": "88888888-8888-4888-8888-888888888888", + "storage_class": "standard", + "volume_name": "pvc-object-store", + "phase": "Bound", + } + return { + "context": "docker-desktop", + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "11111111-1111-4111-8111-111111111111", + }, + "service": { + "name": "gravitino-persistence", + "uid": "22222222-2222-4222-8222-222222222222", + "type": "ClusterIP", + }, + "object_store_service": { + "name": "metadata-object-store", + "uid": "33333333-3333-4333-8333-333333333333", + "type": "ClusterIP", + }, + "postgresql": _workload( + "44444444-4444-4444-8444-444444444444", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + node="desktop-worker", + pvc=postgresql_pvc, + ), + "gravitino": _workload( + "66666666-6666-4666-8666-666666666666", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + node="desktop-worker", + ), + "object_store": _workload( + "77777777-7777-4777-8777-777777777777", + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + node="desktop-control-plane", + pvc=object_store_pvc, + ), + } + + +def _runtime_binding(snapshot=None): + return object_promotion._provider_runtime_binding( + snapshot or _runtime_snapshot(), + cluster_uid="99999999-9999-4999-8999-999999999999", + target=_profile().target, + ) + + +def _plan(runtime_binding=None): + return object_promotion.build_projection_plan( + _profile(), + _source(), + _openmetadata(), + runtime_binding or _runtime_binding(), + ) + + +def test_profile_binds_checked_m320_and_m310_dependencies(): + profile = _profile() + source, runtime_profile = object_promotion._load_dependencies(profile) + + assert source["evidence_sha256"] == object_promotion.M320_EVIDENCE_SHA256 + assert source["dataset_bundle"]["content_sha256"] == ( + "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007" + ) + assert runtime_profile.catalog.backend == "jdbc" + assert runtime_profile.catalog.warehouse == "s3://gda-metadata-warehouse/warehouse" + assert profile.target.identity == ( + "gda_chongqing_m3_21/lakehouse/cultural_heritage/cultural_districts" + ) + assert profile.claims.production_object_store_verified is False + + +def test_dependencies_use_full_predecessor_validators(monkeypatch): + profile = _profile() + durable_called = False + object_store_called = False + durable_validator = durable.build_validation_report + object_store_validator = m310.build_validation_report + + def checked_durable(**kwargs): + nonlocal durable_called + durable_called = True + return durable_validator(**kwargs) + + def checked_object_store(**kwargs): + nonlocal object_store_called + object_store_called = True + return object_store_validator(**kwargs) + + monkeypatch.setattr(durable, "build_validation_report", checked_durable) + monkeypatch.setattr(m310, "build_validation_report", checked_object_store) + + object_promotion._load_dependencies(profile) + + assert durable_called is True + assert object_store_called is True + + +def test_runtime_binding_binds_s3_identity_but_excludes_rotating_pods(): + snapshot = _runtime_snapshot() + binding = _runtime_binding(snapshot) + changed_pods = deepcopy(snapshot) + changed_pods["postgresql"]["pod_uid"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + changed_pods["gravitino"]["pod_uid"] = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" + + assert _runtime_binding(changed_pods) == binding + assert "pod_uid" not in json.dumps(binding, sort_keys=True) + assert binding["catalog"] == { + "backend": "jdbc", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "s3://gda-metadata-warehouse/warehouse", + "io_impl": "org.apache.iceberg.aws.s3.S3FileIO", + "s3_endpoint": "http://metadata-object-store:9000", + "s3_region": "us-east-1", + "s3_path_style_access": True, + "bucket": "gda-metadata-warehouse", + } + assert binding["storage"]["gravitino_persistent_volume_claims"] == [] + + changed_storage = deepcopy(snapshot) + changed_storage["object_store"]["pvc"]["uid"] = "ffffffff-ffff-4fff-8fff-ffffffffffff" + assert _runtime_binding(changed_storage) != binding + + +def test_runtime_binding_rejects_shared_pvc_or_same_node(): + shared_pvc = _runtime_snapshot() + shared_pvc["gravitino"]["pvc"] = deepcopy(shared_pvc["object_store"]["pvc"]) + shared_pvc["gravitino"]["persistent_volume_claims"] = ["data-metadata-object-store-0"] + with pytest.raises(object_promotion.ObjectStoreActiveMetadataPromotionError): + _runtime_binding(shared_pvc) + + same_node = _runtime_snapshot() + same_node["object_store"]["node_name"] = "desktop-worker" + with pytest.raises(object_promotion.ObjectStoreActiveMetadataPromotionError): + _runtime_binding(same_node) + + +def test_plan_has_independent_predecessor_and_candidate_fingerprints(): + plan = _plan() + + assert plan.predecessor_promotion_candidate_sha256 == _source()["promotion_candidate_sha256"] + assert plan.promotion_candidate_sha256 != (plan.predecessor_promotion_candidate_sha256) + assert plan.runtime_binding_sha256 == canonical_json_fingerprint(plan.runtime_binding) + assert plan.target.table_location == ( + "s3://gda-metadata-warehouse/warehouse/cultural_heritage/cultural_districts" + ) + + tampered = plan.model_dump(mode="json", by_alias=True) + tampered["runtime_binding"]["catalog"]["bucket"] = "wrong-bucket" + with pytest.raises(ValidationError): + object_promotion.ObjectStoreProjectionPlan.model_validate(tampered) + + +def test_authorization_binds_exact_object_store_plan(): + plan = _plan() + authorization = object_promotion.build_apply_authorization(plan, _profile(), authorized_at=AT) + + object_promotion.validate_apply_authorization(plan, authorization, at=AT) + changed = list(authorization) + changed[-1] = "0" * 64 + with pytest.raises(object_promotion.ObjectStoreActiveMetadataPromotionError): + object_promotion.validate_apply_authorization(plan, tuple(changed), at=AT) + + +def test_bootstrap_source_configures_s3_without_recording_material(): + source = inspect.getsource(object_promotion.ObjectStoreProjectionRehearsal.bootstrap) + + for marker in ( + '"io-impl": profile.target.io_impl', + '"s3-access-key-id": object_store_user.get_secret_value()', + '"s3-secret-access-key": object_store_material.get_secret_value()', + '"s3-endpoint": profile.target.s3_endpoint', + '"material_recorded": False', + ): + assert marker in source + + +def test_restart_orders_postgresql_before_gravitino(): + source = inspect.getsource(object_promotion.restart_provider_runtime) + assert source.index("statefulset/gravitino-persistence-postgresql") < source.index( + '"statefulset/gravitino-persistence",' + ) + + +class _Body: + def __init__(self, payload): + self.payload = payload + + def read(self): + return self.payload + + +class _S3Client: + def __init__(self, metadata): + self.metadata = json.dumps(metadata).encode() + self.closed = False + + def list_objects_v2(self, **request): + assert request == { + "Bucket": "gda-metadata-warehouse", + "Prefix": "warehouse/cultural_heritage/cultural_districts/", + } + return { + "IsTruncated": False, + "Contents": [ + { + "Key": ( + "warehouse/cultural_heritage/cultural_districts/" + "metadata/00000-test.metadata.json" + ), + "Size": len(self.metadata), + "ETag": '"etag"', + } + ], + } + + def get_object(self, **request): + assert request["Bucket"] == "gda-metadata-warehouse" + return {"Body": _Body(self.metadata)} + + def close(self): + self.closed = True + + +class _Runtime: + def __init__(self, client): + self.client = client + + def _s3_client(self, **kwargs): + assert kwargs["endpoint_url"] == "http://127.0.0.1:9000" + return self.client + + +def test_direct_s3_observation_requires_exact_empty_table_metadata(): + metadata = { + "location": ("s3://gda-metadata-warehouse/warehouse/cultural_heritage/cultural_districts"), + "current-schema-id": 0, + "current-snapshot-id": -1, + "schemas": [ + { + "schema-id": 0, + "fields": [ + {"name": "BSM", "required": True, "type": "string"}, + {"name": "geometry", "required": True, "type": "binary"}, + ], + } + ], + } + client = _S3Client(metadata) + + observed = object_promotion.observe_table_object_store( + _Runtime(client), + _profile(), + endpoint_url="http://127.0.0.1:9000", + object_store_user=SecretStr("user"), + object_store_material=SecretStr("material"), + ) + + assert observed["object_count"] == 1 + assert observed["data_keys"] == [] + assert observed["manifest_keys"] == [] + assert observed["source_feature_rows_present"] is False + assert client.closed is True + + +def test_direct_s3_observation_rejects_wrong_location(): + metadata = { + "location": "s3://wrong/table", + "current-schema-id": 0, + "schemas": [ + { + "schema-id": 0, + "fields": [ + {"name": "BSM", "required": True, "type": "string"}, + {"name": "geometry", "required": True, "type": "binary"}, + ], + } + ], + } + with pytest.raises(object_promotion.ObjectStoreActiveMetadataPromotionError): + object_promotion.observe_table_object_store( + _Runtime(_S3Client(metadata)), + _profile(), + endpoint_url="http://127.0.0.1:9000", + object_store_user=SecretStr("user"), + object_store_material=SecretStr("material"), + ) + + +def test_contract_is_valid_and_fail_closed_for_production(): + report = object_promotion.build_contract_report() + + assert report["status"] == "valid" + assert report["source_m320_evidence_sha256"] == (object_promotion.M320_EVIDENCE_SHA256) + assert report["m310_evidence_fingerprint"] == (object_promotion.M310_EVIDENCE_FINGERPRINT) + assert report["production_object_store_verified"] is False + assert report["production_ready"] is False + + +def test_evidence_integrity_rejects_sensitive_material_and_overclaim(): + evidence = { + "schema": object_promotion.EVIDENCE_SCHEMA, + "errors": [], + "production_object_store_verified": True, + "local_object_store_active_metadata_promotion_verified": True, + "credential": "must-not-appear", + } + stable = dict(evidence) + evidence["evidence_sha256"] = canonical_json_fingerprint(stable) + + errors = object_promotion.verify_evidence_integrity(evidence) + + assert "object-store promotion evidence contains sensitive material" in errors + assert any("production_object_store_verified" in error for error in errors) + + +def test_checked_evidence_is_valid_and_content_bound(): + evidence = object_promotion._load_json_object(object_promotion.DEFAULT_EVIDENCE_PATH) + report = object_promotion.build_validation_report() + + assert evidence["evidence_sha256"] == EXPECTED_EVIDENCE_SHA256 + assert evidence["promotion_candidate_sha256"] == ( + "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9" + ) + assert report["status"] == "valid" + assert report["errors"] == [] + assert report["direct_object_store_metadata_verified"] is True + + tampered = deepcopy(evidence) + tampered["observation"]["object_store_after_restart"]["objects"][0]["etag"] = "tampered" + stable = {key: value for key, value in tampered.items() if key != "evidence_sha256"} + tampered["evidence_sha256"] = canonical_json_fingerprint(stable) + rebuilt = object_promotion.build_evidence(tampered["observation"], profile=_profile()) + assert "direct S3 Iceberg metadata did not survive provider restart" in rebuilt["errors"] diff --git a/data_agent/test_platform_truth.py b/data_agent/test_platform_truth.py index 9779fb75..2a252622 100644 --- a/data_agent/test_platform_truth.py +++ b/data_agent/test_platform_truth.py @@ -286,6 +286,12 @@ def test_repository_source_access_and_runtime_baselines_match(): and item["production_role"] == "local_verification_only" for item in static_report["runtime"]["inventory"] ) + assert any( + item["runtime_id"] + == "metadata_object_store_active_metadata_promotion_rehearsal" + and item["production_role"] == "local_verification_only" + for item in static_report["runtime"]["inventory"] + ) def test_runtime_report_detects_unregistered_background_mechanism(tmp_path): diff --git a/docs/architecture-decisions/adr-067-object-store-runtime-bound-active-metadata-promotion.md b/docs/architecture-decisions/adr-067-object-store-runtime-bound-active-metadata-promotion.md new file mode 100644 index 00000000..76befaf6 --- /dev/null +++ b/docs/architecture-decisions/adr-067-object-store-runtime-bound-active-metadata-promotion.md @@ -0,0 +1,64 @@ +# ADR-067: Object-store runtime-bound Active Metadata promotion + +**Status**: Accepted + +**Date**: 2026-07-31 + +**Decision owners**: Data Platform, Metadata Platform, Data Governance, Security, Platform Architecture + +**Related decisions**: [ADR-056](adr-056-local-spark-object-store-interoperability.md) · [ADR-057](adr-057-production-object-store-readiness-gate.md) · [ADR-066](adr-066-runtime-bound-durable-active-metadata-promotion.md) + +## Context + +M3-20 bound the real Chongqing Active Metadata ResourceVersion to a restart-continuous Gravitino JDBC runtime, but its Iceberg warehouse was a local `ReadWriteOnce` PVC mounted by Gravitino. M3-10 separately proved cross-node MinIO interoperability for a synthetic Spark table. Neither evidence proved that the real ResourceVersion promotion was served by a JDBC catalog whose table metadata lived behind an object-store boundary. + +Changing the M3-20 candidate or reusing its SHA would erase the distinction between filesystem and object-store provider runtimes. M3-21 therefore creates a new successor candidate and keeps all predecessor evidence immutable. + +## Decision + +### 1. Promote from M3-20 without rewriting history + +The M3-21 plan contains the exact M3-20 promotion candidate SHA as `predecessor_promotion_candidate_sha256`. It creates a new logical Gravitino target at `gda_chongqing_m3_21/lakehouse/cultural_heritage/cultural_districts`, a new runtime binding and a new promotion candidate. No M3-19/M3-20 schema, evidence or GDA Control row is changed. + +The same retained OpenMetadata UUID, ResourceVersion UUID, content SHA, governance refs and snapshot are read before provider apply. OpenMetadata remains read-only. + +### 2. Bind both catalog and object-store runtime identity + +The stable binding includes cluster and namespace UID; Gravitino and MinIO Service UIDs; PostgreSQL, Gravitino and MinIO StatefulSet UIDs; PostgreSQL and MinIO PVC/volume identity; pinned container image IDs; node separation; JDBC URI; S3 warehouse, FileIO, endpoint, region, path-style mode and bucket. + +Pod UIDs are excluded from the stable hash. PostgreSQL and Gravitino Pod UIDs must rotate during ordered restart. The MinIO Pod, Service, StatefulSet and PVC must remain unchanged. Gravitino must expose an empty PVC list and may not mount the object-store warehouse volume. + +### 3. Require provider and direct S3 agreement + +An ephemeral admin creates the JDBC catalog with `org.apache.iceberg.aws.s3.S3FileIO`; a schema-bounded Basic principal has only `USE_CATALOG`, `USE_SCHEMA` and `CREATE_TABLE`. Catalog administration must return `403` before and after restart. + +The first authorized apply creates exactly one table. Immediate replay and the first post-restart replay must both be `no_op/0 mutations`. Direct S3 inspection must find the table under `warehouse/cultural_heritage/cultural_districts/`, with the exact `BSM string required` and `geometry binary required` Iceberg schema. Because M3-21 is a metadata promotion rather than row ingestion, the prefix must contain one metadata JSON and no Parquet or manifest files. The object inventory, ETag and metadata body SHA must be unchanged across restart. + +### 4. Local MinIO is not production object storage + +MinIO, both Kubernetes nodes and both PVCs share one Docker Desktop host. Credentials are generated static local material; transport is HTTP; there is no KMS, protected workload identity, independent account or failure domain. Namespace and volumes are deleted after the rehearsal. + +Therefore `durable_catalog_verified`, `production_object_store_verified`, `source_feature_rows_ingested`, protected identity, OIDC, TLS, production ingestion and `production_ready` remain false. + +## Verification + +The local rehearsal recorded: + +- Chongqing ResourceVersion `a6000000-0000-4000-8000-000000000001`, content SHA `fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007` and OpenMetadata UUID `9d043410-02b5-487d-bb70-da5f3969a978`; +- M3-20 predecessor candidate `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`; +- one bounded `gravitino.table.create`, immediate `no_op/0`, and first post-restart `no_op/0`; +- stable direct S3 object key, ETag and metadata body SHA with no data or manifest objects; +- PostgreSQL and Gravitino Pod rotation with stable MinIO Pod, Services, StatefulSets, PVCs and images; +- logical binding SHA `614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1`; +- runtime binding SHA `dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b`; +- promotion candidate SHA `63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9`; +- contract SHA `b1a2db34a70eaa7dd55da1d6c85da9f420c755c71868aafe7972e3794034a6cc` and evidence SHA `d73754c53cf16d888aa345baa5d079cc7fd98d8b84db747f52188c1a69bf1628`; +- complete namespace, PVC and port-forward cleanup. + +## Consequences + +**Positive**: the real Active Metadata target is now content-bound to a restart-continuous JDBC catalog and an independently observed cross-node S3-compatible warehouse, without weakening historical evidence. + +**Negative**: the table contains metadata only, the candidate is not authoritative, and local MinIO does not satisfy the production storage gate. + +**Next gate**: ingest a bounded real Chongqing feature slice through an authorized Spark/Sedona path into this provider model, then define the versioned promotion ledger transaction. Production promotion still requires selected object storage, protected identity/TLS/KMS, independent failure domains, backup/PITR and tenant isolation. diff --git a/docs/evidence/metadata-fabric-object-store-active-metadata-promotion-2026-07-31.json b/docs/evidence/metadata-fabric-object-store-active-metadata-promotion-2026-07-31.json new file mode 100644 index 00000000..bb2a8e63 --- /dev/null +++ b/docs/evidence/metadata-fabric-object-store-active-metadata-promotion-2026-07-31.json @@ -0,0 +1,985 @@ +{ + "bounded_gravitino_projection_verified": true, + "contract_sha256": "b1a2db34a70eaa7dd55da1d6c85da9f420c755c71868aafe7972e3794034a6cc", + "dataset_absolute_path_committed": false, + "dataset_bundle": { + "components": [ + { + "component": ".cpg", + "sha256": "3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824", + "size_bytes": 5 + }, + { + "component": ".dbf", + "sha256": "ee7c6c4c6957aea296b69d62118d416e5ee989aa77f7b98cf0fe580874ce5127", + "size_bytes": 44990 + }, + { + "component": ".prj", + "sha256": "b10dbe4d6d1de908d340f892c90b3d31a552630af3742bb515bfe1bd26124f2c", + "size_bytes": 176 + }, + { + "component": ".sbn", + "sha256": "7d0279465b18beec40308717e0ef0ea5701a586bc5c84e6a9aa309d5bc0ec99a", + "size_bytes": 308 + }, + { + "component": ".sbx", + "sha256": "019156149b2c7771ec0dd249c757dd7aa01a98075e246a77f77b080860c57333", + "size_bytes": 124 + }, + { + "component": ".shp", + "sha256": "6ac0d5c8c8db66fc0e2a74d8232b7779bd2454257df14efa2930e3dbc181aed0", + "size_bytes": 283640 + }, + { + "component": ".shp.xml", + "sha256": "8ef222ce1952552b366acf14a996e1c8cbdbe3eed0bb829dacfe7eafd068d948", + "size_bytes": 43100 + }, + { + "component": ".shx", + "sha256": "f3fbb6a7775ca833c066e3a3f2a332f99f979840045909ae187d08dac126a119", + "size_bytes": 260 + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "format": "ESRI Shapefile", + "schema": "gda.spatial_dataset_bundle.v1", + "source_label": "chongqing-central-cultural-districts", + "spatial_inventory": { + "bounds": [ + 106.37987914500007, + 29.558008447000077, + 106.59532712300008, + 29.877271985000025 + ], + "crs": { + "authority": "EPSG", + "code": 4490, + "name": "China Geodetic Coordinate System 2000" + }, + "driver": "ESRI Shapefile", + "feature_count": 20, + "field_count": 33, + "geometry_type": "PolygonZ" + } + }, + "dataset_required_in_ci": false, + "dataset_source_committed": false, + "deployment_applied": false, + "direct_object_store_metadata_verified": true, + "durable_catalog_verified": false, + "environment": "local_docker_desktop", + "errors": [], + "evidence_sha256": "d73754c53cf16d888aa345baa5d079cc7fd98d8b84db747f52188c1a69bf1628", + "flink_conformance_verified": false, + "local_cross_node_object_store_runtime_bound": true, + "local_jdbc_s3_catalog_restart_continuity_verified": true, + "local_object_store_active_metadata_promotion_verified": true, + "logical_binding_sha256": "614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1", + "m310_evidence_fingerprint": "05844457efb378581fb7fc2e7ed3c706819b2d8fa5a52b2f82577051d38c2cd1", + "m320_history_untouched": true, + "observation": { + "authorization": { + "action": "metadata_fabric.promote_object_store_projection", + "approval_artifact_id": "28d4ebc5-5215-5b7f-93c2-e91d1498b826", + "authorization_sha256": "9d61cd00714d2b39a000958bb0feade063d4c66f9ae4a899e54096939ad987c6", + "execution_plan_artifact_id": "0beb3694-9f87-589b-8458-db957a4e387b", + "policy_decision_artifact_id": "38986008-e32e-5cf8-9acf-9eed6451fc8f", + "provider_apply_authorized": true, + "run_id": "a9000000-0000-4000-8000-000000000007" + }, + "bootstrap": { + "admin_authentication_status": 200, + "bounded_authentication_status": 200, + "bucket": "gda-metadata-warehouse", + "catalog": "lakehouse", + "catalog_backend": "jdbc", + "catalog_uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "denied_catalog_create_status": 403, + "io_impl": "org.apache.iceberg.aws.s3.S3FileIO", + "material_recorded": false, + "metalake": "gda_chongqing_m3_21", + "role": { + "name": "gda-object-store-cultural-district-projector", + "securable_objects": [ + { + "fullName": "lakehouse", + "privileges": [ + { + "condition": "ALLOW", + "name": "USE_CATALOG" + } + ], + "type": "CATALOG" + }, + { + "fullName": "lakehouse.cultural_heritage", + "privileges": [ + { + "condition": "ALLOW", + "name": "CREATE_TABLE" + }, + { + "condition": "ALLOW", + "name": "USE_SCHEMA" + } + ], + "type": "SCHEMA" + } + ] + }, + "s3_endpoint": "http://metadata-object-store:9000", + "s3_path_style_access": true, + "s3_region": "us-east-1", + "schema": "cultural_heritage", + "server_version": "1.3.0", + "warehouse": "s3://gda-metadata-warehouse/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "contract": { + "contract_sha256": "b1a2db34a70eaa7dd55da1d6c85da9f420c755c71868aafe7972e3794034a6cc", + "m310_evidence_fingerprint": "05844457efb378581fb7fc2e7ed3c706819b2d8fa5a52b2f82577051d38c2cd1", + "source_m320_evidence_sha256": "53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b" + }, + "dataset_bundle": { + "components": [ + { + "component": ".cpg", + "sha256": "3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824", + "size_bytes": 5 + }, + { + "component": ".dbf", + "sha256": "ee7c6c4c6957aea296b69d62118d416e5ee989aa77f7b98cf0fe580874ce5127", + "size_bytes": 44990 + }, + { + "component": ".prj", + "sha256": "b10dbe4d6d1de908d340f892c90b3d31a552630af3742bb515bfe1bd26124f2c", + "size_bytes": 176 + }, + { + "component": ".sbn", + "sha256": "7d0279465b18beec40308717e0ef0ea5701a586bc5c84e6a9aa309d5bc0ec99a", + "size_bytes": 308 + }, + { + "component": ".sbx", + "sha256": "019156149b2c7771ec0dd249c757dd7aa01a98075e246a77f77b080860c57333", + "size_bytes": 124 + }, + { + "component": ".shp", + "sha256": "6ac0d5c8c8db66fc0e2a74d8232b7779bd2454257df14efa2930e3dbc181aed0", + "size_bytes": 283640 + }, + { + "component": ".shp.xml", + "sha256": "8ef222ce1952552b366acf14a996e1c8cbdbe3eed0bb829dacfe7eafd068d948", + "size_bytes": 43100 + }, + { + "component": ".shx", + "sha256": "f3fbb6a7775ca833c066e3a3f2a332f99f979840045909ae187d08dac126a119", + "size_bytes": 260 + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "format": "ESRI Shapefile", + "schema": "gda.spatial_dataset_bundle.v1", + "source_label": "chongqing-central-cultural-districts", + "spatial_inventory": { + "bounds": [ + 106.37987914500007, + 29.558008447000077, + 106.59532712300008, + 29.877271985000025 + ], + "crs": { + "authority": "EPSG", + "code": 4490, + "name": "China Geodetic Coordinate System 2000" + }, + "driver": "ESRI Shapefile", + "feature_count": 20, + "field_count": 33, + "geometry_type": "PolygonZ" + } + }, + "first_apply": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_21/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "6edbb77cd356c18a1b58b92ca658c9a211741b2d6d6af6c00cfafc352971a804" + }, + "logical_binding_sha256": "614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1", + "mutation_count": 1, + "mutations": [ + "gravitino.table.create" + ], + "promotion_candidate_sha256": "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9", + "status": "created", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "immediate_replay": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_21/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "6edbb77cd356c18a1b58b92ca658c9a211741b2d6d6af6c00cfafc352971a804" + }, + "logical_binding_sha256": "614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1", + "mutation_count": 0, + "mutations": [], + "promotion_candidate_sha256": "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9", + "status": "no_op", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "initial_runtime": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "persistent_volume_claims": [], + "pod_name": "gravitino-persistence-0", + "pod_uid": "a200aa98-a4d5-4777-a408-308531b9cf3c", + "pvc": null, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "e0397f21-e327-4198-981f-b26dc37625dd" + }, + "gravitino_aws_sdk_mounted": true, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "gravitino_jdbc_driver_mounted": true, + "iceberg_rest": { + "aws_sdk_mounted": true, + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "jdbc_driver_mounted": true, + "path": "/iceberg", + "ready": true + }, + "minio_host_image_id": "sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "1a7c8b28-0046-4b4b-9d2b-be8b21107803" + }, + "object_store": { + "image": "docker.io/minio/minio:RELEASE.2025-04-22T22-12-26Z", + "image_id": "docker.io/minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "node_name": "desktop-control-plane", + "persistent_volume_claims": [ + "data-metadata-object-store-0" + ], + "pod_name": "metadata-object-store-0", + "pod_uid": "12509eb6-fc4f-4195-a606-87f5e8db0bff", + "pvc": { + "name": "data-metadata-object-store-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "volume_name": "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7" + }, + "ready_replicas": 1, + "service_account": "metadata-object-store", + "service_account_automount_disabled": true, + "statefulset_uid": "75c24c7d-c1e7-4b03-b361-56dc221f1ecc" + }, + "object_store_service": { + "name": "metadata-object-store", + "ports": [ + { + "name": "api", + "port": 9000 + } + ], + "type": "ClusterIP", + "uid": "51a29fb7-610f-49fd-86ef-859dd18b249f" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "persistent_volume_claims": [ + "data-gravitino-persistence-postgresql-0" + ], + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "580f964d-709d-4c1f-85a8-909fdaa767b4", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "6c551197-493a-4786-a3bb-1280d00c2cde", + "volume_name": "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "270bbbce-635e-4034-a16e-c673e123b70a" + }, + "service": { + "name": "gravitino-persistence", + "ports": [ + { + "name": "http", + "port": 8090 + }, + { + "name": "iceberg-rest", + "port": 9001 + } + ], + "type": "ClusterIP", + "uid": "43dfb54a-f082-4960-a722-9f44f2d1c776" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df", + "spark_host_image_id": "sha256:f201367640c7583add224796a629150e63d3859ddd7fe9fd47741662a6d415bb" + }, + "object_store_after_restart": { + "bucket": "gda-metadata-warehouse", + "data_keys": [], + "latest_metadata": { + "body_sha256": "af38eeb0e25827b8971b8e20b0d1fcef759ede4177c995ab34bebc3592164c7d", + "current_schema_id": 0, + "current_snapshot_id": -1, + "fields": [ + { + "name": "BSM", + "required": true, + "type": "string" + }, + { + "name": "geometry", + "required": true, + "type": "binary" + } + ], + "key": "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json", + "location": "s3://gda-metadata-warehouse/warehouse/cultural_heritage/cultural_districts" + }, + "manifest_keys": [], + "material_recorded": false, + "metadata_keys": [ + "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json" + ], + "object_count": 1, + "objects": [ + { + "etag": "05bded1c38bf27dcc7edc36b09ca60f6", + "key": "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json", + "size": 1306 + } + ], + "prefix": "warehouse/cultural_heritage/cultural_districts/", + "source_feature_rows_present": false + }, + "object_store_before_restart": { + "bucket": "gda-metadata-warehouse", + "data_keys": [], + "latest_metadata": { + "body_sha256": "af38eeb0e25827b8971b8e20b0d1fcef759ede4177c995ab34bebc3592164c7d", + "current_schema_id": 0, + "current_snapshot_id": -1, + "fields": [ + { + "name": "BSM", + "required": true, + "type": "string" + }, + { + "name": "geometry", + "required": true, + "type": "binary" + } + ], + "key": "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json", + "location": "s3://gda-metadata-warehouse/warehouse/cultural_heritage/cultural_districts" + }, + "manifest_keys": [], + "material_recorded": false, + "metadata_keys": [ + "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json" + ], + "object_count": 1, + "objects": [ + { + "etag": "05bded1c38bf27dcc7edc36b09ca60f6", + "key": "warehouse/cultural_heritage/cultural_districts/metadata/00000-514e4a31-dfd4-4cce-9bd5-c909b6b7b95b.metadata.json", + "size": 1306 + } + ], + "prefix": "warehouse/cultural_heritage/cultural_districts/", + "source_feature_rows_present": false + }, + "object_store_prepared": { + "bucket": "gda-metadata-warehouse", + "created": true, + "head_bucket_verified": true, + "material_recorded": false, + "path_style_access": true, + "region": "us-east-1", + "service": "metadata-object-store" + }, + "observed_at": "2026-07-30T15:47:12.839524+00:00", + "openmetadata": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "domain_refs": [ + "domain:natural-resources" + ], + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "owner_refs": [ + "team:data-platform" + ], + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "tag_refs": [ + "CulturalHeritage.CulturalDistrict", + "Sensitivity.Internal" + ] + }, + "openmetadata_mutation_count": 0, + "plan": { + "apply_plan_sha256": "e0148dab72e413a63e895bee4c6183dd16ca691903a01db8d1d6381373428fe0", + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "definition_version_id": "a9000000-0000-4000-8000-000000000006", + "gravitino_ref": { + "api_profile": "v1", + "catalog": "lakehouse", + "metalake": "gda_chongqing_m3_21", + "object_type": "table", + "provider": "gravitino", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "schema_name": "cultural_heritage", + "server_version": "1.3.0", + "table_name": "cultural_districts" + }, + "logical_binding_sha256": "614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1", + "openmetadata_ref": { + "api_profile": "v1", + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_type": "table", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "provider": "openmetadata", + "server_version": "1.13.1" + }, + "openmetadata_snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "predecessor_promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "promotion_candidate_sha256": "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "run_id": "a9000000-0000-4000-8000-000000000007", + "runtime_binding": { + "catalog": { + "backend": "jdbc", + "bucket": "gda-metadata-warehouse", + "io_impl": "org.apache.iceberg.aws.s3.S3FileIO", + "s3_endpoint": "http://metadata-object-store:9000", + "s3_path_style_access": true, + "s3_region": "us-east-1", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "s3://gda-metadata-warehouse/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "context": "docker-desktop", + "images": { + "gravitino_image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "object_store_image_id": "docker.io/minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "postgresql_image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74" + }, + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "1a7c8b28-0046-4b4b-9d2b-be8b21107803" + }, + "schema": "gda.object_store_provider_runtime_binding.v1", + "services": { + "gravitino": { + "name": "gravitino-persistence", + "uid": "43dfb54a-f082-4960-a722-9f44f2d1c776" + }, + "object_store": { + "name": "metadata-object-store", + "uid": "51a29fb7-610f-49fd-86ef-859dd18b249f" + } + }, + "storage": { + "gravitino_persistent_volume_claims": [], + "object_store_pvc_uid": "3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "object_store_volume_name": "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "postgresql_pvc_uid": "6c551197-493a-4786-a3bb-1280d00c2cde", + "postgresql_volume_name": "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + }, + "topology": { + "object_store_node": "desktop-control-plane", + "provider_node": "desktop-worker" + }, + "workloads": { + "gravitino_statefulset_uid": "e0397f21-e327-4198-981f-b26dc37625dd", + "object_store_statefulset_uid": "75c24c7d-c1e7-4b03-b361-56dc221f1ecc", + "postgresql_statefulset_uid": "270bbbce-635e-4034-a16e-c673e123b70a" + } + }, + "runtime_binding_sha256": "dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b", + "schema": "gda.object_store_active_metadata_projection_plan.v1", + "target": { + "bucket": "gda-metadata-warehouse", + "catalog": "lakehouse", + "catalog_backend": "jdbc", + "catalog_provider": "lakehouse-iceberg", + "catalog_type": "RELATIONAL", + "io_impl": "org.apache.iceberg.aws.s3.S3FileIO", + "jdbc_driver": "org.postgresql.Driver", + "metalake": "gda_chongqing_m3_21", + "object_prefix": "warehouse/cultural_heritage/cultural_districts/", + "s3_endpoint": "http://metadata-object-store:9000", + "s3_path_style_access": true, + "s3_region": "us-east-1", + "schema": "cultural_heritage", + "table": "cultural_districts", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "s3://gda-metadata-warehouse/warehouse" + }, + "tenant_id": "metadata-authorization-local", + "writes_to_gda_control": false, + "writes_to_legacy": false + }, + "post_restart_first_replay": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_21/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "6edbb77cd356c18a1b58b92ca658c9a211741b2d6d6af6c00cfafc352971a804" + }, + "logical_binding_sha256": "614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1", + "mutation_count": 0, + "mutations": [], + "promotion_candidate_sha256": "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9", + "status": "no_op", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "post_restart_security": { + "bounded_authentication_status": 200, + "denied_catalog_create_status": 403, + "material_recorded": false, + "role": { + "name": "gda-object-store-cultural-district-projector", + "securable_objects": [ + { + "fullName": "lakehouse", + "privileges": [ + { + "condition": "ALLOW", + "name": "USE_CATALOG" + } + ], + "type": "CATALOG" + }, + { + "fullName": "lakehouse.cultural_heritage", + "privileges": [ + { + "condition": "ALLOW", + "name": "CREATE_TABLE" + }, + { + "condition": "ALLOW", + "name": "USE_SCHEMA" + } + ], + "type": "SCHEMA" + } + ] + } + }, + "predecessor_promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "restart": { + "after": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "persistent_volume_claims": [], + "pod_name": "gravitino-persistence-0", + "pod_uid": "98bd5afe-ce94-4a54-98bd-5272daf6db36", + "pvc": null, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "e0397f21-e327-4198-981f-b26dc37625dd" + }, + "gravitino_aws_sdk_mounted": true, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "gravitino_jdbc_driver_mounted": true, + "iceberg_rest": { + "aws_sdk_mounted": true, + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "jdbc_driver_mounted": true, + "path": "/iceberg", + "ready": true + }, + "minio_host_image_id": "sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "1a7c8b28-0046-4b4b-9d2b-be8b21107803" + }, + "object_store": { + "image": "docker.io/minio/minio:RELEASE.2025-04-22T22-12-26Z", + "image_id": "docker.io/minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "node_name": "desktop-control-plane", + "persistent_volume_claims": [ + "data-metadata-object-store-0" + ], + "pod_name": "metadata-object-store-0", + "pod_uid": "12509eb6-fc4f-4195-a606-87f5e8db0bff", + "pvc": { + "name": "data-metadata-object-store-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "volume_name": "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7" + }, + "ready_replicas": 1, + "service_account": "metadata-object-store", + "service_account_automount_disabled": true, + "statefulset_uid": "75c24c7d-c1e7-4b03-b361-56dc221f1ecc" + }, + "object_store_service": { + "name": "metadata-object-store", + "ports": [ + { + "name": "api", + "port": 9000 + } + ], + "type": "ClusterIP", + "uid": "51a29fb7-610f-49fd-86ef-859dd18b249f" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "persistent_volume_claims": [ + "data-gravitino-persistence-postgresql-0" + ], + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "c885c2b1-b74e-4d9d-92d1-c5eecb416f44", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "6c551197-493a-4786-a3bb-1280d00c2cde", + "volume_name": "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "270bbbce-635e-4034-a16e-c673e123b70a" + }, + "service": { + "name": "gravitino-persistence", + "ports": [ + { + "name": "http", + "port": 8090 + }, + { + "name": "iceberg-rest", + "port": 9001 + } + ], + "type": "ClusterIP", + "uid": "43dfb54a-f082-4960-a722-9f44f2d1c776" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df", + "spark_host_image_id": "sha256:f201367640c7583add224796a629150e63d3859ddd7fe9fd47741662a6d415bb" + }, + "before": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "persistent_volume_claims": [], + "pod_name": "gravitino-persistence-0", + "pod_uid": "a200aa98-a4d5-4777-a408-308531b9cf3c", + "pvc": null, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "e0397f21-e327-4198-981f-b26dc37625dd" + }, + "gravitino_aws_sdk_mounted": true, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "gravitino_jdbc_driver_mounted": true, + "iceberg_rest": { + "aws_sdk_mounted": true, + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "jdbc_driver_mounted": true, + "path": "/iceberg", + "ready": true + }, + "minio_host_image_id": "sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "1a7c8b28-0046-4b4b-9d2b-be8b21107803" + }, + "object_store": { + "image": "docker.io/minio/minio:RELEASE.2025-04-22T22-12-26Z", + "image_id": "docker.io/minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "node_name": "desktop-control-plane", + "persistent_volume_claims": [ + "data-metadata-object-store-0" + ], + "pod_name": "metadata-object-store-0", + "pod_uid": "12509eb6-fc4f-4195-a606-87f5e8db0bff", + "pvc": { + "name": "data-metadata-object-store-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "volume_name": "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7" + }, + "ready_replicas": 1, + "service_account": "metadata-object-store", + "service_account_automount_disabled": true, + "statefulset_uid": "75c24c7d-c1e7-4b03-b361-56dc221f1ecc" + }, + "object_store_service": { + "name": "metadata-object-store", + "ports": [ + { + "name": "api", + "port": 9000 + } + ], + "type": "ClusterIP", + "uid": "51a29fb7-610f-49fd-86ef-859dd18b249f" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "persistent_volume_claims": [ + "data-gravitino-persistence-postgresql-0" + ], + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "580f964d-709d-4c1f-85a8-909fdaa767b4", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "6c551197-493a-4786-a3bb-1280d00c2cde", + "volume_name": "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "270bbbce-635e-4034-a16e-c673e123b70a" + }, + "service": { + "name": "gravitino-persistence", + "ports": [ + { + "name": "http", + "port": 8090 + }, + { + "name": "iceberg-rest", + "port": 9001 + } + ], + "type": "ClusterIP", + "uid": "43dfb54a-f082-4960-a722-9f44f2d1c776" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df", + "spark_host_image_id": "sha256:f201367640c7583add224796a629150e63d3859ddd7fe9fd47741662a6d415bb" + } + }, + "runtime_binding": { + "catalog": { + "backend": "jdbc", + "bucket": "gda-metadata-warehouse", + "io_impl": "org.apache.iceberg.aws.s3.S3FileIO", + "s3_endpoint": "http://metadata-object-store:9000", + "s3_path_style_access": true, + "s3_region": "us-east-1", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "s3://gda-metadata-warehouse/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "context": "docker-desktop", + "images": { + "gravitino_image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "object_store_image_id": "docker.io/minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e", + "postgresql_image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74" + }, + "namespace": { + "name": "gda-metadata-spark-object-store", + "uid": "1a7c8b28-0046-4b4b-9d2b-be8b21107803" + }, + "schema": "gda.object_store_provider_runtime_binding.v1", + "services": { + "gravitino": { + "name": "gravitino-persistence", + "uid": "43dfb54a-f082-4960-a722-9f44f2d1c776" + }, + "object_store": { + "name": "metadata-object-store", + "uid": "51a29fb7-610f-49fd-86ef-859dd18b249f" + } + }, + "storage": { + "gravitino_persistent_volume_claims": [], + "object_store_pvc_uid": "3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "object_store_volume_name": "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "postgresql_pvc_uid": "6c551197-493a-4786-a3bb-1280d00c2cde", + "postgresql_volume_name": "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + }, + "topology": { + "object_store_node": "desktop-control-plane", + "provider_node": "desktop-worker" + }, + "workloads": { + "gravitino_statefulset_uid": "e0397f21-e327-4198-981f-b26dc37625dd", + "object_store_statefulset_uid": "75c24c7d-c1e7-4b03-b361-56dc221f1ecc", + "postgresql_statefulset_uid": "270bbbce-635e-4034-a16e-c673e123b70a" + } + }, + "runtime_binding_sha256": "dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b", + "runtime_checks": { + "all_runtime_port_forwards_stopped": true, + "material_recorded": false, + "namespace_absent": true, + "namespace_delete_completed": true, + "object_store_objects_retained": false, + "openmetadata_port_forward_stopped": true, + "persistent_volume_names": [ + "pvc-3a3794f3-c801-40d7-9335-2c51bcbe2be7", + "pvc-6c551197-493a-4786-a3bb-1280d00c2cde" + ], + "persistent_volumes_absent": true, + "provider_objects_retained": false + }, + "schema": "gda.object_store_active_metadata_promotion_observation.v1", + "source_m320_openmetadata": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "domain_refs": [ + "domain:natural-resources" + ], + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "owner_refs": [ + "team:data-platform" + ], + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "tag_refs": [ + "CulturalHeritage.CulturalDistrict", + "Sensitivity.Internal" + ] + } + }, + "oidc_verified": false, + "openmetadata_read_only_verified": true, + "platform_run_succeeded": false, + "post_restart_first_replay_no_op_verified": true, + "pre_restart_replay_no_op_verified": true, + "predecessor_history_changed": false, + "predecessor_promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "production_ingestion_verified": false, + "production_object_store_verified": false, + "production_ready": false, + "production_scheduler_submission_verified": false, + "promotion_candidate_sha256": "63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9", + "promotion_persisted_to_gda_control": false, + "protected_workload_identity_verified": false, + "provider_minimum_privilege_verified": false, + "real_dataset_resource_version_bound": true, + "resource_version_content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "runtime_binding_sha256": "dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b", + "schema": "gda.object_store_active_metadata_promotion_evidence.v1", + "source_feature_rows_ingested": false, + "source_m320_evidence_sha256": "53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b", + "spark_conformance_verified": false, + "status": "local_object_store_active_metadata_promotion_verified", + "tls_verified": false +} diff --git a/docs/roadmap-ar0-platform-truth-2026-07-24.md b/docs/roadmap-ar0-platform-truth-2026-07-24.md index 23128b50..e3e24ba8 100644 --- a/docs/roadmap-ar0-platform-truth-2026-07-24.md +++ b/docs/roadmap-ar0-platform-truth-2026-07-24.md @@ -199,7 +199,7 @@ Temporal 继续保持目标组件状态,不在这一包并行接入。OpenMeta 当前完成仅指本地合同、授权 evidence、outbox/callback 代码、数据库成功终局门、托管 worker 代码、默认关闭的部署模板及离线 activation/release preflight、candidate/registry/provenance/artifact-release/live observation evidence gate、合成 golden slice、定向测试、真实 PostgreSQL 16 事务边界和 canonical mainline 治理。`candidate_validated`、`registry_subject_bound`、本地合成 `provenance_verified`、`ready_for_activation`、`ready_for_staging_apply`、`verified_for_staging_apply` 和本地 live collection 都不等于真实镜像已 attested 或 staging 已部署;真实 IAM/OIDC 与 service token 生命周期、首次 GHCR publish/verify、真实 provenance artifact verify、registry-backed live staging revision、worker/callback 扩容运行、golden slice staging 运行链、受保护 release/live evidence provenance、独立 DolphinScheduler metadata PostgreSQL 和真实数据终局证据仍属于 4.7 后续切片。 -### 4.8 Metadata Fabric Bridge M1 + M2 + M3-20(本地 runtime-bound durable promotion 已验证,生产验证待执行) +### 4.8 Metadata Fabric Bridge M1 + M2 + M3-21(本地 JDBC/S3 runtime-bound promotion 已验证,生产验证待执行) 第八块回到 AR-1 的 metadata control plane,以 [ADR-036](architecture-decisions/adr-036-read-only-metadata-fabric-bridge-contract.md) 固定 OpenMetadata + Gravitino + GDA Control Ledger 的首条 table slice: @@ -240,6 +240,9 @@ Temporal 继续保持目标组件状态,不在这一包并行接入。OpenMeta 35. [ADR-064](architecture-decisions/adr-064-local-scheduler-triggered-active-metadata-projection-execution.md) 将 M3-17 的真实 dispatch 与 M3-2 provider client 串成单条本地执行链:官方 DolphinScheduler Shell task 经 Docker Desktop host gateway 向短生命周期 executor 发送 1 个内容绑定请求;executor 在内存中验证独立 `metadata_fabric.apply` PolicyDecision/Approval,首次向 OpenMetadata/Gravitino 创建 10 个 projection 层级对象并回读相同重庆 ResourceVersion,随后精确 replay 为 `no_op/0 mutations`。两次 read-back 的 OpenMetadata UUID、Gravitino identity 与 binding candidate 完全一致;scheduler 仍形成 `submitted/success` 两条 observation,PlatformRun 保持 `reconciling`。callback、两条 port-forward、standalone 容器和临时数据库均清理,provider projection 保留。contract fingerprint 为 `a6632ae0edd4d4f3389129a8c07411a8d101ae56fbfc26b03fb0aff6928bb7bd`,evidence fingerprint 为 `397c0f1a29f53935c5508155470c4972cfc50260f0d0686fb48cb3f75519b17b`。该结论不证明 protected identity、provider minimum privilege、Gravitino authentication/TLS、生产 scheduler/executor、持久 binding、production ingestion 或 `production_ready`。 36. [ADR-065](architecture-decisions/adr-065-local-active-metadata-binding-reconciliation.md) 在提交 M3-18 binding 前先验证 retained OpenMetadata UUID/FQN/version/content/governance/snapshot 完全一致。Gravitino `memory` catalog 重启后出现 connector 空状态与 provider entity index 残留的分裂;M3-19 只在专用 catalog 配置精确且可见 schema inventory 为空时执行 provider-native reset,并以 4 个 `gravitino.*` mutations 重建 catalog/schema/table,OpenMetadata 零写入。即时 replay 为 `no_op/0 mutations` 且 binding SHA 仍为 `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b`。PlatformGateway 首次 binding commit `created=true`、重放 `created=false`、仅 1 行,FORCE RLS、跨租户隔离、append-only 和 direct UPDATE/DELETE 拒绝均通过;Run 保持 `reconciling`,所有临时资源清理。contract fingerprint 为 `012a7c86ba9fe53217e721ff7286b8f2a246b9394efd2999abbcd025e13ac7f5`,evidence fingerprint 为 `e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33`。`durable_catalog_verified=false`,该结论不证明生产 identity/catalog/executor/binding deployment、production ingestion 或 `production_ready`。 37. [ADR-066](architecture-decisions/adr-066-runtime-bound-durable-active-metadata-promotion.md) 保持 M3-19 binding schema/ledger/evidence 不变,将同一重庆 ResourceVersion 投影到隔离 JDBC metadata + warehouse PVC 的 Gravitino target,并把 logical provider ref 与 cluster/namespace/Service/StatefulSet/PVC/image identity 组合为独立 promotion candidate。受限 Basic principal 首次只执行 1 个 `gravitino.table.create`;即时 replay 与 PostgreSQL -> Gravitino 有序 restart 后的第一次 replay 都为 `no_op/0 mutations`,两次 Pod UID 变化而稳定 runtime/PVC identity 与 table projection 不变。logical binding SHA 为 `8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750`,runtime binding SHA 为 `a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7`,promotion candidate SHA 为 `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`,contract/evidence SHA 分别为 `307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8` / `53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b`。candidate 未写 GDA Control,namespace/PVC 已清理;`durable_catalog_verified=false`、`production_object_store_verified=false`、`production_ready=false`。 +38. [ADR-067](architecture-decisions/adr-067-object-store-runtime-bound-active-metadata-promotion.md) 以 M3-20 promotion candidate 为不可变 predecessor,在 M3-10 跨节点 MinIO runtime 中创建独立 `gda_chongqing_m3_21` JDBC/S3 target。runtime binding 同时包含 Gravitino/MinIO Service、PostgreSQL/Gravitino/MinIO StatefulSet、PostgreSQL/MinIO PVC、镜像、节点分离与 S3 warehouse/endpoint/bucket,且 Gravitino 无 warehouse PVC。受限 principal 首次仅 1 个 table create,即时及 PostgreSQL -> Gravitino restart 后首个 replay 均为 `no_op/0`;S3 直读在精确 prefix 下只见 1 个 Iceberg metadata JSON,无 data/manifest,key/ETag/body SHA/表 schema 重启前后不变。predecessor/logical/runtime/promotion SHA 分别为 `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`、`614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1`、`dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b`、`63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9`;contract/evidence SHA 为 `b1a2db34a70eaa7dd55da1d6c85da9f420c755c71868aafe7972e3794034a6cc` / `d73754c53cf16d888aa345baa5d079cc7fd98d8b84db747f52188c1a69bf1628`。candidate 未落账、未 ingest feature rows,namespace/PVC/port-forward 已清理;生产对象存储与 readiness 仍为 `false`。 + +M3-21 只证明本地同主机双节点上的 JDBC/S3 provider identity、空表 Iceberg metadata 与 restart continuity;它不替代真实 feature-row ingestion、生产对象存储 attestation、独立 failure domain、KMS/TLS/workload identity、备份/PITR、tenant isolation 或权威 promotion ledger。 此处 M1 只证明静态合同和只读 HTTP 边界;M2a 只证明本地 live foundation 与 PVC 重挂载连续性;M2b-1/M2b-2 分别限定在同集群新 PVC 和同集群隔离 repository;M2b-3 的 `local_cross_cluster_recovery_verified=true` 只限定在 `local_same_host_distinct_kubernetes_clusters_external_s3_repository`;M2c-1/M2c-2/M2c-3 分别限定本地 provider metrics、临时双周期 OTel 和单 job scrape recovery;M2c-4/M2d-2 只证明 production observability/NetworkPolicy profile 与 attestation 合同可校验;M2d-1 只证明本地两节点 kindnet 的隔离合成流量;M3-1 的 terminal evidence 与 M3-2 的 PolicyDecision/Approval 仍是 deterministic local fixtures。M3-2 只把 projection 写入本地 provider 并证明 retained target 的单次零写入 replay;M3-3 只把该本地 evidence 对应的 binding 写入临时 GDA Control 账本;M3-4 只向无认证 loopback receiver 发送精确 candidate 并验证 503 后幂等恢复;M3-5 只证明 OpenMetadata 在 provider 强制默认 role 之上的项目新增 grant 限定为 `table/Create`,以及本地 JWT 轮换/吊销和越权拒绝;M3-6 只证明隔离 Gravitino Basic IdP 的 bounded table-create、catalog-create 拒绝、登录轮换/吊销和完整清理;M3-7 只证明 pending production identity profile、profile-bound attestation 和派生 claim 的 fail-closed 合同可校验,没有部署或证明真实身份路径;M3-8 只证明同一 Docker Desktop 集群内 Basic 用户、JDBC metadata 与 file warehouse PVC 在受控 Pod restart 后连续;M3-9 只证明同节点共享 RWO PVC 的 Spark interoperability;M3-10 移除了该共享 PVC,并证明同一 Docker Desktop 主机/集群内 Spark 与 MinIO 的跨节点 S3-compatible 互操作,但不证明生产云对象存储、独立 failure domain、持久 identity binding、Flink 或完整 engine conformance;M3-11 只冻结 provider-neutral production object-store profile、精确 attestation binding 与 fail-closed claims,没有选择 provider、部署 bucket/KMS/policy 或提交真实 attestation;M3-12 只证明同一本地路径的 pre-forward commit failure 不改变可见 table state,随后一次显式重试产生一个新 snapshot/row,且无孤儿 data file;M3-13 只证明单次本地 append 在 provider 200 响应丢失并映射为 commit-state-unknown 后,可以由即时 table readback 判定 committed 且不重提,不覆盖持久 controller、进程崩溃、并发写或任意 mutation;M3-14 只证明 ResourceVersion 注册与 Active Metadata 事件在本地 PostgreSQL 同事务创建,并验证租户/workload scoped claim/retry/complete;M3-15 只证明默认零副本 managed consumer 的代码/部署边界,以及本地 PostgreSQL 中 inert activation request 与 event completion 的原子性;M3-16 只证明本地真实数据 content fingerprint、证据绑定授权与 pending command 的 PostgreSQL 原子性;M3-17 只证明本地 standalone 中既有 consumer/adapter 的真实 submission、精确 correlation read-back 和 provider success observation;M3-18 只证明同一 Docker Desktop 主机上 scheduler 通过 ephemeral HTTP executor 触发 bootstrap-admin/unauthenticated providers 的一次创建和同进程零写 replay;M3-19 只证明同一主机上 exact OpenMetadata + absent Gravitino 的受限修复、即时 no-op replay 和临时 PostgreSQL binding commit,且 `memory` catalog reset 明确不等于 durable catalog recovery。生产持久 binding deployment、ResourceVersion 和 legacy authority 都未切换;生产对象存储、双 provider/生产最小权限、protected workload identity、OIDC、TLS、生产持久 catalog、tenant isolation、真实 receiver/alert/SLO、受保护 provider policy、生产故障注入、source-loss recovery、cancel/reconcile/lineage、完整 Spark/Flink conformance、生产 ingest、四项 production gate 和 `production_ready` 仍为 `false`。 diff --git a/docs/system-of-record-matrix-2026-07-24.md b/docs/system-of-record-matrix-2026-07-24.md index 4f738427..f3c1e7b6 100644 --- a/docs/system-of-record-matrix-2026-07-24.md +++ b/docs/system-of-record-matrix-2026-07-24.md @@ -64,6 +64,7 @@ 19. M3-18 只证明同一 Docker Desktop 主机上的官方 DolphinScheduler task 经 ephemeral HTTP executor 触发一次独立授权的本地 OpenMetadata/Gravitino projection:首次 10 mutations,精确 replay 为 `no_op/0 mutations`,两次 provider read-back 与 binding candidate 一致,PlatformRun 仍为 `reconciling`。OpenMetadata 使用 bootstrap admin,Gravitino 无认证且为 memory catalog;callback 不是 protected workload identity 或生产服务。生产 scheduler/executor、双 provider minimum privilege/OIDC/TLS、持久 binding/catalog、告警/SLO、production ingestion 与 production readiness 仍未验证。 20. M3-19 只允许在 retained OpenMetadata UUID/FQN/version/content/governance/snapshot 完全匹配时修复缺失 Gravitino target;专用 `memory` catalog 只有配置精确且可见 schema inventory 为空才可 provider-native reset。修复限于 4 个 `gravitino.*` mutations,OpenMetadata 零写入,即时 replay 为 `no_op/0 mutations`,binding 通过临时 PostgreSQL PlatformGateway 幂等追加且 Run 留在 `reconciling`。这不证明 durable catalog、protected identity、生产 executor/scheduler/provider、生产 binding deployment/ingestion 或 terminal success。 21. M3-20 不修改 M3-19 binding schema、ledger 或 evidence,而是为同一重庆 ResourceVersion 新建 runtime-bound durable promotion candidate。受限 Basic principal 在隔离 JDBC metadata + warehouse PVC target 中只创建一次表;即时 replay 与 PostgreSQL/Gravitino restart 后第一次 replay 均为 `no_op/0 mutations`。cluster/namespace/Service/StatefulSet/PVC/image identity 被绑定且重启前后稳定,Pod UID 必须变化;candidate 未写 GDA Control,namespace/PVC 已清理。这只证明本地 restart continuity,不证明生产 durable catalog/object store、protected identity、OIDC/TLS、生产 ingestion 或 readiness。 +22. M3-21 不修改 M3-20/M3-19 历史,而是以 M3-20 candidate 为 predecessor,将同一重庆 ResourceVersion 投影到 JDBC catalog + 跨节点 MinIO warehouse。稳定 binding 包含双 Service、三个 StatefulSet、PostgreSQL/MinIO PVC、镜像、节点和 S3 配置,Gravitino 无 warehouse PVC。首次 apply 为 1 个 table create,即时及有序重启后首个 replay 均为 `no_op/0`;直接 S3 metadata key/ETag/body SHA/表 schema 不变。该表没有 source feature rows,candidate 未落账,所有临时资源已清理。这不证明生产对象存储、durable catalog、protected identity、TLS/OIDC、生产 ingestion 或 readiness。 ## 已建立的 AR-0/AR-1 entry 证据 @@ -107,9 +108,11 @@ - Metadata Fabric M3-18 已由同一真实 DolphinScheduler `SUCCESS` 实例触发短生命周期 projection executor;独立 `metadata_fabric.apply` authorization 在 provider 调用前验证。重庆 ResourceVersion fingerprint 被写入 OpenMetadata `gda_chongqing_m3_18.cultural_heritage.published.cultural_districts` 与 Gravitino `gda_chongqing_m3_18/iceberg/cultural_heritage/cultural_districts`;首次 10 mutations,精确 replay 为 `no_op/0 mutations`,两次 read-back 的 OpenMetadata UUID `9d043410-02b5-487d-bb70-da5f3969a978`、Gravitino identity 与 binding SHA `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b` 一致。callback、两条 port-forward、standalone 容器和临时数据库已清理,Run 保持 `reconciling`。contract fingerprint 为 `a6632ae0edd4d4f3389129a8c07411a8d101ae56fbfc26b03fb0aff6928bb7bd`,evidence fingerprint 为 `397c0f1a29f53935c5508155470c4972cfc50260f0d0686fb48cb3f75519b17b`。`protected_workload_identity_verified=false`、`provider_minimum_privilege_verified=false`、`gravitino_authentication_verified=false`、`production_scheduler_submission_verified=false`、`production_ingestion_verified=false`、`production_ready=false`。 - Metadata Fabric M3-19 已在 retained OpenMetadata snapshot 精确匹配 M3-18 后,识别 Gravitino `memory` connector 空状态与持久 entity index 残留。专用 catalog 配置精确且 visible schema inventory 为零后,scheduler callback 只执行 `gravitino.catalog.reset_stale_empty_memory`、catalog/schema/table create 四次 mutation;OpenMetadata 零写入,立即 replay 为 `no_op/0 mutations`,binding SHA 保持 `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b`。PlatformGateway 首次 commit `created=true`、重放 `created=false`、仅 1 行,FORCE RLS、跨租户隔离、append-only 和 direct UPDATE/DELETE 拒绝通过;Run 保持 `reconciling`,临时资源均清理。contract fingerprint 为 `012a7c86ba9fe53217e721ff7286b8f2a246b9394efd2999abbcd025e13ac7f5`,evidence fingerprint 为 `e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33`。`durable_catalog_verified=false`、`production_ingestion_verified=false`、`production_ready=false`。 - Metadata Fabric M3-20 已将同一重庆 ResourceVersion 的 retained OpenMetadata ref 与新 Gravitino JDBC target 组合为 logical binding,并另行绑定 Docker Desktop cluster UID、namespace/Service/StatefulSet/PVC/image identity。受限 `gda-cultural-district-projector` 只有 `USE_CATALOG`、`USE_SCHEMA`、`CREATE_TABLE`,catalog create 前后均为 403;首次 projection 为 `created/1 gravitino.table.create`,即时 replay 与 PostgreSQL -> Gravitino restart 后第一次 replay 均为 `no_op/0 mutations`,表 projection/snapshot 不变。logical/runtime/promotion SHA 分别为 `8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750`、`a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7`、`bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`;contract/evidence SHA 为 `307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8` / `53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b`。candidate 未落 ledger,namespace/PVC/port-forward 已清理;`durable_catalog_verified=false`、`production_object_store_verified=false`、`production_ready=false`。 +- Metadata Fabric M3-21 已把 M3-20 candidate 作为 predecessor,在 M3-10 JDBC + 跨节点 MinIO runtime 中创建同一重庆 ResourceVersion 的独立 target。受限 `gda-object-store-cultural-district-projector` 首次只创建一次表,catalog create 前后均为 403;即时和 PostgreSQL -> Gravitino restart 后首个 replay 均为 `no_op/0`。Gravitino 无 warehouse PVC;MinIO 位于 `desktop-control-plane`,provider 位于 `desktop-worker`。直接 S3 检查在 `warehouse/cultural_heritage/cultural_districts/` 下只找到 1 个 metadata JSON,无 data/manifest,key/ETag/body SHA/表 schema 重启前后相同。predecessor/logical/runtime/promotion SHA 分别为 `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`、`614ce5e4c45dba1437dc888cbd79b2d58954184113a62c20170ab84b5570d9e1`、`dd63917b6354a2e92853763ddc3e3a981cb40717f84c0f819b1a4e6844ae100b`、`63812c311b3f239bc6a944748c4ff384250eb9c9ed9009d3384fc699f1d3eaa9`;contract/evidence SHA 为 `b1a2db34a70eaa7dd55da1d6c85da9f420c755c71868aafe7972e3794034a6cc` / `d73754c53cf16d888aa345baa5d079cc7fd98d8b84db747f52188c1a69bf1628`。candidate 未落 ledger、feature rows 未 ingest、临时资源已清理;生产对象存储与 `production_ready` 仍为 `false`。 ## 下一验收证据 +- M3-21 的本地 MinIO 空表 metadata promotion 不计入生产对象存储或 ingestion 退出门;下一步需要受授权 Spark/Sedona 写入真实重庆 feature slice,并继续保持生产 provider attestation 与 ledger promotion 独立验收; - 完成首次 application subject publish 与 protected verifier run;当前 mainline、archive refs、ruleset、required reviewer、禁止 bypass 和 environment enable variable 已配置并复核; - 真实 provenance artifact verify、受保护 overlay 的 `verified_for_staging_apply` release report,以及 staging/production 的 schema、config/runtime snapshot、registry/live DeploymentRevision 绑定、release/live artifact attestation 和环境 compare 报告; - staging 的 migration role、应用 login membership、连接池 role/tenant 复位、双租户 API 和 success finalization 运行产物; diff --git a/scripts/metadata-fabric-object-store-active-metadata-promotion.sh b/scripts/metadata-fabric-object-store-active-metadata-promotion.sh new file mode 100755 index 00000000..afa3d1b5 --- /dev/null +++ b/scripts/metadata-fabric-object-store-active-metadata-promotion.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMMON_GIT_DIR="$(git -C "$ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" +SHARED_ROOT="" +if [ -n "$COMMON_GIT_DIR" ]; then + SHARED_ROOT="$(cd "$COMMON_GIT_DIR/.." && pwd)" +fi + +if [ -n "${PYTHON:-}" ]; then + : +elif [ -x "$ROOT/.venv/bin/python" ]; then + PYTHON="$ROOT/.venv/bin/python" +elif [ -n "$SHARED_ROOT" ] && [ -x "$SHARED_ROOT/.venv/bin/python" ]; then + PYTHON="$SHARED_ROOT/.venv/bin/python" +else + PYTHON="python" +fi + +cd "$ROOT" +exec "$PYTHON" -m data_agent.metadata_fabric_object_store_active_metadata_promotion "$@"