修复构建窗口异常恢复并完成远程前端生产构建验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 01:37:29 +08:00
parent 4f5b05e95c
commit c1dd533c80
4 changed files with 166 additions and 7 deletions

View File

@ -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 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。

View File

@ -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,尚不能声称无停机自动续期已启用。
- [!] 公网端口和可信代理候选虽已有回归,仍未部署到线上应用。整体生产放行维持未通过。

View File

@ -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()

View File

@ -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)