整改基线:保留远程构建维护窗口及恢复回归工具
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 01:25:02 +08:00
parent 78c5936388
commit 4f5b05e95c
2 changed files with 176 additions and 0 deletions

View File

@ -0,0 +1,50 @@
"""维护窗口恢复逻辑回归,不触碰真实服务。"""
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import call, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import build_frontend_review as review
class RestoreServicesTest(unittest.TestCase):
unit = 'smail-frontend-build-20260918-010000'
def test_stop_build_before_restoring_services(self):
with patch.object(review.subprocess, 'run') as run:
self.assertEqual(review.restore_services(self.unit), [])
self.assertEqual(run.call_args_list, [
call(['systemctl', 'stop', self.unit], timeout=90, check=False),
call(['systemctl', 'start', 'mysql.service'], check=True, timeout=90),
call(['systemctl', 'start', 'Scert.service'], check=True, timeout=90),
])
def test_attempt_remaining_restore_after_database_failure(self):
error = subprocess.CalledProcessError(1, 'systemctl')
with patch.object(review.subprocess, 'run', side_effect=[None, error, None]) as run:
self.assertEqual(review.restore_services(self.unit), [
{'service': 'mysql.service', 'error': 'CalledProcessError'},
])
self.assertEqual(run.call_args_list[-1].args[0], ['systemctl', 'start', 'Scert.service'])
def test_site_timeout_is_reported(self):
error = subprocess.TimeoutExpired('systemctl', 90)
with patch.object(review.subprocess, 'run', side_effect=[None, None, error]):
self.assertEqual(review.restore_services(self.unit), [
{'service': 'Scert.service', 'error': 'TimeoutExpired'},
])
def test_reject_unrelated_service_targets(self):
for name in ('nginx', 'smail.service', self.unit + ' other', '../mysql.service'):
with self.subTest(name=name), patch.object(review.subprocess, 'run') as run:
with self.assertRaises(AssertionError):
review.restore_services(name)
run.assert_not_called()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,126 @@
"""在授权维护窗口构建前端,退出或超时后恢复原先运行的服务。"""
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())