127 lines
6.4 KiB
Python
127 lines
6.4 KiB
Python
"""在授权维护窗口构建前端,退出或超时后恢复原先运行的服务。"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import subprocess
|
||
import time
|
||
|
||
from run_frontend_review import dependency_bindings
|
||
|
||
|
||
PAUSED = ('Scert.service', 'mysql.service')
|
||
PROTECTED = ('smail.service', 'nginx.service')
|
||
|
||
|
||
def run(*args):
|
||
return subprocess.check_output(args, text=True, timeout=90).strip()
|
||
|
||
|
||
def states():
|
||
return {name: dict(line.split('=', 1) for line in run(
|
||
'systemctl', 'show', name, '--property=ActiveState,MainPID,NRestarts').splitlines())
|
||
for name in PAUSED + PROTECTED}
|
||
|
||
|
||
def restore_services(build_unit):
|
||
assert re.fullmatch(r'smail-frontend-build-[0-9]{8}-[0-9]{6}', build_unit)
|
||
subprocess.run(['systemctl', 'stop', build_unit], timeout=90, check=False)
|
||
errors = []
|
||
for name in reversed(PAUSED):
|
||
try:
|
||
subprocess.run(['systemctl', 'start', name], check=True, timeout=90)
|
||
except Exception as error:
|
||
errors.append({'service': name, 'error': type(error).__name__})
|
||
return errors
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('directory')
|
||
parser.add_argument('--pause-other-services', action='store_true')
|
||
parser.add_argument('--worker', action='store_true')
|
||
parser.add_argument('--build-unit')
|
||
parser.add_argument('--restore-unit')
|
||
args = parser.parse_args()
|
||
root = Path(args.directory).resolve(strict=True)
|
||
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
|
||
assert root.parent == Path('/var/tmp') and root.name.startswith('smail-review-')
|
||
if args.restore_unit:
|
||
return int(bool(restore_services(args.restore_unit)))
|
||
assert args.pause_other_services, '必须先获得暂停其他服务的授权'
|
||
before = states()
|
||
assert all(item['ActiveState'] == 'active' for item in before.values())
|
||
live = json.loads(Path('/opt/smail/config/config.json').read_text())
|
||
assert live['database']['type'] == 'sqlite', '线上 Smail 依赖外部数据库时不允许暂停 MySQL'
|
||
bindings = dependency_bindings(root)
|
||
assert not (root / 'out/frontend').exists(), '已有构建结果,必须先保留证据再执行'
|
||
if not args.worker:
|
||
stamp = time.strftime('%Y%m%d-%H%M%S')
|
||
unit = 'smail-build-window-' + stamp
|
||
build_unit = 'smail-frontend-build-' + stamp
|
||
script = Path(__file__).resolve(strict=True)
|
||
assert script.stat().st_uid == 0 and script.parent.stat().st_uid == 0
|
||
assert script.stat().st_mode & 0o022 == 0 and script.parent.stat().st_mode & 0o022 == 0
|
||
print('维护窗口单元:' + unit, flush=True)
|
||
# 由独立 systemd 单元负责恢复,SSH 断开或工作进程被终止也不会取消恢复钩子。
|
||
return subprocess.run([
|
||
'systemd-run', '--wait', '--pipe', '--unit=' + unit,
|
||
'-p', 'RuntimeMaxSec=900s', '-p', 'TimeoutStopSec=180s',
|
||
'-p', 'KillMode=control-group', '-p', 'UMask=0077', '-p', 'LimitCORE=0',
|
||
'-p', 'ExecStopPost=/usr/bin/python3 ' + str(script) + ' ' + str(root)
|
||
+ ' --restore-unit ' + build_unit,
|
||
'python3', str(script), str(root),
|
||
'--pause-other-services', '--worker', '--build-unit', build_unit,
|
||
]).returncode
|
||
|
||
stamp = time.strftime('%Y%m%d-%H%M%S')
|
||
unit = args.build_unit
|
||
assert unit and re.fullmatch(r'smail-frontend-build-[0-9]{8}-[0-9]{6}', unit)
|
||
result = {'before': before, 'unit': unit, 'started': time.strftime('%Y-%m-%dT%H:%M:%S%z')}
|
||
try:
|
||
for name in PAUSED:
|
||
subprocess.run(['systemctl', 'stop', name], check=True, timeout=90)
|
||
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
|
||
result['available_kib'] = int(memory['MemAvailable'].split()[0])
|
||
assert result['available_kib'] >= 1088 * 1024, '暂停服务后仍不足以保留 128 MiB 系统预算'
|
||
node = root / 'node-v22.23.2-linux-x64/bin/node'
|
||
command = [
|
||
'systemd-run', '--wait', '--pipe', '--unit=' + unit,
|
||
'-p', 'User=smail', '-p', 'Group=smail', '-p', 'MemoryMax=960M', '-p', 'MemorySwapMax=0',
|
||
'-p', 'CPUQuota=75%', '-p', 'TasksMax=128', '-p', 'RuntimeMaxSec=600s',
|
||
'-p', 'TimeoutStopSec=10s', '-p', 'KillMode=control-group', '-p', 'OOMPolicy=stop',
|
||
'-p', 'PrivateTmp=yes', '-p', 'PrivateNetwork=yes', '-p', 'ProtectSystem=strict',
|
||
'-p', 'ProtectHome=yes', '-p', 'NoNewPrivileges=yes', '-p', 'LimitCORE=0',
|
||
'-p', 'InaccessiblePaths=/opt/smail /run/smail', '-p', 'BindPaths=' + str(root),
|
||
'-p', 'ReadWritePaths=' + str(root), '-p', 'WorkingDirectory=' + str(root / 'frontend'),
|
||
*[argument for target in bindings for argument in ('-p', 'BindReadOnlyPaths=' + target)],
|
||
'--setenv=NODE_OPTIONS=--max-old-space-size=768', '--setenv=HOME=' + str(root / 'build-home'),
|
||
'/bin/sh', '-c',
|
||
'set -eu\n' + str(node) + ' node_modules/vite/bin/vite.js build\n'
|
||
+ str(node) + ' ../deploy/prepare-frontend.mjs\n',
|
||
]
|
||
with (root / ('frontend-build-' + stamp + '.txt')).open('w') as output:
|
||
result['build_code'] = subprocess.run(command, stdout=output, stderr=subprocess.STDOUT,
|
||
timeout=660).returncode
|
||
result['resources'] = run('systemctl', 'show', unit,
|
||
'--property=Result,ExecMainStatus,MemoryPeak,MemorySwapPeak')
|
||
assert result['build_code'] == 0, '前端构建失败,保留日志并恢复服务'
|
||
finally:
|
||
# 先恢复数据库,再恢复依赖数据库的站点;构建成功与否均执行。
|
||
result['restore_errors'] = restore_services(unit)
|
||
result['after'] = states()
|
||
result['protected_unchanged'] = all(result['after'][name] == before[name] for name in PROTECTED)
|
||
result['restored'] = all(result['after'][name]['ActiveState'] == 'active' for name in PAUSED)
|
||
result['finished'] = time.strftime('%Y-%m-%dT%H:%M:%S%z')
|
||
(root / ('build-window-' + stamp + '.json')).write_text(
|
||
json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
|
||
assert result['restored'] and result['protected_unchanged'] and not result['restore_errors']
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|