artifacts.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. """Digest-bound, bounded Parquet artifacts owned by the DataOps Runner."""
  2. from __future__ import annotations
  3. import hashlib
  4. import io
  5. import json
  6. import os
  7. import re
  8. import tempfile
  9. from base64 import urlsafe_b64decode, urlsafe_b64encode
  10. from collections.abc import Mapping
  11. from contextlib import suppress
  12. from datetime import UTC, datetime, timedelta
  13. from typing import Any
  14. import polars as pl
  15. from sqlalchemy import text
  16. from app.core.common.identifiers import (
  17. ensure_governance_uid,
  18. new_governance_uid,
  19. )
  20. from app.core.data_rules.execution_contracts import canonical_schema_hash
  21. PARQUET_CONTENT_TYPE = "application/x-parquet"
  22. _DIGEST = re.compile(r"^[0-9a-f]{64}$")
  23. def _now_utc(clock) -> datetime:
  24. value = clock()
  25. if not isinstance(value, datetime):
  26. raise ValueError("artifact clock must return a datetime")
  27. if value.tzinfo is None:
  28. value = value.replace(tzinfo=UTC)
  29. return value.astimezone(UTC)
  30. def _timestamp(value: datetime) -> str:
  31. return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
  32. def _parse_timestamp(value: Any) -> datetime:
  33. if not isinstance(value, str) or not value.endswith("Z"):
  34. raise ValueError("artifact expiry metadata is invalid")
  35. try:
  36. parsed = datetime.fromisoformat(value[:-1] + "+00:00")
  37. except ValueError as exc:
  38. raise ValueError("artifact expiry metadata is invalid") from exc
  39. return parsed.astimezone(UTC)
  40. def _uid(value: Any, label: str) -> str:
  41. try:
  42. return ensure_governance_uid({"uid": str(value)})
  43. except ValueError as exc:
  44. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  45. def _inferred_schema_fields(
  46. frame: pl.DataFrame | pl.LazyFrame,
  47. ) -> list[dict[str, Any]]:
  48. schema = (
  49. frame.collect_schema()
  50. if isinstance(frame, pl.LazyFrame)
  51. else frame.schema
  52. )
  53. fields = []
  54. for name, dtype in schema.items():
  55. field: dict[str, Any] = {
  56. "name": name,
  57. "nullable": True,
  58. }
  59. if dtype == pl.Boolean:
  60. field["type"] = "boolean"
  61. elif dtype == pl.Date:
  62. field["type"] = "date"
  63. elif dtype == pl.String:
  64. field["type"] = "string"
  65. elif dtype.is_integer():
  66. field["type"] = "integer"
  67. elif dtype == pl.Float32:
  68. field["type"] = "float"
  69. elif dtype == pl.Float64:
  70. field["type"] = "double"
  71. elif dtype.is_decimal():
  72. field.update(
  73. {
  74. "type": "decimal",
  75. "precision": dtype.precision,
  76. "scale": dtype.scale,
  77. }
  78. )
  79. elif isinstance(dtype, pl.Datetime):
  80. field["type"] = (
  81. "timestamptz" if dtype.time_zone else "timestamp"
  82. )
  83. if dtype.time_zone:
  84. field["timezone"] = dtype.time_zone
  85. else:
  86. raise ValueError(f"unsupported artifact dtype for {name}")
  87. fields.append(field)
  88. return sorted(fields, key=lambda item: item["name"])
  89. def _normalized_schema_fields(value: Any) -> list[dict[str, Any]]:
  90. canonical_schema_hash(value)
  91. return sorted(
  92. [dict(field) for field in value],
  93. key=lambda item: item["name"],
  94. )
  95. def _schema_contract(value: Any) -> tuple[list[dict[str, Any]], str, str]:
  96. fields = _normalized_schema_fields(value)
  97. encoded = json.dumps(
  98. fields,
  99. sort_keys=True,
  100. separators=(",", ":"),
  101. ensure_ascii=False,
  102. ).encode("utf-8")
  103. return (
  104. fields,
  105. canonical_schema_hash(fields),
  106. urlsafe_b64encode(encoded).decode("ascii"),
  107. )
  108. def _decode_schema_contract(value: str) -> list[dict[str, Any]]:
  109. try:
  110. decoded = urlsafe_b64decode(value.encode("ascii"))
  111. fields = json.loads(decoded.decode("utf-8"))
  112. except Exception as exc:
  113. raise ValueError("artifact schema contract metadata is invalid") from exc
  114. return _normalized_schema_fields(fields)
  115. def _validate_frame_schema(
  116. frame: pl.DataFrame | pl.LazyFrame,
  117. fields: list[dict[str, Any]],
  118. ) -> None:
  119. schema = (
  120. frame.collect_schema()
  121. if isinstance(frame, pl.LazyFrame)
  122. else frame.schema
  123. )
  124. expected_names = {field["name"] for field in fields}
  125. if set(schema.names()) != expected_names:
  126. raise ValueError("artifact schema fields do not match")
  127. for field in fields:
  128. dtype = schema[field["name"]]
  129. field_type = field["type"]
  130. matches = (
  131. (field_type == "boolean" and dtype == pl.Boolean)
  132. or (field_type == "date" and dtype == pl.Date)
  133. or (field_type == "string" and dtype == pl.String)
  134. or (field_type == "integer" and dtype.is_integer())
  135. or (field_type == "float" and dtype == pl.Float32)
  136. or (field_type == "double" and dtype == pl.Float64)
  137. or (
  138. field_type == "decimal"
  139. and dtype.is_decimal()
  140. and dtype.precision == field.get("precision")
  141. and dtype.scale == field.get("scale")
  142. )
  143. or (
  144. field_type == "timestamp"
  145. and isinstance(dtype, pl.Datetime)
  146. and dtype.time_zone is None
  147. )
  148. or (
  149. field_type == "timestamptz"
  150. and isinstance(dtype, pl.Datetime)
  151. and dtype.time_zone == field.get("timezone")
  152. )
  153. )
  154. if not matches:
  155. raise ValueError(
  156. f"artifact schema type for {field['name']} does not match"
  157. )
  158. if isinstance(frame, pl.DataFrame):
  159. for field in fields:
  160. if not field["nullable"] and frame[field["name"]].null_count():
  161. raise ValueError(
  162. f"artifact nullable contract for {field['name']} does not match"
  163. )
  164. def _metadata(value: Any) -> dict[str, str]:
  165. if not isinstance(value, Mapping):
  166. raise ValueError("artifact content metadata is missing")
  167. normalized = {}
  168. for key, item in value.items():
  169. name = str(key).lower()
  170. if name.startswith("x-amz-meta-"):
  171. name = name[len("x-amz-meta-") :]
  172. if name in {
  173. "sha256",
  174. "row-count",
  175. "schema-sha256",
  176. "expires-at",
  177. "artifact-bytes",
  178. "schema-contract",
  179. }:
  180. normalized[name] = str(item)
  181. required = {
  182. "sha256",
  183. "row-count",
  184. "schema-sha256",
  185. "expires-at",
  186. "artifact-bytes",
  187. "schema-contract",
  188. }
  189. if set(normalized) != required:
  190. raise ValueError("artifact content metadata is incomplete")
  191. return normalized
  192. class ArtifactStore:
  193. """Read and write only server-owned, bounded Parquet artifacts."""
  194. def __init__(
  195. self,
  196. client,
  197. *,
  198. bucket: str,
  199. max_artifact_bytes: int,
  200. max_rows: int,
  201. memory_limit_bytes: int,
  202. max_ttl_seconds: int = 86400,
  203. clock=None,
  204. ):
  205. if not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket):
  206. raise ValueError("artifact bucket name is invalid")
  207. self.client = client
  208. self.bucket = bucket
  209. self.max_artifact_bytes = int(max_artifact_bytes)
  210. self.max_rows = int(max_rows)
  211. self.memory_limit_bytes = int(memory_limit_bytes)
  212. self.max_ttl_seconds = int(max_ttl_seconds)
  213. self.clock = clock or (lambda: datetime.now(UTC))
  214. if (
  215. self.max_artifact_bytes < 1024
  216. or self.max_rows < 1
  217. or self.memory_limit_bytes < self.max_artifact_bytes
  218. or self.max_ttl_seconds < 1
  219. ):
  220. raise ValueError("artifact resource limits are invalid")
  221. if not self.client.bucket_exists(self.bucket):
  222. raise ValueError("artifact bucket does not exist")
  223. def _limits(self, value: Any = None) -> dict[str, int]:
  224. configured = {
  225. "max_rows": self.max_rows,
  226. "max_artifact_bytes": self.max_artifact_bytes,
  227. "memory_limit_bytes": self.memory_limit_bytes,
  228. }
  229. if value is None:
  230. return configured
  231. if not isinstance(value, dict) or set(value) != set(configured):
  232. raise ValueError("artifact limits must have a closed shape")
  233. result = {}
  234. for key, ceiling in configured.items():
  235. item = value[key]
  236. if (
  237. isinstance(item, bool)
  238. or not isinstance(item, int)
  239. or item < 1
  240. or item > ceiling
  241. ):
  242. raise ValueError(
  243. f"artifact {key} exceeds the configured ceiling"
  244. )
  245. result[key] = item
  246. return result
  247. def _parse_ref(self, ref: Any) -> str:
  248. prefix = f"minio://{self.bucket}/"
  249. if not isinstance(ref, str) or not ref.startswith(prefix):
  250. raise ValueError("artifact reference is not owned by this store")
  251. key = ref[len(prefix) :]
  252. match = re.fullmatch(
  253. r"rules/([0-9a-f-]{36})/([0-9a-f-]{36})\.parquet",
  254. key,
  255. )
  256. if match is None:
  257. raise ValueError("artifact reference is invalid")
  258. _uid(match.group(1), "artifact correlation id")
  259. _uid(match.group(2), "artifact id")
  260. return key
  261. def _validated_stat(
  262. self,
  263. key: str,
  264. *,
  265. expected_digest: str | None = None,
  266. limits: dict[str, int] | None = None,
  267. ) -> tuple[Any, dict[str, str]]:
  268. effective = self._limits(limits)
  269. stat = self.client.stat_object(self.bucket, key)
  270. size = int(getattr(stat, "size", -1))
  271. if size < 1 or size > effective["max_artifact_bytes"]:
  272. raise ValueError("artifact size exceeds the configured limit")
  273. if size > effective["memory_limit_bytes"]:
  274. raise ValueError("artifact download exceeds the memory limit")
  275. if str(getattr(stat, "content_type", "")).lower() != PARQUET_CONTENT_TYPE:
  276. raise ValueError("artifact content type is invalid")
  277. metadata = _metadata(getattr(stat, "metadata", None))
  278. digest = metadata["sha256"]
  279. if _DIGEST.fullmatch(digest) is None:
  280. raise ValueError("artifact digest metadata is invalid")
  281. if expected_digest is not None and digest != expected_digest:
  282. raise ValueError("artifact digest does not match")
  283. try:
  284. row_count = int(metadata["row-count"])
  285. metadata_size = int(metadata["artifact-bytes"])
  286. except (TypeError, ValueError) as exc:
  287. raise ValueError("artifact count metadata is invalid") from exc
  288. if row_count < 0 or row_count > effective["max_rows"]:
  289. raise ValueError("artifact row count exceeds the configured limit")
  290. if metadata_size != size:
  291. raise ValueError("artifact size metadata does not match")
  292. if _DIGEST.fullmatch(metadata["schema-sha256"]) is None:
  293. raise ValueError("artifact schema metadata is invalid")
  294. if _parse_timestamp(metadata["expires-at"]) <= _now_utc(self.clock):
  295. raise ValueError("artifact has expired")
  296. return stat, metadata
  297. def write(
  298. self,
  299. frame: pl.LazyFrame | pl.DataFrame,
  300. correlation_id: str,
  301. ttl_seconds: int,
  302. *,
  303. schema_fields: list[dict[str, Any]] | None = None,
  304. limits: dict[str, int] | None = None,
  305. ) -> dict[str, Any]:
  306. effective = self._limits(limits)
  307. correlation = _uid(correlation_id, "correlation_id")
  308. if (
  309. isinstance(ttl_seconds, bool)
  310. or not isinstance(ttl_seconds, int)
  311. or ttl_seconds < 1
  312. or ttl_seconds > self.max_ttl_seconds
  313. ):
  314. raise ValueError("artifact TTL is outside the configured limit")
  315. if isinstance(frame, pl.DataFrame):
  316. lazy = frame.lazy()
  317. elif isinstance(frame, pl.LazyFrame):
  318. lazy = frame
  319. else:
  320. raise ValueError("artifact frame must be a Polars frame")
  321. collected = lazy.head(effective["max_rows"] + 1).collect(
  322. engine="streaming"
  323. )
  324. if collected.height > effective["max_rows"]:
  325. raise ValueError("artifact row count exceeds the configured limit")
  326. if collected.estimated_size() > effective["memory_limit_bytes"]:
  327. raise ValueError("artifact frame exceeds the configured memory limit")
  328. fields, schema_digest, schema_encoded = _schema_contract(
  329. schema_fields or _inferred_schema_fields(collected)
  330. )
  331. _validate_frame_schema(collected, fields)
  332. expires_at = _timestamp(
  333. _now_utc(self.clock) + timedelta(seconds=ttl_seconds)
  334. )
  335. artifact_id = new_governance_uid()
  336. key = f"rules/{correlation}/{artifact_id}.parquet"
  337. path = None
  338. uploaded = False
  339. try:
  340. with tempfile.NamedTemporaryFile(
  341. prefix="dataops-rule-artifact-",
  342. suffix=".parquet",
  343. delete=False,
  344. ) as handle:
  345. path = handle.name
  346. collected.write_parquet(path)
  347. size = os.path.getsize(path)
  348. if size < 1 or size > effective["max_artifact_bytes"]:
  349. raise ValueError("artifact size exceeds the configured limit")
  350. if size > effective["memory_limit_bytes"]:
  351. raise ValueError("serialized artifact exceeds the memory limit")
  352. if (
  353. size + collected.estimated_size()
  354. > effective["memory_limit_bytes"]
  355. ):
  356. raise ValueError(
  357. "artifact serialization exceeds the memory limit"
  358. )
  359. digest = hashlib.sha256()
  360. with open(path, "rb") as handle:
  361. while chunk := handle.read(1024 * 1024):
  362. digest.update(chunk)
  363. digest_hex = digest.hexdigest()
  364. with open(path, "rb") as handle:
  365. self.client.put_object(
  366. self.bucket,
  367. key,
  368. handle,
  369. size,
  370. content_type=PARQUET_CONTENT_TYPE,
  371. metadata={
  372. "sha256": digest_hex,
  373. "row-count": str(collected.height),
  374. "schema-sha256": schema_digest,
  375. "expires-at": expires_at,
  376. "artifact-bytes": str(size),
  377. "schema-contract": schema_encoded,
  378. },
  379. )
  380. uploaded = True
  381. self._validated_stat(
  382. key,
  383. expected_digest=digest_hex,
  384. limits=effective,
  385. )
  386. artifact_ref = f"minio://{self.bucket}/{key}"
  387. self.read(
  388. artifact_ref,
  389. digest_hex,
  390. expected_schema_fields=fields,
  391. limits=effective,
  392. )
  393. except Exception:
  394. if uploaded:
  395. with suppress(Exception):
  396. self.client.remove_object(self.bucket, key)
  397. raise
  398. finally:
  399. if path is not None:
  400. with suppress(FileNotFoundError):
  401. os.unlink(path)
  402. return {
  403. "artifact_ref": artifact_ref,
  404. "digest": digest_hex,
  405. "row_count": collected.height,
  406. "schema_hash": schema_digest,
  407. "schema_fields": fields,
  408. "expires_at": expires_at,
  409. }
  410. def describe(self, ref: str) -> dict[str, Any]:
  411. """Return validated object metadata without exposing MinIO credentials."""
  412. key = self._parse_ref(ref)
  413. _stat, metadata = self._validated_stat(key)
  414. fields = _decode_schema_contract(metadata["schema-contract"])
  415. if canonical_schema_hash(fields) != metadata["schema-sha256"]:
  416. raise ValueError("artifact schema contract does not match")
  417. return {
  418. "artifact_ref": ref,
  419. "digest": metadata["sha256"],
  420. "row_count": int(metadata["row-count"]),
  421. "schema_hash": metadata["schema-sha256"],
  422. "schema_fields": fields,
  423. "expires_at": metadata["expires-at"],
  424. }
  425. def read(
  426. self,
  427. ref: str,
  428. expected_digest: str,
  429. *,
  430. expected_schema_fields: list[dict[str, Any]] | None = None,
  431. limits: dict[str, int] | None = None,
  432. ) -> pl.LazyFrame:
  433. effective = self._limits(limits)
  434. if _DIGEST.fullmatch(str(expected_digest or "")) is None:
  435. raise ValueError("expected artifact digest is invalid")
  436. key = self._parse_ref(ref)
  437. _stat, metadata = self._validated_stat(
  438. key,
  439. expected_digest=expected_digest,
  440. limits=effective,
  441. )
  442. fields = _decode_schema_contract(metadata["schema-contract"])
  443. if canonical_schema_hash(fields) != metadata["schema-sha256"]:
  444. raise ValueError("artifact schema contract does not match")
  445. if expected_schema_fields is not None:
  446. expected = _normalized_schema_fields(expected_schema_fields)
  447. if expected != fields:
  448. raise ValueError("artifact schema contract is not expected")
  449. response = self.client.get_object(self.bucket, key)
  450. digest = hashlib.sha256()
  451. payload = io.BytesIO()
  452. size = 0
  453. try:
  454. while chunk := response.read(1024 * 1024):
  455. size += len(chunk)
  456. if size > effective["max_artifact_bytes"]:
  457. raise ValueError(
  458. "artifact size exceeds the configured limit"
  459. )
  460. if size > effective["memory_limit_bytes"]:
  461. raise ValueError(
  462. "artifact download exceeds the memory limit"
  463. )
  464. digest.update(chunk)
  465. payload.write(chunk)
  466. finally:
  467. response.close()
  468. release = getattr(response, "release_conn", None)
  469. if callable(release):
  470. release()
  471. if digest.hexdigest() != expected_digest:
  472. raise ValueError("artifact digest does not match content")
  473. payload.seek(0)
  474. try:
  475. frame = pl.read_parquet(
  476. payload,
  477. n_rows=effective["max_rows"] + 1,
  478. memory_map=False,
  479. )
  480. except Exception as exc:
  481. raise ValueError("artifact is not valid Parquet") from exc
  482. if frame.height != int(metadata["row-count"]):
  483. raise ValueError("artifact row count does not match metadata")
  484. if frame.height > effective["max_rows"]:
  485. raise ValueError("artifact row count exceeds the configured limit")
  486. if frame.estimated_size() > effective["memory_limit_bytes"]:
  487. raise ValueError("artifact frame exceeds the configured memory limit")
  488. if size + frame.estimated_size() > effective["memory_limit_bytes"]:
  489. raise ValueError("artifact decompression exceeds the memory limit")
  490. _validate_frame_schema(frame, fields)
  491. return frame.lazy()
  492. def cleanup_expired(self, correlation_id: str) -> int:
  493. correlation = _uid(correlation_id, "correlation_id")
  494. prefix = f"rules/{correlation}/"
  495. removed = 0
  496. for item in self.client.list_objects(
  497. self.bucket,
  498. prefix=prefix,
  499. recursive=True,
  500. ):
  501. key = str(getattr(item, "object_name", ""))
  502. if not key.startswith(prefix):
  503. continue
  504. try:
  505. self._parse_ref(f"minio://{self.bucket}/{key}")
  506. stat = self.client.stat_object(self.bucket, key)
  507. metadata = _metadata(getattr(stat, "metadata", None))
  508. expired = _parse_timestamp(
  509. metadata["expires-at"]
  510. ) <= _now_utc(self.clock)
  511. except ValueError:
  512. continue
  513. if expired:
  514. self.client.remove_object(self.bucket, key)
  515. removed += 1
  516. return removed
  517. def delete(self, ref: str) -> None:
  518. """Delete one exact store-owned artifact after validating its key."""
  519. key = self._parse_ref(ref)
  520. self.client.remove_object(self.bucket, key)
  521. class PostgresArtifactResolver:
  522. """Resolve a canonical artifact binding without accepting caller paths."""
  523. def __init__(self, engine, artifact_store: ArtifactStore):
  524. self.engine = engine
  525. self.artifact_store = artifact_store
  526. def resolve(self, *, binding_id: str, correlation_id: str) -> dict[str, Any]:
  527. binding = _uid(binding_id, "artifact binding id")
  528. correlation = _uid(correlation_id, "artifact correlation id")
  529. statement = text(
  530. """
  531. SELECT
  532. a.artifact_ref,
  533. a.artifact_digest,
  534. a.row_count,
  535. a.schema_hash,
  536. a.schema_fields,
  537. a.expires_at,
  538. b.binding_hash
  539. FROM public.rule_run_artifacts a
  540. JOIN public.dataflow_dataset_bindings b
  541. ON b.id = a.binding_id
  542. WHERE a.binding_id = CAST(:binding_id AS uuid)
  543. AND a.correlation_id = CAST(:correlation_id AS uuid)
  544. AND a.expires_at > CURRENT_TIMESTAMP
  545. AND b.object_kind = 'parquet_artifact'
  546. AND b.access_mode IN ('read', 'read_write')
  547. ORDER BY a.created_at DESC
  548. LIMIT 1
  549. """
  550. )
  551. with self.engine.connect() as connection:
  552. row = connection.execute(
  553. statement,
  554. {
  555. "binding_id": binding,
  556. "correlation_id": correlation,
  557. },
  558. ).mappings().one_or_none()
  559. if row is None:
  560. raise ValueError("canonical artifact binding was not found")
  561. artifact_ref = str(row["artifact_ref"])
  562. key = self.artifact_store._parse_ref(artifact_ref)
  563. if not key.startswith(f"rules/{correlation}/"):
  564. raise ValueError(
  565. "catalog artifact does not match the execution correlation"
  566. )
  567. described = self.artifact_store.describe(artifact_ref)
  568. row_fields = row["schema_fields"]
  569. if isinstance(row_fields, str):
  570. row_fields = json.loads(row_fields)
  571. if (
  572. described["digest"] != str(row["artifact_digest"])
  573. or described["row_count"] != int(row["row_count"])
  574. or described["schema_hash"] != str(row["schema_hash"])
  575. or described["schema_fields"]
  576. != _normalized_schema_fields(row_fields)
  577. ):
  578. raise ValueError("catalog artifact metadata does not match storage")
  579. return {
  580. **described,
  581. "binding_hash": str(row["binding_hash"]),
  582. }
  583. def attest_binding(
  584. self,
  585. *,
  586. binding_id: str,
  587. binding_hash: str,
  588. access_mode: str,
  589. ) -> dict[str, str]:
  590. binding = _uid(binding_id, "artifact binding id")
  591. if _DIGEST.fullmatch(str(binding_hash or "")) is None:
  592. raise ValueError("artifact binding hash is invalid")
  593. allowed = {
  594. "read": {"read", "read_write"},
  595. "write": {"write", "read_write"},
  596. }.get(access_mode)
  597. if allowed is None:
  598. raise ValueError("artifact access mode is invalid")
  599. with self.engine.connect() as connection:
  600. row = connection.execute(
  601. text(
  602. """
  603. SELECT binding_hash, access_mode, object_kind
  604. FROM public.dataflow_dataset_bindings
  605. WHERE id = CAST(:binding_id AS uuid)
  606. """
  607. ),
  608. {"binding_id": binding},
  609. ).mappings().one_or_none()
  610. if (
  611. row is None
  612. or row["object_kind"] != "parquet_artifact"
  613. or row["access_mode"] not in allowed
  614. or str(row["binding_hash"]) != binding_hash
  615. ):
  616. raise ValueError("canonical artifact binding no longer matches")
  617. return {"binding_hash": str(row["binding_hash"])}
  618. def register(
  619. self,
  620. *,
  621. binding_id: str,
  622. correlation_id: str,
  623. artifact: dict[str, Any],
  624. kind: str,
  625. binding_hash: str,
  626. ) -> None:
  627. binding = _uid(binding_id, "artifact binding id")
  628. correlation = _uid(correlation_id, "artifact correlation id")
  629. if kind not in {"input", "lookup", "output"}:
  630. raise ValueError("artifact kind is invalid")
  631. self.attest_binding(
  632. binding_id=binding,
  633. binding_hash=binding_hash,
  634. access_mode="write" if kind == "output" else "read",
  635. )
  636. if not isinstance(artifact, dict):
  637. raise ValueError("artifact metadata is invalid")
  638. artifact_ref = artifact.get("artifact_ref")
  639. key = self.artifact_store._parse_ref(artifact_ref)
  640. if not key.startswith(f"rules/{correlation}/"):
  641. raise ValueError(
  642. "artifact does not match the execution correlation"
  643. )
  644. described = self.artifact_store.describe(artifact_ref)
  645. for key in (
  646. "artifact_ref",
  647. "digest",
  648. "row_count",
  649. "schema_hash",
  650. "schema_fields",
  651. "expires_at",
  652. ):
  653. if described[key] != artifact.get(key):
  654. raise ValueError("artifact metadata does not match storage")
  655. with self.engine.begin() as connection:
  656. connection.execute(
  657. text(
  658. """
  659. INSERT INTO public.rule_run_artifacts (
  660. id, correlation_id, binding_id, artifact_ref,
  661. artifact_digest, row_count, schema_hash, schema_fields,
  662. artifact_kind, expires_at
  663. ) VALUES (
  664. CAST(:id AS uuid), CAST(:correlation_id AS uuid),
  665. CAST(:binding_id AS uuid), :artifact_ref,
  666. :artifact_digest, :row_count, :schema_hash,
  667. CAST(:schema_fields AS jsonb), :artifact_kind,
  668. CAST(:expires_at AS timestamptz)
  669. )
  670. ON CONFLICT (
  671. correlation_id, binding_id, artifact_digest
  672. ) DO NOTHING
  673. """
  674. ),
  675. {
  676. "id": new_governance_uid(),
  677. "correlation_id": correlation,
  678. "binding_id": binding,
  679. "artifact_ref": described["artifact_ref"],
  680. "artifact_digest": described["digest"],
  681. "row_count": described["row_count"],
  682. "schema_hash": described["schema_hash"],
  683. "schema_fields": json.dumps(
  684. described["schema_fields"],
  685. sort_keys=True,
  686. separators=(",", ":"),
  687. ),
  688. "artifact_kind": kind,
  689. "expires_at": described["expires_at"],
  690. },
  691. )