test_database_migrations.py 14 KB

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