spec.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. from __future__ import annotations
  2. import copy
  3. import hashlib
  4. import json
  5. import re
  6. from collections import deque
  7. from typing import Any
  8. from app.core.common.identifiers import ensure_governance_uid
  9. SCHEMA_VERSION = "1.0"
  10. NODE_TYPES = {
  11. "sql.query",
  12. "sql.execute",
  13. "rule.apply",
  14. "quality.check",
  15. "python",
  16. "http",
  17. "condition",
  18. "parallel",
  19. "subflow",
  20. "notify",
  21. }
  22. SECRET_KEY_NAMES = {
  23. "apikey",
  24. "authorization",
  25. "connectionstring",
  26. "credential",
  27. "credentials",
  28. "dsn",
  29. "password",
  30. "secret",
  31. "token",
  32. }
  33. WORKFLOW_ROOT_KEYS = {
  34. "schema_version",
  35. "dataflow_uid",
  36. "name",
  37. "description",
  38. "nodes",
  39. "edges",
  40. "parameters",
  41. "labels",
  42. }
  43. NODE_KEYS = {
  44. "id",
  45. "type",
  46. "data_source_uid",
  47. "purpose",
  48. "config",
  49. "idempotency",
  50. }
  51. EDGE_KEYS = {"from", "to", "condition"}
  52. SCHEDULE_ROOT_KEYS = {
  53. "schema_version",
  54. "timezone",
  55. "triggers",
  56. "max_concurrency",
  57. "conflict_policy",
  58. "timeout_seconds",
  59. "retry",
  60. "backfill",
  61. }
  62. CONFLICT_POLICIES = {"skip", "queue", "cancel_previous"}
  63. RULE_NODE_CONFIG_KEYS = {
  64. "component_binding_id",
  65. "rule_version_id",
  66. "execution_plan_hash",
  67. "provenance",
  68. }
  69. RULE_NODE_PROVENANCE_KEYS = {"standard_version_id", "clause_id"}
  70. WORKFLOW_SPEC_SCHEMA = {
  71. "$schema": "https://json-schema.org/draft/2020-12/schema",
  72. "$id": "https://dataops.local/schemas/workflow-spec-1.0.json",
  73. "title": "DataOps WorkflowSpec",
  74. "type": "object",
  75. "additionalProperties": False,
  76. "required": [
  77. "schema_version",
  78. "dataflow_uid",
  79. "name",
  80. "nodes",
  81. "edges",
  82. "parameters",
  83. ],
  84. "properties": {
  85. "schema_version": {"const": SCHEMA_VERSION},
  86. "dataflow_uid": {"type": "string", "format": "uuid"},
  87. "name": {"type": "string", "minLength": 1, "maxLength": 200},
  88. "description": {"type": "string", "maxLength": 2000},
  89. "nodes": {
  90. "type": "array",
  91. "minItems": 1,
  92. "items": {"$ref": "#/$defs/node"},
  93. },
  94. "edges": {"type": "array", "items": {"$ref": "#/$defs/edge"}},
  95. "parameters": {"type": "object"},
  96. "labels": {"type": "object"},
  97. },
  98. "$defs": {
  99. "node": {
  100. "type": "object",
  101. "additionalProperties": False,
  102. "required": ["id", "type", "config"],
  103. "properties": {
  104. "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,99}$"},
  105. "type": {"type": "string", "enum": sorted(NODE_TYPES)},
  106. "data_source_uid": {"type": "string", "format": "uuid"},
  107. "purpose": {"type": "string", "enum": ["read", "write"]},
  108. "config": {"type": "object"},
  109. "idempotency": {"type": "object"},
  110. },
  111. },
  112. "edge": {
  113. "type": "object",
  114. "additionalProperties": False,
  115. "required": ["from", "to"],
  116. "properties": {
  117. "from": {"type": "string"},
  118. "to": {"type": "string"},
  119. "condition": {"type": "string"},
  120. },
  121. },
  122. },
  123. }
  124. SCHEDULE_PLAN_SCHEMA = {
  125. "$schema": "https://json-schema.org/draft/2020-12/schema",
  126. "$id": "https://dataops.local/schemas/schedule-plan-1.0.json",
  127. "title": "DataOps SchedulePlan",
  128. "type": "object",
  129. "additionalProperties": False,
  130. "required": [
  131. "schema_version",
  132. "timezone",
  133. "triggers",
  134. "max_concurrency",
  135. "conflict_policy",
  136. "timeout_seconds",
  137. "retry",
  138. "backfill",
  139. ],
  140. "properties": {
  141. "schema_version": {"const": SCHEMA_VERSION},
  142. "timezone": {"type": "string"},
  143. "triggers": {"type": "array", "minItems": 1},
  144. "max_concurrency": {"type": "integer", "minimum": 1, "maximum": 100},
  145. "conflict_policy": {"enum": sorted(CONFLICT_POLICIES)},
  146. "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 86400},
  147. "retry": {"type": "object"},
  148. "backfill": {"type": "object"},
  149. },
  150. }
  151. def _closed_object(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
  152. if not isinstance(value, dict):
  153. raise ValueError(f"{label} must be an object")
  154. unknown = sorted(set(value) - allowed)
  155. if unknown:
  156. raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
  157. return value
  158. def _required_string(value: Any, label: str, *, maximum: int = 200) -> str:
  159. if not isinstance(value, str) or not value.strip():
  160. raise ValueError(f"{label} is required")
  161. normalized = value.strip()
  162. if len(normalized) > maximum:
  163. raise ValueError(f"{label} exceeds {maximum} characters")
  164. return normalized
  165. def _validate_uid(value: Any, label: str) -> str:
  166. try:
  167. return ensure_governance_uid({"uid": str(value)})
  168. except ValueError as exc:
  169. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  170. def _normalized_secret_key(value: Any) -> str:
  171. return re.sub(r"[^a-z0-9]", "", str(value).lower())
  172. def _reject_secret_material(value: Any, path: str = "$") -> None:
  173. if isinstance(value, dict):
  174. for key, item in value.items():
  175. if _normalized_secret_key(key) in SECRET_KEY_NAMES:
  176. raise ValueError(f"secret material is not allowed at {path}.{key}")
  177. _reject_secret_material(item, f"{path}.{key}")
  178. elif isinstance(value, list):
  179. for index, item in enumerate(value):
  180. _reject_secret_material(item, f"{path}[{index}]")
  181. def _validate_acyclic(node_ids: set[str], edges: list[dict[str, Any]]) -> None:
  182. indegree = {node_id: 0 for node_id in node_ids}
  183. adjacency = {node_id: [] for node_id in node_ids}
  184. seen_edges = set()
  185. for edge in edges:
  186. source = edge["from"]
  187. target = edge["to"]
  188. if source not in node_ids or target not in node_ids:
  189. raise ValueError("workflow edge references an unknown node")
  190. pair = (source, target)
  191. if pair in seen_edges:
  192. raise ValueError("workflow graph contains a duplicate edge")
  193. seen_edges.add(pair)
  194. adjacency[source].append(target)
  195. indegree[target] += 1
  196. queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
  197. visited = 0
  198. while queue:
  199. current = queue.popleft()
  200. visited += 1
  201. for target in adjacency[current]:
  202. indegree[target] -= 1
  203. if indegree[target] == 0:
  204. queue.append(target)
  205. if visited != len(node_ids):
  206. raise ValueError("workflow graph must be acyclic")
  207. def _validate_rule_node_config(config: Any) -> dict[str, Any]:
  208. normalized = copy.deepcopy(
  209. _closed_object(config, RULE_NODE_CONFIG_KEYS, "rule node config")
  210. )
  211. normalized["component_binding_id"] = _validate_uid(
  212. normalized.get("component_binding_id"), "component_binding_id"
  213. )
  214. normalized["rule_version_id"] = _validate_uid(
  215. normalized.get("rule_version_id"), "rule_version_id"
  216. )
  217. plan_hash = _required_string(
  218. normalized.get("execution_plan_hash"),
  219. "execution_plan_hash",
  220. maximum=64,
  221. )
  222. if not re.fullmatch(r"[0-9a-f]{64}", plan_hash):
  223. raise ValueError("execution_plan_hash must be a sha256 hex digest")
  224. normalized["execution_plan_hash"] = plan_hash
  225. if "provenance" in normalized:
  226. provenance = copy.deepcopy(
  227. _closed_object(
  228. normalized["provenance"],
  229. RULE_NODE_PROVENANCE_KEYS,
  230. "rule node provenance",
  231. )
  232. )
  233. provenance["standard_version_id"] = _validate_uid(
  234. provenance.get("standard_version_id"), "standard_version_id"
  235. )
  236. provenance["clause_id"] = _required_string(
  237. provenance.get("clause_id"), "standard clause_id", maximum=100
  238. )
  239. normalized["provenance"] = provenance
  240. return normalized
  241. def validate_workflow_spec(spec: Any) -> dict[str, Any]:
  242. payload = copy.deepcopy(
  243. _closed_object(spec, WORKFLOW_ROOT_KEYS, "workflow spec")
  244. )
  245. if payload.get("schema_version") != SCHEMA_VERSION:
  246. raise ValueError(f"workflow spec schema_version must be {SCHEMA_VERSION}")
  247. payload["dataflow_uid"] = _validate_uid(
  248. payload.get("dataflow_uid"), "dataflow_uid"
  249. )
  250. payload["name"] = _required_string(payload.get("name"), "workflow name")
  251. if "description" in payload:
  252. payload["description"] = _required_string(
  253. payload["description"], "workflow description", maximum=2000
  254. )
  255. nodes = payload.get("nodes")
  256. if not isinstance(nodes, list) or not nodes:
  257. raise ValueError("workflow nodes must be a non-empty array")
  258. normalized_nodes = []
  259. node_ids = set()
  260. for raw_node in nodes:
  261. node = copy.deepcopy(_closed_object(raw_node, NODE_KEYS, "workflow node"))
  262. node_id = _required_string(node.get("id"), "node id", maximum=100)
  263. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", node_id):
  264. raise ValueError("node id contains unsupported characters")
  265. if node_id in node_ids:
  266. raise ValueError("workflow node ids must be unique")
  267. node_ids.add(node_id)
  268. node_type = _required_string(node.get("type"), "node type", maximum=100)
  269. if node_type not in NODE_TYPES:
  270. raise ValueError(f"unsupported node type: {node_type}")
  271. config = node.get("config")
  272. if not isinstance(config, dict):
  273. raise ValueError("node config must be an object")
  274. node["id"] = node_id
  275. node["type"] = node_type
  276. if node_type in {"sql.query", "sql.execute"}:
  277. node["data_source_uid"] = _validate_uid(
  278. node.get("data_source_uid"), "node data_source_uid"
  279. )
  280. if node_type == "sql.query" and node.get("purpose") != "read":
  281. raise ValueError("sql.query node purpose must be read")
  282. if node_type == "sql.execute":
  283. if node.get("purpose") != "write":
  284. raise ValueError("sql.execute node purpose must be write")
  285. idempotency = node.get("idempotency")
  286. if not isinstance(idempotency, dict):
  287. raise ValueError("write node requires idempotency")
  288. strategy = idempotency.get("strategy")
  289. key = idempotency.get("key")
  290. if strategy not in {"partition_replace", "upsert", "deduplication_key"}:
  291. raise ValueError("write node requires idempotency strategy")
  292. _required_string(key, "write node idempotency key", maximum=500)
  293. if node_type in {"rule.apply", "quality.check"}:
  294. node["config"] = _validate_rule_node_config(config)
  295. if node_type == "quality.check":
  296. if node.get("purpose") != "read":
  297. raise ValueError("quality.check node purpose must be read")
  298. if "idempotency" in node:
  299. raise ValueError("quality.check node cannot define idempotency")
  300. else:
  301. if node.get("purpose") != "write":
  302. raise ValueError("rule.apply node purpose must be write")
  303. idempotency = node.get("idempotency")
  304. if not isinstance(idempotency, dict):
  305. raise ValueError("rule.apply node requires idempotency")
  306. if idempotency.get("strategy") not in {
  307. "partition_replace",
  308. "upsert",
  309. "deduplication_key",
  310. }:
  311. raise ValueError(
  312. "rule.apply node requires idempotency strategy"
  313. )
  314. _required_string(
  315. idempotency.get("key"),
  316. "rule.apply node idempotency key",
  317. maximum=500,
  318. )
  319. normalized_nodes.append(node)
  320. payload["nodes"] = normalized_nodes
  321. raw_edges = payload.get("edges")
  322. if not isinstance(raw_edges, list):
  323. raise ValueError("workflow edges must be an array")
  324. edges = []
  325. for raw_edge in raw_edges:
  326. edge = copy.deepcopy(_closed_object(raw_edge, EDGE_KEYS, "workflow edge"))
  327. edge["from"] = _required_string(edge.get("from"), "edge from", maximum=100)
  328. edge["to"] = _required_string(edge.get("to"), "edge to", maximum=100)
  329. if "condition" in edge:
  330. edge["condition"] = _required_string(
  331. edge["condition"], "edge condition", maximum=500
  332. )
  333. edges.append(edge)
  334. _validate_acyclic(node_ids, edges)
  335. payload["edges"] = edges
  336. parameters = payload.get("parameters")
  337. if not isinstance(parameters, dict):
  338. raise ValueError("workflow parameters must be an object")
  339. if "labels" in payload and not isinstance(payload["labels"], dict):
  340. raise ValueError("workflow labels must be an object")
  341. _reject_secret_material(payload)
  342. return payload
  343. def workflow_spec_hash(spec: Any) -> str:
  344. canonical = json.dumps(
  345. validate_workflow_spec(spec),
  346. sort_keys=True,
  347. separators=(",", ":"),
  348. ensure_ascii=False,
  349. )
  350. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  351. def _positive_bounded_integer(
  352. value: Any, label: str, *, minimum: int, maximum: int
  353. ) -> int:
  354. if isinstance(value, bool) or not isinstance(value, int):
  355. raise ValueError(f"{label} must be an integer")
  356. if value < minimum or value > maximum:
  357. raise ValueError(f"{label} must be between {minimum} and {maximum}")
  358. return value
  359. def validate_schedule_plan(plan: Any) -> dict[str, Any]:
  360. payload = copy.deepcopy(
  361. _closed_object(plan, SCHEDULE_ROOT_KEYS, "schedule plan")
  362. )
  363. if payload.get("schema_version") != SCHEMA_VERSION:
  364. raise ValueError(f"schedule plan schema_version must be {SCHEMA_VERSION}")
  365. payload["timezone"] = _required_string(
  366. payload.get("timezone"), "schedule timezone", maximum=100
  367. )
  368. triggers = payload.get("triggers")
  369. if not isinstance(triggers, list) or not triggers:
  370. raise ValueError("schedule triggers must be a non-empty array")
  371. normalized_triggers = []
  372. for raw_trigger in triggers:
  373. trigger = _closed_object(
  374. copy.deepcopy(raw_trigger),
  375. {"type", "expression", "at", "event_type"},
  376. "schedule trigger",
  377. )
  378. trigger_type = trigger.get("type")
  379. if trigger_type not in {"manual", "cron", "at", "event"}:
  380. raise ValueError("unsupported schedule trigger type")
  381. if trigger_type == "cron":
  382. expression = _required_string(
  383. trigger.get("expression"), "cron expression", maximum=200
  384. )
  385. if len(expression.split()) not in {5, 6, 7}:
  386. raise ValueError("cron expression must have 5, 6, or 7 fields")
  387. if trigger_type == "at":
  388. _required_string(trigger.get("at"), "at trigger timestamp", maximum=100)
  389. if trigger_type == "event":
  390. _required_string(
  391. trigger.get("event_type"), "event trigger type", maximum=200
  392. )
  393. normalized_triggers.append(trigger)
  394. payload["triggers"] = normalized_triggers
  395. payload["max_concurrency"] = _positive_bounded_integer(
  396. payload.get("max_concurrency"),
  397. "max_concurrency",
  398. minimum=1,
  399. maximum=100,
  400. )
  401. if payload.get("conflict_policy") not in CONFLICT_POLICIES:
  402. raise ValueError("unsupported conflict_policy")
  403. payload["timeout_seconds"] = _positive_bounded_integer(
  404. payload.get("timeout_seconds"),
  405. "timeout_seconds",
  406. minimum=1,
  407. maximum=86400,
  408. )
  409. retry = _closed_object(
  410. payload.get("retry"), {"max_attempts", "delay_seconds"}, "retry"
  411. )
  412. retry["max_attempts"] = _positive_bounded_integer(
  413. retry.get("max_attempts"), "retry max_attempts", minimum=1, maximum=10
  414. )
  415. retry["delay_seconds"] = _positive_bounded_integer(
  416. retry.get("delay_seconds"),
  417. "retry delay_seconds",
  418. minimum=0,
  419. maximum=86400,
  420. )
  421. backfill = _closed_object(
  422. payload.get("backfill"), {"max_days", "max_runs"}, "backfill"
  423. )
  424. backfill["max_days"] = _positive_bounded_integer(
  425. backfill.get("max_days"), "backfill max_days", minimum=1, maximum=90
  426. )
  427. backfill["max_runs"] = _positive_bounded_integer(
  428. backfill.get("max_runs"), "backfill max_runs", minimum=1, maximum=1000
  429. )
  430. _reject_secret_material(payload)
  431. return payload