mysql_schema_migration_smoke.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. from __future__ import annotations
  2. import argparse
  3. import os
  4. import re
  5. from alembic import command
  6. from alembic.config import Config
  7. from sqlalchemy import create_engine, inspect, text
  8. from sqlalchemy.engine import make_url
  9. from supply_infra.config import InfraSettings
  10. from supply_infra.pipeline.preflight import REQUIRED_PIPELINE_TABLES
  11. _SAFE_DATABASE = re.compile(r"^supply_agent_preflight_[a-z0-9_]+$")
  12. def _quoted(identifier: str) -> str:
  13. if not re.fullmatch(r"[A-Za-z0-9_]+", identifier):
  14. raise ValueError(f"unsafe SQL identifier: {identifier!r}")
  15. return f"`{identifier}`"
  16. def run_smoke(
  17. target_database: str,
  18. *,
  19. downgrade_to: str | None = None,
  20. ) -> dict[str, object]:
  21. settings = InfraSettings()
  22. source_database = settings.mysql_database
  23. if not _SAFE_DATABASE.fullmatch(target_database):
  24. raise ValueError(
  25. "temporary database name must start with "
  26. "supply_agent_preflight_ and contain only lowercase letters, "
  27. "digits and underscores"
  28. )
  29. if target_database == source_database:
  30. raise ValueError("temporary database must differ from source database")
  31. source_url = make_url(settings.mysql_url)
  32. server_url = source_url.set(database=None)
  33. target_url = source_url.set(database=target_database)
  34. server_engine = create_engine(server_url)
  35. target_engine = None
  36. created = False
  37. try:
  38. with server_engine.begin() as connection:
  39. exists = connection.scalar(
  40. text(
  41. "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA "
  42. "WHERE SCHEMA_NAME = :database"
  43. ),
  44. {"database": target_database},
  45. )
  46. if int(exists or 0) > 0:
  47. raise RuntimeError(
  48. f"temporary database already exists: {target_database}"
  49. )
  50. connection.execute(
  51. text(
  52. f"CREATE DATABASE {_quoted(target_database)} "
  53. "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
  54. )
  55. )
  56. created = True
  57. source_engine = create_engine(source_url)
  58. try:
  59. source_tables = inspect(source_engine).get_table_names()
  60. with source_engine.connect() as connection:
  61. source_revision = connection.scalar(
  62. text("SELECT version_num FROM alembic_version")
  63. )
  64. finally:
  65. source_engine.dispose()
  66. with server_engine.begin() as connection:
  67. for table_name in source_tables:
  68. connection.execute(
  69. text(
  70. f"CREATE TABLE {_quoted(target_database)}."
  71. f"{_quoted(table_name)} LIKE "
  72. f"{_quoted(source_database)}.{_quoted(table_name)}"
  73. )
  74. )
  75. connection.execute(
  76. text(
  77. f"INSERT INTO {_quoted(target_database)}.`alembic_version` "
  78. f"SELECT * FROM {_quoted(source_database)}.`alembic_version`"
  79. )
  80. )
  81. alembic_config = Config("alembic.ini")
  82. target_url_text = target_url.render_as_string(hide_password=False)
  83. previous_alembic_url = os.environ.get("ALEMBIC_DATABASE_URL")
  84. os.environ["ALEMBIC_DATABASE_URL"] = target_url_text
  85. try:
  86. if downgrade_to is not None:
  87. command.downgrade(alembic_config, downgrade_to)
  88. command.upgrade(alembic_config, "head")
  89. finally:
  90. if previous_alembic_url is None:
  91. os.environ.pop("ALEMBIC_DATABASE_URL", None)
  92. else:
  93. os.environ["ALEMBIC_DATABASE_URL"] = previous_alembic_url
  94. target_engine = create_engine(target_url)
  95. with target_engine.connect() as connection:
  96. target_tables = set(inspect(connection).get_table_names())
  97. revision = connection.scalar(
  98. text("SELECT version_num FROM alembic_version")
  99. )
  100. missing = sorted(REQUIRED_PIPELINE_TABLES - target_tables)
  101. if missing:
  102. raise RuntimeError(
  103. f"migrated schema is missing required tables: {missing}"
  104. )
  105. source_engine = create_engine(source_url)
  106. try:
  107. current_source_tables = inspect(source_engine).get_table_names()
  108. with source_engine.connect() as connection:
  109. current_source_revision = connection.scalar(
  110. text("SELECT version_num FROM alembic_version")
  111. )
  112. finally:
  113. source_engine.dispose()
  114. if (
  115. current_source_tables != source_tables
  116. or current_source_revision != source_revision
  117. ):
  118. raise RuntimeError(
  119. "source schema changed during isolated migration smoke test"
  120. )
  121. return {
  122. "success": True,
  123. "source_database": source_database,
  124. "temporary_database": target_database,
  125. "downgrade_to": downgrade_to,
  126. "source_table_count": len(source_tables),
  127. "target_table_count": len(target_tables),
  128. "revision": revision,
  129. "missing_required_tables": missing,
  130. }
  131. finally:
  132. if target_engine is not None:
  133. target_engine.dispose()
  134. if created:
  135. with server_engine.begin() as connection:
  136. connection.execute(
  137. text(f"DROP DATABASE {_quoted(target_database)}")
  138. )
  139. server_engine.dispose()
  140. def main() -> None:
  141. parser = argparse.ArgumentParser(
  142. description="Clone the current MySQL schema and test Alembic upgrade"
  143. )
  144. parser.add_argument("--database", required=True)
  145. parser.add_argument(
  146. "--downgrade-to",
  147. help="First downgrade the isolated clone to this revision",
  148. )
  149. args = parser.parse_args()
  150. result = run_smoke(args.database, downgrade_to=args.downgrade_to)
  151. for key, value in result.items():
  152. print(f"{key}={value}")
  153. if __name__ == "__main__":
  154. main()