migrate-unified-database.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Plan, back up, migrate and verify the current library in the existing Parse DB.
  2. No credentials are accepted on the command line. The operator supplies a private
  3. libpq service/passfile configuration. Nothing switches a running service here.
  4. """
  5. import argparse
  6. from contextlib import closing
  7. import json
  8. import os
  9. from pathlib import Path
  10. import re
  11. import sqlite3
  12. import sys
  13. sys.dont_write_bytecode = True
  14. sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'server/voc-library'))
  15. import unified_store as store
  16. def write(path, body):
  17. path = Path(path)
  18. path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  19. with path.open('x', encoding='utf-8') as f:
  20. f.write(json.dumps(body, ensure_ascii=False, indent=2) + '\n')
  21. path.chmod(0o600)
  22. def source_snapshot(registry, database):
  23. store.need(bool(registry) != bool(database), 'ONE_CURRENT_LIBRARY_BINDING_REQUIRED')
  24. if registry:
  25. root = Path(registry).resolve()
  26. pointer_raw = (root / 'current.json').read_bytes()
  27. pointer = json.loads(pointer_raw)
  28. path = (root / pointer['file']).resolve()
  29. store.need(path.is_relative_to(root) and path.is_file(), 'INVALID_SOURCE_POINTER')
  30. return path, pointer, store.sha(pointer_raw)
  31. path = Path(database).resolve()
  32. store.need(path.is_file(), 'SOURCE_DATABASE_REQUIRED')
  33. return path, None, None
  34. def backup(source, destination):
  35. store.need(not destination.exists(), 'BACKUP_TARGET_EXISTS')
  36. with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)) as raw:
  37. with closing(sqlite3.connect(destination)) as saved:
  38. raw.backup(saved)
  39. store.need(saved.execute('PRAGMA integrity_check').fetchone()[0] == 'ok', 'BACKUP_INVALID')
  40. destination.chmod(0o600)
  41. def verify(config, sources=None):
  42. results = []
  43. for dataset in ('research', 'monitoring'):
  44. with store.connect_pg(config, readonly=True) as pg:
  45. desc = store.descriptor(pg, dataset)
  46. c = store.reconstruct(pg, desc)
  47. try:
  48. if sources:
  49. with closing(sqlite3.connect(sources[dataset].as_uri() + '?mode=ro', uri=True)) as source:
  50. store.compare_manifest(store.inventory(source), store.inventory(c))
  51. results.append({'dataset': dataset, 'generation': desc['generation'], 'revision': desc['revision'],
  52. 'fingerprint': desc['manifest']['fingerprint'], 'tables': len(desc['manifest']['tables']),
  53. 'rows': sum(t['count'] for t in desc['manifest']['tables']), 'allRowsVerified': True})
  54. finally:
  55. c.close()
  56. archived = []
  57. with store.connect_pg(config, readonly=True) as pg:
  58. active = store.descriptor(pg, 'research')
  59. for generation, storage, manifest in pg.execute('SELECT generation,storage_schema,manifest FROM '
  60. 'ksf_voc_control.snapshots WHERE dataset=\'research\' AND storage_schema<>%s ORDER BY generation', (active['storage_schema'],)).fetchall():
  61. c = store.reconstruct(pg, {'storage_schema': storage, 'manifest': manifest});c.close()
  62. archived.append({'generation': generation, 'fingerprint': manifest['fingerprint'], 'allRowsVerified': True})
  63. return {'ok': True, 'schema': store.VERSION, 'database': store.DATABASE, 'datasets': results,
  64. 'archivedResearchGenerations': archived,
  65. 'readOnly': True, 'databaseWrites': 0, 'businessRowsChanged': 0}
  66. def main():
  67. parser = argparse.ArgumentParser(description=__doc__)
  68. parser.add_argument('command', choices=('plan', 'apply', 'verify'))
  69. parser.add_argument('--config', required=True, help='Private unified connection configuration')
  70. source = parser.add_mutually_exclusive_group()
  71. source.add_argument('--registry')
  72. source.add_argument('--db')
  73. parser.add_argument('--monitoring-store')
  74. parser.add_argument('--expected-generation')
  75. parser.add_argument('--out', required=True, help='NEW private receipt/backup directory, outside source directories')
  76. args = parser.parse_args()
  77. applying = args.command == 'apply'
  78. try:
  79. output = Path(args.out).absolute()
  80. store.need(not output.exists() and not output.is_symlink(), 'NEW_OUTPUT_DIRECTORY_REQUIRED')
  81. if args.command == 'verify':
  82. result = verify(args.config)
  83. else:
  84. db, pointer, pointer_sha = source_snapshot(args.registry, args.db)
  85. monitor = Path(args.monitoring_store or '').resolve() / 'monitoring.sqlite'
  86. store.need(monitor.is_file(), 'CURRENT_MONITORING_DATABASE_REQUIRED')
  87. store.need(not any(output == p or output.is_relative_to(p) for p in (db.parent, monitor.parent)), 'OUTPUT_OVERLAPS_SOURCE')
  88. with closing(sqlite3.connect(db.as_uri() + '?mode=ro', uri=True)) as c:
  89. generation = dict(c.execute('SELECT key,value FROM meta'))['generation']
  90. store.need(args.expected_generation == generation, 'CURRENT_GENERATION_CONFIRMATION_REQUIRED')
  91. # Read-only target identity and both source inventories before any import.
  92. plans = [store.migrate(db, args.config, registry_pointer=pointer),
  93. store.migrate(monitor, args.config, 'monitoring')]
  94. history = []
  95. seen = {generation}
  96. for entry in (pointer or {}).get('history', []):
  97. if entry['generation'] in seen:
  98. continue
  99. path = (Path(args.registry).resolve() / entry['file']).resolve()
  100. store.need(path.is_relative_to(Path(args.registry).resolve()), 'INVALID_HISTORICAL_PATH')
  101. entry = {**entry, 'history': pointer.get('history', [])}
  102. plan = store.archive_research(path, args.config, entry)
  103. history.append((path, entry, plan))
  104. seen.add(entry['generation'])
  105. result = {'ok': True, 'schema': store.VERSION, 'readOnly': not applying,
  106. 'database': store.DATABASE, 'sourceGeneration': generation,
  107. 'sourcePointerSha256': pointer_sha, 'plans': plans,
  108. 'runningServicesSwitched': False, 'sourceDeleted': False, 'businessRowsChanged': 0}
  109. result['historicalGenerations'] = [p for _, _, p in history]
  110. if applying:
  111. output.mkdir(parents=True, mode=0o700)
  112. backup(db, output / 'research-before.sqlite')
  113. backup(monitor, output / 'monitoring-before.sqlite')
  114. if pointer:
  115. write(output / 'pointer-before.json', pointer)
  116. # Backups are independent and include any SQLite WAL contents.
  117. for name, path, plan in [('research', output / 'research-before.sqlite', plans[0]),
  118. ('monitoring', output / 'monitoring-before.sqlite', plans[1])]:
  119. with closing(sqlite3.connect(path)) as c:
  120. store.need(store.inventory(c)['fingerprint'] == plan['fingerprint'], 'SOURCE_CHANGED_BEFORE_BACKUP')
  121. result['imports'] = [store.migrate(output / 'research-before.sqlite', args.config,
  122. registry_pointer=pointer, apply=True),
  123. store.migrate(output / 'monitoring-before.sqlite', args.config,
  124. 'monitoring', apply=True)]
  125. result['historyImports'] = []
  126. for path, entry, plan in history:
  127. imported = store.archive_research(path, args.config, entry, apply=True)
  128. store.need(imported['fingerprint'] == plan['fingerprint'], 'HISTORICAL_SOURCE_CHANGED')
  129. result['historyImports'].append(imported)
  130. if args.registry:
  131. store.need(store.sha((Path(args.registry) / 'current.json').read_bytes()) == pointer_sha,
  132. 'SOURCE_GENERATION_CHANGED_DURING_IMPORT')
  133. # The operator pauses only source ingestion while taking and switching this baseline.
  134. result['verification'] = verify(args.config, {'research': db, 'monitoring': monitor})
  135. result['readyForServiceCutover'] = True
  136. write(output / 'migration-receipt.json', result)
  137. print(json.dumps({'ok': True, 'command': args.command, 'database': store.DATABASE,
  138. 'receipt': str(output / 'migration-receipt.json'),
  139. 'readyForServiceCutover': result.get('readyForServiceCutover', False)}, ensure_ascii=False))
  140. return 0
  141. except Exception as e:
  142. code = str(e) if isinstance(e, store.StoreError) and re.fullmatch('[A-Z][A-Z0-9_]+', str(e)) else 'MIGRATION_FAILED'
  143. print(json.dumps({'ok': False, 'error': {'code': code}, 'runningServicesSwitched': False,
  144. 'sourceDeleted': False, 'targetImportsMayExist': applying,
  145. 'note': 'Inspect the private backup and receipt directory; no credentials or raw error printed.'}))
  146. return 1
  147. if __name__ == '__main__':
  148. sys.exit(main())