This commit is contained in:
parent
78c5936388
commit
4f5b05e95c
50
test/deployment/test_frontend_build.py
Normal file
50
test/deployment/test_frontend_build.py
Normal 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()
|
||||
126
test/tools/build_frontend_review.py
Normal file
126
test/tools/build_frontend_review.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user