test_database_migrations.py 13 KB

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