| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- from __future__ import annotations
- import copy
- import hashlib
- import json
- import re
- from collections import deque
- from typing import Any
- from app.core.common.identifiers import ensure_governance_uid
- SCHEMA_VERSION = "1.0"
- NODE_TYPES = {
- "sql.query",
- "sql.execute",
- "python",
- "http",
- "condition",
- "parallel",
- "subflow",
- "notify",
- }
- SECRET_KEY_NAMES = {
- "apikey",
- "authorization",
- "connectionstring",
- "credential",
- "credentials",
- "dsn",
- "password",
- "secret",
- "token",
- }
- WORKFLOW_ROOT_KEYS = {
- "schema_version",
- "dataflow_uid",
- "name",
- "description",
- "nodes",
- "edges",
- "parameters",
- "labels",
- }
- NODE_KEYS = {
- "id",
- "type",
- "data_source_uid",
- "purpose",
- "config",
- "idempotency",
- }
- EDGE_KEYS = {"from", "to", "condition"}
- SCHEDULE_ROOT_KEYS = {
- "schema_version",
- "timezone",
- "triggers",
- "max_concurrency",
- "conflict_policy",
- "timeout_seconds",
- "retry",
- "backfill",
- }
- CONFLICT_POLICIES = {"skip", "queue", "cancel_previous"}
- WORKFLOW_SPEC_SCHEMA = {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://dataops.local/schemas/workflow-spec-1.0.json",
- "title": "DataOps WorkflowSpec",
- "type": "object",
- "additionalProperties": False,
- "required": [
- "schema_version",
- "dataflow_uid",
- "name",
- "nodes",
- "edges",
- "parameters",
- ],
- "properties": {
- "schema_version": {"const": SCHEMA_VERSION},
- "dataflow_uid": {"type": "string", "format": "uuid"},
- "name": {"type": "string", "minLength": 1, "maxLength": 200},
- "description": {"type": "string", "maxLength": 2000},
- "nodes": {
- "type": "array",
- "minItems": 1,
- "items": {"$ref": "#/$defs/node"},
- },
- "edges": {"type": "array", "items": {"$ref": "#/$defs/edge"}},
- "parameters": {"type": "object"},
- "labels": {"type": "object"},
- },
- "$defs": {
- "node": {
- "type": "object",
- "additionalProperties": False,
- "required": ["id", "type", "config"],
- "properties": {
- "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,99}$"},
- "type": {"type": "string", "enum": sorted(NODE_TYPES)},
- "data_source_uid": {"type": "string", "format": "uuid"},
- "purpose": {"type": "string", "enum": ["read", "write"]},
- "config": {"type": "object"},
- "idempotency": {"type": "object"},
- },
- },
- "edge": {
- "type": "object",
- "additionalProperties": False,
- "required": ["from", "to"],
- "properties": {
- "from": {"type": "string"},
- "to": {"type": "string"},
- "condition": {"type": "string"},
- },
- },
- },
- }
- SCHEDULE_PLAN_SCHEMA = {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://dataops.local/schemas/schedule-plan-1.0.json",
- "title": "DataOps SchedulePlan",
- "type": "object",
- "additionalProperties": False,
- "required": [
- "schema_version",
- "timezone",
- "triggers",
- "max_concurrency",
- "conflict_policy",
- "timeout_seconds",
- "retry",
- "backfill",
- ],
- "properties": {
- "schema_version": {"const": SCHEMA_VERSION},
- "timezone": {"type": "string"},
- "triggers": {"type": "array", "minItems": 1},
- "max_concurrency": {"type": "integer", "minimum": 1, "maximum": 100},
- "conflict_policy": {"enum": sorted(CONFLICT_POLICIES)},
- "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 86400},
- "retry": {"type": "object"},
- "backfill": {"type": "object"},
- },
- }
- def _closed_object(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
- if not isinstance(value, dict):
- raise ValueError(f"{label} must be an object")
- unknown = sorted(set(value) - allowed)
- if unknown:
- raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
- return value
- def _required_string(value: Any, label: str, *, maximum: int = 200) -> str:
- if not isinstance(value, str) or not value.strip():
- raise ValueError(f"{label} is required")
- normalized = value.strip()
- if len(normalized) > maximum:
- raise ValueError(f"{label} exceeds {maximum} characters")
- return normalized
- def _validate_uid(value: Any, label: str) -> str:
- try:
- return ensure_governance_uid({"uid": str(value)})
- except ValueError as exc:
- raise ValueError(f"{label} must be a valid UUIDv7") from exc
- def _normalized_secret_key(value: Any) -> str:
- return re.sub(r"[^a-z0-9]", "", str(value).lower())
- def _reject_secret_material(value: Any, path: str = "$") -> None:
- if isinstance(value, dict):
- for key, item in value.items():
- if _normalized_secret_key(key) in SECRET_KEY_NAMES:
- raise ValueError(f"secret material is not allowed at {path}.{key}")
- _reject_secret_material(item, f"{path}.{key}")
- elif isinstance(value, list):
- for index, item in enumerate(value):
- _reject_secret_material(item, f"{path}[{index}]")
- def _validate_acyclic(node_ids: set[str], edges: list[dict[str, Any]]) -> None:
- indegree = {node_id: 0 for node_id in node_ids}
- adjacency = {node_id: [] for node_id in node_ids}
- seen_edges = set()
- for edge in edges:
- source = edge["from"]
- target = edge["to"]
- if source not in node_ids or target not in node_ids:
- raise ValueError("workflow edge references an unknown node")
- pair = (source, target)
- if pair in seen_edges:
- raise ValueError("workflow graph contains a duplicate edge")
- seen_edges.add(pair)
- adjacency[source].append(target)
- indegree[target] += 1
- queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
- visited = 0
- while queue:
- current = queue.popleft()
- visited += 1
- for target in adjacency[current]:
- indegree[target] -= 1
- if indegree[target] == 0:
- queue.append(target)
- if visited != len(node_ids):
- raise ValueError("workflow graph must be acyclic")
- def validate_workflow_spec(spec: Any) -> dict[str, Any]:
- payload = copy.deepcopy(
- _closed_object(spec, WORKFLOW_ROOT_KEYS, "workflow spec")
- )
- if payload.get("schema_version") != SCHEMA_VERSION:
- raise ValueError(f"workflow spec schema_version must be {SCHEMA_VERSION}")
- payload["dataflow_uid"] = _validate_uid(
- payload.get("dataflow_uid"), "dataflow_uid"
- )
- payload["name"] = _required_string(payload.get("name"), "workflow name")
- if "description" in payload:
- payload["description"] = _required_string(
- payload["description"], "workflow description", maximum=2000
- )
- nodes = payload.get("nodes")
- if not isinstance(nodes, list) or not nodes:
- raise ValueError("workflow nodes must be a non-empty array")
- normalized_nodes = []
- node_ids = set()
- for raw_node in nodes:
- node = copy.deepcopy(_closed_object(raw_node, NODE_KEYS, "workflow node"))
- node_id = _required_string(node.get("id"), "node id", maximum=100)
- if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", node_id):
- raise ValueError("node id contains unsupported characters")
- if node_id in node_ids:
- raise ValueError("workflow node ids must be unique")
- node_ids.add(node_id)
- node_type = _required_string(node.get("type"), "node type", maximum=100)
- if node_type not in NODE_TYPES:
- raise ValueError(f"unsupported node type: {node_type}")
- config = node.get("config")
- if not isinstance(config, dict):
- raise ValueError("node config must be an object")
- node["id"] = node_id
- node["type"] = node_type
- if node_type in {"sql.query", "sql.execute"}:
- node["data_source_uid"] = _validate_uid(
- node.get("data_source_uid"), "node data_source_uid"
- )
- if node_type == "sql.query" and node.get("purpose") != "read":
- raise ValueError("sql.query node purpose must be read")
- if node_type == "sql.execute":
- if node.get("purpose") != "write":
- raise ValueError("sql.execute node purpose must be write")
- idempotency = node.get("idempotency")
- if not isinstance(idempotency, dict):
- raise ValueError("write node requires idempotency")
- strategy = idempotency.get("strategy")
- key = idempotency.get("key")
- if strategy not in {"partition_replace", "upsert", "deduplication_key"}:
- raise ValueError("write node requires idempotency strategy")
- _required_string(key, "write node idempotency key", maximum=500)
- normalized_nodes.append(node)
- payload["nodes"] = normalized_nodes
- raw_edges = payload.get("edges")
- if not isinstance(raw_edges, list):
- raise ValueError("workflow edges must be an array")
- edges = []
- for raw_edge in raw_edges:
- edge = copy.deepcopy(_closed_object(raw_edge, EDGE_KEYS, "workflow edge"))
- edge["from"] = _required_string(edge.get("from"), "edge from", maximum=100)
- edge["to"] = _required_string(edge.get("to"), "edge to", maximum=100)
- if "condition" in edge:
- edge["condition"] = _required_string(
- edge["condition"], "edge condition", maximum=500
- )
- edges.append(edge)
- _validate_acyclic(node_ids, edges)
- payload["edges"] = edges
- parameters = payload.get("parameters")
- if not isinstance(parameters, dict):
- raise ValueError("workflow parameters must be an object")
- if "labels" in payload and not isinstance(payload["labels"], dict):
- raise ValueError("workflow labels must be an object")
- _reject_secret_material(payload)
- return payload
- def workflow_spec_hash(spec: Any) -> str:
- canonical = json.dumps(
- validate_workflow_spec(spec),
- sort_keys=True,
- separators=(",", ":"),
- ensure_ascii=False,
- )
- return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
- def _positive_bounded_integer(
- value: Any, label: str, *, minimum: int, maximum: int
- ) -> int:
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError(f"{label} must be an integer")
- if value < minimum or value > maximum:
- raise ValueError(f"{label} must be between {minimum} and {maximum}")
- return value
- def validate_schedule_plan(plan: Any) -> dict[str, Any]:
- payload = copy.deepcopy(
- _closed_object(plan, SCHEDULE_ROOT_KEYS, "schedule plan")
- )
- if payload.get("schema_version") != SCHEMA_VERSION:
- raise ValueError(f"schedule plan schema_version must be {SCHEMA_VERSION}")
- payload["timezone"] = _required_string(
- payload.get("timezone"), "schedule timezone", maximum=100
- )
- triggers = payload.get("triggers")
- if not isinstance(triggers, list) or not triggers:
- raise ValueError("schedule triggers must be a non-empty array")
- normalized_triggers = []
- for raw_trigger in triggers:
- trigger = _closed_object(
- copy.deepcopy(raw_trigger),
- {"type", "expression", "at", "event_type"},
- "schedule trigger",
- )
- trigger_type = trigger.get("type")
- if trigger_type not in {"manual", "cron", "at", "event"}:
- raise ValueError("unsupported schedule trigger type")
- if trigger_type == "cron":
- expression = _required_string(
- trigger.get("expression"), "cron expression", maximum=200
- )
- if len(expression.split()) not in {5, 6, 7}:
- raise ValueError("cron expression must have 5, 6, or 7 fields")
- if trigger_type == "at":
- _required_string(trigger.get("at"), "at trigger timestamp", maximum=100)
- if trigger_type == "event":
- _required_string(
- trigger.get("event_type"), "event trigger type", maximum=200
- )
- normalized_triggers.append(trigger)
- payload["triggers"] = normalized_triggers
- payload["max_concurrency"] = _positive_bounded_integer(
- payload.get("max_concurrency"),
- "max_concurrency",
- minimum=1,
- maximum=100,
- )
- if payload.get("conflict_policy") not in CONFLICT_POLICIES:
- raise ValueError("unsupported conflict_policy")
- payload["timeout_seconds"] = _positive_bounded_integer(
- payload.get("timeout_seconds"),
- "timeout_seconds",
- minimum=1,
- maximum=86400,
- )
- retry = _closed_object(
- payload.get("retry"), {"max_attempts", "delay_seconds"}, "retry"
- )
- retry["max_attempts"] = _positive_bounded_integer(
- retry.get("max_attempts"), "retry max_attempts", minimum=1, maximum=10
- )
- retry["delay_seconds"] = _positive_bounded_integer(
- retry.get("delay_seconds"),
- "retry delay_seconds",
- minimum=0,
- maximum=86400,
- )
- backfill = _closed_object(
- payload.get("backfill"), {"max_days", "max_runs"}, "backfill"
- )
- backfill["max_days"] = _positive_bounded_integer(
- backfill.get("max_days"), "backfill max_days", minimum=1, maximum=90
- )
- backfill["max_runs"] = _positive_bounded_integer(
- backfill.get("max_runs"), "backfill max_runs", minimum=1, maximum=1000
- )
- _reject_secret_material(payload)
- return payload
|