gateway.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. """Machine policy and compound scheduling operations for MCP."""
  2. import json
  3. import uuid
  4. from dataclasses import dataclass
  5. from datetime import UTC, datetime
  6. from app.core.orchestration.compilers.kestra import compile_kestra_flow
  7. from app.core.orchestration.spec import (
  8. validate_schedule_plan,
  9. validate_workflow_spec,
  10. workflow_spec_hash,
  11. )
  12. ALLOWED_SCHEDULING_TOOLS = {
  13. "create_candidate_plan",
  14. "deploy_disabled_version",
  15. "run_canary",
  16. "promote_candidate",
  17. "pause_schedule",
  18. "retry_failed_execution",
  19. "backfill_bounded_window",
  20. "rollback_to_previous_version",
  21. }
  22. @dataclass(frozen=True)
  23. class GatewayLimits:
  24. max_concurrency: int = 10
  25. max_timeout_seconds: int = 7200
  26. max_retry_attempts: int = 3
  27. max_backfill_days: int = 7
  28. max_backfill_runs: int = 20
  29. class SchedulingPolicy:
  30. _EDITOR_TOOLS = {
  31. "create_candidate_plan",
  32. "deploy_disabled_version",
  33. "run_canary",
  34. }
  35. _SCHEDULER_TOOLS = ALLOWED_SCHEDULING_TOOLS
  36. def __init__(self, limits=None):
  37. self.limits = limits or GatewayLimits()
  38. def authorize_tool(
  39. self,
  40. identity,
  41. tool_name,
  42. *,
  43. domain,
  44. environment,
  45. ):
  46. if tool_name not in ALLOWED_SCHEDULING_TOOLS:
  47. raise PermissionError("tool is not exposed by scheduling policy")
  48. identity.require_domain(domain)
  49. identity.require_environment(environment)
  50. if identity.has_any_role({"admin", "scheduler"}):
  51. allowed = self._SCHEDULER_TOOLS
  52. elif identity.has_any_role({"editor"}):
  53. allowed = self._EDITOR_TOOLS
  54. else:
  55. allowed = set()
  56. if tool_name not in allowed:
  57. raise PermissionError("role is not allowed to use this tool")
  58. def validate_plan_limits(self, plan):
  59. if plan.get("max_concurrency", 0) > self.limits.max_concurrency:
  60. raise ValueError("max concurrency exceeds gateway limit")
  61. if plan.get("timeout_seconds", 0) > self.limits.max_timeout_seconds:
  62. raise ValueError("timeout exceeds gateway limit")
  63. retry = plan.get("retry") or {}
  64. if retry.get("max_attempts", 0) > self.limits.max_retry_attempts:
  65. raise ValueError("retry attempts exceed gateway limit")
  66. backfill = plan.get("backfill") or {}
  67. if backfill.get("max_days", 0) > self.limits.max_backfill_days:
  68. raise ValueError("backfill days exceed gateway limit")
  69. if backfill.get("max_runs", 0) > self.limits.max_backfill_runs:
  70. raise ValueError("backfill runs exceed gateway limit")
  71. class SchedulingGateway:
  72. def __init__(
  73. self,
  74. *,
  75. plans,
  76. audit,
  77. policy=None,
  78. engine=None,
  79. token_issuer=None,
  80. compiler=None,
  81. ):
  82. self.plans = plans
  83. self.audit = audit
  84. self.policy = policy or SchedulingPolicy()
  85. self.engine = engine
  86. self.token_issuer = token_issuer
  87. self.compiler = compiler or compile_kestra_flow
  88. @staticmethod
  89. def _event(identity, action, decision, domain, environment, **detail):
  90. return {
  91. "subject": identity.subject,
  92. "roles": sorted(identity.roles),
  93. "business_domain": domain,
  94. "environment": environment,
  95. "correlation_id": identity.correlation_id,
  96. "action": action,
  97. "decision": decision,
  98. "detail": detail,
  99. }
  100. @staticmethod
  101. def _idempotency_key(value, label):
  102. if not isinstance(value, str) or not value.strip() or len(value) > 500:
  103. raise ValueError(f"{label} idempotency key is required")
  104. return value.strip()
  105. @staticmethod
  106. def _timestamp(value, label):
  107. if not isinstance(value, str) or not value.strip():
  108. raise ValueError(f"{label} is required")
  109. source = value.strip()
  110. if source.endswith("Z"):
  111. source = f"{source[:-1]}+00:00"
  112. try:
  113. parsed = datetime.fromisoformat(source)
  114. except ValueError as exc:
  115. raise ValueError(f"{label} must be an ISO-8601 timestamp") from exc
  116. if parsed.tzinfo is None:
  117. raise ValueError(f"{label} must include a timezone")
  118. return parsed.astimezone(UTC)
  119. def _require_engine(self):
  120. if self.engine is None:
  121. raise RuntimeError("scheduling engine is not configured")
  122. return self.engine
  123. def _candidate(self, candidate_id, business_domain, environment):
  124. candidate = self.plans.get_candidate(candidate_id)
  125. if (
  126. candidate.get("business_domain") != business_domain
  127. or candidate.get("environment") != environment
  128. ):
  129. raise PermissionError("candidate is outside identity scope")
  130. return candidate
  131. def _deployment(self, candidate_id, business_domain, environment):
  132. self._candidate(candidate_id, business_domain, environment)
  133. deployment = self.plans.get_deployment(candidate_id)
  134. if not isinstance(deployment, dict):
  135. raise ValueError("candidate has no deployment")
  136. if not deployment.get("namespace") or not deployment.get("flow_id"):
  137. raise ValueError("candidate deployment is incomplete")
  138. return deployment
  139. def _record(
  140. self,
  141. identity,
  142. action,
  143. decision,
  144. business_domain,
  145. environment,
  146. **detail,
  147. ):
  148. self.audit.record(
  149. self._event(
  150. identity,
  151. action,
  152. decision,
  153. business_domain,
  154. environment,
  155. **detail,
  156. )
  157. )
  158. def create_candidate_plan(
  159. self,
  160. identity,
  161. *,
  162. business_domain,
  163. environment,
  164. workflow_spec,
  165. schedule_plan,
  166. context_text=None,
  167. ):
  168. del context_text # External metadata is untrusted and never policy input.
  169. action = "create_candidate_plan"
  170. self.policy.authorize_tool(
  171. identity,
  172. action,
  173. domain=business_domain,
  174. environment=environment,
  175. )
  176. self.policy.validate_plan_limits(schedule_plan)
  177. spec = validate_workflow_spec(workflow_spec)
  178. plan = validate_schedule_plan(schedule_plan)
  179. result = self.plans.create_candidate(
  180. {
  181. "dataflow_uid": spec["dataflow_uid"],
  182. "business_domain": business_domain,
  183. "environment": environment,
  184. "workflow_spec": spec,
  185. "schedule_plan": plan,
  186. "workflow_hash": workflow_spec_hash(spec),
  187. "status": "candidate",
  188. "created_by": identity.subject,
  189. "correlation_id": identity.correlation_id,
  190. }
  191. )
  192. self.audit.record(
  193. self._event(
  194. identity,
  195. action,
  196. "allowed",
  197. business_domain,
  198. environment,
  199. candidate_id=result["candidate_id"],
  200. workflow_hash=result["workflow_hash"],
  201. )
  202. )
  203. return result
  204. def deploy_disabled_version(
  205. self,
  206. identity,
  207. *,
  208. candidate_id,
  209. business_domain,
  210. environment,
  211. ):
  212. action = "deploy_disabled_version"
  213. self.policy.authorize_tool(
  214. identity,
  215. action,
  216. domain=business_domain,
  217. environment=environment,
  218. )
  219. candidate = self._candidate(candidate_id, business_domain, environment)
  220. compiled = self.compiler(
  221. candidate["workflow_spec"],
  222. candidate["schedule_plan"],
  223. environment,
  224. int(candidate["version_no"]),
  225. )
  226. response = self._require_engine().deploy_disabled(compiled.yaml)
  227. deployment = {
  228. "candidate_id": candidate_id,
  229. "namespace": compiled.namespace,
  230. "flow_id": compiled.flow_id,
  231. "definition_hash": compiled.definition_hash,
  232. "engine_revision": (
  233. response.get("revision") if isinstance(response, dict) else None
  234. ),
  235. "status": "deployed_disabled",
  236. }
  237. result = self.plans.record_deployment(candidate_id, deployment)
  238. self._record(
  239. identity,
  240. action,
  241. "allowed",
  242. business_domain,
  243. environment,
  244. candidate_id=candidate_id,
  245. flow_id=compiled.flow_id,
  246. definition_hash=compiled.definition_hash,
  247. )
  248. return result
  249. def run_canary(
  250. self,
  251. identity,
  252. *,
  253. candidate_id,
  254. business_domain,
  255. environment,
  256. inputs,
  257. ):
  258. action = "run_canary"
  259. self.policy.authorize_tool(
  260. identity,
  261. action,
  262. domain=business_domain,
  263. environment=environment,
  264. )
  265. if not isinstance(inputs, dict):
  266. raise ValueError("canary inputs must be an object")
  267. if "dataops_task_tokens" in inputs:
  268. raise ValueError("reserved task token input cannot be supplied")
  269. if len(inputs) > 100:
  270. raise ValueError("canary inputs exceed gateway limit")
  271. try:
  272. input_size = len(
  273. json.dumps(
  274. inputs,
  275. sort_keys=True,
  276. separators=(",", ":"),
  277. ensure_ascii=False,
  278. ).encode("utf-8")
  279. )
  280. except (TypeError, ValueError) as exc:
  281. raise ValueError("canary inputs must be JSON serializable") from exc
  282. if input_size > 65536:
  283. raise ValueError("canary inputs exceed gateway size limit")
  284. candidate = self._candidate(candidate_id, business_domain, environment)
  285. unknown_inputs = sorted(
  286. set(inputs) - set(candidate["workflow_spec"]["parameters"])
  287. )
  288. if unknown_inputs:
  289. raise ValueError(
  290. f"canary inputs contain unknown parameters: {', '.join(unknown_inputs)}"
  291. )
  292. deployment = self._deployment(candidate_id, business_domain, environment)
  293. if self.token_issuer is None:
  294. raise RuntimeError("runner task token issuer is not configured")
  295. task_tokens = {}
  296. for node in candidate["workflow_spec"]["nodes"]:
  297. task_tokens[node["id"]] = self.token_issuer.issue(
  298. task_uid=str(uuid.uuid4()),
  299. dataflow_uid=candidate["dataflow_uid"],
  300. workflow_version=int(candidate["version_no"]),
  301. correlation_id=identity.correlation_id,
  302. node=node,
  303. write_authorized=bool(candidate.get("trusted_write_authorized", False)),
  304. )
  305. engine = self._require_engine()
  306. # Kestra rejects manual execution while a flow is disabled. Compiled
  307. # schedule triggers remain individually disabled, so the gateway opens
  308. # only a bounded manual-execution window and always closes it again.
  309. engine.activate(deployment["namespace"], deployment["flow_id"])
  310. try:
  311. response = engine.execute(
  312. deployment["namespace"],
  313. deployment["flow_id"],
  314. inputs={**inputs, "dataops_task_tokens": task_tokens},
  315. )
  316. finally:
  317. engine.deactivate(deployment["namespace"], deployment["flow_id"])
  318. execution_id = response.get("id") if isinstance(response, dict) else None
  319. if not execution_id:
  320. raise RuntimeError("canary execution did not return an id")
  321. evidence = {
  322. "candidate_id": candidate_id,
  323. "execution_id": execution_id,
  324. "status": "started",
  325. "sample_runs": 0,
  326. }
  327. result = self.plans.record_canary_execution(candidate_id, evidence)
  328. self._record(
  329. identity,
  330. action,
  331. "allowed",
  332. business_domain,
  333. environment,
  334. candidate_id=candidate_id,
  335. execution_id=execution_id,
  336. )
  337. return result
  338. def promote_candidate(
  339. self,
  340. identity,
  341. *,
  342. candidate_id,
  343. business_domain,
  344. environment,
  345. idempotency_key,
  346. ):
  347. action = "promote_candidate"
  348. self.policy.authorize_tool(
  349. identity,
  350. action,
  351. domain=business_domain,
  352. environment=environment,
  353. )
  354. self._candidate(candidate_id, business_domain, environment)
  355. deployment = self._deployment(candidate_id, business_domain, environment)
  356. key = self._idempotency_key(idempotency_key, "promotion")
  357. canary = self.plans.get_canary_evidence(candidate_id)
  358. if (
  359. not isinstance(canary, dict)
  360. or canary.get("status") != "passed"
  361. or int(canary.get("sample_runs", 0)) < 1
  362. or not canary.get("verified_by")
  363. ):
  364. raise ValueError("trusted successful canary evidence is required")
  365. claimed, result = self.plans.claim_promotion(
  366. key,
  367. candidate_id,
  368. actor_subject=identity.subject,
  369. correlation_id=identity.correlation_id,
  370. )
  371. decision = "allowed" if claimed else "idempotent_replay"
  372. if claimed:
  373. active_lookup = getattr(self.plans, "get_active_deployment", None)
  374. previous = (
  375. active_lookup(candidate_id) if active_lookup is not None else None
  376. )
  377. try:
  378. self._require_engine().activate(
  379. deployment["namespace"], deployment["flow_id"]
  380. )
  381. if previous is not None:
  382. self._require_engine().deactivate(
  383. previous["namespace"], previous["flow_id"]
  384. )
  385. complete = getattr(self.plans, "complete_promotion", None)
  386. if complete is not None:
  387. complete(key, candidate_id, result)
  388. except Exception:
  389. try:
  390. self._require_engine().deactivate(
  391. deployment["namespace"], deployment["flow_id"]
  392. )
  393. if previous is not None:
  394. self._require_engine().activate(
  395. previous["namespace"], previous["flow_id"]
  396. )
  397. except Exception:
  398. pass
  399. release = getattr(self.plans, "release_promotion", None)
  400. if release is not None:
  401. release(key, candidate_id)
  402. raise
  403. self._record(
  404. identity,
  405. action,
  406. decision,
  407. business_domain,
  408. environment,
  409. candidate_id=candidate_id,
  410. idempotency_key=key,
  411. )
  412. return result
  413. def pause_schedule(
  414. self,
  415. identity,
  416. *,
  417. candidate_id,
  418. business_domain,
  419. environment,
  420. ):
  421. action = "pause_schedule"
  422. self.policy.authorize_tool(
  423. identity,
  424. action,
  425. domain=business_domain,
  426. environment=environment,
  427. )
  428. deployment = self._deployment(candidate_id, business_domain, environment)
  429. self._require_engine().deactivate(
  430. deployment["namespace"], deployment["flow_id"]
  431. )
  432. self.plans.mark_paused(candidate_id)
  433. result = {
  434. "candidate_id": candidate_id,
  435. "status": "paused",
  436. "flow_id": deployment["flow_id"],
  437. }
  438. self._record(
  439. identity,
  440. action,
  441. "allowed",
  442. business_domain,
  443. environment,
  444. candidate_id=candidate_id,
  445. flow_id=deployment["flow_id"],
  446. )
  447. return result
  448. def retry_failed_execution(
  449. self,
  450. identity,
  451. *,
  452. execution_id,
  453. business_domain,
  454. environment,
  455. max_retries=1,
  456. ):
  457. action = "retry_failed_execution"
  458. self.policy.authorize_tool(
  459. identity,
  460. action,
  461. domain=business_domain,
  462. environment=environment,
  463. )
  464. if (
  465. isinstance(max_retries, bool)
  466. or not isinstance(max_retries, int)
  467. or max_retries < 1
  468. or max_retries > self.policy.limits.max_retry_attempts
  469. ):
  470. raise ValueError("max retries exceed gateway limit")
  471. record = self.plans.get_execution_record(execution_id)
  472. if (
  473. record.get("business_domain") != business_domain
  474. or record.get("environment") != environment
  475. ):
  476. raise PermissionError("execution is outside identity scope")
  477. if str(record.get("status", "")).upper() != "FAILED":
  478. raise ValueError("only failed executions can be retried")
  479. if int(record.get("retry_count", 0)) >= max_retries:
  480. raise ValueError("execution retry limit is exhausted")
  481. result = self._require_engine().replay(execution_id)
  482. self.plans.record_retry(execution_id, result)
  483. self._record(
  484. identity,
  485. action,
  486. "allowed",
  487. business_domain,
  488. environment,
  489. execution_id=execution_id,
  490. replay_execution_id=(
  491. result.get("id") if isinstance(result, dict) else None
  492. ),
  493. )
  494. return result
  495. def backfill_bounded_window(
  496. self,
  497. identity,
  498. *,
  499. candidate_id,
  500. business_domain,
  501. environment,
  502. trigger_id,
  503. start,
  504. end,
  505. ):
  506. action = "backfill_bounded_window"
  507. self.policy.authorize_tool(
  508. identity,
  509. action,
  510. domain=business_domain,
  511. environment=environment,
  512. )
  513. candidate = self._candidate(candidate_id, business_domain, environment)
  514. deployment = self._deployment(candidate_id, business_domain, environment)
  515. start_at = self._timestamp(start, "backfill start")
  516. end_at = self._timestamp(end, "backfill end")
  517. if end_at <= start_at:
  518. raise ValueError("backfill end must be after start")
  519. window_days = (end_at - start_at).total_seconds() / 86400
  520. allowed_days = min(
  521. self.policy.limits.max_backfill_days,
  522. int(candidate["schedule_plan"]["backfill"]["max_days"]),
  523. )
  524. if window_days > allowed_days:
  525. raise ValueError("backfill window exceeds gateway limit")
  526. estimated_runs = int(
  527. self.plans.estimate_backfill_runs(candidate_id, start, end)
  528. )
  529. allowed_runs = min(
  530. self.policy.limits.max_backfill_runs,
  531. int(candidate["schedule_plan"]["backfill"]["max_runs"]),
  532. )
  533. if estimated_runs < 1 or estimated_runs > allowed_runs:
  534. raise ValueError("backfill run count exceeds gateway limit")
  535. response = self._require_engine().backfill(
  536. deployment["namespace"],
  537. deployment["flow_id"],
  538. trigger_id,
  539. start,
  540. end,
  541. )
  542. result = {
  543. **(response if isinstance(response, dict) else {}),
  544. "candidate_id": candidate_id,
  545. "estimated_runs": estimated_runs,
  546. }
  547. self.plans.record_backfill(candidate_id, result)
  548. self._record(
  549. identity,
  550. action,
  551. "allowed",
  552. business_domain,
  553. environment,
  554. candidate_id=candidate_id,
  555. trigger_id=trigger_id,
  556. estimated_runs=estimated_runs,
  557. )
  558. return result
  559. def rollback_to_previous_version(
  560. self,
  561. identity,
  562. *,
  563. candidate_id,
  564. business_domain,
  565. environment,
  566. idempotency_key,
  567. ):
  568. action = "rollback_to_previous_version"
  569. self.policy.authorize_tool(
  570. identity,
  571. action,
  572. domain=business_domain,
  573. environment=environment,
  574. )
  575. current = self._deployment(candidate_id, business_domain, environment)
  576. previous = self.plans.get_previous_deployment(candidate_id)
  577. if (
  578. not isinstance(previous, dict)
  579. or not previous.get("namespace")
  580. or not previous.get("flow_id")
  581. ):
  582. raise ValueError("previous deployment is not available")
  583. key = self._idempotency_key(idempotency_key, "rollback")
  584. claimed, result = self.plans.claim_rollback(
  585. key,
  586. candidate_id,
  587. actor_subject=identity.subject,
  588. correlation_id=identity.correlation_id,
  589. )
  590. if not claimed:
  591. self._record(
  592. identity,
  593. action,
  594. "idempotent_replay",
  595. business_domain,
  596. environment,
  597. candidate_id=candidate_id,
  598. idempotency_key=key,
  599. )
  600. return result
  601. engine = self._require_engine()
  602. engine.deactivate(current["namespace"], current["flow_id"])
  603. try:
  604. engine.activate(previous["namespace"], previous["flow_id"])
  605. except Exception:
  606. engine.activate(current["namespace"], current["flow_id"])
  607. release = getattr(self.plans, "release_rollback", None)
  608. if release is not None:
  609. release(key, candidate_id)
  610. raise
  611. complete = getattr(self.plans, "complete_rollback", None)
  612. if complete is not None:
  613. complete(key, candidate_id, result)
  614. self._record(
  615. identity,
  616. action,
  617. "allowed",
  618. business_domain,
  619. environment,
  620. candidate_id=candidate_id,
  621. active_candidate_id=previous.get("candidate_id"),
  622. idempotency_key=key,
  623. )
  624. return result