check_postgres_upgrade.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """Check that upgrading a PostgreSQL database gives the schema of a fresh install.
  2. A fresh install gets every table from the models (``create_all``); an existing
  3. database only gets what the migrations in ``core/database.py`` add to it. Run
  4. on an empty database, as a plain migration check would, ``create_all`` builds
  5. everything first and the migrations have nothing left to do, so a migration
  6. that is missing or adds a column differently goes unnoticed.
  7. This builds a database with an older release's code, upgrades it with the
  8. current code twice, and compares its schema with a fresh install: every column
  9. (type with length, precision and element type, nullability, default), and how
  10. many of each constraint and index there are. Constraints and indexes are
  11. compared by definition, so only a name differing is not drift. A restart must
  12. leave every schema, fresh or upgraded, exactly as the first start left it.
  13. Drift that existed when this check was introduced is listed in
  14. scripts/postgres_schema_drift.txt, each line the object and exactly how it
  15. differs. Drift not listed there fails, including a listed object that now
  16. differs in another way, and so does a listed line that no longer drifts:
  17. remove it.
  18. Usage (needs git with the release tags and a PostgreSQL server the given user
  19. may create databases on; every database it creates is dropped again):
  20. python scripts/check_postgres_upgrade.py --url postgresql://user:pw@host:5432/postgres
  21. Never point it at a server holding data you care about.
  22. """
  23. import argparse
  24. import asyncio
  25. import os
  26. import re
  27. import subprocess
  28. import sys
  29. import tarfile
  30. import tempfile
  31. from collections import Counter
  32. from pathlib import Path
  33. from urllib.parse import urlsplit, urlunsplit
  34. REPO = Path(__file__).resolve().parents[1]
  35. ALLOWLIST = REPO / "scripts" / "postgres_schema_drift.txt"
  36. # The first release with PostgreSQL support: the oldest schema an install can have.
  37. FIRST_PG_RELEASE = "v0.2.3"
  38. _INIT_DB = """
  39. import asyncio
  40. from backend.app.core import database
  41. from backend.app.core.config import settings
  42. assert settings.database_url.startswith("postgresql+asyncpg://"), settings.database_url
  43. asyncio.run(database.init_db())
  44. """
  45. def newest_stable_release() -> str:
  46. tags = subprocess.run(
  47. ["git", "tag", "--list", "v*", "--sort=-v:refname"], cwd=REPO, check=True, capture_output=True, text=True
  48. ).stdout.split()
  49. stable = [t for t in tags if re.fullmatch(r"v\d+(\.\d+)+", t)]
  50. if not stable:
  51. sys.exit("no release tags found; fetch them (git fetch --tags)")
  52. return stable[0]
  53. def extract_release(tag: str, into: Path) -> Path:
  54. """The release's backend/ as a directory to run init_db from (git archive, read-only)."""
  55. archive = into / f"{tag}.tar"
  56. with archive.open("wb") as out:
  57. subprocess.run(["git", "archive", tag, "backend"], cwd=REPO, check=True, stdout=out)
  58. target = into / tag
  59. with tarfile.open(archive) as tar:
  60. tar.extractall(target, filter="data")
  61. return target
  62. def run_init_db(code_root: Path, url: str, label: str) -> None:
  63. """Run init_db (create_all + migrations) of the code under code_root against url."""
  64. with tempfile.TemporaryDirectory() as data_dir:
  65. env = {**os.environ, "DATABASE_URL": url, "DATA_DIR": data_dir, "PYTHONPATH": str(code_root)}
  66. done = subprocess.run([sys.executable, "-c", _INIT_DB], cwd=data_dir, env=env, capture_output=True, text=True)
  67. if done.returncode != 0:
  68. sys.exit(f"init_db failed ({label}):\n{done.stdout}{done.stderr}")
  69. def with_database(url: str, name: str, driver: str = "") -> str:
  70. parts = urlsplit(url)
  71. scheme = "postgresql" + (f"+{driver}" if driver else "")
  72. return urlunsplit((scheme, parts.netloc, f"/{name}", parts.query, parts.fragment))
  73. async def dump_schema(url: str) -> dict[str, str]:
  74. """Every column, constraint and index of the public schema, as {object: definition}."""
  75. import asyncpg
  76. conn = await asyncpg.connect(url)
  77. try:
  78. schema: dict[str, str] = {}
  79. for row in await conn.fetch(
  80. "SELECT table_name, column_name, data_type, udt_name, character_maximum_length, numeric_precision, "
  81. "numeric_scale, datetime_precision, is_nullable, column_default "
  82. "FROM information_schema.columns WHERE table_schema = 'public'"
  83. ):
  84. table, column, data_type, udt, length, precision, scale, dt_precision, nullable, default = row
  85. # udt_name names what data_type leaves generic: the element type of an
  86. # ARRAY (_int4), the enum or domain of a USER-DEFINED column.
  87. attributes = [f"udt {udt}"]
  88. if length is not None:
  89. attributes.append(f"length {length}")
  90. if precision is not None:
  91. attributes.append(f"precision {precision}")
  92. if scale is not None:
  93. attributes.append(f"scale {scale}")
  94. if dt_precision is not None:
  95. attributes.append(f"time precision {dt_precision}")
  96. schema[f"column {table}.{column}"] = (
  97. f"{data_type} ({', '.join(attributes)})"
  98. + (" NULL" if nullable == "YES" else " NOT NULL")
  99. + f" default {default}"
  100. )
  101. # Counted: two identical constraints or indexes are drift too (an
  102. # unnamed constraint that every start adds again, say).
  103. objects: Counter[str] = Counter()
  104. for row in await conn.fetch(
  105. "SELECT conrelid::regclass::text, pg_get_constraintdef(oid) FROM pg_constraint "
  106. "WHERE connamespace = 'public'::regnamespace"
  107. ):
  108. objects[f"constraint {row[0]}: {row[1]}"] += 1
  109. for row in await conn.fetch("SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = 'public'"):
  110. definition = re.sub(r"^CREATE (UNIQUE )?INDEX \S+ ON ", r"CREATE \1INDEX ON ", row[1])
  111. objects[f"index {row[0]}: {definition}"] += 1
  112. schema.update({key: f"{count}x" for key, count in objects.items()})
  113. return schema
  114. finally:
  115. await conn.close()
  116. def drift(
  117. fresh: dict[str, str], upgraded: dict[str, str], labels: tuple[str, str] = ("fresh install", "upgraded")
  118. ) -> set[str]:
  119. """One line per object that differs between the two schemas: the object and how."""
  120. return {
  121. f"{key} | {labels[0]}: {fresh.get(key) or 'missing'}; {labels[1]}: {upgraded.get(key) or 'missing'}"
  122. for key in fresh.keys() | upgraded.keys()
  123. if fresh.get(key) != upgraded.get(key)
  124. }
  125. def read_allowlist(path: Path) -> set[str]:
  126. """The allowlist's lines; '#' starts a comment only at the beginning of a line."""
  127. if not path.exists():
  128. return set()
  129. lines = (line.strip() for line in path.read_text(encoding="utf-8").splitlines())
  130. return {line for line in lines if line and not line.startswith("#")}
  131. def compare(found: dict[str, set[str]], allowed: set[str]) -> list[str]:
  132. """Problems: drift (per start release) not in the allowlist, and allowlist lines no start shows."""
  133. problems = []
  134. for release, lines in sorted(found.items()):
  135. for line in sorted(lines - allowed):
  136. problems.append(f"upgraded from {release}: {line}")
  137. seen = set().union(*found.values()) if found else set()
  138. for line in sorted(allowed - seen):
  139. problems.append(f"no longer drifts, remove it from {ALLOWLIST.name}: {line}")
  140. return problems
  141. def start_twice(url: str, label: str) -> tuple[dict[str, str], list[str]]:
  142. """Start the current code on url twice; the schema, and what the restart changed."""
  143. run_init_db(REPO, url, label)
  144. first = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
  145. run_init_db(REPO, url, f"{label}, restarted")
  146. second = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
  147. changed = drift(first, second, ("first start", "restart"))
  148. return second, [f"{label}: a restart changed the schema: {line}" for line in sorted(changed)]
  149. async def _admin(url: str, *statements: str) -> None:
  150. import asyncpg
  151. conn = await asyncpg.connect(url)
  152. try:
  153. for statement in statements:
  154. await conn.execute(statement)
  155. finally:
  156. await conn.close()
  157. def main() -> int:
  158. parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
  159. parser.add_argument("--url", required=True, help="PostgreSQL server URL (its database is only used to connect)")
  160. parser.add_argument(
  161. "--from-release", action="append", help=f"start release(s); default {FIRST_PG_RELEASE} and the newest stable"
  162. )
  163. args = parser.parse_args()
  164. releases = args.from_release or sorted({FIRST_PG_RELEASE, newest_stable_release()})
  165. admin_url = with_database(args.url, urlsplit(args.url).path.lstrip("/") or "postgres")
  166. databases = {"fresh": "bambuddy_check_fresh"} | {r: "bambuddy_check_" + re.sub(r"\W", "_", r) for r in releases}
  167. asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
  168. asyncio.run(_admin(admin_url, *(f"CREATE DATABASE {db}" for db in databases.values())))
  169. try:
  170. with tempfile.TemporaryDirectory() as tmp:
  171. fresh, problems = start_twice(with_database(args.url, databases["fresh"], "asyncpg"), "fresh install")
  172. found = {}
  173. for release in releases:
  174. url = with_database(args.url, databases[release], "asyncpg")
  175. run_init_db(extract_release(release, Path(tmp)), url, f"{release} install")
  176. upgraded, changed = start_twice(url, f"upgrade from {release}")
  177. problems += changed
  178. found[release] = drift(fresh, upgraded)
  179. print(f"{release}: upgrade ok, {len(found[release])} object(s) differ from a fresh install")
  180. finally:
  181. asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
  182. problems += compare(found, read_allowlist(ALLOWLIST))
  183. if problems:
  184. print("\nSchema problems:\n " + "\n ".join(problems))
  185. print(
  186. "\nA new column or constraint needs a migration in core/database.py that builds it exactly as the "
  187. "model does. See the docstring of scripts/check_postgres_upgrade.py."
  188. )
  189. return 1
  190. print("Upgraded schemas match a fresh install (apart from the listed known drift).")
  191. return 0
  192. if __name__ == "__main__":
  193. sys.exit(main())