verify-optional-stage.py 5.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. """Verify migration and boot against a private, network-isolated database copy."""
  2. import argparse
  3. import hashlib
  4. import json
  5. import os
  6. import secrets
  7. import subprocess
  8. import time
  9. from pathlib import Path
  10. from urllib.request import urlopen
  11. parser = argparse.ArgumentParser()
  12. parser.add_argument('step', choices=['prepare', 'start', 'check'])
  13. args = parser.parse_args()
  14. os.umask(0o077)
  15. root = Path('/opt/chengchuan-relay')
  16. release = root / 'releases/optional-controls-20260915'
  17. evidence = root / 'evidence/optional-controls-20260915'
  18. db, cache, app, network = ['optional-release-' + name + '-20260915' for name in ['db','redis','app','network']]
  19. def run(command, **kwargs):
  20. return subprocess.check_output(command, **kwargs)
  21. def sql(statement):
  22. return run(['docker','exec',db,'psql','-U','relay','-d','relay','-At','-v','ON_ERROR_STOP=1','-c',statement]).decode().strip()
  23. def fingerprint():
  24. return {table: sql("SELECT md5(COALESCE(string_agg(row_to_json(t)::text,E'\\n' ORDER BY id),'')) FROM " + table + ' t')
  25. for table in ['users','accounts','api_keys','usage_logs','account_tests']}
  26. if args.step == 'prepare':
  27. settings = dict(line.split('=',1) for line in (release/'source/deploy/.env').read_text().splitlines() if line and not line.startswith('#') and '=' in line)
  28. run(['docker','network','create','--internal',network])
  29. postgres_env = release/'stage-postgres.env'
  30. password = secrets.token_urlsafe(30)
  31. postgres_env.write_text('POSTGRES_USER=relay\nPOSTGRES_DB=relay\nPOSTGRES_PASSWORD='+password+'\n')
  32. run(['docker','run','-d','--name',db,'--network',network,'--network-alias','postgres','--memory','768m','--env-file',str(postgres_env),'postgres:18-alpine'])
  33. run(['docker','run','-d','--name',cache,'--network',network,'--network-alias','redis','--memory','128m','redis:7-alpine'])
  34. ready = False
  35. for _ in range(40):
  36. if subprocess.run(['docker','exec',db,'pg_isready','-U','relay'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode == 0:
  37. ready=True; break
  38. time.sleep(1)
  39. assert ready, 'staging database did not start'
  40. with (root/'backups/before-optional-controls-20260915/database-before.dump').open('rb') as dump:
  41. subprocess.run(['docker','exec','-i',db,'pg_restore','-U','relay','-d','relay','--no-owner','--no-acl','--exit-on-error'],stdin=dump,check=True)
  42. before = fingerprint()
  43. original = sql("SELECT md5(COALESCE(string_agg(to_jsonb(t)::text,E'\\n' ORDER BY id),'')) FROM account_tests t")
  44. migration = (release/'source/backend/migrations/248_intelligent_assessment_v2.sql').read_text().strip()
  45. checksum = hashlib.sha256(migration.encode()).hexdigest()
  46. # Run the exact new migration transactionally on the copy, then confirm idempotency.
  47. statement = "BEGIN;"+migration+"\nINSERT INTO schema_migrations(filename,checksum) VALUES ('248_intelligent_assessment_v2.sql','"+checksum+"');COMMIT;"
  48. run(['docker','exec','-i',db,'psql','-U','relay','-d','relay','-v','ON_ERROR_STOP=1'],input=statement.encode())
  49. run(['docker','exec','-i',db,'psql','-U','relay','-d','relay','-v','ON_ERROR_STOP=1'],input=('BEGIN;'+migration+'\nCOMMIT;').encode())
  50. after = fingerprint()
  51. # account_tests gained scheduling columns, compare original fields separately below.
  52. unchanged = all(before[t] == after[t] for t in ['users','accounts','api_keys','usage_logs'])
  53. assert unchanged, 'migration altered unrelated business rows'
  54. account_tests_original = sql("SELECT md5(COALESCE(string_agg((to_jsonb(t)-'available_at'-'queue_reason')::text,E'\\n' ORDER BY id),'')) FROM account_tests t")
  55. assert original == account_tests_original, 'historical test fields changed'
  56. env = {'AUTO_SETUP':'true','SERVER_HOST':'0.0.0.0','SERVER_PORT':'8080','SERVER_MODE':'release','RUN_MODE':'standard',
  57. 'DATABASE_HOST':'postgres','DATABASE_PORT':'5432','DATABASE_USER':'relay','DATABASE_PASSWORD':password,'DATABASE_DBNAME':'relay','DATABASE_SSLMODE':'disable',
  58. 'REDIS_HOST':'redis','REDIS_PORT':'6379','REDIS_PASSWORD':'','JWT_SECRET':secrets.token_hex(32),'TOTP_ENCRYPTION_KEY':settings['TOTP_ENCRYPTION_KEY'],
  59. 'ADMIN_EMAIL':settings['ADMIN_EMAIL'],'ADMIN_PASSWORD':settings['ADMIN_PASSWORD'],'TZ':'Asia/Hong_Kong'}
  60. (release/'stage-app.env').write_text('\n'.join(k+'='+v for k,v in env.items())+'\n')
  61. result={'isolated_network':network,'migration':'248_intelligent_assessment_v2.sql','checksum':checksum,'repeat_passed':True,'business_rows_preserved':unchanged,'historical_tests_preserved':original==account_tests_original}
  62. (evidence/'stage-migration.json').write_text(json.dumps(result,indent=2))
  63. print(json.dumps(result))
  64. elif args.step == 'start':
  65. run(['docker','run','-d','--name',app,'--network',network,'--memory','1536m','--cpus','1.5','--security-opt','no-new-privileges:true','--env-file',str(release/'stage-app.env'),'-p','127.0.0.1:18085:8080','chengchuan-relay:optional-controls-20260915'])
  66. print('ISOLATED_STAGE_STARTED')
  67. else:
  68. app_info = json.loads(run(['docker','inspect',app]))[0]
  69. internal_ip = app_info['NetworkSettings']['Networks'][network]['IPAddress']
  70. with urlopen('http://'+internal_ip+':8080/health',timeout=10) as response:
  71. assert response.status == 200 and json.load(response)['status'] == 'ok'
  72. result={'health':True,'internal_network':json.loads(run(['docker','network','inspect',network]))[0]['Internal'],
  73. 'image':json.loads(run(['docker','inspect',app]))[0]['Image'],
  74. 'migration_applied':sql("SELECT count(*) FROM schema_migrations WHERE filename='248_intelligent_assessment_v2.sql'") == '1'}
  75. assert result['internal_network'] and result['migration_applied']
  76. (evidence/'stage-boot.json').write_text(json.dumps(result,indent=2))
  77. print(json.dumps(result))