Files
kefu/im/deploy/baota_publish_application.py
T
2026-09-03 08:38:17 +08:00

201 lines
9.5 KiB
Python

#!/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])