activate-optional-release.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. """Switch only the app after verified preparation; retain an immediate image rollback."""
  2. import json
  3. import os
  4. import subprocess
  5. import time
  6. from pathlib import Path
  7. os.umask(0o077)
  8. root=Path('/opt/chengchuan-relay')
  9. release=root/'releases/optional-controls-20260915'
  10. deploy=release/'source/deploy'
  11. evidence=root/'evidence/optional-controls-20260915'
  12. backup=root/'backups/before-optional-controls-20260915'
  13. assert (evidence/'build.exit').read_text().strip()=='0'
  14. assert json.loads((evidence/'stage-http.json').read_text())['passed']
  15. assert json.loads((evidence/'stage-migration.json').read_text())['historical_tests_preserved']
  16. def sql(statement):
  17. return subprocess.check_output(['docker','exec','chengchuan-relay-postgres-1','psql','-U','relay','-d','relay','-At','-v','ON_ERROR_STOP=1','-c',statement]).decode().strip()
  18. def configuration_hash():
  19. return sql("SELECT md5(COALESCE(string_agg(jsonb_build_array(id,concurrency,proxy_id,extra)::text,E'\\n' ORDER BY id),'')) FROM accounts")
  20. def inspect(name):
  21. return json.loads(subprocess.check_output(['docker','inspect',name]))[0]
  22. old=inspect('chengchuan-relay-app-1')
  23. dependencies={name:inspect(name)['Id'] for name in ['chengchuan-relay-postgres-1','chengchuan-relay-redis-1']}
  24. assert old['Image']=='sha256:3140765d60ec62efad35a922cbc5a1fdc4de85ea77e47b8b6ebd217dc07c50cd'
  25. config_before=configuration_hash()
  26. started=time.time()
  27. subprocess.run(['docker','stop','--time','60','chengchuan-relay-app-1'],check=True,stdout=subprocess.DEVNULL)
  28. try:
  29. with (backup/'database-at-cutover.dump').open('xb') as output:
  30. subprocess.run(['docker','exec','chengchuan-relay-postgres-1','pg_dump','-U','relay','-d','relay','-Fc'],stdout=output,check=True)
  31. command=['docker','compose','--env-file','.env','-f','compose.relay.yml','up','-d','--no-deps','--no-build','--wait','--wait-timeout','180','app']
  32. with (evidence/'cutover.log').open('w') as log:
  33. subprocess.run(command,cwd=deploy,stdout=log,stderr=subprocess.STDOUT,check=True,timeout=220)
  34. with (evidence/'production-http.log').open('w') as log:
  35. subprocess.run(['python3',str(release/'check-release.py'),'--base-url','https://juhe.pub','--env',str(deploy/'.env'),'--output',str(evidence/'production-http.json'),'--db-container','chengchuan-relay-postgres-1'],stdout=log,stderr=subprocess.STDOUT,check=True,timeout=180)
  36. current=inspect('chengchuan-relay-app-1')
  37. config_after=configuration_hash()
  38. assert config_before==config_after,'account configuration changed during cutover; investigate before accepting'
  39. assert all(inspect(name)['Id']==cid for name,cid in dependencies.items())
  40. result={'version':'optional-controls-20260915','image':current['Image'],'healthy':current['State']['Health']['Status']=='healthy',
  41. 'account_configuration_preserved':config_before==config_after,'postgres_and_redis_not_recreated':True,
  42. 'migration_applied':sql("SELECT count(*) FROM schema_migrations WHERE filename='248_intelligent_assessment_v2.sql'")=='1',
  43. 'strict_rpm_enabled_accounts':int(sql("SELECT count(*) FROM accounts WHERE extra->'account_traffic_control'->>'strict_rpm_enabled'='true'")),
  44. 'adaptive_enabled_accounts':int(sql("SELECT count(*) FROM accounts WHERE extra->'account_traffic_control'->>'adaptive_enabled'='true'")),
  45. 'elapsed_seconds':round(time.time()-started,1),'url':'https://juhe.pub'}
  46. assert result['healthy'] and result['migration_applied']
  47. (evidence/'activation.json').write_text(json.dumps(result,indent=2))
  48. print(json.dumps(result,indent=2))
  49. except Exception as error:
  50. rollback_env=backup/'rollback.env'
  51. lines=(backup/'app.env').read_text().splitlines()
  52. lines=[line for line in lines if not line.startswith('RELAY_VERSION=')]
  53. lines.append('RELAY_VERSION=rollback-optional-controls-20260915')
  54. rollback_env.write_text('\n'.join(lines)+'\n')
  55. with (evidence/'rollback.log').open('w') as log:
  56. subprocess.run(['docker','compose','--env-file',str(rollback_env),'-f',str(backup/'compose.relay.yml'),'up','-d','--no-deps','--no-build','--wait','--wait-timeout','180','app'],cwd=backup,stdout=log,stderr=subprocess.STDOUT,check=True,timeout=220)
  57. print('APPLICATION_ROLLED_BACK: '+type(error).__name__)
  58. raise