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

236 lines
10 KiB
Python

#!/usr/bin/env python3
"""One-time, reversible takeover by the installed BaoTa project manager.
Run as root using /www/server/panel/pyenv/bin/python on the target host.
Secrets are read only on the server and are never printed.
"""
import argparse
import hashlib
import json
import os
import pwd
import shlex
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
PANEL = Path('/www/server/panel')
NAME = 'xingyu_im'
DOMAIN = 'im.bchongw.com'
EXECUTABLE = Path('/www/server/xingyu-im/bt/xingyu-api')
ENV_FILE = Path('/etc/xingyu-im-bt.env')
OLD_NGINX = PANEL / 'vhost/nginx/im.bchongw.com.conf'
HTML_NGINX = PANEL / 'vhost/nginx/html_im.bchongw.com.conf'
GO_NGINX = PANEL / 'vhost/nginx/go_xingyu_im.conf'
def command(*args):
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
if result.returncode:
raise RuntimeError('Command failed: {}: {}'.format(args[0], result.stderr.strip()))
return result.stdout.strip()
def atomic_write(path, content, mode=0o600):
temporary = path.with_name(path.name + '.takeover-new')
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
with os.fdopen(descriptor, 'w', encoding='utf-8') as target:
target.write(content)
os.replace(str(temporary), str(path))
def env_values():
values = {}
for line in Path('/etc/xingyu-im.env').read_text().splitlines():
if not line.strip() or line.lstrip().startswith('#'):
continue
key, value = line.split('=', 1)
parts = shlex.split(value)
if len(parts) > 1:
raise RuntimeError('Unexpected environment syntax for ' + key)
values[key] = parts[0] if parts else ''
assert values['IM_HOST'] == '127.0.0.1'
assert values['IM_PORT'] == '18888'
assert values['IM_ENV'] == 'production'
return values
def health(url='http://127.0.0.1:18888/healthz'):
for _ in range(140):
try:
with urllib.request.urlopen(url, timeout=3) as response:
data = json.load(response)
if data.get('code') == 0 and data.get('data', {}).get('status') == 'ok':
return True
except Exception:
time.sleep(0.25)
raise RuntimeError('Health check failed: ' + url)
def request(**values):
obj = public.dict_obj()
for key, value in values.items():
setattr(obj, key, value)
return obj
def status():
go = GoProject()
project = go.get_project_find(NAME)
domain = public.M('domain').where('name=? AND port=?', (DOMAIN, 80)).find()
site = public.M('sites').where('id=?', (domain['pid'],)).find() if domain else {}
print(json.dumps({
'go_project': NAME if project else None,
'running': go.get_project_run_state(project_name=NAME) if project else False,
'auto_start': project['project_config']['is_power_on'] if project else None,
'environment_file': project['project_config']['env_file'] if project else None,
'web_project_type': site.get('project_type'),
'web_project_name': site.get('name'),
'web_root': site.get('path'),
'domains': project['project_config']['domains'] if project else [],
'external_mapping': bool(project['project_config']['bind_extranet']) if project else False,
'gateway_nginx_config': str(GO_NGINX if GO_NGINX.is_file() else HTML_NGINX),
}, ensure_ascii=False))
def takeover():
go = GoProject()
if go.get_project_find(NAME):
raise RuntimeError('The BaoTa project already exists; use status/verify instead.')
site = public.M('sites').where('name=?', (DOMAIN,)).find()
assert site and site['project_type'] == 'PHP'
assert site['path'] == '/www/wwwroot/im.bchongw.com'
assert OLD_NGINX.is_file() and not HTML_NGINX.exists()
assert not EXECUTABLE.parent.exists() and not ENV_FILE.exists()
health()
values = env_values()
backup = Path('/www/backup/xingyu-panel-takeover-' + time.strftime('%Y%m%d-%H%M%S'))
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
os.chmod(str(backup), 0o700)
for source in (OLD_NGINX, Path('/etc/systemd/system/xingyu-im.service'), Path('/etc/xingyu-im.env')):
shutil.copy2(str(source), str(backup / source.name))
for source in (PANEL / 'data/db/site.db', PANEL / 'data/default.db'):
with sqlite3.connect(str(source)) as connection:
with sqlite3.connect(str(backup / source.name)) as destination:
connection.backup(destination)
atomic_write(backup / 'original-site.json', json.dumps(site, ensure_ascii=False))
shutil.copytree('/www/server/xingyu-im/current', str(EXECUTABLE.parent))
launcher = Path(__file__).with_name('baota-go-start.sh')
assert launcher.is_file(), 'Upload baota-go-start.sh alongside this script.'
shutil.copy2(str(launcher), str(EXECUTABLE.parent / 'start.sh'))
os.chmod(str(EXECUTABLE.parent / 'start.sh'), 0o750)
original_hash = hashlib.sha256(Path('/www/server/xingyu-im/current/xingyu-api').read_bytes()).hexdigest()
assert hashlib.sha256(EXECUTABLE.read_bytes()).hexdigest() == original_hash
atomic_write(ENV_FILE, '# BaoTa Go project environment. Keep this file private.\n' + ''.join(
'export {}={}\n'.format(key, shlex.quote(value)) for key, value in values.items()))
os.chown(str(ENV_FILE), 0, pwd.getpwnam('www').pw_gid)
os.chmod(str(ENV_FILE), 0o640)
print('BACKUP=' + str(backup), flush=True)
old_stopped = False
site_changed = False
try:
command('systemctl', 'stop', 'xingyu-im')
old_stopped = True
result = go.create_project(request(
project_name=NAME, project_exe=str(EXECUTABLE),
project_ps='星遇 IM 后端 · im.bchongw.com · 内网 18888',
bind_extranet=0, domains=[], is_power_on=1, run_user='www',
project_cmd=str(EXECUTABLE.parent / 'start.sh'), port=18888,
env_file=str(ENV_FILE), env_list=[], release_firewall=0,
))
if not result.get('status'):
raise RuntimeError('BaoTa create_project failed: ' + str(result.get('msg')))
health()
assert go.get_project_run_state(project_name=NAME)
os.rename(str(OLD_NGINX), str(HTML_NGINX))
site_changed = True
public.M('sites').where('id=? AND name=?', (site['id'], DOMAIN)).update({
'project_type': 'html',
'project_config': site.get('project_config') or '{}',
'ps': '星遇管理端 /admin/ · IM HTTPS/WSS 网关',
})
command('/www/server/nginx/sbin/nginx', '-t')
command('/etc/init.d/nginx', 'reload')
health('https://im.bchongw.com/healthz')
command('systemctl', 'disable', 'xingyu-im')
public.WriteLog('项目管理', '星遇服务已由 systemd 接管至 Go 项目 xingyu_im;域名网页转为 HTML 项目;备份:' + str(backup))
atomic_write(backup / 'result.json', json.dumps({
'go_project': NAME, 'site_id': site['id'], 'executable_sha256': original_hash,
'html_config': str(HTML_NGINX), 'previous_systemd': 'xingyu-im.service',
}, ensure_ascii=False))
print('TAKEOVER_OK', flush=True)
status()
except Exception:
# Restore only this project's settings; never replace the full panel database.
if site_changed:
if HTML_NGINX.exists():
os.rename(str(HTML_NGINX), str(OLD_NGINX))
public.M('sites').where('id=?', (site['id'],)).update({
'project_type': site['project_type'], 'project_config': site.get('project_config'), 'ps': site['ps'],
})
command('/www/server/nginx/sbin/nginx', '-t')
command('/etc/init.d/nginx', 'reload')
project = go.get_project_find(NAME)
if project:
if go.get_project_run_state(project_name=NAME):
go.stop_project(request(project_name=NAME))
time.sleep(1)
config = project['project_config']
config['is_power_on'] = 0
public.M('sites').where('id=?', (project['id'],)).setField('project_config', json.dumps(config))
if old_stopped:
command('systemctl', 'enable', '--now', 'xingyu-im')
health()
print('ROLLED_BACK_TO_SYSTEMD; backup=' + str(backup), flush=True)
raise
def verify():
go = GoProject()
project = go.get_project_find(NAME)
assert project and project['project_config']['is_power_on'] == 1
before_pid = go.get_pid_by_command(NAME)
result = go.restart_project(request(project_name=NAME))
if not result.get('status'):
time.sleep(2)
recovered = go.start_project(request(project_name=NAME))
health()
raise RuntimeError('BaoTa restart failed; service recovery status: ' + str(recovered.get('status')))
health()
after_pid = go.get_pid_by_command(NAME)
assert after_pid and after_pid != before_pid
health('https://im.bchongw.com/healthz')
domain = public.M('domain').where('name=? AND port=?', (DOMAIN, 80)).find()
assert domain
if project['project_config']['bind_extranet']:
assert domain['pid'] == project['id'] and GO_NGINX.is_file()
else:
assert public.M('sites').where('id=?', (domain['pid'],)).getField('project_type') == 'html'
assert command('systemctl', 'is-active', 'xingyu-mysql8') == 'active'
old = subprocess.run(['systemctl', 'is-active', 'xingyu-im'], stdout=subprocess.PIPE, universal_newlines=True)
assert old.stdout.strip() == 'inactive'
with urllib.request.urlopen('https://im.bchongw.com/admin/', timeout=10) as response:
assert '星遇社交运营中心' in response.read().decode('utf-8')
data = go.get_project_list(request(p=1, limit=20, search=NAME))
assert any(row['name'] == NAME and row['run'] for row in data['data'])
print('PANEL_RESTART_OK; PUBLIC_API_OK; ADMIN_WEB_OK; MYSQL_UNCHANGED; GO_LIST_RUNNING', flush=True)
status()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=('status', 'takeover', 'verify'))
args = parser.parse_args()
if os.geteuid() != 0 or not PANEL.is_dir():
raise SystemExit('Run on the BaoTa host as root.')
os.chdir(str(PANEL))
sys.path.insert(0, str(PANEL))
sys.path.insert(0, str(PANEL / 'class'))
import public
from projectModel.goModel import main as GoProject
{'status': status, 'takeover': takeover, 'verify': verify}[args.action]()