check_postgres_upgrade.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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. Some upgrades only go wrong with data in the database; SCENARIOS builds those
  14. along a fixed chain of releases, adds the rows, upgrades and checks the data.
  15. scripts/postgres_schema_drift.txt can list known drift, each line the object
  16. and exactly how it differs. Drift not listed there fails, including a listed
  17. object that now differs in another way, and so does a listed line that no
  18. longer drifts. The drift found when this check was introduced has been fixed
  19. and the list is empty: fix new drift with a migration rather than listing it.
  20. Usage (needs git with the release tags and a PostgreSQL server the given user
  21. may create databases on; every database it creates is dropped again):
  22. python scripts/check_postgres_upgrade.py --url postgresql://user:pw@host:5432/postgres
  23. Never point it at a server holding data you care about.
  24. """
  25. import argparse
  26. import asyncio
  27. import os
  28. import re
  29. import subprocess
  30. import sys
  31. import tarfile
  32. import tempfile
  33. from collections import Counter
  34. from dataclasses import dataclass
  35. from pathlib import Path
  36. from urllib.parse import urlsplit, urlunsplit
  37. REPO = Path(__file__).resolve().parents[1]
  38. ALLOWLIST = REPO / "scripts" / "postgres_schema_drift.txt"
  39. # The first release with PostgreSQL support: the oldest schema an install can have.
  40. FIRST_PG_RELEASE = "v0.2.3"
  41. @dataclass(frozen=True)
  42. class Scenario:
  43. """An upgrade that needs data to go wrong.
  44. The database is built along ``chain`` (old releases, pinned: a newer one may
  45. no longer produce the state this needs), then ``seed`` runs and the current
  46. code upgrades it twice. ``check`` must then return no rows; each row it
  47. returns is a problem.
  48. """
  49. name: str
  50. chain: tuple[str, ...]
  51. seed: str
  52. check: str
  53. SCENARIOS = (
  54. Scenario(
  55. name="orphaned references",
  56. # v1.2.5.7 adds these columns to a v0.2.3 database without their foreign
  57. # keys, so deleting an archive, user or library file left ids behind.
  58. # Run 2 is such a leftover; its print_name matches archive 1, which an
  59. # every-start backfill that fills in a NULL archive_id would link it to.
  60. chain=("v0.2.3", "v1.2.5.7"),
  61. seed="""
  62. INSERT INTO users (id, username, role, is_active, auth_source) VALUES (1, 'kept', 'user', true, 'local');
  63. INSERT INTO library_files (id, filename, file_path, file_type, file_size, print_count, is_external)
  64. VALUES (1, 'kept.3mf', 'l/kept.3mf', '3mf', 1, 0, false);
  65. INSERT INTO print_archives (id, filename, file_path, file_size, status, quantity, is_favorite,
  66. print_name, library_file_id)
  67. VALUES (1, 'kept.3mf', 'a/kept.3mf', 1, 'completed', 1, false, 'Benchy', 1),
  68. (2, 'gone.3mf', 'a/gone.3mf', 1, 'completed', 1, false, 'Other', 999999);
  69. INSERT INTO print_log_entries (id, status, print_name, archive_id, created_by_id)
  70. VALUES (1, 'completed', 'Benchy', 1, 1),
  71. (2, 'completed', 'Benchy', 999999, 999999);
  72. """,
  73. check="""
  74. SELECT 'print_archives.library_file_id still points at a missing file'
  75. FROM print_archives WHERE library_file_id IS NOT NULL
  76. AND NOT EXISTS (SELECT 1 FROM library_files WHERE id = library_file_id)
  77. UNION ALL
  78. SELECT 'print_log_entries.archive_id still points at a missing archive'
  79. FROM print_log_entries WHERE archive_id IS NOT NULL
  80. AND NOT EXISTS (SELECT 1 FROM print_archives WHERE id = archive_id)
  81. UNION ALL
  82. SELECT 'print_log_entries.created_by_id still points at a missing user'
  83. FROM print_log_entries WHERE created_by_id IS NOT NULL
  84. AND NOT EXISTS (SELECT 1 FROM users WHERE id = created_by_id)
  85. UNION ALL
  86. SELECT 'the run of a deleted archive was linked to archive ' || archive_id
  87. FROM print_log_entries WHERE id = 2 AND archive_id IS NOT NULL
  88. UNION ALL
  89. SELECT 'a valid reference was changed'
  90. WHERE NOT EXISTS (SELECT 1 FROM print_log_entries WHERE id = 1 AND archive_id = 1 AND created_by_id = 1)
  91. OR NOT EXISTS (SELECT 1 FROM print_archives WHERE id = 1 AND library_file_id = 1)
  92. """,
  93. ),
  94. Scenario(
  95. name="column types",
  96. # v1.2.5.7 has these as TEXT and REAL in a v0.2.3 database, where a
  97. # fresh install has JSON and DOUBLE PRECISION. The values must survive
  98. # the conversion as the user saw them, and invalid JSON values must not
  99. # stop the upgrade (they are cleared). extra_float_digits = 0, which a
  100. # DBA may set, must not cost the floats their digits.
  101. chain=("v0.2.3", "v1.2.5.7"),
  102. seed="""
  103. DO $$ BEGIN
  104. EXECUTE format('ALTER DATABASE %I SET extra_float_digits = 0', current_database());
  105. END $$;
  106. INSERT INTO printers (id, name, serial_number, ip_address, access_code, auto_archive,
  107. awaiting_plate_clear, camera_rotation, external_camera_enabled, is_active,
  108. nozzle_count, plate_detection_enabled, print_hours_offset, runtime_seconds)
  109. VALUES (1, 'p', 'S1', '192.0.2.1', 'x', true, false, 0, false, true, 1, false, 0, 0);
  110. INSERT INTO print_archives (id, printer_id, filename, file_path, file_size, status, quantity, is_favorite)
  111. VALUES (1, 1, 'a.3mf', 'a/a.3mf', 1, 'completed', 1, false),
  112. (2, 1, 'b.3mf', 'a/b.3mf', 1, 'completed', 1, false),
  113. (3, 1, 'c.3mf', 'a/c.3mf', 1, 'completed', 1, false);
  114. INSERT INTO active_print_spoolman (id, printer_id, archive_id, ams_trays, tray_remain_start)
  115. VALUES (1, 1, 1, '{}', '{"0-1": {"remain": 80}}'),
  116. (2, 1, 2, '{}', 'not json'),
  117. (3, 1, 3, '{}', '{"0-1": {"remain": 80, "tray_uuid": NaN}}');
  118. INSERT INTO smart_plugs (id, name, plug_type, auto_off, auto_off_executed, auto_off_pending,
  119. auto_off_persistent, auto_on, enabled, mqtt_energy_multiplier, mqtt_multiplier,
  120. mqtt_power_multiplier, off_delay_minutes, off_delay_mode, off_temp_threshold,
  121. power_alert_enabled, schedule_enabled, show_in_switchbar, show_on_printer_card,
  122. rest_energy_total_multiplier)
  123. VALUES (1, 'plug', 'rest', false, false, false, false, false, true, 1, 1, 1, 0, 'time', 70,
  124. false, false, false, false, 0.001);
  125. INSERT INTO spool (id, material, core_weight, label_weight, weight_locked, weight_used,
  126. weight_used_baseline)
  127. VALUES (1, 'PLA', 250, 1000, false, 1234.567, 1234.567);
  128. """,
  129. check="""
  130. SELECT 'active_print_spoolman.tray_remain_start is ' || pg_typeof(tray_remain_start) || ', not json'
  131. FROM active_print_spoolman WHERE id = 1 AND pg_typeof(tray_remain_start)::text <> 'json'
  132. UNION ALL
  133. SELECT 'active_print_spoolman.tray_remain_start changed: ' || tray_remain_start::text
  134. FROM active_print_spoolman
  135. WHERE id = 1 AND tray_remain_start::text::jsonb IS DISTINCT FROM '{"0-1": {"remain": 80}}'::jsonb
  136. UNION ALL
  137. SELECT 'an invalid tray_remain_start was kept: ' || tray_remain_start::text
  138. FROM active_print_spoolman WHERE id IN (2, 3) AND tray_remain_start IS NOT NULL
  139. UNION ALL
  140. SELECT 'smart_plugs.rest_energy_total_multiplier is ' || rest_energy_total_multiplier::text
  141. FROM smart_plugs
  142. WHERE id = 1 AND rest_energy_total_multiplier IS DISTINCT FROM CAST(0.001 AS double precision)
  143. UNION ALL
  144. SELECT 'spool.weight_used_baseline is ' || weight_used_baseline::text
  145. FROM spool WHERE id = 1 AND weight_used_baseline IS DISTINCT FROM CAST(1234.567 AS double precision)
  146. """,
  147. ),
  148. Scenario(
  149. name="NULLs in columns that become NOT NULL",
  150. # A v0.2.3 database upgraded by v1.2.5.7 has these columns nullable.
  151. # A NULL is filled with the model's default, except an API key's
  152. # permissions: the code reads NULL as denied, and that must stay so
  153. # rather than become the default (granted). An OIDC provider with no
  154. # email claim never resolved an email, so it never linked an account:
  155. # filling the claim must not start that, so auto-linking goes off.
  156. chain=("v0.2.3", "v1.2.5.7"),
  157. seed="""
  158. INSERT INTO api_keys (id, name, key_hash, key_prefix, enabled, can_queue, can_control_printer,
  159. can_read_status, can_manage_archives, can_access_cloud)
  160. VALUES (1, 'k', 'hash', 'bb_', true, false, false, true, NULL, NULL);
  161. INSERT INTO library_files (id, filename, file_path, file_type, file_size, print_count, is_external,
  162. variant_position)
  163. VALUES (1, 'f.3mf', 'l/f.3mf', '3mf', 1, 0, false, NULL);
  164. INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,
  165. auto_create_users, auto_link_existing_accounts, email_claim,
  166. require_email_verified)
  167. VALUES (1, 'unsafe once filled', 'https://idp.example', 'c', 's', 'openid', true, false, true, NULL,
  168. false),
  169. (2, 'strict once filled', 'https://idp2.example', 'c', 's', 'openid', true, false, true, NULL,
  170. NULL),
  171. (3, 'has a claim', 'https://idp3.example', 'c', 's', 'openid', true, false, true, 'upn', NULL);
  172. """,
  173. check="""
  174. SELECT 'api_keys.can_manage_archives is ' || coalesce(can_manage_archives::text, 'NULL')
  175. FROM api_keys WHERE id = 1 AND can_manage_archives IS DISTINCT FROM false
  176. UNION ALL
  177. SELECT 'api_keys.can_access_cloud is ' || coalesce(can_access_cloud::text, 'NULL')
  178. FROM api_keys WHERE id = 1 AND can_access_cloud IS DISTINCT FROM false
  179. UNION ALL
  180. SELECT 'library_files.variant_position is ' || coalesce(variant_position::text, 'NULL')
  181. FROM library_files WHERE id = 1 AND variant_position IS DISTINCT FROM 0
  182. UNION ALL
  183. SELECT 'oidc_providers ' || id || ': ' || coalesce(email_claim, 'NULL') || ', verified '
  184. || coalesce(require_email_verified::text, 'NULL') || ', auto-link '
  185. || auto_link_existing_accounts::text
  186. FROM oidc_providers
  187. WHERE (id = 1 AND (email_claim IS DISTINCT FROM 'email' OR require_email_verified IS DISTINCT FROM false
  188. OR auto_link_existing_accounts))
  189. OR (id = 2 AND (email_claim IS DISTINCT FROM 'email' OR require_email_verified IS DISTINCT FROM true
  190. OR auto_link_existing_accounts))
  191. OR (id = 3 AND (email_claim IS DISTINCT FROM 'upn' OR require_email_verified IS DISTINCT FROM true
  192. OR NOT auto_link_existing_accounts))
  193. """,
  194. ),
  195. )
  196. _INIT_DB = """
  197. import asyncio
  198. from backend.app.core import database
  199. from backend.app.core.config import settings
  200. assert settings.database_url.startswith("postgresql+asyncpg://"), settings.database_url
  201. asyncio.run(database.init_db())
  202. """
  203. def newest_stable_release() -> str:
  204. tags = subprocess.run(
  205. ["git", "tag", "--list", "v*", "--sort=-v:refname"], cwd=REPO, check=True, capture_output=True, text=True
  206. ).stdout.split()
  207. stable = [t for t in tags if re.fullmatch(r"v\d+(\.\d+)+", t)]
  208. if not stable:
  209. sys.exit("no release tags found; fetch them (git fetch --tags)")
  210. return stable[0]
  211. def extract_release(tag: str, into: Path) -> Path:
  212. """The release's backend/ as a directory to run init_db from (git archive, read-only)."""
  213. target = into / tag
  214. if target.exists():
  215. return target
  216. archive = into / f"{tag}.tar"
  217. with archive.open("wb") as out:
  218. subprocess.run(["git", "archive", tag, "backend"], cwd=REPO, check=True, stdout=out)
  219. with tarfile.open(archive) as tar:
  220. tar.extractall(target, filter="data")
  221. return target
  222. def run_init_db(code_root: Path, url: str, label: str) -> None:
  223. """Run init_db (create_all + migrations) of the code under code_root against url."""
  224. with tempfile.TemporaryDirectory() as data_dir:
  225. env = {**os.environ, "DATABASE_URL": url, "DATA_DIR": data_dir, "PYTHONPATH": str(code_root)}
  226. done = subprocess.run([sys.executable, "-c", _INIT_DB], cwd=data_dir, env=env, capture_output=True, text=True)
  227. if done.returncode != 0:
  228. sys.exit(f"init_db failed ({label}):\n{done.stdout}{done.stderr}")
  229. def with_database(url: str, name: str, driver: str = "") -> str:
  230. parts = urlsplit(url)
  231. scheme = "postgresql" + (f"+{driver}" if driver else "")
  232. return urlunsplit((scheme, parts.netloc, f"/{name}", parts.query, parts.fragment))
  233. async def dump_schema(url: str) -> dict[str, str]:
  234. """Every column, constraint and index of the public schema, as {object: definition}."""
  235. import asyncpg
  236. conn = await asyncpg.connect(url)
  237. try:
  238. schema: dict[str, str] = {}
  239. for row in await conn.fetch(
  240. "SELECT table_name, column_name, data_type, udt_name, character_maximum_length, numeric_precision, "
  241. "numeric_scale, datetime_precision, is_nullable, column_default "
  242. "FROM information_schema.columns WHERE table_schema = 'public'"
  243. ):
  244. table, column, data_type, udt, length, precision, scale, dt_precision, nullable, default = row
  245. # udt_name names what data_type leaves generic: the element type of an
  246. # ARRAY (_int4), the enum or domain of a USER-DEFINED column.
  247. attributes = [f"udt {udt}"]
  248. if length is not None:
  249. attributes.append(f"length {length}")
  250. if precision is not None:
  251. attributes.append(f"precision {precision}")
  252. if scale is not None:
  253. attributes.append(f"scale {scale}")
  254. if dt_precision is not None:
  255. attributes.append(f"time precision {dt_precision}")
  256. schema[f"column {table}.{column}"] = (
  257. f"{data_type} ({', '.join(attributes)})"
  258. + (" NULL" if nullable == "YES" else " NOT NULL")
  259. + f" default {default}"
  260. )
  261. # Counted: two identical constraints or indexes are drift too (an
  262. # unnamed constraint that every start adds again, say).
  263. objects: Counter[str] = Counter()
  264. for row in await conn.fetch(
  265. "SELECT conrelid::regclass::text, pg_get_constraintdef(oid) FROM pg_constraint "
  266. "WHERE connamespace = 'public'::regnamespace"
  267. ):
  268. objects[f"constraint {row[0]}: {row[1]}"] += 1
  269. for row in await conn.fetch("SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = 'public'"):
  270. definition = re.sub(r"^CREATE (UNIQUE )?INDEX \S+ ON ", r"CREATE \1INDEX ON ", row[1])
  271. objects[f"index {row[0]}: {definition}"] += 1
  272. schema.update({key: f"{count}x" for key, count in objects.items()})
  273. return schema
  274. finally:
  275. await conn.close()
  276. def drift(
  277. fresh: dict[str, str], upgraded: dict[str, str], labels: tuple[str, str] = ("fresh install", "upgraded")
  278. ) -> set[str]:
  279. """One line per object that differs between the two schemas: the object and how."""
  280. return {
  281. f"{key} | {labels[0]}: {fresh.get(key) or 'missing'}; {labels[1]}: {upgraded.get(key) or 'missing'}"
  282. for key in fresh.keys() | upgraded.keys()
  283. if fresh.get(key) != upgraded.get(key)
  284. }
  285. def read_allowlist(path: Path) -> set[str]:
  286. """The allowlist's lines; '#' starts a comment only at the beginning of a line."""
  287. if not path.exists():
  288. return set()
  289. lines = (line.strip() for line in path.read_text(encoding="utf-8").splitlines())
  290. return {line for line in lines if line and not line.startswith("#")}
  291. def compare(found: dict[str, set[str]], allowed: set[str]) -> list[str]:
  292. """Problems: drift (per start release) not in the allowlist, and allowlist lines no start shows."""
  293. problems = []
  294. for release, lines in sorted(found.items()):
  295. for line in sorted(lines - allowed):
  296. problems.append(f"upgraded from {release}: {line}")
  297. seen = set().union(*found.values()) if found else set()
  298. for line in sorted(allowed - seen):
  299. problems.append(f"no longer drifts, remove it from {ALLOWLIST.name}: {line}")
  300. return problems
  301. def start_twice(url: str, label: str) -> tuple[dict[str, str], list[str]]:
  302. """Start the current code on url twice; the schema, and what the restart changed."""
  303. run_init_db(REPO, url, label)
  304. first = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
  305. run_init_db(REPO, url, f"{label}, restarted")
  306. second = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
  307. changed = drift(first, second, ("first start", "restart"))
  308. return second, [f"{label}: a restart changed the schema: {line}" for line in sorted(changed)]
  309. def run_scenario(scenario: Scenario, url: str, releases_dir: Path) -> list[str]:
  310. """Build, seed and upgrade the scenario's database; its problems."""
  311. for release in scenario.chain:
  312. run_init_db(extract_release(release, releases_dir), url, f"{scenario.name}: {release} install")
  313. plain_url = with_database(url, urlsplit(url).path.lstrip("/"))
  314. asyncio.run(_admin(plain_url, scenario.seed))
  315. _, problems = start_twice(url, f"{scenario.name} (via {', '.join(scenario.chain)})")
  316. rows = asyncio.run(_fetch(plain_url, scenario.check))
  317. return problems + [f"{scenario.name}: {row[0]}" for row in rows]
  318. async def _fetch(url: str, query: str) -> list:
  319. import asyncpg
  320. conn = await asyncpg.connect(url)
  321. try:
  322. return await conn.fetch(query)
  323. finally:
  324. await conn.close()
  325. async def _admin(url: str, *statements: str) -> None:
  326. import asyncpg
  327. conn = await asyncpg.connect(url)
  328. try:
  329. for statement in statements:
  330. await conn.execute(statement)
  331. finally:
  332. await conn.close()
  333. def main() -> int:
  334. parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
  335. parser.add_argument("--url", required=True, help="PostgreSQL server URL (its database is only used to connect)")
  336. parser.add_argument(
  337. "--from-release", action="append", help=f"start release(s); default {FIRST_PG_RELEASE} and the newest stable"
  338. )
  339. args = parser.parse_args()
  340. releases = args.from_release or sorted({FIRST_PG_RELEASE, newest_stable_release()})
  341. admin_url = with_database(args.url, urlsplit(args.url).path.lstrip("/") or "postgres")
  342. databases = (
  343. {"fresh": "bambuddy_check_fresh"}
  344. | {r: "bambuddy_check_" + re.sub(r"\W", "_", r) for r in releases}
  345. | {s.name: f"bambuddy_check_scenario_{i}" for i, s in enumerate(SCENARIOS)}
  346. )
  347. asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
  348. asyncio.run(_admin(admin_url, *(f"CREATE DATABASE {db}" for db in databases.values())))
  349. try:
  350. with tempfile.TemporaryDirectory() as tmp:
  351. fresh, problems = start_twice(with_database(args.url, databases["fresh"], "asyncpg"), "fresh install")
  352. found = {}
  353. for release in releases:
  354. url = with_database(args.url, databases[release], "asyncpg")
  355. run_init_db(extract_release(release, Path(tmp)), url, f"{release} install")
  356. upgraded, changed = start_twice(url, f"upgrade from {release}")
  357. problems += changed
  358. found[release] = drift(fresh, upgraded)
  359. print(f"{release}: upgrade ok, {len(found[release])} object(s) differ from a fresh install")
  360. for scenario in SCENARIOS:
  361. url = with_database(args.url, databases[scenario.name], "asyncpg")
  362. scenario_problems = run_scenario(scenario, url, Path(tmp))
  363. problems += scenario_problems
  364. print(f"{scenario.name}: {'ok' if not scenario_problems else 'FAILED'}")
  365. finally:
  366. asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
  367. allowed = read_allowlist(ALLOWLIST)
  368. problems += compare(found, allowed)
  369. if problems:
  370. print("\nSchema problems:\n " + "\n ".join(problems))
  371. print(
  372. "\nA new column or constraint needs a migration in core/database.py that builds it exactly as the "
  373. "model does. See the docstring of scripts/check_postgres_upgrade.py."
  374. )
  375. return 1
  376. print("Upgraded schemas match a fresh install" + (" (apart from the listed known drift)." if allowed else "."))
  377. return 0
  378. if __name__ == "__main__":
  379. sys.exit(main())