test_v53_mcp_kestra_runner_l3.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. """Opt-in V53 MCP -> Kestra -> Runner production-wiring acceptance."""
  2. import json
  3. import os
  4. import time
  5. from pathlib import Path
  6. import anyio
  7. import pytest
  8. import requests
  9. from mcp import ClientSession
  10. from mcp.client.stdio import StdioServerParameters, stdio_client
  11. from neo4j import GraphDatabase
  12. from sqlalchemy import create_engine, text
  13. from app.core.common.identifiers import new_governance_uid
  14. from app.core.data_source.credentials import (
  15. CredentialCodec,
  16. DataSourceCredentialRepository,
  17. )
  18. from app.core.data_source.definitions import DataSourceDefinitionRepository
  19. from app.core.data_source.models import (
  20. DataSourceCredential,
  21. DataSourceDefinition,
  22. )
  23. from app.core.mcp.bootstrap import build_canary_verifier
  24. pytestmark = pytest.mark.integration
  25. ROOT = Path(__file__).resolve().parents[2]
  26. DATABASE_URL = "postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops"
  27. KESTRA_URL = "http://127.0.0.1:18080/api/v1"
  28. KESTRA_AUTH = ("admin@dataops.local", "DataOpsKestra1!")
  29. RUNNER_SECRET = "dataops-local-runner-task-token-secret-change-me"
  30. def _workflow_spec(dataflow_uid, source_uid, version):
  31. return {
  32. "schema_version": "1.0",
  33. "dataflow_uid": dataflow_uid,
  34. "name": f"V53 MCP L3 acceptance v{version}",
  35. "nodes": [
  36. {
  37. "id": "read_customers",
  38. "type": "sql.query",
  39. "data_source_uid": source_uid,
  40. "purpose": "read",
  41. "config": {
  42. "statement": (
  43. "SELECT customer_name FROM acceptance_customers "
  44. "WHERE id >= :minimum_id ORDER BY id"
  45. ),
  46. "parameters": {"minimum_id": "${parameters.minimum_id}"},
  47. },
  48. }
  49. ],
  50. "edges": [],
  51. "parameters": {"minimum_id": {"type": "integer", "required": True}},
  52. }
  53. def _schedule_plan():
  54. return {
  55. "schema_version": "1.0",
  56. "timezone": "Asia/Shanghai",
  57. "triggers": [{"type": "manual"}],
  58. "max_concurrency": 1,
  59. "conflict_policy": "skip",
  60. "timeout_seconds": 120,
  61. "retry": {"max_attempts": 1, "delay_seconds": 0},
  62. "backfill": {"max_days": 1, "max_runs": 2},
  63. }
  64. def _mcp_environment(correlation_id):
  65. return {
  66. **os.environ,
  67. "PYTHONPATH": str(ROOT),
  68. "DATAOPS_MCP_SUBJECT": "v53-l3-scheduler",
  69. "DATAOPS_MCP_ROLES": "scheduler",
  70. "DATAOPS_MCP_BUSINESS_DOMAINS": "sales",
  71. "DATAOPS_MCP_ENVIRONMENTS": "test",
  72. "DATAOPS_MCP_CORRELATION_ID": correlation_id,
  73. "DATAOPS_MCP_DATABASE_URL": DATABASE_URL,
  74. "KESTRA_BASE_URL": KESTRA_URL,
  75. "KESTRA_USERNAME": KESTRA_AUTH[0],
  76. "KESTRA_PASSWORD": KESTRA_AUTH[1],
  77. "KESTRA_TENANT_ID": "main",
  78. "RUNNER_TASK_TOKEN_SECRET": RUNNER_SECRET,
  79. "RUNNER_TASK_TOKEN_TTL_SECONDS": "120",
  80. }
  81. async def _call(session, name, arguments):
  82. response = await session.call_tool(name, arguments)
  83. assert not response.isError, response.content
  84. if response.structuredContent is not None:
  85. return response.structuredContent.get("result", response.structuredContent)
  86. assert len(response.content) == 1
  87. return json.loads(response.content[0].text)
  88. def _wait_for_terminal(verifier, candidate_id, timeout=30):
  89. deadline = time.monotonic() + timeout
  90. evidence = None
  91. while time.monotonic() < deadline:
  92. evidence = verifier.verify(candidate_id)
  93. if evidence["status"] in {"passed", "failed"}:
  94. return evidence
  95. time.sleep(0.25)
  96. raise AssertionError(f"Canary did not finish: {evidence}")
  97. def _flow_disabled(namespace, flow_id):
  98. response = requests.get(
  99. f"{KESTRA_URL}/main/flows/{namespace}/{flow_id}",
  100. auth=KESTRA_AUTH,
  101. timeout=20,
  102. )
  103. response.raise_for_status()
  104. return bool(response.json().get("disabled"))
  105. def test_v53_real_mcp_candidate_canary_promote_and_rollback():
  106. if os.environ.get("RUN_V53_MCP_L3") != "1":
  107. pytest.skip("set RUN_V53_MCP_L3=1 for the V53 L3 acceptance")
  108. platform_engine = create_engine(DATABASE_URL, pool_pre_ping=True)
  109. graph_driver = GraphDatabase.driver(
  110. "bolt://127.0.0.1:17687",
  111. auth=("neo4j", "Passw0rd"),
  112. )
  113. source_uid = new_governance_uid()
  114. dataflow_uid = new_governance_uid()
  115. correlation_id = new_governance_uid()
  116. candidate_ids = []
  117. deployments = []
  118. codec = CredentialCodec.from_base64(
  119. "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
  120. "v1",
  121. )
  122. async def exercise():
  123. parameters = StdioServerParameters(
  124. command=str(ROOT / ".venv/bin/python"),
  125. args=[
  126. str(ROOT / "mcp-servers/dataops_mcp_stdio.py"),
  127. "--kind",
  128. "scheduling",
  129. "--factory",
  130. "app.core.mcp.bootstrap:build_scheduling_gateway",
  131. ],
  132. env=_mcp_environment(correlation_id),
  133. cwd=str(ROOT),
  134. )
  135. async with (
  136. stdio_client(parameters) as (read_stream, write_stream),
  137. ClientSession(read_stream, write_stream) as session,
  138. ):
  139. await session.initialize()
  140. verifier = build_canary_verifier()
  141. for version in (1, 2):
  142. candidate = await _call(
  143. session,
  144. "create_candidate_plan",
  145. {
  146. "business_domain": "sales",
  147. "environment": "test",
  148. "workflow_spec": _workflow_spec(
  149. dataflow_uid, source_uid, version
  150. ),
  151. "schedule_plan": _schedule_plan(),
  152. },
  153. )
  154. candidate_ids.append(candidate["candidate_id"])
  155. deployment = await _call(
  156. session,
  157. "deploy_disabled_version",
  158. {
  159. "candidate_id": candidate["candidate_id"],
  160. "business_domain": "sales",
  161. "environment": "test",
  162. },
  163. )
  164. deployments.append(deployment)
  165. canary = await _call(
  166. session,
  167. "run_canary",
  168. {
  169. "candidate_id": candidate["candidate_id"],
  170. "business_domain": "sales",
  171. "environment": "test",
  172. "inputs": {"minimum_id": 1},
  173. },
  174. )
  175. assert canary["status"] == "started"
  176. evidence = _wait_for_terminal(verifier, candidate["candidate_id"])
  177. assert evidence["status"] == "passed"
  178. promoted = await _call(
  179. session,
  180. "promote_candidate",
  181. {
  182. "candidate_id": candidate["candidate_id"],
  183. "business_domain": "sales",
  184. "environment": "test",
  185. "idempotency_key": (
  186. f"v53-l3-{correlation_id}-promote-{version}"
  187. ),
  188. },
  189. )
  190. assert promoted["status"] == "promoted"
  191. replay = await _call(
  192. session,
  193. "promote_candidate",
  194. {
  195. "candidate_id": candidate_ids[1],
  196. "business_domain": "sales",
  197. "environment": "test",
  198. "idempotency_key": (f"v53-l3-{correlation_id}-promote-2"),
  199. },
  200. )
  201. assert replay["candidate_id"] == candidate_ids[1]
  202. assert _flow_disabled(
  203. deployments[0]["namespace"],
  204. deployments[0]["flow_id"],
  205. )
  206. assert not _flow_disabled(
  207. deployments[1]["namespace"],
  208. deployments[1]["flow_id"],
  209. )
  210. rollback = await _call(
  211. session,
  212. "rollback_to_previous_version",
  213. {
  214. "candidate_id": candidate_ids[1],
  215. "business_domain": "sales",
  216. "environment": "test",
  217. "idempotency_key": (f"v53-l3-{correlation_id}-rollback-2"),
  218. },
  219. )
  220. assert rollback["active_candidate_id"] == candidate_ids[0]
  221. assert not _flow_disabled(
  222. deployments[0]["namespace"],
  223. deployments[0]["flow_id"],
  224. )
  225. assert _flow_disabled(
  226. deployments[1]["namespace"],
  227. deployments[1]["flow_id"],
  228. )
  229. try:
  230. with platform_engine.begin() as connection:
  231. sealed = DataSourceCredentialRepository(codec).create_version(
  232. connection,
  233. data_source_uid=source_uid,
  234. credential=DataSourceCredential(
  235. "source_reader", "source-test-password"
  236. ),
  237. actor_uid=None,
  238. )
  239. with graph_driver.session() as session:
  240. DataSourceDefinitionRepository(session).save(
  241. DataSourceDefinition(
  242. uid=source_uid,
  243. name_en="v53-mcp-l3-source",
  244. database_type="postgresql",
  245. host="source-postgres",
  246. port=5432,
  247. database="acceptance",
  248. credential_ref=source_uid,
  249. credential_version=sealed.credential_version,
  250. pool_size=1,
  251. max_overflow=1,
  252. )
  253. )
  254. os.environ.update(_mcp_environment(correlation_id))
  255. anyio.run(exercise)
  256. with platform_engine.connect() as connection:
  257. runner_successes = connection.execute(
  258. text(
  259. "SELECT COUNT(*) FROM public.runner_task_executions "
  260. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  261. "AND status = 'success'"
  262. ),
  263. {"uid": dataflow_uid},
  264. ).scalar_one()
  265. operations = (
  266. connection.execute(
  267. text(
  268. "SELECT action, status, COUNT(*) AS count "
  269. "FROM public.workflow_gateway_operations "
  270. "WHERE workflow_version_id = ANY(CAST(:ids AS uuid[])) "
  271. "GROUP BY action, status ORDER BY action"
  272. ),
  273. {"ids": candidate_ids},
  274. )
  275. .mappings()
  276. .all()
  277. )
  278. assert runner_successes == 2
  279. assert [dict(row) for row in operations] == [
  280. {
  281. "action": "promote_candidate",
  282. "status": "succeeded",
  283. "count": 2,
  284. },
  285. {
  286. "action": "rollback_to_previous_version",
  287. "status": "succeeded",
  288. "count": 1,
  289. },
  290. ]
  291. finally:
  292. for deployment in deployments:
  293. requests.delete(
  294. f"{KESTRA_URL}/main/flows/"
  295. f"{deployment['namespace']}/{deployment['flow_id']}",
  296. auth=KESTRA_AUTH,
  297. timeout=20,
  298. )
  299. with graph_driver.session() as session:
  300. DataSourceDefinitionRepository(session).delete(source_uid)
  301. with platform_engine.begin() as connection:
  302. if candidate_ids:
  303. connection.execute(
  304. text(
  305. "DELETE FROM public.workflow_gateway_operations "
  306. "WHERE correlation_id = CAST(:id AS uuid)"
  307. ),
  308. {"id": correlation_id},
  309. )
  310. connection.execute(
  311. text(
  312. "DELETE FROM public.workflow_plan_audits "
  313. "WHERE workflow_version_id = "
  314. "ANY(CAST(:ids AS uuid[]))"
  315. ),
  316. {"ids": candidate_ids},
  317. )
  318. connection.execute(
  319. text(
  320. "DELETE FROM public.dataflow_workflow_versions "
  321. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  322. ),
  323. {"ids": candidate_ids},
  324. )
  325. connection.execute(
  326. text(
  327. "DELETE FROM public.runner_task_executions "
  328. "WHERE dataflow_uid = CAST(:uid AS uuid)"
  329. ),
  330. {"uid": dataflow_uid},
  331. )
  332. connection.execute(
  333. text(
  334. "DELETE FROM public.datasource_credential_audit_events "
  335. "WHERE data_source_uid = CAST(:uid AS uuid)"
  336. ),
  337. {"uid": source_uid},
  338. )
  339. connection.execute(
  340. text(
  341. "DELETE FROM public.datasource_credentials "
  342. "WHERE data_source_uid = CAST(:uid AS uuid)"
  343. ),
  344. {"uid": source_uid},
  345. )
  346. graph_driver.close()
  347. platform_engine.dispose()