#!/usr/bin/env python3 """Publish this reviewed application release on the existing BaoTa host. Uses the installed, previously verified transport/filesystem helpers. Never seeds data, changes credentials, enables OAuth channels, or restores a live DB. """ import fcntl import gzip import hashlib import json import os import pwd import re import shutil import subprocess import sys import time import urllib.error 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, switch EXPECTED_OLD = '92b306000ab787e2eb20e69daf25cc3374d082a88f7301e24811a2b7d0022d9d' MIGRATIONS = ['027_app_oauth_login.sql', '029_admin_create_users.sql'] def marker(conn): # Fingerprints only; configuration values and personal records never leave # this process or appear in deployment logs. configs = query(conn, "SELECT config_key,config_value FROM system_configs WHERE config_key NOT LIKE 'oauth.app.%%' ORDER BY config_key") profiles = query(conn, "SELECT u.id,u.public_id,u.is_test,u.test_batch,p.avatar_url FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 ORDER BY u.id") return { 'configValuesSha256': hashlib.sha256(repr(configs).encode()).hexdigest(), 'testProfilesSha256': hashlib.sha256(repr(profiles).encode()).hexdigest(), 'testUsers': len(profiles), } def prepare_static(stage, kind, stamp): entry = WEB / ('admin' if kind == 'admin' else 'app') releases = Path('/www/wwwroot/xingyu-' + kind + '/releases') assert entry.is_symlink() previous = entry.resolve() assert previous.parent == releases release = releases / ('application-' + stamp) extract(stage / (kind + '.zip'), release) index = (release / 'index.html').read_text() assert ('/admin/jse/' if kind == 'admin' else '/app/assets/') in index if kind == 'admin': assert '"/admin/v1"' in (release / '_app.config.js').read_text() # Preserve resources used by tabs opened before the atomic entry switch. for folder in (['js', 'jse', 'css'] if kind == 'admin' else ['assets']): for source in (previous / folder).rglob('*'): if source.is_file(): destination = release / source.relative_to(previous) if not destination.exists(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(source), str(destination)) www = pwd.getpwnam('www') for parent, directories, files in os.walk(str(release)): os.chown(parent, www.pw_uid, www.pw_gid) os.chmod(parent, 0o755) for name in files: path = os.path.join(parent, name) os.chown(path, www.pw_uid, www.pw_gid) os.chmod(path, 0o644) return entry, previous, release def replace_binary(source, suffix): target = BT / ('xingyu-api.' + suffix) assert not target.exists() shutil.copy2(str(source), str(target)) www = pwd.getpwnam('www') os.chown(str(target), www.pw_uid, www.pw_gid) os.chmod(str(target), 0o750) os.replace(str(target), str(BT / 'xingyu-api')) def public_checks(admin, h5): for prefix, release in [('/admin/', admin), ('/app/', h5)]: status, headers, body = fetch(prefix) assert status == 200 and body == (release / 'index.html').read_bytes() assert 'no-store' in ','.join(headers.get_all('Cache-Control', [])) paths = re.findall(r'(?:src|href)="(/(?:admin|app)/[^"?#]+)', body.decode()) for path in paths: if path.endswith(('.js', '.css')): assert fetch(path)[0] == 200 assert fetch('/admin/_app.config.js')[2] == (admin / '_app.config.js').read_bytes() for path in ['/admin/v1/users', '/admin/v1/auth/codes', '/ws']: try: fetch(path) except urllib.error.HTTPError as error: assert error.code == 401 else: raise RuntimeError('private endpoint became public') for platform in ['app', 'h5']: status, _, body = fetch('/api/v1/auth/oauth/providers?platform=' + platform) assert status == 200 and json.loads(body)['code'] == 0 health() def publish(archive, expected): assert os.geteuid() == 0 archive = Path(archive).resolve() assert archive.parent == Path('/tmp') and re.fullmatch('[a-f0-9]{64}', expected) assert digest(archive) == expected lock = open('/www/server/xingyu-im/ops/application-release.lock', 'a') fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) values = env_values() conn, db_options = database(values) assert digest(BT / 'xingyu-api') == EXPECTED_OLD, 'production changed; re-audit required' health() subprocess.run(['/www/server/nginx/sbin/nginx', '-t'], check=True) stamp = time.strftime('%Y%m%d-%H%M%S') stage = Path('/www/server/xingyu-im/releases/application-' + stamp) extract(archive, stage) os.chmod(str(stage), 0o700) manifest = json.loads((stage / 'manifest.json').read_text()) for name, sha in manifest['files'].items(): path = (stage / name).resolve() assert stage in path.parents and path.is_file() and digest(path) == sha recorded = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) for name, checksum in recorded.items(): assert (stage / 'migrations' / name).is_file() and digest(stage / 'migrations' / name) == checksum, 'migration history mismatch: ' + name pending = sorted(file.name for file in (stage / 'migrations').glob('*.sql') if file.name not in recorded) assert pending == MIGRATIONS, 'unreviewed pending migrations' before = marker(conn) state = { 'stamp': stamp, 'archiveSha256': expected, 'stage': str(stage), 'previousBackendSha256': EXPECTED_OLD, 'rollbackBackendSha256': manifest['files']['xingyu-api-rollback'], 'previousAdmin': str((WEB / 'admin').resolve()), 'previousH5': str((WEB / 'app').resolve()), 'before': before, } backup = Path('/www/backup/xingyu-application-' + stamp) backup.mkdir(mode=0o700) state['backup'] = str(backup) dump = subprocess.Popen(['/opt/mysql-8.4.11/bin/mysqldump', '-h', '127.0.0.1', '-P', '3307', '-u', db_options['user'], '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF', '--routines', '--triggers', 'im'], env=dict(os.environ, MYSQL_PWD=db_options['password']), stdout=subprocess.PIPE, stderr=subprocess.PIPE) with gzip.open(str(backup / 'im.sql.gz'), 'wb') as output: shutil.copyfileobj(dump.stdout, output) _, errors = dump.communicate() if dump.returncode: raise RuntimeError('database backup failed') os.chmod(str(backup / 'im.sql.gz'), 0o600) with gzip.open(str(backup / 'im.sql.gz'), 'rb') as check: assert b'MySQL dump' in check.read(4096) while check.read(1024 * 1024): pass shutil.copy2(str(BT / 'xingyu-api'), str(backup / 'xingyu-api-original')) shutil.copy2(str(stage / 'xingyu-api-rollback'), str(backup / 'xingyu-api-compatible-rollback')) shutil.copy2('/etc/xingyu-im-bt.env', str(backup / 'environment.env')) os.chmod(str(backup / 'environment.env'), 0o600) (backup / 'state.json').write_text(json.dumps(state, indent=2)) print('BACKUP_VERIFIED=' + str(backup), flush=True) admin = prepare_static(stage, 'admin', stamp) h5 = prepare_static(stage, 'h5', stamp) switched = [] binary_changed = False try: for name in pending: file = stage / 'migrations' / name sql = '\n'.join(line for line in file.read_text().splitlines() if not line.lstrip().startswith('--')) for statement in sql.split(';'): if statement.strip(): query(conn, statement) query(conn, 'INSERT INTO schema_migrations(version,checksum) VALUES(%s,%s)', (name, digest(file))) shutil.copy2(str(file), str(BT / 'migrations' / name)) print('MIGRATION_APPLIED=' + name, flush=True) assert marker(conn) == before, 'existing data or config changed during migrations' assert query(conn, "SELECT COUNT(*) FROM admin_role_permissions rp JOIN admin_roles r ON r.id=rp.role_id WHERE r.role_code='super_admin' AND rp.permission_code='users:create'")[0][0] == 1 replace_binary(stage / 'xingyu-api', 'next-' + stamp) binary_changed = True print('BACKEND_RESTARTING', flush=True) restart() print('BACKEND_HEALTHY', flush=True) for entry, previous, release in [admin, h5]: switch(entry, release, stamp) switched.append((entry, previous)) public_checks(admin[2], h5[2]) assert marker(conn) == before, 'existing config or test profiles changed during release' assert digest(BT / 'xingyu-api') == manifest['files']['xingyu-api'] state.update({'backendSha256': digest(BT / 'xingyu-api'), 'admin': str(admin[2]), 'h5': str(h5[2]), 'migrationsApplied': pending, 'publicChecks': True, 'preservedExistingDataAndConfig': True}) (backup / 'result.json').write_text(json.dumps(state, indent=2)) print('PUBLISHED=' + json.dumps(state), flush=True) except Exception: for entry, previous in reversed(switched): switch(entry, previous, stamp + '-rollback') if binary_changed: replace_binary(backup / 'xingyu-api-compatible-rollback', 'rollback-' + stamp) restart() print('APPLICATION_ROLLED_BACK; added schema retained; backup=' + str(backup), flush=True) raise finally: conn.close() if __name__ == '__main__': publish(sys.argv[1], sys.argv[2])