spec.py 14 KB

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