201 lines
9.5 KiB
Python
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])
|