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

129 lines
5.5 KiB
Python

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