#!/usr/bin/env python3 """Scoped release of the labelled 100-user fixture batch on the existing BaoTa host. Run with the panel Python as root. DB credentials stay in memory; backups are root-only. No admin login, fabricated sessions, or OAuth migration is performed. """ import argparse import gzip import hashlib import json import os import pwd import re import shlex import shutil import subprocess import sys import time import urllib.error import urllib.request import zipfile from pathlib import Path import pymysql DOMAIN = 'https://im.bchongw.com' BATCH = 'cn-adults-20260831-v1' BT = Path('/www/server/xingyu-im/bt') WEB = Path('/www/wwwroot/im.bchongw.com') MEDIA = Path('/www/wwwroot/xingyu-data/uploads') OLD_BINARY = '23ab263e16593b6f588e42bf480e08f364f8f46a12cf8c143077b767860b8b30' def digest(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() def env_values(): values = {} for line in Path('/etc/xingyu-im-bt.env').read_text().splitlines(): line = line[7:] if line.startswith('export ') else line if not line.strip() or line.lstrip().startswith('#'): continue key, value = line.split('=', 1) parts = shlex.split(value) assert len(parts) <= 1 values[key] = parts[0] if parts else '' assert values['IM_ENV'] == 'production' assert values['IM_HOST'] == '127.0.0.1' and values['IM_PORT'] == '18888' return values def database(values): match = re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+)\?.*', values['IM_DB_DSN']) assert match and match[3] == '127.0.0.1' and match[4] == '3307' and match[5] == 'im' options = dict(host=match[3], port=int(match[4]), user=match[1], password=match[2], database=match[5], charset='utf8mb4', autocommit=True) return pymysql.connect(**options), options def query(conn, sql, args=()): with conn.cursor() as cursor: cursor.execute(sql, args) return cursor.fetchall() def fetch(path): with urllib.request.urlopen(DOMAIN + path, timeout=20) as response: return response.status, response.headers, response.read() def health(): for _ in range(100): try: status, _, data = fetch('/healthz') if status == 200 and json.loads(data)['data']['status'] == 'ok': return except Exception: pass time.sleep(0.5) raise RuntimeError('backend failed health check') def restart(): os.chdir('/www/server/panel') sys.path.insert(0, '/www/server/panel') sys.path.insert(0, '/www/server/panel/class') import public from projectModel.goModel import main as GoProject request = public.dict_obj() request.project_name = 'xingyu_im' manager = GoProject() project = manager.get_project_find('xingyu_im') assert project and project['project_config']['is_power_on'] == 1 result = manager.restart_project(request) if not result.get('status'): recovered = manager.start_project(request) if not recovered.get('status'): raise RuntimeError('BaoTa could not start project') health() def extract(archive, directory): directory.mkdir(mode=0o755, parents=True, exist_ok=False) with zipfile.ZipFile(str(archive)) as package: seen = set() for item in package.infolist(): name = item.filename.replace('\\', '/') target = (directory / name).resolve() assert name not in seen and name and ':' not in name assert not name.startswith('/') and '..' not in Path(name).parts assert os.path.commonpath([str(directory.resolve()), str(target)]) == str(directory.resolve()) assert (item.external_attr >> 16) & 0o170000 != 0o120000 seen.add(name) if item.is_dir() or name.endswith('/'): target.mkdir(parents=True, exist_ok=True) else: target.parent.mkdir(parents=True, exist_ok=True) with package.open(item) as source, target.open('xb') as output: shutil.copyfileobj(source, output) def switch(entry, target, stamp): next_link = entry.with_name(entry.name + '.next-' + stamp) assert not next_link.exists() and not next_link.is_symlink() os.symlink(str(target), str(next_link)) os.replace(str(next_link), str(entry)) def prepare_static(archive, 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 / ('test-users-' + stamp) extract(archive, 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() # Keep hashed resources used by tabs opened before this release. for folder in (['js', 'jse', 'css'] if kind == 'admin' else ['assets']): if not (previous / folder).is_dir(): continue for source in (previous / folder).rglob('*'): if source.is_file(): target = release / source.relative_to(previous) if not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(source), str(target)) www = pwd.getpwnam('www') for path in [release] + list(release.rglob('*')): assert not path.is_symlink() os.chown(str(path), www.pw_uid, www.pw_gid) os.chmod(str(path), 0o755 if path.is_dir() else 0o644) return entry, previous, release def verify(conn): rows = query(conn, "SELECT p.gender,COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 AND u.test_batch=%s AND u.deleted_at IS NULL GROUP BY p.gender ORDER BY p.gender", (BATCH,)) assert rows == ((1, 50), (2, 50)), rows count = query(conn, "SELECT COUNT(*) FROM users WHERE is_test=1 AND test_batch=%s AND phone_hash IS NULL AND phone_cipher IS NULL AND password_hash='!TEST_PROFILE_NO_LOGIN'", (BATCH,))[0][0] assert count == 100 avatars = query(conn, "SELECT DISTINCT p.avatar_url FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 AND u.test_batch=%s", (BATCH,)) assert len(avatars) == 10 for (url,) in avatars: assert url.startswith(DOMAIN + '/uploads/') status, headers, body = fetch(url[len(DOMAIN):]) assert status == 200 and headers.get_content_type() == 'image/png' and body.startswith(b'\x89PNG\r\n\x1a\n') for public_path in ['/admin/', '/app/']: status, headers, data = fetch(public_path) cache_control = ','.join(headers.get_all('Cache-Control', [])) assert status == 200 and b'