From 4f5b05e95ce2ac294fa84f4bc73ee8a208fbc8bb Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 18 Sep 2026 01:25:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E6=94=B9=E5=9F=BA=E7=BA=BF=EF=BC=9A?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=9C=E7=A8=8B=E6=9E=84=E5=BB=BA=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E7=AA=97=E5=8F=A3=E5=8F=8A=E6=81=A2=E5=A4=8D=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/deployment/test_frontend_build.py | 50 ++++++++++ test/tools/build_frontend_review.py | 126 +++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 test/deployment/test_frontend_build.py create mode 100644 test/tools/build_frontend_review.py diff --git a/test/deployment/test_frontend_build.py b/test/deployment/test_frontend_build.py new file mode 100644 index 0000000..fea1ad0 --- /dev/null +++ b/test/deployment/test_frontend_build.py @@ -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() diff --git a/test/tools/build_frontend_review.py b/test/tools/build_frontend_review.py new file mode 100644 index 0000000..e282722 --- /dev/null +++ b/test/tools/build_frontend_review.py @@ -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())