更新
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
#!/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]()
|
||||
Reference in New Issue
Block a user