#!/usr/bin/env python3 """Publish only the reviewed IM hotfix; no DB writes, migrations, or frontend changes.""" import fcntl import hashlib import json import os import pwd import shutil import sys import time import urllib.error import zipfile from pathlib import Path sys.path.insert(0, '/www/server/xingyu-im/ops') from baota_publish_test_users import BT, WEB, database, digest, env_values, extract, fetch, health, query, restart OLD = '54e699a73fd6d3cec7baae3b1224a03cca6f2ddfdcb91c23f05e1fa258514b56' BASE = Path('/www/server/xingyu-im/releases/application-20260831-121140') ALLOWED = {'backend/internal/app/im.go', 'backend/internal/app/im_unread_integration_test.go'} def source_hashes(archive): with zipfile.ZipFile(str(archive)) as package: result = {} for item in package.infolist(): if item.is_dir(): continue name = item.filename.replace('\\', '/') assert name not in result and not name.startswith('/') and '..' not in Path(name).parts result[name] = hashlib.sha256(package.read(item)).hexdigest() return result def running(expected): pids = [] for proc in Path('/proc').iterdir(): if not proc.name.isdigit(): continue try: if os.readlink(str(proc / 'exe')) == str(BT / 'xingyu-api'): assert digest(proc / 'exe') == expected pids.append(int(proc.name)) except (FileNotFoundError, PermissionError): pass assert len(pids) == 1, 'expected one running project executable' return pids def replace(source, stamp): temporary = BT / ('xingyu-api.im-unread-' + stamp) assert not temporary.exists() shutil.copy2(str(source), str(temporary)) www = pwd.getpwnam('www') os.chown(str(temporary), www.pw_uid, www.pw_gid) os.chmod(str(temporary), 0o750) os.replace(str(temporary), str(BT / 'xingyu-api')) def publish(archive, expected): assert os.geteuid() == 0 archive = Path(archive).resolve() assert archive.parent == Path('/tmp') and digest(archive) == expected lock = open('/www/server/xingyu-im/ops/application-release.lock', 'a') fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) assert digest(BT / 'xingyu-api') == OLD, 'production changed; re-audit required' running(OLD) health() stamp = time.strftime('%Y%m%d-%H%M%S') stage = Path('/www/server/xingyu-im/releases/im-unread-' + stamp) extract(archive, stage) os.chmod(str(stage), 0o700) old_source = source_hashes(BASE / 'backend-source.zip') new_source = source_hashes(stage / 'backend-source.zip') changed = {name for name in set(old_source) | set(new_source) if old_source.get(name) != new_source.get(name)} assert changed == ALLOWED, 'unreviewed source changes: ' + repr(changed) new_hash = digest(stage / 'xingyu-api') assert (stage / 'xingyu-api').read_bytes()[:4] == b'\x7fELF' and new_hash != OLD manifest = json.loads((stage / 'manifest.json').read_text()) for name in ['xingyu-api', 'backend-source.zip']: assert digest(stage / name) == manifest['files'][name] conn, _ = database(env_values()) try: before_migrations = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) for name, sha in before_migrations.items(): assert new_source['backend/migrations/' + name] == sha assert len(before_migrations) == len([name for name in new_source if name.startswith('backend/migrations/')]) frontend = {name: str((WEB / name).resolve()) for name in ['admin', 'app']} backup = Path('/www/backup/xingyu-im-unread-' + stamp) backup.mkdir(mode=0o700) shutil.copy2(str(BT / 'xingyu-api'), str(backup / 'xingyu-api-original')) state = {'backup': str(backup), 'stage': str(stage), 'previousBackendSha256': OLD, 'backendSha256': new_hash, 'archiveSha256': expected, 'changedSourceFiles': sorted(changed), 'frontendUnchanged': frontend, 'databaseMigrationsApplied': [], 'businessDataWrites': False} (backup / 'before.json').write_text(json.dumps(state, indent=2)) print('BACKUP_VERIFIED=' + str(backup), flush=True) changed_binary = False try: replace(stage / 'xingyu-api', stamp) changed_binary = True restart() state['runningPids'] = running(new_hash) assert digest(BT / 'xingyu-api') == new_hash assert dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) == before_migrations assert {name: str((WEB / name).resolve()) for name in frontend} == frontend for path in ['/api/v1/im/conversations', '/admin/v1/users', '/ws']: try: fetch(path) except urllib.error.HTTPError as error: assert error.code == 401 else: raise RuntimeError('private endpoint became public') state['healthAndAuthChecks'] = True (backup / 'result.json').write_text(json.dumps(state, indent=2)) print(json.dumps(state), flush=True) except Exception: if changed_binary: replace(backup / 'xingyu-api-original', stamp + '-rollback') restart() running(OLD) (backup / 'rolled-back.txt').write_text('Original binary restored. No database restore performed.\n') raise finally: conn.close() if __name__ == '__main__': publish(*sys.argv[1:])