diff --git a/docs/生产整改验收清单.md b/docs/生产整改验收清单.md index 0e95a42..0b82497 100644 --- a/docs/生产整改验收清单.md +++ b/docs/生产整改验收清单.md @@ -2,6 +2,14 @@ ## 范围与结论 +### 当前进展:远程生产前端构建完成 + +- [✓] 2026 年 9 月 18 日 01:30,完整锁定依赖下远程前端回归 24 个文件、185 项通过,零失败、零跳过。 +- [✓] 修复维护窗口停止构建超时会跳过服务恢复的问题,11 项隔离回归通过;保留 128 MiB 系统预算,受限前端生产构建成功。 +- [✓] 01:31:26 至 01:32:07 授权窗口内仅暂停 MySQL、Scert,构建峰值 758.5 MiB,交换空间为零。两服务已恢复,数据库就绪、两站点可信 HTTPS 页面均通过;Smail、Nginx 主进程未变。 +- [x] 新前端产物与源码及嵌入目录核对完成,待完整 Linux 候选构建、隔离首次安装及正式部署复审。 +- [!] 尚未替换线上应用或续期任务。公网端口、可信代理的线上验收及其他生产门槛继续未完成,不能以构建成功代替放行。详情见《远程前端构建验收记录》。 + ### 当前候选:可信代理客户端地址整改 - [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。 diff --git a/docs/远程前端构建验收记录.md b/docs/远程前端构建验收记录.md new file mode 100644 index 0000000..928d183 --- /dev/null +++ b/docs/远程前端构建验收记录.md @@ -0,0 +1,63 @@ +# 远程前端构建验收记录 + +## 范围与结论 + +本轮时间为 47 服务器上的 2026 年 9 月 18 日 01:24 至 01:36,时区为 Asia/Shanghai。应用回归、维护窗口及前端生产构建均在 `47.108.78.12` 执行,本机仅编辑、Git、证据下载与静态摘要检查。 + +- [✓] 复查上一轮维护窗口恢复:MySQL 返回 `mysqld is alive`,Smail 与 Scert 的可信 HTTPS 页面均返回 200,四项服务正常。 +- [✓] 修复停止构建单元超时会跳过服务恢复的问题;远程隔离回归 11 项通过。 +- [✓] 使用新安装的锁定依赖重跑前端回归:24 个文件、185 项通过,零失败、零跳过。 +- [✓] 在用户授权的维护窗口内完成生产前端构建,生成新产物并核对嵌入目录内容完全一致。 +- [✓] 恢复 MySQL 和 Scert 后验证数据库就绪与两个站点真实 HTTPS 页面;Smail、Nginx 主进程未变。 +- [x] 新前端产物已归档并下载,等待构建完整 Linux 候选与后续业务验收。 +- [!] 尚未替换线上应用,不构成首次安装、浏览器业务或整体验收通过。 + +本轮没有清理数据库、删除文件、修改正式证书、替换自动续期任务或停止 Nginx。没有进行业务数据测试,因此不将本轮描述为空库首次安装验收。 + +## 恢复工具整改 + +修改 `test/tools/build_frontend_review.py` 与 `test/deployment/test_frontend_build.py`。基线提交为 `4f5b05e`,已推送项目的 `smail` 远程;仓库没有 `origin` 远程,最初对此名称的推送失败后改用实际远程成功。 + +停止构建失败时先区分预检拒绝导致的单元不存在;其他异常记录为失败,并尝试终止该构建单元的残留进程。无论停止和终止操作是否超时,仍依次尝试恢复 MySQL、Scert,不再提前跳过恢复。单元名称限定为本工具生成的构建单元,不能传入业务服务名。父单元停止预算增加到 480 秒,以覆盖恢复路径的命令超时;保留 `finally` 与 systemd `ExecStopPost` 两层恢复。 + +构建硬上限从 960 MiB 调整为 896 MiB,Node 堆上限从 768 MiB 调整为 640 MiB;系统保留预算仍为 128 MiB。预检要求暂停服务后至少 1024 MiB 可用内存,失败则拒绝构建并恢复服务。此调整针对构建工作进程,不降低业务服务预算或密码计算参数。 + +远程控制目录为 `/var/tmp/smail-review-build-control-20260918-v2`,目录为 root 所有、0750,工具为 0640,smail 用户不可修改控制脚本。测试单元 `smail-build-control-tests-20260918-v2` 在非 root、独立网络及生产目录不可访问的环境中运行,真实服务调用均被模拟。11 项回归覆盖预算边界、停止超时、强制终止失败、数据库恢复失败、站点恢复超时、不存在的构建单元和非法服务名称,均通过;峰值 14.9 MiB,交换空间为零。证据为该目录的 `tests.txt`。 + +这些模拟回归不等同于真实故障期间服务恢复的完整演练,本轮真实窗口验证的是正常构建成功后的自动恢复。 + +## 前端回归与构建 + +工作目录为 `/var/tmp/smail-review-public-port-20260918`。完整依赖此前已使用 Yarn 1.22.22、项目 `yarn.lock`、冻结锁文件及禁用安装脚本方式安装;本次不再借用旧的测试专用依赖。 + +前端测试单元为 `smail-frontend-tests-20260918-013007`,结果目录为 `results-20260918-013008`。24 个文件、185 项通过,零失败、零跳过;运行 37.775 秒,峰值内存 99.3 MiB,交换空间为零。 + +维护窗口为 `smail-build-window-20260918-013126`,子构建为 `smail-frontend-build-20260918-013126`。窗口记录从 01:31:26 到 01:32:07,约 41 秒;Scert 和 MySQL 在窗口内临时停止,Smail 和 Nginx 未停止。暂停后可用内存为 1,091,984 KiB,通过原 128 MiB 保留预算检查。 + +Vite 生产构建成功,前端产物与 Go 嵌入目录各约 3.5 MiB,递归比较无差异。Vite 用时 37.20 秒,子单元用时 38.410 秒,峰值内存 758.5 MiB,交换空间为零。成功的临时单元已被 systemd 回收,因此 JSON 中后续查询的 `MemoryPeak` 为未设置;峰值取自同一构建日志中 systemd 的结束统计,不能把未设置当作零用量。 + +保留日志中的大于 500 kB 分块警告,未通过提高警告阈值隐藏它。该警告不阻止构建,但浏览器加载性能仍需验收。此前失败的 384 MiB Node 堆构建日志、被资源预检拒绝的窗口记录及旧工具均保留,没有删除或冒充成功结果。 + +## 恢复与证据 + +恢复后 MySQL PID 为 `589785`,Scert PID 为 `589832`;Smail PID `574584`、Nginx PID `578671` 前后一致,四项服务均正常,重启计数为零。01:33 收尾检查中 `mail.solit.cn` 和 `web.apmmk.cn` 的 HTTPS 页面均为 200,MySQL 就绪。该窗口之后的内核日志未发现 OOM 记录;没有连续请求采样,不能扩大为全窗口零请求失败或生产容量保证。 + +主工作目录内证据如下: + +- `build-window-20260918-013126.json`:前后服务状态、构建退出码、预算与恢复结果。 +- `frontend-build-20260918-013126.txt`:完整构建输出及资源统计。 +- `build-closeout-0133.txt`:真实页面、数据库就绪、服务状态和产物比较。 +- `frontend-source-0134.sha256`:154 个前端源码文件、3 个构建配置文件和同步工具摘要。 +- `frontend-production-013126.tar.gz`:本轮新前端产物,权限 0600。 + +本机下载目录为 `out/public-port-build-20260918`,已确认由 Git 忽略规则排除。157 个源码及配置文件的原始 SHA-256 与本地一致;同步工具 `deploy/prepare-frontend.mjs` 因 CRLF/LF 差异原始摘要不同,下载远端原文后经 Git 比较及 16 行逐行比较均无内容差异。没有修改中文字符或替换源文件。 + +产物归档 SHA-256 为 `91f775c666e01a24e47b7ac35132ac171a087aac9641c5bffb3456113d311f01`,远程与下载文件一致;首页摘要为 `15703150ff67244c045bc5e83128412782f5ffcb1fa243ec9c046f8bbda97e58`。 + +## 后续门槛 + +- [ ] 使用本轮前端产物及新编译的 Linux 插件、应用、管理工具组成完整候选;不得直接使用偏 Windows 的开发构建脚本生成 Linux 发布包。 +- [ ] 在远程隔离空库完成最新候选首次安装、内部 25 收信与附件验证,并扩展到实际 8001/8002 和 Nginx 反代结构。 +- [ ] 进行可信 HTTPS 桌面浏览器业务、管理员恢复撤销、凭据轮换、队列故障及异机恢复验收。 +- [!] Smail 专用 DNS 配置仍不存在;01:24 复核旧续期定时器 active,尚不能声称无停机自动续期已启用。 +- [!] 公网端口和可信代理候选虽已有回归,仍未部署到线上应用。整体生产放行维持未通过。 diff --git a/test/deployment/test_frontend_build.py b/test/deployment/test_frontend_build.py index fea1ad0..99f495c 100644 --- a/test/deployment/test_frontend_build.py +++ b/test/deployment/test_frontend_build.py @@ -18,7 +18,7 @@ class RestoreServicesTest(unittest.TestCase): 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', 'stop', self.unit], timeout=90, check=True), call(['systemctl', 'start', 'mysql.service'], check=True, timeout=90), call(['systemctl', 'start', 'Scert.service'], check=True, timeout=90), ]) @@ -45,6 +45,64 @@ class RestoreServicesTest(unittest.TestCase): review.restore_services(name) run.assert_not_called() + def test_stop_timeout_does_not_skip_restore(self): + error = subprocess.TimeoutExpired('systemctl', 90) + with patch.object(review.subprocess, 'run', side_effect=[error, None, None, None]) as run: + self.assertEqual(review.restore_services(self.unit), [ + {'service': self.unit, 'error': 'TimeoutExpired'}, + ]) + self.assertEqual(run.call_args_list[1].args[0], [ + 'systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', self.unit, + ]) + self.assertEqual([item.args[0] for item in run.call_args_list[-2:]], [ + ['systemctl', 'start', 'mysql.service'], ['systemctl', 'start', 'Scert.service'], + ]) + + def test_missing_build_unit_is_not_a_restore_failure(self): + error = subprocess.CalledProcessError(5, 'systemctl') + with patch.object(review.subprocess, 'run', side_effect=[error, None, None]) as run, \ + patch.object(review, 'run', return_value='not-found'): + self.assertEqual(review.restore_services(self.unit), []) + self.assertEqual(run.call_count, 3) + + def test_existing_unit_stop_failure_is_reported(self): + error = subprocess.CalledProcessError(1, 'systemctl') + with patch.object(review.subprocess, 'run', side_effect=[error, None, None, None]), \ + patch.object(review, 'run', return_value='loaded'): + self.assertEqual(review.restore_services(self.unit), [ + {'service': self.unit, 'error': 'CalledProcessError'}, + ]) + + def test_stop_and_kill_failures_do_not_skip_remaining_restores(self): + failures = [OSError(), subprocess.TimeoutExpired('systemctl', 30), + subprocess.CalledProcessError(1, 'systemctl'), None] + with patch.object(review.subprocess, 'run', side_effect=failures) as run: + self.assertEqual(review.restore_services(self.unit), [ + {'service': self.unit, 'error': 'OSError'}, + {'service': self.unit, 'error': 'TimeoutExpired', 'action': 'kill'}, + {'service': 'mysql.service', 'error': 'CalledProcessError'}, + ]) + self.assertEqual(run.call_args_list[-1].args[0], ['systemctl', 'start', 'Scert.service']) + + def test_unknown_unit_state_cannot_hide_stop_failure(self): + error = subprocess.CalledProcessError(1, 'systemctl') + with patch.object(review.subprocess, 'run', side_effect=[error, None, None, None]), \ + patch.object(review, 'run', side_effect=OSError()): + self.assertEqual(review.restore_services(self.unit), [ + {'service': self.unit, 'error': 'CalledProcessError'}, + ]) + + +class BuildBudgetTest(unittest.TestCase): + def test_full_budget_is_accepted(self): + review.check_memory_budget(1024 * 1024) + self.assertEqual(review.SYSTEM_RESERVE_MIB, 128) + self.assertLess(review.NODE_HEAP_MIB, review.BUILD_MEMORY_MIB) + + def test_insufficient_reserve_is_rejected(self): + with self.assertRaises(RuntimeError): + review.check_memory_budget(1024 * 1024 - 1) + if __name__ == '__main__': unittest.main() diff --git a/test/tools/build_frontend_review.py b/test/tools/build_frontend_review.py index e282722..e30a679 100644 --- a/test/tools/build_frontend_review.py +++ b/test/tools/build_frontend_review.py @@ -13,6 +13,9 @@ from run_frontend_review import dependency_bindings PAUSED = ('Scert.service', 'mysql.service') PROTECTED = ('smail.service', 'nginx.service') +BUILD_MEMORY_MIB = 896 +NODE_HEAP_MIB = 640 +SYSTEM_RESERVE_MIB = 128 def run(*args): @@ -27,8 +30,24 @@ def states(): 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 = [] + try: + subprocess.run(['systemctl', 'stop', build_unit], timeout=90, check=True) + except Exception as error: + # 预检拒绝时构建单元尚不存在;其他停止失败必须记录并尝试终止残留进程。 + missing = False + if isinstance(error, subprocess.CalledProcessError): + try: + missing = run('systemctl', 'show', build_unit, '--property=LoadState', '--value') == 'not-found' + except Exception: + pass + if not missing: + errors.append({'service': build_unit, 'error': type(error).__name__}) + try: + subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', build_unit], + timeout=30, check=True) + except Exception as kill_error: + errors.append({'service': build_unit, 'error': type(kill_error).__name__, 'action': 'kill'}) for name in reversed(PAUSED): try: subprocess.run(['systemctl', 'start', name], check=True, timeout=90) @@ -37,6 +56,12 @@ def restore_services(build_unit): return errors +def check_memory_budget(available_kib): + required_kib = (BUILD_MEMORY_MIB + SYSTEM_RESERVE_MIB) * 1024 + if available_kib < required_kib: + raise RuntimeError('暂停服务后仍不足以保留 128 MiB 系统预算') + + def main(): parser = argparse.ArgumentParser() parser.add_argument('directory') @@ -68,7 +93,7 @@ def main(): # 由独立 systemd 单元负责恢复,SSH 断开或工作进程被终止也不会取消恢复钩子。 return subprocess.run([ 'systemd-run', '--wait', '--pipe', '--unit=' + unit, - '-p', 'RuntimeMaxSec=900s', '-p', 'TimeoutStopSec=180s', + '-p', 'RuntimeMaxSec=900s', '-p', 'TimeoutStopSec=480s', '-p', 'KillMode=control-group', '-p', 'UMask=0077', '-p', 'LimitCORE=0', '-p', 'ExecStopPost=/usr/bin/python3 ' + str(script) + ' ' + str(root) + ' --restore-unit ' + build_unit, @@ -79,17 +104,19 @@ def main(): 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')} + result = {'before': before, 'unit': unit, 'started': time.strftime('%Y-%m-%dT%H:%M:%S%z'), + 'build_memory_mib': BUILD_MEMORY_MIB, 'node_heap_mib': NODE_HEAP_MIB, + 'system_reserve_mib': SYSTEM_RESERVE_MIB} 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 系统预算' + check_memory_budget(result['available_kib']) 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', 'User=smail', '-p', 'Group=smail', '-p', f'MemoryMax={BUILD_MEMORY_MIB}M', '-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', @@ -97,7 +124,7 @@ def main(): '-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'), + f'--setenv=NODE_OPTIONS=--max-old-space-size={NODE_HEAP_MIB}', '--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', @@ -108,6 +135,9 @@ def main(): result['resources'] = run('systemctl', 'show', unit, '--property=Result,ExecMainStatus,MemoryPeak,MemorySwapPeak') assert result['build_code'] == 0, '前端构建失败,保留日志并恢复服务' + except Exception as error: + result['error'] = type(error).__name__ + raise finally: # 先恢复数据库,再恢复依赖数据库的站点;构建成功与否均执行。 result['restore_errors'] = restore_services(unit)