test_database_migrations.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. from __future__ import annotations
  2. import os
  3. import subprocess
  4. import uuid
  5. from pathlib import Path
  6. import psycopg2
  7. import pytest
  8. from sqlalchemy import create_engine, inspect
  9. from sqlalchemy.engine import make_url
  10. ROOT = Path(__file__).resolve().parents[1]
  11. EXPECTED_BASELINE_TABLES = {
  12. "data_orders",
  13. "data_products",
  14. "metadata_review_records",
  15. "metadata_version_history",
  16. "task_list",
  17. "users",
  18. }
  19. EXPECTED_UPGRADED_TABLES = {
  20. "datasource_credentials",
  21. "datasource_credential_audit_events",
  22. "workflow_schedules",
  23. "workflow_runs",
  24. "workflow_task_runs",
  25. "workflow_engine_bindings",
  26. "workflow_plan_audits",
  27. "runner_task_executions",
  28. "workflow_canary_evidence",
  29. "workflow_gateway_operations",
  30. "workflow_migration_states",
  31. "workflow_dual_runs",
  32. "workflow_reconciliation_reports",
  33. "workflow_cutover_operations",
  34. "rule_sql_staging_receipts",
  35. "ingestion_sources",
  36. "source_artifacts",
  37. "ingestion_jobs",
  38. "evidence_fragments",
  39. "extraction_candidates",
  40. "data_elements",
  41. "data_element_versions",
  42. "candidate_decisions",
  43. "ontologies",
  44. "ontology_versions",
  45. "ontology_domain_links",
  46. "ontology_change_sets",
  47. "ontology_publish_runs",
  48. }
  49. def test_data_research_ingestion_migration_is_additive_and_constrained():
  50. migration = (
  51. ROOT
  52. / "migrations"
  53. / "versions"
  54. / "20260722_100_data_research_ingestion.py"
  55. ).read_text(encoding="utf-8")
  56. assert 'revision = "20260722_100"' in migration
  57. assert 'down_revision = "20260720_100"' in migration
  58. for table in (
  59. "ingestion_sources",
  60. "source_artifacts",
  61. "ingestion_jobs",
  62. "evidence_fragments",
  63. "extraction_candidates",
  64. ):
  65. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  66. assert "idempotency_key" in migration
  67. assert "parser_version" in migration
  68. assert "locator JSONB" in migration
  69. assert "DROP TABLE" not in migration.upper()
  70. def test_data_element_migration_adds_versioned_governance_tables():
  71. migration = (
  72. ROOT
  73. / "migrations"
  74. / "versions"
  75. / "20260722_105_data_research_elements.py"
  76. ).read_text(encoding="utf-8")
  77. assert 'revision = "20260722_105"' in migration
  78. assert 'down_revision = "20260722_100"' in migration
  79. for table in ("data_elements", "data_element_versions", "candidate_decisions"):
  80. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  81. assert "UNIQUE (data_element_uid, version)" in migration
  82. assert "DROP TABLE" not in migration.upper()
  83. def test_ontology_migration_adds_versioned_control_plane_tables():
  84. migration = (
  85. ROOT / "migrations" / "versions" / "20260722_110_data_research_ontology.py"
  86. ).read_text(encoding="utf-8")
  87. assert 'revision = "20260722_110"' in migration
  88. assert 'down_revision = "20260722_105"' in migration
  89. for table in (
  90. "ontologies",
  91. "ontology_versions",
  92. "ontology_domain_links",
  93. "ontology_change_sets",
  94. "ontology_publish_runs",
  95. ):
  96. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  97. assert "UNIQUE (ontology_uid, version)" in migration
  98. assert "DROP TABLE" not in migration.upper()
  99. def test_alembic_configuration_is_environment_only():
  100. ini = (ROOT / "alembic.ini").read_text(encoding="utf-8")
  101. env = (ROOT / "migrations" / "env.py").read_text(encoding="utf-8")
  102. assert "sqlalchemy.url" not in ini
  103. assert "SQLALCHEMY_DATABASE_URI" in env
  104. assert "DATABASE_URL" in env
  105. assert "password" not in env.lower()
  106. def test_baseline_migration_is_non_destructive():
  107. baseline = (
  108. ROOT / "migrations" / "versions" / "20260716_01_baseline.py"
  109. ).read_text(encoding="utf-8")
  110. assert "def upgrade" in baseline
  111. assert "def downgrade" in baseline
  112. assert "drop_table" not in baseline
  113. for table in EXPECTED_BASELINE_TABLES:
  114. assert table in baseline
  115. @pytest.mark.integration
  116. def test_alembic_upgrade_is_repeatable_and_downgrade_preserves_tables():
  117. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  118. if not admin_url:
  119. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  120. parsed = make_url(admin_url)
  121. database_name = f"dataops_migration_{uuid.uuid4().hex[:12]}"
  122. target_url = parsed.set(database=database_name).render_as_string(
  123. hide_password=False
  124. )
  125. connection = psycopg2.connect(admin_url)
  126. connection.autocommit = True
  127. try:
  128. with connection.cursor() as cursor:
  129. cursor.execute(f'CREATE DATABASE "{database_name}"')
  130. env = os.environ.copy()
  131. env["SQLALCHEMY_DATABASE_URI"] = target_url
  132. command = [str(ROOT / ".venv" / "bin" / "alembic"), "-c", "alembic.ini"]
  133. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  134. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  135. engine = create_engine(target_url)
  136. try:
  137. tables = set(inspect(engine).get_table_names(schema="public"))
  138. assert EXPECTED_BASELINE_TABLES | {"alembic_version"} <= tables
  139. assert tables >= EXPECTED_UPGRADED_TABLES
  140. finally:
  141. engine.dispose()
  142. subprocess.run(command + ["downgrade", "-1"], cwd=ROOT, env=env, check=True)
  143. engine = create_engine(target_url)
  144. try:
  145. tables = set(inspect(engine).get_table_names(schema="public"))
  146. assert tables >= EXPECTED_BASELINE_TABLES
  147. finally:
  148. engine.dispose()
  149. finally:
  150. with connection.cursor() as cursor:
  151. cursor.execute(
  152. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  153. "WHERE datname = %s AND pid <> pg_backend_pid()",
  154. (database_name,),
  155. )
  156. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  157. connection.close()
  158. @pytest.mark.integration
  159. @pytest.mark.parametrize(
  160. ("legacy_rows", "diagnostic"),
  161. (
  162. ((1, 1), "duplicate rule_run_id"),
  163. ((101,), "rows above 100"),
  164. ),
  165. )
  166. def test_upgrade_from_150_rejects_malformed_legacy_samples(
  167. legacy_rows,
  168. diagnostic,
  169. ):
  170. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  171. if not admin_url:
  172. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  173. parsed = make_url(admin_url)
  174. database_name = f"dataops_evidence_{uuid.uuid4().hex[:12]}"
  175. target_url = parsed.set(database=database_name).render_as_string(
  176. hide_password=False
  177. )
  178. admin = psycopg2.connect(admin_url)
  179. admin.autocommit = True
  180. try:
  181. with admin.cursor() as cursor:
  182. cursor.execute(f'CREATE DATABASE "{database_name}"')
  183. env = os.environ.copy()
  184. env["SQLALCHEMY_DATABASE_URI"] = target_url
  185. command = [
  186. str(ROOT / ".venv" / "bin" / "alembic"),
  187. "-c",
  188. "alembic.ini",
  189. ]
  190. subprocess.run(
  191. command + ["upgrade", "20260723_150"],
  192. cwd=ROOT,
  193. env=env,
  194. check=True,
  195. )
  196. database = psycopg2.connect(target_url)
  197. try:
  198. database.autocommit = True
  199. with database.cursor() as cursor:
  200. cursor.execute("SET session_replication_role = replica")
  201. run_id = str(uuid.uuid4())
  202. cursor.execute(
  203. """
  204. INSERT INTO public.rule_runs (
  205. id, deployment_id, component_binding_id,
  206. rule_version_id, plan_hash, status, correlation_id
  207. ) VALUES (%s, %s, %s, %s, %s, 'failed', %s)
  208. """,
  209. (
  210. run_id,
  211. str(uuid.uuid4()),
  212. str(uuid.uuid4()),
  213. str(uuid.uuid4()),
  214. "a" * 64,
  215. str(uuid.uuid4()),
  216. ),
  217. )
  218. for count in legacy_rows:
  219. cursor.execute(
  220. """
  221. INSERT INTO public.rule_violation_samples (
  222. id, rule_run_id, artifact_ref, sample_count,
  223. redaction_policy, expires_at
  224. ) VALUES (%s, %s, %s, %s, 'legacy-v1', NOW())
  225. """,
  226. (
  227. str(uuid.uuid4()),
  228. run_id,
  229. f"minio://legacy/{uuid.uuid4()}",
  230. count,
  231. ),
  232. )
  233. cursor.execute("SET session_replication_role = origin")
  234. finally:
  235. database.close()
  236. failed = subprocess.run(
  237. command + ["upgrade", "head"],
  238. cwd=ROOT,
  239. env=env,
  240. text=True,
  241. capture_output=True,
  242. )
  243. assert failed.returncode != 0
  244. assert diagnostic in (failed.stdout + failed.stderr)
  245. database = psycopg2.connect(target_url)
  246. try:
  247. with database.cursor() as cursor:
  248. cursor.execute(
  249. "SELECT version_num FROM public.alembic_version"
  250. )
  251. assert cursor.fetchone()[0] == "20260723_150"
  252. finally:
  253. database.close()
  254. finally:
  255. with admin.cursor() as cursor:
  256. cursor.execute(
  257. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  258. "WHERE datname = %s AND pid <> pg_backend_pid()",
  259. (database_name,),
  260. )
  261. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  262. admin.close()
  263. @pytest.mark.integration
  264. def test_concurrent_alembic_upgrades_are_serialized():
  265. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  266. if not admin_url:
  267. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  268. parsed = make_url(admin_url)
  269. database_name = f"dataops_concurrent_{uuid.uuid4().hex[:12]}"
  270. target_url = parsed.set(database=database_name).render_as_string(
  271. hide_password=False
  272. )
  273. connection = psycopg2.connect(admin_url)
  274. connection.autocommit = True
  275. try:
  276. with connection.cursor() as cursor:
  277. cursor.execute(f'CREATE DATABASE "{database_name}"')
  278. env = os.environ.copy()
  279. env["SQLALCHEMY_DATABASE_URI"] = target_url
  280. command = [
  281. str(ROOT / ".venv" / "bin" / "alembic"),
  282. "-c",
  283. "alembic.ini",
  284. "upgrade",
  285. "head",
  286. ]
  287. processes = [
  288. subprocess.Popen(
  289. command,
  290. cwd=ROOT,
  291. env=env,
  292. stdout=subprocess.PIPE,
  293. stderr=subprocess.STDOUT,
  294. text=True,
  295. )
  296. for _ in range(2)
  297. ]
  298. results = [process.communicate(timeout=120) for process in processes]
  299. failures = [
  300. output
  301. for process, (output, _) in zip(processes, results)
  302. if process.returncode != 0
  303. ]
  304. assert failures == []
  305. engine = create_engine(target_url)
  306. try:
  307. assert set(inspect(engine).get_table_names(schema="public")) >= (
  308. EXPECTED_UPGRADED_TABLES | {"alembic_version"}
  309. )
  310. finally:
  311. engine.dispose()
  312. finally:
  313. with connection.cursor() as cursor:
  314. cursor.execute(
  315. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  316. "WHERE datname = %s AND pid <> pg_backend_pid()",
  317. (database_name,),
  318. )
  319. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  320. connection.close()