test_runner_datasource_pool.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """Opt-in end-to-end Runner test against governed PostgreSQL/MySQL pools."""
  2. import os
  3. import pytest
  4. import requests
  5. from neo4j import GraphDatabase
  6. from sqlalchemy import create_engine, text
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_source.credentials import (
  9. CredentialCodec,
  10. DataSourceCredentialRepository,
  11. )
  12. from app.core.data_source.definitions import DataSourceDefinitionRepository
  13. from app.core.data_source.models import (
  14. DataSourceCredential,
  15. DataSourceDefinition,
  16. )
  17. from app.runner.auth import TaskTokenIssuer
  18. pytestmark = pytest.mark.integration
  19. def _require_runner():
  20. if os.environ.get("RUN_RUNNER_INTEGRATION") != "1":
  21. pytest.skip("set RUN_RUNNER_INTEGRATION=1 to test the Runner container")
  22. def _seed_source(graph_driver, platform_engine, definition, password):
  23. codec = CredentialCodec.from_base64(
  24. os.environ.get(
  25. "DATASOURCE_CREDENTIAL_MASTER_KEY",
  26. "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
  27. ),
  28. "v1",
  29. )
  30. credentials = DataSourceCredentialRepository(codec)
  31. with platform_engine.begin() as connection:
  32. sealed = credentials.create_version(
  33. connection,
  34. data_source_uid=definition.uid,
  35. credential=DataSourceCredential("source_reader", password),
  36. actor_uid=None,
  37. )
  38. saved = DataSourceDefinition(
  39. **{
  40. **definition.__dict__,
  41. "credential_ref": definition.uid,
  42. "credential_version": sealed.credential_version,
  43. }
  44. )
  45. with graph_driver.session() as session:
  46. DataSourceDefinitionRepository(session).save(saved)
  47. def _cleanup(graph_driver, platform_engine, dataflow_uid, source_uids):
  48. with graph_driver.session() as session:
  49. repository = DataSourceDefinitionRepository(session)
  50. for uid in source_uids:
  51. repository.delete(uid)
  52. with platform_engine.begin() as connection:
  53. connection.execute(
  54. text(
  55. """
  56. DELETE FROM public.runner_task_executions
  57. WHERE dataflow_uid = CAST(:dataflow_uid AS uuid)
  58. """
  59. ),
  60. {"dataflow_uid": dataflow_uid},
  61. )
  62. connection.execute(
  63. text(
  64. """
  65. DELETE FROM public.datasource_credential_audit_events
  66. WHERE data_source_uid = ANY(CAST(:uids AS uuid[]))
  67. """
  68. ),
  69. {"uids": list(source_uids)},
  70. )
  71. connection.execute(
  72. text(
  73. """
  74. DELETE FROM public.datasource_credentials
  75. WHERE data_source_uid = ANY(CAST(:uids AS uuid[]))
  76. """
  77. ),
  78. {"uids": list(source_uids)},
  79. )
  80. def test_runner_reuses_governed_pools_and_rejects_duplicate_submit():
  81. _require_runner()
  82. runner_url = os.environ.get(
  83. "TEST_RUNNER_URL",
  84. "http://127.0.0.1:15600",
  85. )
  86. platform_engine = create_engine(
  87. os.environ.get(
  88. "TEST_DATABASE_URL",
  89. "postgresql://dataops:dataops-test-password"
  90. "@127.0.0.1:15432/dataops",
  91. )
  92. )
  93. graph_driver = GraphDatabase.driver(
  94. os.environ.get("TEST_NEO4J_URI", "bolt://127.0.0.1:17687"),
  95. auth=("neo4j", "Passw0rd"),
  96. )
  97. dataflow_uid = new_governance_uid()
  98. source_uids = (new_governance_uid(), new_governance_uid())
  99. definitions = (
  100. DataSourceDefinition(
  101. uid=source_uids[0],
  102. name_en="runner-acceptance-postgres",
  103. database_type="postgresql",
  104. host="source-postgres",
  105. port=5432,
  106. database="acceptance",
  107. pool_size=1,
  108. max_overflow=1,
  109. ),
  110. DataSourceDefinition(
  111. uid=source_uids[1],
  112. name_en="runner-acceptance-mysql",
  113. database_type="mysql",
  114. host="source-mysql",
  115. port=3306,
  116. database="acceptance",
  117. pool_size=1,
  118. max_overflow=1,
  119. ),
  120. )
  121. issuer = TaskTokenIssuer(
  122. os.environ.get(
  123. "RUNNER_TASK_TOKEN_SECRET",
  124. "dataops-local-runner-task-token-secret-change-me",
  125. )
  126. )
  127. try:
  128. _seed_source(
  129. graph_driver,
  130. platform_engine,
  131. definitions[0],
  132. "source-test-password",
  133. )
  134. _seed_source(
  135. graph_driver,
  136. platform_engine,
  137. definitions[1],
  138. "source-test-password",
  139. )
  140. for definition in definitions:
  141. task_uid = new_governance_uid()
  142. node = {
  143. "id": f"read_{definition.database_type}",
  144. "type": "sql.query",
  145. "data_source_uid": definition.uid,
  146. "purpose": "read",
  147. "config": {
  148. "statement": (
  149. "SELECT customer_name FROM acceptance_customers "
  150. "WHERE id >= :minimum_id ORDER BY id"
  151. ),
  152. "parameters": {
  153. "minimum_id": "${parameters.minimum_id}"
  154. },
  155. },
  156. }
  157. token = issuer.issue(
  158. task_uid=task_uid,
  159. dataflow_uid=dataflow_uid,
  160. workflow_version=1,
  161. correlation_id=new_governance_uid(),
  162. node=node,
  163. )
  164. payload = {
  165. "task_token": token,
  166. "node": node,
  167. "parameters": {"minimum_id": 1},
  168. }
  169. response = requests.post(
  170. f"{runner_url}/v1/tasks/execute",
  171. json=payload,
  172. timeout=20,
  173. )
  174. response.raise_for_status()
  175. assert response.json()["result"]["rows"] == [
  176. {"customer_name": "Alpha"},
  177. {"customer_name": "Beta"},
  178. ]
  179. replay = requests.post(
  180. f"{runner_url}/v1/tasks/execute",
  181. json=payload,
  182. timeout=20,
  183. )
  184. assert replay.status_code == 409
  185. with platform_engine.connect() as connection:
  186. rows = connection.execute(
  187. text(
  188. """
  189. SELECT status, commit_outcome, COUNT(*) AS count
  190. FROM public.runner_task_executions
  191. WHERE dataflow_uid = CAST(:uid AS uuid)
  192. GROUP BY status, commit_outcome
  193. """
  194. ),
  195. {"uid": dataflow_uid},
  196. ).mappings().all()
  197. assert [dict(row) for row in rows] == [
  198. {
  199. "status": "success",
  200. "commit_outcome": "not_applicable",
  201. "count": 2,
  202. }
  203. ]
  204. finally:
  205. _cleanup(graph_driver, platform_engine, dataflow_uid, source_uids)
  206. graph_driver.close()
  207. platform_engine.dispose()