236 lines
10 KiB
Python
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]()
|