test_database_migrations.py 12 KB

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