test_database_migrations.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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. "governance_audit_seals",
  59. }
  60. def test_data_research_ingestion_migration_is_additive_and_constrained():
  61. migration = (
  62. ROOT
  63. / "migrations"
  64. / "versions"
  65. / "20260722_100_data_research_ingestion.py"
  66. ).read_text(encoding="utf-8")
  67. assert 'revision = "20260722_100"' in migration
  68. assert 'down_revision = "20260720_100"' in migration
  69. for table in (
  70. "ingestion_sources",
  71. "source_artifacts",
  72. "ingestion_jobs",
  73. "evidence_fragments",
  74. "extraction_candidates",
  75. ):
  76. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  77. assert "idempotency_key" in migration
  78. assert "parser_version" in migration
  79. assert "locator JSONB" in migration
  80. assert "DROP TABLE" not in migration.upper()
  81. def test_catalog_execution_migration_adds_attempts_and_snapshots():
  82. migration = (
  83. ROOT
  84. / "migrations"
  85. / "versions"
  86. / "20260729_280_catalog_ingestion_execution.py"
  87. ).read_text(encoding="utf-8")
  88. assert 'revision = "20260729_280"' in migration
  89. assert 'down_revision = "20260729_270"' in migration
  90. assert "ADD COLUMN attempt_count" in migration
  91. assert "ADD COLUMN failure_stage" in migration
  92. assert "CREATE TABLE public.catalog_snapshots" in migration
  93. assert "UNIQUE (job_uid, attempt)" in migration
  94. assert "DROP TABLE" not in migration.upper()
  95. def test_device_asset_catalog_migration_is_versioned_and_traceable():
  96. migration = (
  97. ROOT
  98. / "migrations"
  99. / "versions"
  100. / "20260729_290_device_asset_catalog.py"
  101. ).read_text(encoding="utf-8")
  102. assert 'revision = "20260729_290"' in migration
  103. assert 'down_revision = "20260729_280"' in migration
  104. for table in (
  105. "device_assets",
  106. "device_asset_source_mappings",
  107. "device_asset_versions",
  108. ):
  109. assert f"CREATE TABLE public.{table}" in migration
  110. assert (
  111. "UNIQUE (source_uid, source_entity, asset_type, source_code)"
  112. in migration
  113. )
  114. assert "UNIQUE (asset_uid, version)" in migration
  115. assert "source_updated_at" in migration
  116. assert "snapshot JSONB NOT NULL" in migration
  117. assert "DROP TABLE" not in migration.upper()
  118. def test_device_semantics_migration_is_versioned_reviewed_and_traceable():
  119. migration = (
  120. ROOT
  121. / "migrations"
  122. / "versions"
  123. / "20260729_300_device_semantics.py"
  124. ).read_text(encoding="utf-8")
  125. assert 'revision = "20260729_300"' in migration
  126. assert 'down_revision = "20260729_290"' in migration
  127. for table in (
  128. "device_semantic_codes",
  129. "device_semantic_code_versions",
  130. "device_semantic_code_reviews",
  131. ):
  132. assert f"CREATE TABLE public.{table}" in migration
  133. assert "UNIQUE (ontology_uid, code_type, canonical_code)" in migration
  134. assert "UNIQUE (code_uid, version)" in migration
  135. assert "source_mappings JSONB NOT NULL" in migration
  136. assert "evidence_uids JSONB NOT NULL" in migration
  137. assert "decision IN ('approve','reject')" in migration
  138. assert "DROP TABLE" not in migration.upper()
  139. def test_device_entity_resolution_migration_is_non_destructive_and_reversible():
  140. migration = (
  141. ROOT
  142. / "migrations"
  143. / "versions"
  144. / "20260729_310_device_entity_resolution.py"
  145. ).read_text(encoding="utf-8")
  146. assert 'revision = "20260729_310"' in migration
  147. assert 'down_revision = "20260729_300"' in migration
  148. for table in (
  149. "device_entity_match_candidates",
  150. "device_entity_match_reviews",
  151. "device_entity_merge_events",
  152. "device_entity_merge_rollbacks",
  153. ):
  154. assert f"CREATE TABLE public.{table}" in migration
  155. assert "explanation JSONB NOT NULL" in migration
  156. assert "evidence_uids JSONB NOT NULL" in migration
  157. assert "decision IN ('approve','reject','auto_approve')" in migration
  158. assert "canonical_asset_uid" in migration
  159. assert "member_asset_uid" in migration
  160. assert "snapshot JSONB NOT NULL" in migration
  161. assert "DROP TABLE" not in migration.upper()
  162. assert "DELETE FROM public.device_assets" not in migration
  163. assert "UPDATE public.device_asset_source_mappings" not in migration
  164. def test_device_quality_migration_is_non_destructive_and_reversible():
  165. migration = (
  166. ROOT
  167. / "migrations"
  168. / "versions"
  169. / "20260729_320_device_quality.py"
  170. ).read_text(encoding="utf-8")
  171. assert 'revision = "20260729_320"' in migration
  172. assert 'down_revision = "20260729_310"' in migration
  173. for table in (
  174. "device_quality_profiles",
  175. "device_quality_profile_versions",
  176. "device_quality_runs",
  177. "device_quality_rule_results",
  178. "device_quality_violation_samples",
  179. "device_quality_asset_scores",
  180. ):
  181. assert f"CREATE TABLE public.{table}" in migration
  182. assert "rules JSONB NOT NULL" in migration
  183. assert "policy_hash CHAR(64) NOT NULL" in migration
  184. assert "evidence JSONB NOT NULL" in migration
  185. assert "UNIQUE (run_uid, rule_code)" in migration
  186. assert "UNIQUE (run_uid, asset_uid)" in migration
  187. assert "DROP TABLE" not in migration.upper()
  188. assert "DELETE FROM public.device_assets" not in migration
  189. assert "UPDATE public.device_asset_source_mappings" not in migration
  190. def test_device_quality_responsibility_type_migration_is_additive():
  191. migration = (
  192. ROOT
  193. / "migrations"
  194. / "versions"
  195. / "20260729_330_device_quality_responsibility_type.py"
  196. ).read_text(encoding="utf-8")
  197. assert 'revision = "20260729_330"' in migration
  198. assert 'down_revision = "20260729_320"' in migration
  199. assert "device_quality" in migration
  200. assert "DROP TABLE" not in migration.upper()
  201. assert "DELETE FROM" not in migration.upper()
  202. def test_governance_audit_seal_migration_is_append_only_and_constrained():
  203. migration = (
  204. ROOT
  205. / "migrations"
  206. / "versions"
  207. / "20260730_360_governance_audit_seals.py"
  208. ).read_text(encoding="utf-8")
  209. assert 'revision = "20260730_360"' in migration
  210. assert 'down_revision = "20260729_350"' in migration
  211. assert "CREATE TABLE public.governance_audit_seals" in migration
  212. assert "root_hash CHAR(64) NOT NULL" in migration
  213. assert "signature CHAR(64) NOT NULL" in migration
  214. assert "event_count INTEGER NOT NULL" in migration
  215. assert "raise RuntimeError" in migration
  216. assert "DROP TABLE" not in migration.upper()
  217. assert "DELETE FROM" not in migration.upper()
  218. def test_data_element_migration_adds_versioned_governance_tables():
  219. migration = (
  220. ROOT
  221. / "migrations"
  222. / "versions"
  223. / "20260722_105_data_research_elements.py"
  224. ).read_text(encoding="utf-8")
  225. assert 'revision = "20260722_105"' in migration
  226. assert 'down_revision = "20260722_100"' in migration
  227. for table in ("data_elements", "data_element_versions", "candidate_decisions"):
  228. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  229. assert "UNIQUE (data_element_uid, version)" in migration
  230. assert "DROP TABLE" not in migration.upper()
  231. def test_ontology_migration_adds_versioned_control_plane_tables():
  232. migration = (
  233. ROOT / "migrations" / "versions" / "20260722_110_data_research_ontology.py"
  234. ).read_text(encoding="utf-8")
  235. assert 'revision = "20260722_110"' in migration
  236. assert 'down_revision = "20260722_105"' in migration
  237. for table in (
  238. "ontologies",
  239. "ontology_versions",
  240. "ontology_domain_links",
  241. "ontology_change_sets",
  242. "ontology_publish_runs",
  243. ):
  244. assert f"CREATE TABLE IF NOT EXISTS public.{table}" in migration
  245. assert "UNIQUE (ontology_uid, version)" in migration
  246. assert "DROP TABLE" not in migration.upper()
  247. def test_alembic_configuration_is_environment_only():
  248. ini = (ROOT / "alembic.ini").read_text(encoding="utf-8")
  249. env = (ROOT / "migrations" / "env.py").read_text(encoding="utf-8")
  250. assert "sqlalchemy.url" not in ini
  251. assert "SQLALCHEMY_DATABASE_URI" in env
  252. assert "DATABASE_URL" in env
  253. assert "password" not in env.lower()
  254. def test_baseline_migration_is_non_destructive():
  255. baseline = (
  256. ROOT / "migrations" / "versions" / "20260716_01_baseline.py"
  257. ).read_text(encoding="utf-8")
  258. assert "def upgrade" in baseline
  259. assert "def downgrade" in baseline
  260. assert "drop_table" not in baseline
  261. for table in EXPECTED_BASELINE_TABLES:
  262. assert table in baseline
  263. @pytest.mark.integration
  264. def test_alembic_upgrade_is_repeatable_and_downgrade_preserves_tables():
  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_migration_{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 = [str(ROOT / ".venv" / "bin" / "alembic"), "-c", "alembic.ini"]
  281. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  282. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  283. engine = create_engine(target_url)
  284. try:
  285. tables = set(inspect(engine).get_table_names(schema="public"))
  286. assert EXPECTED_BASELINE_TABLES | {"alembic_version"} <= tables
  287. assert tables >= EXPECTED_UPGRADED_TABLES
  288. finally:
  289. engine.dispose()
  290. subprocess.run(command + ["downgrade", "-1"], cwd=ROOT, env=env, check=True)
  291. engine = create_engine(target_url)
  292. try:
  293. tables = set(inspect(engine).get_table_names(schema="public"))
  294. assert tables >= EXPECTED_BASELINE_TABLES
  295. finally:
  296. engine.dispose()
  297. finally:
  298. with connection.cursor() as cursor:
  299. cursor.execute(
  300. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  301. "WHERE datname = %s AND pid <> pg_backend_pid()",
  302. (database_name,),
  303. )
  304. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  305. connection.close()
  306. @pytest.mark.integration
  307. @pytest.mark.parametrize(
  308. ("legacy_rows", "diagnostic"),
  309. (
  310. ((1, 1), "duplicate rule_run_id"),
  311. ((101,), "rows above 100"),
  312. ),
  313. )
  314. def test_upgrade_from_150_rejects_malformed_legacy_samples(
  315. legacy_rows,
  316. diagnostic,
  317. ):
  318. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  319. if not admin_url:
  320. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  321. parsed = make_url(admin_url)
  322. database_name = f"dataops_evidence_{uuid.uuid4().hex[:12]}"
  323. target_url = parsed.set(database=database_name).render_as_string(
  324. hide_password=False
  325. )
  326. admin = psycopg2.connect(admin_url)
  327. admin.autocommit = True
  328. try:
  329. with admin.cursor() as cursor:
  330. cursor.execute(f'CREATE DATABASE "{database_name}"')
  331. env = os.environ.copy()
  332. env["SQLALCHEMY_DATABASE_URI"] = target_url
  333. command = [
  334. str(ROOT / ".venv" / "bin" / "alembic"),
  335. "-c",
  336. "alembic.ini",
  337. ]
  338. subprocess.run(
  339. command + ["upgrade", "20260723_150"],
  340. cwd=ROOT,
  341. env=env,
  342. check=True,
  343. )
  344. database = psycopg2.connect(target_url)
  345. try:
  346. database.autocommit = True
  347. with database.cursor() as cursor:
  348. cursor.execute("SET session_replication_role = replica")
  349. run_id = str(uuid.uuid4())
  350. cursor.execute(
  351. """
  352. INSERT INTO public.rule_runs (
  353. id, deployment_id, component_binding_id,
  354. rule_version_id, plan_hash, status, correlation_id
  355. ) VALUES (%s, %s, %s, %s, %s, 'failed', %s)
  356. """,
  357. (
  358. run_id,
  359. str(uuid.uuid4()),
  360. str(uuid.uuid4()),
  361. str(uuid.uuid4()),
  362. "a" * 64,
  363. str(uuid.uuid4()),
  364. ),
  365. )
  366. for count in legacy_rows:
  367. cursor.execute(
  368. """
  369. INSERT INTO public.rule_violation_samples (
  370. id, rule_run_id, artifact_ref, sample_count,
  371. redaction_policy, expires_at
  372. ) VALUES (%s, %s, %s, %s, 'legacy-v1', NOW())
  373. """,
  374. (
  375. str(uuid.uuid4()),
  376. run_id,
  377. f"minio://legacy/{uuid.uuid4()}",
  378. count,
  379. ),
  380. )
  381. cursor.execute("SET session_replication_role = origin")
  382. finally:
  383. database.close()
  384. failed = subprocess.run(
  385. command + ["upgrade", "head"],
  386. cwd=ROOT,
  387. env=env,
  388. text=True,
  389. capture_output=True,
  390. )
  391. assert failed.returncode != 0
  392. assert diagnostic in (failed.stdout + failed.stderr)
  393. database = psycopg2.connect(target_url)
  394. try:
  395. with database.cursor() as cursor:
  396. cursor.execute(
  397. "SELECT version_num FROM public.alembic_version"
  398. )
  399. assert cursor.fetchone()[0] == "20260723_150"
  400. finally:
  401. database.close()
  402. finally:
  403. with admin.cursor() as cursor:
  404. cursor.execute(
  405. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  406. "WHERE datname = %s AND pid <> pg_backend_pid()",
  407. (database_name,),
  408. )
  409. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  410. admin.close()
  411. @pytest.mark.integration
  412. def test_concurrent_alembic_upgrades_are_serialized():
  413. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  414. if not admin_url:
  415. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  416. parsed = make_url(admin_url)
  417. database_name = f"dataops_concurrent_{uuid.uuid4().hex[:12]}"
  418. target_url = parsed.set(database=database_name).render_as_string(
  419. hide_password=False
  420. )
  421. connection = psycopg2.connect(admin_url)
  422. connection.autocommit = True
  423. try:
  424. with connection.cursor() as cursor:
  425. cursor.execute(f'CREATE DATABASE "{database_name}"')
  426. env = os.environ.copy()
  427. env["SQLALCHEMY_DATABASE_URI"] = target_url
  428. command = [
  429. str(ROOT / ".venv" / "bin" / "alembic"),
  430. "-c",
  431. "alembic.ini",
  432. "upgrade",
  433. "head",
  434. ]
  435. processes = [
  436. subprocess.Popen(
  437. command,
  438. cwd=ROOT,
  439. env=env,
  440. stdout=subprocess.PIPE,
  441. stderr=subprocess.STDOUT,
  442. text=True,
  443. )
  444. for _ in range(2)
  445. ]
  446. results = [process.communicate(timeout=120) for process in processes]
  447. failures = [
  448. output
  449. for process, (output, _) in zip(processes, results)
  450. if process.returncode != 0
  451. ]
  452. assert failures == []
  453. engine = create_engine(target_url)
  454. try:
  455. assert set(inspect(engine).get_table_names(schema="public")) >= (
  456. EXPECTED_UPGRADED_TABLES | {"alembic_version"}
  457. )
  458. finally:
  459. engine.dispose()
  460. finally:
  461. with connection.cursor() as cursor:
  462. cursor.execute(
  463. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  464. "WHERE datname = %s AND pid <> pg_backend_pid()",
  465. (database_name,),
  466. )
  467. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  468. connection.close()