Client/Agent 自动升级、失败回滚与进程守护
Client/Agent 自动升级、失败回滚与进程守护
摘要
client run 与 agent run 默认变成一个 supervisor:真正的服务进程(worker)作为子进程运行,崩溃后按退避重启。
worker 就绪后 supervisor 按计划检查新版本,下载并校验后在状态目录里切换到新版本;新版本必须在
health_timeout 内就绪并稳定运行 stable_window,否则自动回滚到旧版本并把该版本记为坏版本一段时间。
安装目录里的二进制从不被修改。
升级目标是“最新 Release”与“Server 版本”中较小的一个,Server 版本通过握手 ack 的新增可选字段 server_version
下发。升级源支持 GitHub Releases(可配镜像)和 Server 自己分发两种,完整性只校验同一发布里的 SHA256SUMS。
用户影响
- 默认开启:升级后的
run进程树变成 supervisor + worker 两个进程;进程退出码有了约定(0/70/75/77/78)。 - 关闭方式:
update.enabled: false只关闭升级;再加update.supervise: false,run与此前完全一致。enabled: true且supervise: false被配置校验拒绝。 - 官方 Docker 镜像把两项默认设为
false(容器由编排器守护,镜像标签应不可变),可用环境变量重新打开。 - 运维顺序变化:升级不会超过所连接 Server 的版本,所以要先升级 Server,client/agent 才会跟随。
- 不涉及托盘应用和 Server 自身的升级。
API、配置与授权
- 新增配置:
client.update.*、agent.update.*(结构相同)、server.updates.dir。均有环境变量,默认值写在setDefaults(),校验见validateUpdate。详见配置参考。 - WebSocket 握手:
ClientMetadataAckPayload与AgentMetadataAckPayload新增可选字段server_version。 载荷解码不是严格模式,老版本会忽略它;缺失时视为版本未知,不升级。 - 新增 Server 接口(
server.updates.dir为空时均返回 404):GET /api/v1/{client|agent}/updates/latest与GET /api/v1/{client|agent}/updates/files/{name},使用对应命名空间的服务令牌, 已写入docs/api/openapi.yaml并由TestClientScopedOpenAPIMatchesTheServedSurface守护。 - 授权与安全:文件名只允许
SHA256SUMS、manifest.json和固定格式的发行包名;EvalSymlinks拒绝逃出目录的链接; 下载有并发上限(全局 8,每令牌 2);令牌只发往配置的 Server,重定向不跟随。 - 压缩包按不可信输入处理:只解出根目录下名为
tunnelmesh-<role>[.exe]的一个普通文件,拒绝..、绝对路径、 符号链接、硬链接、重复项,并限制大小。 - AGENTS.md 的“不实现任意远程命令执行”边界补充了受限自更新的约束,见 ADR 0003。
测试证据
go build ./...、go vet ./...:通过;GOOS=windows|darwin|linux go vet针对supervisor、selfupdate、filelock、workerproto、semver、reconnect、cmd/tunnelmesh-{agent,client}:通过。go test ./... -count=1:全部包通过。go test -race:supervisor、selfupdate、filelock、workerproto、semver、reconnect、config、protocol、agent、client通过。- 端到端(
internal/cli,真实二进制):- 真实
tunnelmesh-agentv1.3.0 通过模拟 GitHub 升级到真实 v1.4.0,并确认安装目录里的二进制摘要未变; - 升级到“预检通过但一启动就崩溃”的版本,自动回滚到旧版本,坏版本被记录且文件被清理;
- 从 Server 自己的分发目录(
source: server)完成升级。 这三个场景在-race下连续 12 轮(共 36 次)通过。排查中发现并修复了一个竞争:测试通过改写全局build.Version让进程内 Server 宣称版本,而 Server 的协程在并发读取它;现改为通过RuntimeConfig.Version注入。
- 真实
- 故障注入:
Upgrader在下载后、安装后、激活前、激活后四个点注入崩溃,重启后的Resume均恢复到一致状态。
发布与回滚
- 发布顺序:先发布 Server(它决定升级上限),再发布 client/agent。
- 功能默认开启,发现问题时在配置里设置
update.enabled: false与update.supervise: false即可恢复旧行为,无需回退二进制。 - 已升级的节点:删除
<state_dir>/updates/或把state.json的current清空,下次启动回到已安装版本。 - Server 侧:
server.updates.dir留空即关闭分发;server_version是可选字段,老 client/agent 忽略。 - 无数据库 schema 变更。
Reviewer 关注点
SHA256SUMS与压缩包同源,只防损坏不防篡改;source: server会扩大 Server 被攻破时的影响面,因此分发默认关闭。- supervisor 逻辑固定在已安装版本,updater 的缺陷靠重装而不是自升级修复。
- READY 的含义是“已连上且收到 ack”,覆盖不到“某个监听端口绑定失败”这类问题。
- 已知的既有问题(本次未处理,见实施计划 Task 3b):系统 unit 下
client.lock位于只读的/etc/tunnelmesh/,acquireRuntimeLock的创建可能失败;supervisor 依赖这把锁完成新旧 worker 交接,建议先在真机确认再单独处理。 - 未在真机验证的假设:Windows 上
CTRL_BREAK_EVENT映射为os.Interrupt;macOS arm64 发布包的 ad-hoc 签名在解出后仍然有效; systemdMemoryDenyWriteExecute对子进程的影响。
集成状态
- 未提交、未推送,等待明确授权。