From f536987a7ed00bccf104db4e72309bca2f6d64ad Mon Sep 17 00:00:00 2001 From: Zhan Ziyang Date: Mon, 17 Aug 2026 10:10:14 +0800 Subject: [PATCH] refactor: use nginx -s reload instead of systemd - doc: add comment --- RUNBOOK_CONTAINER_BACKEND.md | 2 +- UPDATE_PLAN.md | 116 +++++-------- .../native_backend_executor_linux_test.go | 29 +++- integration/transaction_kernel_linux_test.go | 13 +- internal/backendexecutor/executor.go | 145 ++++++++++++----- internal/backendexecutor/executor_test.go | 56 ++++++- internal/backendexecutor/operations.go | 92 ++++++++--- internal/backendupdate/container.go | 100 +++++++++--- internal/backendupdate/container_test.go | 51 +++++- internal/backendupdate/intents.go | 13 ++ internal/backendupdate/model.go | 123 +++++++++----- internal/backendupdate/operations.go | 62 ++++++- internal/backendupdate/operations_test.go | 11 ++ internal/backendupdate/progress.go | 15 +- internal/backendupdate/restart.go | 18 +- internal/backendupdate/restart_test.go | 2 + internal/backendupdate/updater.go | 132 ++++++++++++--- internal/backendupdate/updater_test.go | 25 +++ internal/containerengine/engine.go | 136 +++++++++++----- internal/containerengine/moby.go | 31 +++- internal/containerengine/moby_test.go | 3 + internal/daemonapi/protocol.go | 56 +++++-- internal/daemonclient/client.go | 22 ++- internal/daemonserver/server.go | 40 ++++- internal/daemonserver/server_test.go | 31 +++- internal/deploymentconfig/config.go | 129 +++++++++++---- internal/deploymentconfig/config_test.go | 39 +++++ internal/filestore/store.go | 48 ++++-- internal/filestore/store_test.go | 12 ++ internal/healthcheck/actuator.go | 20 ++- internal/healthcheck/actuator_test.go | 12 ++ internal/hostnginx/config.go | 38 ++++- internal/hostnginx/config_test.go | 7 + internal/hostnginx/controller.go | 73 +++++---- internal/hostnginx/controller_test.go | 24 ++- internal/logging/logger.go | 5 + internal/logging/logger_test.go | 1 + internal/nativebackendexecutor/executor.go | 130 ++++++++++++--- .../nativebackendexecutor/executor_test.go | 32 ++++ internal/nativebackendexecutor/operations.go | 45 +++++ internal/processlock/lock.go | 4 + internal/processlock/lock_linux.go | 5 +- internal/processlock/lock_linux_test.go | 2 + internal/runtimepaths/paths.go | 21 ++- internal/systemd/manager.go | 23 ++- internal/systemd/systemctl.go | 27 ++- internal/systemd/systemctl_test.go | 3 + .../backend_container_deployment.go | 46 +++++- internal/transaction/coordinator.go | 74 +++++++-- internal/transaction/coordinator_test.go | 26 +++ internal/transaction/errors.go | 16 +- internal/transaction/model.go | 122 +++++++++----- internal/transaction/state.go | 50 ++++-- internal/transaction/state_test.go | 5 + internal/transaction/store.go | 151 ++++++++++++++++- internal/transaction/store_test.go | 20 +++ internal/updatepackage/backend_native.go | 154 ++++++++++++++---- internal/updatepackage/backend_native_test.go | 16 ++ internal/updatepackage/direct_native.go | 27 ++- internal/updatepackage/direct_native_test.go | 8 + main.go | 57 +++++-- main_test.go | 18 ++ 62 files changed, 2216 insertions(+), 598 deletions(-) diff --git a/RUNBOOK_CONTAINER_BACKEND.md b/RUNBOOK_CONTAINER_BACKEND.md index 9f78bda..c587e93 100644 --- a/RUNBOOK_CONTAINER_BACKEND.md +++ b/RUNBOOK_CONTAINER_BACKEND.md @@ -4,7 +4,7 @@ ## 1. daemon 配置 -`/etc/yms-daemon/yms-daemon.toml` 使用以下精确内容。`systemctl_path` 仍用于 host Nginx reload,必须填写该服务器 `command -v systemctl` 的精确输出。 +`/etc/yms-daemon/yms-daemon.toml` 使用以下精确内容。`systemctl_path` 仅用于 native backend systemd 操作;本地 Nginx 切流固定使用 `/usr/sbin/nginx -s reload`。 ```toml [daemon] diff --git a/UPDATE_PLAN.md b/UPDATE_PLAN.md index 3fce0f8..20e8458 100644 --- a/UPDATE_PLAN.md +++ b/UPDATE_PLAN.md @@ -1,7 +1,7 @@ # YMS Daemon 更新体系设计与实施计划 > 状态:研讨中,核心方向已收敛 -> 当前阶段:冻结 native/container 混合过渡、Docker standalone、OpenResty 蓝绿切换、客户 PC 交付工作台、离线包协议与 Jenkins 高频更新语义 +> 当前阶段:冻结 native/container 混合过渡、本地 Nginx gateway、Docker standalone、客户 PC 交付工作台、离线包协议与 Jenkins 高频更新语义 > 本文档只描述设计和实施计划。除本文档外,现网代码、脚本、systemd、Nginx 和容器均未修改。 ## 1. 目标 @@ -319,9 +319,7 @@ daemon 内部先选择服务器已经明确登记的组件执行器,再进入 ├── frontend native directory executor ├── frontend Docker executor └── nodeSsr Docker executor - -> gateway controller - ├── 过渡期 host Nginx - └── 目标态 OpenResty container + -> host Nginx gateway controller ``` 本机部署配置必须为每个组件明确记录精确值 `native` 或 `container`。daemon 不根据文件名、目录、进程、systemd unit 或 Docker 容器自行推断运行类型。 @@ -359,8 +357,8 @@ nodeSsr = container 客户服务器 ├── yms-daemon.service RPM 安装,宿主机常驻 +├── host Nginx 稳定入口,不随业务更新替换 └── Docker Engine - ├── OpenResty gateway 稳定入口,不随业务更新替换 ├── frontend blue/green 镜像内包含 dist 和静态 Nginx ├── backend blue/green └── nodeSsr blue/green @@ -371,7 +369,7 @@ nodeSsr = container ```text 客户服务器 ├── yms-daemon.service -├── host Nginx 或 OpenResty gateway +├── host Nginx gateway ├── backend native blue/green JVM + systemd ├── frontend native blue/green 版本目录 └── Docker Engine @@ -444,10 +442,10 @@ yms-daemon └── version 查询 daemon 版本 ``` -### 4.1 `serve` +### 4.1 `ymsd` ```bash -/usr/bin/yms-daemon serve +/usr/bin/ymsd ``` 职责: @@ -457,7 +455,7 @@ yms-daemon - 为客户 PC 提供经过认证和授权的 WSS 控制与状态通道。 - 串行执行更新事务。 - 访问 Docker Engine API。 -- 通过 gateway controller 管理 host Nginx 或 OpenResty 配置和 graceful reload。 +- 通过 gateway controller 管理本地 Nginx 配置和 graceful reload。 - 执行健康检查和业务验证。 - daemon 或服务器重启后恢复未完成事务。 @@ -571,24 +569,18 @@ native 模式支持: ### 5.4 gateway -gateway 使用独立 OpenResty 容器,固定占用外部业务端口。它不随 `backend`、`frontend`、`nodeSsr` 的普通更新替换。 +gateway 固定使用客户服务器上的本地 Nginx,直接占用外部业务端口。它不随 `backend`、`frontend`、`nodeSsr` 的普通更新替换,也不进入 Docker Compose 或 Docker standalone 生命周期。 请求链路是分支结构: ```text 浏览器 - -> OpenResty gateway - ├── / 和静态资源 -> frontend 静态容器 - ├── /yms_services/ -> backend 容器 + -> host Nginx gateway + ├── / 和静态资源 -> frontend 静态目录或 frontend 容器 + ├── /yms_services/ -> backend native unit 或容器 └── 8910 -> nodeSsr 容器 ``` -不存在以下链路: - -```text -OpenResty -> frontend Nginx -> backend -``` - frontend 静态 Nginx 只负责: - 从镜像内 `/usr/share/nginx/html` 提供静态文件。 @@ -596,56 +588,30 @@ frontend 静态 Nginx 只负责: - 静态缓存响应头。 - 对不存在的资源返回可识别的真实错误。 -backend 和 nodeSsr 由 OpenResty 直接代理。 +backend 和 nodeSsr 由本地 Nginx 直接代理。 -### 5.5 gateway 分阶段迁移 +### 5.5 本地 Nginx 配置与切流 -为避免 native 客户在安装 daemon 时被迫同时迁移全部运行结构,gateway 分为: +daemon 只管理 `/etc/nginx/nginx.conf` 中冻结的 managed upstream 标记块,不覆盖整份 Nginx 配置。每次切流遵循: -```text -第一阶段:复用现有宿主机 Nginx,由 daemon 管理配置检查、原子替换和 reload -第二阶段:显式迁移到 OpenResty gateway 容器 -第三阶段:业务组件按 customer/site 计划逐个迁移到 container -``` +1. 读取当前完整配置并确认唯一活动 backend 端口; +2. 生成下一份完整配置,仅改变 daemon 管理的 upstream 标记块; +3. 写入事务目录并保存更新前快照; +4. 使用 `/usr/sbin/nginx -t -c /etc/nginx/nginx.conf` 验证; +5. 原子替换配置文件; +6. 使用 `/usr/sbin/nginx -s reload` 平滑加载; +7. 通过健康检查和实际配置再次确认切流结果; +8. 任一步失败时恢复更新前配置并再次执行配置检查。 -host Nginx 和 OpenResty container 必须实现同一 gateway controller 语义: +不使用 `systemctl reload nginx.service` 作为切流动作。systemd 只负责 Nginx 开机启动和进程守护,配置切换由 Nginx 原生命令完成。 -- 生成完整下一配置。 -- 配置检查。 -- 原子替换。 -- graceful reload。 -- 恢复更新前配置。 -- 查询当前实际流量目标。 +Nginx 的两个 upstream 后端始终保留,daemon 只切换活动行的注释状态。`max_fails=1` 和 `fail_timeout=2s` 由现场配置固定管理,daemon 不在普通更新中改写。 -单机从占用业务端口的 host Nginx 切换到 OpenResty container 是独立基础设施迁移。没有外部负载均衡时,不承诺该一次性迁移绝对零中断;迁移完成后的普通业务更新必须零停机。 +### 5.6 gateway 自身边界 -### 5.6 为什么 gateway 目标态使用 OpenResty +本地 Nginx 不属于三个业务组件的普通更新事务。daemon 不升级 Nginx 二进制、不安装 Lua、不接管客户未标记的配置段。Nginx 本身的升级、证书更新和全局配置变更由操作系统运维流程负责。 -- 兼容现有 Nginx 路由和超时语义。 -- 支持 SSE、gRPC-Web、大文件上传和 graceful reload。 -- 后续可使用 Lua 增加灰度、鉴权、限流、审计和静态资源兼容。 -- 官方构建支持 `amd64` 和 `arm64/aarch64`。 - -第一版 Lua 不参与核心切流事务。切流依赖经过检查的配置文件和 OpenResty graceful reload,避免同时维护文件状态、共享内存状态和内部路由状态。 - -gateway: - -- 不挂载 Docker Socket。 -- 不创建或删除业务容器。 -- 不接受更新包下发任意 Lua 文件。 -- Lua 代码只能随受控 gateway 镜像发布。 - -### 5.7 gateway 自身升级 - -gateway 自身升级不属于三个业务组件的普通更新。单机只有一个对外入口时,gateway 进程或整机故障无法提供绝对零中断。 - -gateway 自身零停机升级需要以下一种外部条件: - -- 双机入口和外部负载均衡。 -- VRRP 或等价入口漂移机制。 -- Kubernetes Service。 - -第一版只保证三个业务组件的普通更新不停止 gateway。 +第一版只保证业务组件普通更新期间 Nginx 进程不停止,并保证 daemon 的配置切换使用语法检查、原子替换和 graceful reload。 ## 6. 蓝绿双槽与零停机切流 @@ -684,7 +650,7 @@ systemd/Docker/目录 = 组件实际运行状态 daemon 服务端 SQLite = 操作意图、过程、历史和恢复线索 ``` -OpenResty 配置目录整体以 bind mount 提供给 gateway。不能只 bind mount 单个配置文件,否则宿主机原子重命名后容器可能继续引用旧 inode。host Nginx 由 daemon 直接管理宿主机配置目录。 +本地 Nginx 直接读取宿主机配置目录。daemon 只原子替换自身管理的配置文件,不覆盖未标记配置段。 ### 6.3 单组件更新 @@ -698,9 +664,9 @@ OpenResty 配置目录整体以 bind mount 提供给 gateway。不能只 bind mo 5. Docker inspect 取得实际容器信息 6. daemon 直接检查 green 健康状态 7. 生成完整的下一份 gateway 配置 -8. 在 gateway 内执行配置检查 +8. 使用 `/usr/sbin/nginx -t` 执行配置检查 9. 同目录原子替换当前配置 -10. 向 OpenResty master 发送 HUP +10. 使用 `/usr/sbin/nginx -s reload` 平滑加载 11. 新 worker 把新请求发往 green 12. 旧 worker 继续把已有连接交给 blue 13. 通过 gateway 执行业务验证 @@ -711,8 +677,8 @@ OpenResty 配置目录整体以 bind mount 提供给 gateway。不能只 bind mo 核心切换: ```text -旧 OpenResty worker -> blue -> 处理已有连接 -新 OpenResty worker -> green -> 接收新请求 +旧 Nginx worker -> blue -> 处理已有连接 +新 Nginx worker -> green -> 接收新请求 ``` 配置检查失败时不得替换当前配置。reload 失败时旧 worker 继续使用旧配置,daemon 恢复更新前配置并再次检查。 @@ -848,7 +814,7 @@ daemon 启动后: - native JAR 和 repack 后 frontend `dist/...` 文件的暂存、校验与双槽切换。 - 镜像装载或拉取。 - native 实例与 Docker 容器的 blue/green 并行运行。 -- host Nginx 与 OpenResty 的配置检查和 graceful reload。 +- 本地 Nginx 的配置检查和 graceful reload。 - backend 新旧版本兼容。 - Flyway 单次迁移边界。 - SSE、上传和会话兼容。 @@ -1191,7 +1157,7 @@ CREATED - 当前状态机阶段。 - 每一步开始和结束时间。 - systemd 或 Docker Engine 操作结果。 -- host Nginx 或 OpenResty 配置检查与 reload 结果。 +- 本地 Nginx 配置检查与 reload 结果。 - 健康检查和业务验证结果。 - drain 状态。 - 提交、失败或回滚结果。 @@ -1344,8 +1310,6 @@ native 过渡期还需要 daemon 管理的 backend blue/green unit。现有 `yms - Linux 构建验证 ELF 不存在非预期动态依赖。 - 其他 ARM 目标取得客户精确 `GOARCH`、`GOARM` 或 `uname -m` 后再增加。 -OpenResty gateway 也必须生成并验证 `linux/amd64` 和 `linux/arm64` 镜像。官方 `x86_64` OpenResty 预编译包存在 CPU 指令要求,客户老旧 CPU 的精确能力需要纳入现场采集和 CI 兼容基线。 - ## 13. 客户 PC 工作台与 GUI 客户 PC 使用同一 Windows 二进制的两个运行入口: @@ -1540,7 +1504,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: ### 15.4 gateway -- host Nginx 或 OpenResty gateway 不存在或未运行。 +- 本地 Nginx 不存在或未运行。 - 配置目录不可写。 - 生成配置失败。 - 配置检查失败。 @@ -1625,7 +1589,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: - [x] 确认 native 兼容层不执行现有更新脚本。 - [x] 确认 Docker standalone 是 native 客户的主要迁移方向。 - [x] 确认 frontend `dist` 在 CI 阶段进入 frontend 镜像。 -- [x] 确认 OpenResty 作为稳定 gateway。 +- [x] 确认本地 Nginx 作为稳定 gateway。 - [x] 确认业务组件使用 blue/green 和 graceful reload 零停机切流。 - [x] 确认第一版不使用 Lua shared dict 作为核心切流状态。 - [x] 确认开发环境使用同一更新体系进行高频更新。 @@ -1667,7 +1631,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: - [ ] 实现 Docker Engine API 客户端和兼容性检查。 - [ ] 实现镜像 digest 校验。 - [ ] 实现 blue/green 统一生命周期。 -- [ ] 实现 OpenResty gateway controller 的配置生成、检查、原子替换和 HUP。 +- [ ] 实现本地 Nginx gateway controller 的配置生成、检查、原子替换和 `/usr/sbin/nginx -s reload`。 - [ ] 实现 backend、frontend、nodeSsr 健康检查。 - [ ] 实现基于 SQLite 意图、gateway 配置和 systemd/Docker/文件实际状态的重启恢复。 - [ ] 在开发环境按 native 文件 SHA-256 或 container digest 完成高频更新闭环。 @@ -1740,7 +1704,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: 6. Docker standalone 是 native 客户向容器化迁移的主要方向。 7. container 制品使用 OCI digest 作为身份,不依赖 tag 判断版本;native 制品使用 manifest 声明的文件路径、长度和 SHA-256。 8. container frontend 的 `dist` 在 CI 阶段烘焙进 frontend 镜像;native frontend 由 repack 展开源归档,客户 manifest 逐文件声明 `dist/...` 的路径、长度和 SHA-256,daemon 把这些文件写入非活动版本目录。 -9. 目标态由 OpenResty 作为稳定 gateway;过渡期允许 host Nginx 实现同一 gateway controller 语义;frontend 静态 Nginx 不代理 backend。 +9. 目标态由客户服务器本地 Nginx 作为稳定 gateway;frontend 静态 Nginx 不代理 backend。 10. 三个业务组件通过 blue/green 双槽和 gateway graceful reload 实现普通更新零停机。 11. 第一版核心切流不引入动态 Lua 路由 shared dict 和独立路由数据库。 12. 开发环境必须使用同一 daemon;container 验证同一次 multi-platform 构建的实际平台 digest,native 验证 backend JAR 或 frontend 源归档及展开文件的 SHA-256,并复用同一切流和回滚执行器进行高频更新。 @@ -1778,7 +1742,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: 44. `daemon.environment` 固定为必填机器级字段,只接受精确值 `dev`、`prod`;它只选择 container 镜像获取行为,`dev` 为 Registry pull,`prod` 为 repack ZIP load。 45. 第一阶段 backend native 配置精确使用 `[backend]`、`[backend.slot.8080]` 和 `[backend.slot.8081]`;key、必填规则和精确值以 2.9 节为准,loader 不提供配置默认值。 46. daemon 服务端路径固定为:Unix Socket `/run/yms-daemon/yms-daemon.sock`、进程锁 `/run/yms-daemon/yms-daemon.lock`、SQLite `/var/lib/yms-daemon/yms-daemon.db`、事务工作目录 `/var/lib/yms-daemon/work`、本地日志 `/var/log/yms-daemon/yms-daemon.log`。 -47. 当前 native 过渡 gateway 固定管理 `/etc/nginx/nginx.conf` 中唯一一组 `# yms-update managed upstream begin/end` 标记,Nginx 可执行文件固定为 `/usr/sbin/nginx`,systemd unit 固定为 `nginx.service`;配置完整原子替换后执行 `/usr/sbin/nginx -t` 和 `systemctl reload`,任一步失败恢复替换前完整配置。 +47. 当前 gateway 固定管理 `/etc/nginx/nginx.conf` 中唯一一组 `# yms-update managed upstream begin/end` 标记,Nginx 可执行文件固定为 `/usr/sbin/nginx`,systemd unit 固定为 `nginx.service`;配置完整原子替换后执行 `/usr/sbin/nginx -t` 和 `/usr/sbin/nginx -s reload`,任一步失败恢复替换前完整配置。 48. 第一次旧 unit 运行态迁移固定映射为 8080=`yms.service`、8081=`ymsback.service`;Nginx 当前接流端口对应的旧 unit 和 `yms-backend@.service` 必须恰好一个处于运行态,否则拒绝更新。 49. backend native 更新输入分为 `repack-zip` 和 `native-jar`;前者由 `-f` 提交,后者由 `--native-jar` 提交,二者互斥且不通过扩展名推断。直传 JAR 使用完整文件 SHA-256 作为幂等身份,在 `/home/yms/lib/releases` 下保存为 `direct//<原始JAR文件名>`;目录名精确取完整小写十六进制 SHA-256 的前 12 位,完整 SHA-256 继续用于幂等和内容校验,目录冲突时拒绝覆盖。直传 JAR 复用与 repack ZIP 相同的双槽、健康检查、切流、提交和补偿流程。 50. backend native 零停机重启命令固定为 `yms-daemon restart --service backend`,可附加 `--quite`。restart 使用当前接流 release 创建独立事务并轮转到非活动槽,不调用 `systemctl restart`,也不重新上传 JAR;当前 JAR 已在 release 目录时直接复用,兼容入口仍是普通文件时按内容身份导入 release 目录。未完成 restart 由同一命令恢复,其他未完成事务阻止新 restart。 @@ -1795,7 +1759,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: 6. native backend 从现有 `/home/yms/bin/env/yms.env` 迁移到 `/home/yms/conf` 外挂配置的时机和兼容规则。 7. native frontend 双版本目录的精确值。 8. gateway 容器镜像、配置目录、Docker network、blue/green 容器名和 label 的精确值。 -9. host Nginx 和 OpenResty 配置 reload 成功的确认方式。 +9. 本地 Nginx 配置检查和 `/usr/sbin/nginx -s reload` 成功的确认方式。 10. SSE 和上传的最大 drain 时间与强制结束规则。 11. 三个组件的旧槽保留时间、磁盘清理和镜像清理规则。 12. `/home/yms/tmp` 的内部子目录、容量限制、保留时间和异常文件清理规则。 diff --git a/integration/native_backend_executor_linux_test.go b/integration/native_backend_executor_linux_test.go index a992e19..2799171 100644 --- a/integration/native_backend_executor_linux_test.go +++ b/integration/native_backend_executor_linux_test.go @@ -1,5 +1,7 @@ //go:build linux +// Package integration_test 包含 yms-daemon 的集成测试,在真实文件系统与进程环境下 +// 验证原生后端执行器与事务底座的协同行为。相关测试仅在 Linux 平台编译运行。 package integration_test import ( @@ -22,6 +24,9 @@ import ( "yms-daemon/internal/transaction" ) +// TestNativeBackendExecutorIntegration 在临时目录中搭建完整的原生后端发布流程, +// 验证执行器能把传入的 JAR 制品安装到发布存储、更新槽位软链接并触发 systemd 单元启动, +// 且事务状态与事件能正确持久化到数据库。 func TestNativeBackendExecutorIntegration(t *testing.T) { ctx := context.Background() root := t.TempDir() @@ -141,19 +146,28 @@ func TestNativeBackendExecutorIntegration(t *testing.T) { } } +// integrationIdentity 根据内容计算文件的 SHA256 摘要与大小,返回用于发布存储校验的文件身份。 func integrationIdentity(content []byte) filestore.Identity { digest := sha256.Sum256(content) return filestore.Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} } +// integrationUnitManager systemd.Manager 接口的内存实现,用于在集成测试中模拟 systemd 单元, +// 记录启动与停止调用并跟踪单元状态变化。 type integrationUnitManager struct { - mu sync.Mutex - unit systemd.Unit - startCalls int - stopCalls int + // mu 保护以下所有字段的并发访问。 + mu sync.Mutex + // unit 当前模拟的 systemd 单元状态。 + unit systemd.Unit + // startCalls 统计 Start 被调用的次数。 + startCalls int + // stopCalls 统计 Stop 被调用的次数。 + stopCalls int + // startedName 记录最近一次 Start 的单元名。 startedName string } +// Inspect 返回指定名称的模拟单元状态;名称不匹配时返回 ErrUnitNotFound。 func (m *integrationUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { m.mu.Lock() defer m.mu.Unlock() @@ -163,6 +177,8 @@ func (m *integrationUnitManager) Inspect(_ context.Context, name string) (system return m.unit, nil } +// Start 模拟启动指定单元:累计调用次数、记录单元名并把状态更新为 active/running; +// 名称不匹配时返回 ErrUnitNotFound。 func (m *integrationUnitManager) Start(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() @@ -176,6 +192,8 @@ func (m *integrationUnitManager) Start(_ context.Context, name string) error { return nil } +// Stop 模拟停止指定单元:累计调用次数并把状态更新为 inactive/dead; +// 名称不匹配时返回 ErrUnitNotFound。 func (m *integrationUnitManager) Stop(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() @@ -188,8 +206,10 @@ func (m *integrationUnitManager) Stop(_ context.Context, name string) error { return nil } +// integrationRoundTripFunc 一个可替换的 HTTP 传输实现,用于拦截健康检查请求并返回固定响应。 type integrationRoundTripFunc func(*http.Request) (*http.Response, error) +// RoundTrip 校验请求地址是否为预期的健康检查端点,然后委托给底层函数生成响应。 func (f integrationRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { if request.URL.String() != "http://127.0.0.1:8081/yms/actuator/health" { return nil, errors.New("unexpected native backend health endpoint: " + request.URL.String()) @@ -197,4 +217,5 @@ func (f integrationRoundTripFunc) RoundTrip(request *http.Request) (*http.Respon return f(request) } +// 编译期断言 integrationUnitManager 实现了 systemd.Manager 接口。 var _ systemd.Manager = (*integrationUnitManager)(nil) diff --git a/integration/transaction_kernel_linux_test.go b/integration/transaction_kernel_linux_test.go index 80614c3..11d89d3 100644 --- a/integration/transaction_kernel_linux_test.go +++ b/integration/transaction_kernel_linux_test.go @@ -14,11 +14,16 @@ import ( "yms-daemon/internal/transaction" ) +// 锁辅助进程通过环境变量传递模式与锁路径,用于在独立进程中验证 flock 的互斥语义。 const ( + // lockHelperModeEnvironment 传递锁辅助进程运行模式的环境变量名。 lockHelperModeEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_MODE" + // lockHelperPathEnvironment 传递锁文件路径的环境变量名。 lockHelperPathEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_PATH" - lockHelperExpectBlocked = "EXPECT_BLOCKED" - lockHelperExpectAcquired = "EXPECT_ACQUIRED" + // lockHelperExpectBlocked 表示期望锁辅助进程因锁被占用而加锁失败。 + lockHelperExpectBlocked = "EXPECT_BLOCKED" + // lockHelperExpectAcquired 表示期望锁辅助进程在锁释放后成功加锁。 + lockHelperExpectAcquired = "EXPECT_ACQUIRED" ) // TestTransactionKernelIntegration 使用真实子进程、flock 和 SQLite 文件验证事务底座。 @@ -142,6 +147,8 @@ func TestTransactionKernelLockHelper(t *testing.T) { } } +// runLockHelper 启动一个独立的锁辅助测试进程,通过环境变量传入模式与锁路径, +// 并校验该进程按预期成功退出。 func runLockHelper(t *testing.T, mode, lockPath string) { t.Helper() command := exec.Command(os.Args[0], "-test.run=^TestTransactionKernelLockHelper$", "-test.v") @@ -156,6 +163,8 @@ func runLockHelper(t *testing.T, mode, lockPath string) { t.Logf("lock helper mode %s passed", mode) } +// assertStateEvents 校验事务事件流与给定的状态迁移序列一致:事件数量应为迁移数加一, +// 首条为事务创建事件,其后每条状态变更事件的前后状态与序列号递增关系均需匹配,最终状态为 Committed。 func assertStateEvents(t *testing.T, events []transaction.Event, transitions []transaction.State) { t.Helper() if len(events) != len(transitions)+1 { diff --git a/internal/backendexecutor/executor.go b/internal/backendexecutor/executor.go index fddb3cd..ec6b8a6 100644 --- a/internal/backendexecutor/executor.go +++ b/internal/backendexecutor/executor.go @@ -1,5 +1,5 @@ -// Package backendexecutor prepares and starts one explicitly named backend container. -// Gateway switching is deliberately outside this package. +// Package backendexecutor 负责准备并启动一个显式命名的后端容器。 +// 网关流量切换被刻意排除在本包职责之外。 package backendexecutor import ( @@ -25,56 +25,95 @@ import ( ) const ( - healthPath = "/yms/actuator/health" - healthTimeout = 120 * time.Second - healthInterval = time.Second + // healthPath 后端 Actuator 健康检查端点的固定路径。 + healthPath = "/yms/actuator/health" + // healthTimeout 等待后端容器健康的整体超时时间。 + healthTimeout = 120 * time.Second + // healthInterval Actuator 健康检查的轮询间隔。 + healthInterval = time.Second + // containerStopTimeoutSeconds 停止后端容器时允许的最长等待秒数。 containerStopTimeoutSeconds = 150 * 60 - hostNetworkMode = "host" - bindMountType = "bind" - stepLoadImage = "backend.image.load" - stepPullImage = "backend.image.pull" - stepRemoveContainer = "backend.container.remove-inactive" - stepCreateContainer = "backend.container.create" - stepStartContainer = "backend.container.start" - stepCheckHealth = "backend.container.health" + // hostNetworkMode 表示后端容器使用宿主机网络命名空间。 + hostNetworkMode = "host" + // bindMountType 表示后端容器的挂载类型为绑定挂载。 + bindMountType = "bind" + // stepLoadImage 从归档加载后端镜像的持久化步骤键。 + stepLoadImage = "backend.image.load" + // stepPullImage 从仓库拉取后端镜像的持久化步骤键。 + stepPullImage = "backend.image.pull" + // stepRemoveContainer 移除非活动后端容器的持久化步骤键。 + stepRemoveContainer = "backend.container.remove-inactive" + // stepCreateContainer 创建非活动后端容器的持久化步骤键。 + stepCreateContainer = "backend.container.create" + // stepStartContainer 启动非活动后端容器的持久化步骤键。 + stepStartContainer = "backend.container.start" + // stepCheckHealth 等待后端 Actuator 健康的持久化步骤键。 + stepCheckHealth = "backend.container.health" ) const ( + // ImageAcquisitionLoad 表示通过本地归档加载方式获取后端镜像。 ImageAcquisitionLoad = "load" + // ImageAcquisitionPull 表示通过远程仓库拉取方式获取后端镜像。 ImageAcquisitionPull = "pull" ) -// Request contains exact values supplied by the update package and local deployment configuration. -// ImageReference is opaque: the executor never extracts meaning from its tag. +// Request 携带 update 包与本地部署配置提供的精确取值。 +// ImageReference 不透明值:执行器从不解析其标签中的任何含义。 type Request struct { - ImageAcquisition string - ArchivePath string - ImageReference string - ExpectedImageDigest string - Platform containerengine.Platform - ContainerName string - Port int - PortEnvironmentKey string - ConfigSource string - ConfigTarget string - TmpSource string - TmpTarget string + // ImageAcquisition 指定镜像获取方式,取值为 ImageAcquisitionLoad 或 ImageAcquisitionPull。 + ImageAcquisition string + // ArchivePath 当 ImageAcquisition 为 ImageAcquisitionLoad 时本地镜像归档的绝对路径。 + ArchivePath string + // ImageReference 后端镜像的精确引用,执行器不解析其语义。 + ImageReference string + // ExpectedImageDigest 后端镜像期望的清单摘要。 + ExpectedImageDigest string + // Platform 后端镜像显式指定的操作系统与架构。 + Platform containerengine.Platform + // ContainerName 后端容器的精确名称。 + ContainerName string + // Port 后端容器的监听端口,仅允许 8080 或 8081。 + Port int + // PortEnvironmentKey 注入端口值的环境变量键。 + PortEnvironmentKey string + // ConfigSource 宿主机上后端配置文件的绝对路径。 + ConfigSource string + // ConfigTarget 后端配置在容器内的绝对挂载路径。 + ConfigTarget string + // TmpSource 宿主机上后端临时目录的绝对路径。 + TmpSource string + // TmpTarget 后端临时目录在容器内的绝对挂载路径。 + TmpTarget string + // ConfigEnvironmentKey 注入配置位置的环境变量键。 ConfigEnvironmentKey string - ConfigLocation string - RestartPolicy containerengine.RestartPolicy - HealthEndpoint string - StartLog bool - LogReporter func(string) + // ConfigLocation 后端配置在容器内的位置取值。 + ConfigLocation string + // RestartPolicy 后端容器的重启策略。 + RestartPolicy containerengine.RestartPolicy + // HealthEndpoint 后端 Actuator 健康检查的 HTTP 端点。 + HealthEndpoint string + // StartLog 表示是否读取并回传容器启动日志。 + StartLog bool + // LogReporter 用于回传容器启动日志的每一行。 + LogReporter func(string) } -// Executor drives the persisted transaction up to SWITCHING after the new container is healthy. +// Executor 在新容器恢复健康后,将持久化事务推进到 StateSwitching 状态。 type Executor struct { - store *transaction.Store + // store 持久化事务的存储。 + store *transaction.Store + // coordinator 负责事务的独占执行与步骤执行。 coordinator *transaction.Coordinator - engine containerengine.Engine - checker *healthcheck.ActuatorChecker + // engine 底层容器引擎。 + engine containerengine.Engine + // checker Actuator 健康检查器。 + checker *healthcheck.ActuatorChecker } +// New 构造一个 Executor,并校验所有必要依赖非空。 +// store 为事务存储,coordinator 为事务协调器,engine 为容器引擎,httpClient 用于健康检查。 +// 任一必要依赖为 nil 时返回错误;健康检查器构造失败时返回该错误。 func New(store *transaction.Store, coordinator *transaction.Coordinator, engine containerengine.Engine, httpClient *http.Client) (*Executor, error) { if store == nil { return nil, errors.New("transaction store is required") @@ -92,7 +131,9 @@ func New(store *transaction.Store, coordinator *transaction.Coordinator, engine return &Executor{store: store, coordinator: coordinator, engine: engine, checker: checker}, nil } -// Run resumes from the transaction's persisted state. It does not switch gateway traffic. +// Run 从事务的持久化状态恢复执行。它不会切换网关流量。 +// transactionID 待执行的事务标识,request 是本次执行携带的请求参数。 +// transactionID 为空时返回错误;其余错误来自事务协调器的独占执行。 func (e *Executor) Run(ctx context.Context, transactionID string, request Request) error { if strings.TrimSpace(transactionID) == "" { return errors.New("transaction ID is required") @@ -102,6 +143,8 @@ func (e *Executor) Run(ctx context.Context, transactionID string, request Reques }) } +// run 在独占执行上下文内的实现,循环读取事务状态并按状态推进, +// 直到事务进入 StateSwitching 状态后返回。每个状态分支处理失败或推进错误时立即返回。 func (e *Executor) run(ctx context.Context, transactionID string, request Request) error { for { record, err := e.store.Transaction(ctx, transactionID) @@ -147,6 +190,9 @@ func (e *Executor) run(ctx context.Context, transactionID string, request Reques } } +// failUnlessRecoverable 判断 prepare 阶段的错误是否可恢复。 +// 若 cause 是 UncertainStepError 或 ErrStepConflict,则直接原样返回(保留不确定性以便重放恢复); +// 否则将事务标记为 StateFailed 并合并返回 cause 与状态迁移错误。 func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID string, cause error) error { var uncertain *transaction.UncertainStepError if errors.As(cause, &uncertain) || errors.Is(cause, transaction.ErrStepConflict) { @@ -156,6 +202,8 @@ func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID stri return errors.Join(cause, transitionErr) } +// validate 对请求输入做运行时校验:先校验请求字段,再根据镜像获取方式校验镜像归档, +// 随后校验配置文件为普通文件、临时目录为直接目录,最后对容器引擎执行 Ping 探活。 func (e *Executor) validate(ctx context.Context, request Request) error { if err := validateRequest(request); err != nil { return err @@ -177,6 +225,8 @@ func (e *Executor) validate(ctx context.Context, request Request) error { return nil } +// prepare 根据镜像获取方式执行对应的加载或拉取步骤,随后核对镜像、移除旧的非活动容器, +// 并创建新的非活动后端容器。所有步骤均通过事务协调器持久化执行以保证可恢复。 func (e *Executor) prepare(ctx context.Context, transactionID string, request Request) error { switch request.ImageAcquisition { case ImageAcquisitionLoad: @@ -208,6 +258,8 @@ func (e *Executor) prepare(ctx context.Context, transactionID string, request Re return err } +// startAndCheck 先启动非活动后端容器,若请求要求读取启动日志则逐行回传, +// 最后执行健康检查步骤等待后端 Actuator 健康。 func (e *Executor) startAndCheck(ctx context.Context, transactionID string, request Request) error { startOperation := &startContainerOperation{engine: e.engine, name: request.ContainerName} if _, err := e.coordinator.ExecuteStep(ctx, transactionID, startIntent(request), startOperation); err != nil { @@ -239,6 +291,8 @@ func (e *Executor) startAndCheck(ctx context.Context, transactionID string, requ return err } +// validateRequest 对请求字段做静态校验,确保所有取值精确且自洽。 +// 任一字段不符合要求时返回描述性错误。 func validateRequest(request Request) error { switch request.ImageAcquisition { case ImageAcquisitionLoad: @@ -295,6 +349,8 @@ func validateRequest(request Request) error { return nil } +// validateRestartPolicy 校验重启策略名称与其最大重试次数的组合是否合法。 +// "no"、"always"、"unless-stopped" 不接受最大重试次数;"on-failure" 允许非负次数;其余名称不受支持。 func validateRestartPolicy(policy containerengine.RestartPolicy) error { switch policy.Name { case "no", "always", "unless-stopped": @@ -311,6 +367,7 @@ func validateRestartPolicy(policy containerengine.RestartPolicy) error { return nil } +// regularFile 校验 path 指向一个普通文件,description 用于构造错误信息。 func regularFile(path, description string) error { info, err := os.Stat(path) if err != nil { @@ -322,6 +379,7 @@ func regularFile(path, description string) error { return nil } +// directDirectory 校验 path 指向一个目录且不是符号链接(即“直接目录”)。 func directDirectory(path, description string) error { info, err := os.Lstat(path) if err != nil { @@ -333,6 +391,8 @@ func directDirectory(path, description string) error { return nil } +// containerSpec 根据请求构造后端容器的完整规格,包括名称、镜像引用、平台、环境变量、 +// 宿主机网络模式、重启策略、绑定挂载、用户与停止超时。 func containerSpec(request Request) containerengine.ContainerSpec { return containerengine.ContainerSpec{ Name: request.ContainerName, @@ -353,6 +413,7 @@ func containerSpec(request Request) containerengine.ContainerSpec { } } +// pullIntent 构造拉取并校验后端镜像步骤的持久化意图。 func pullIntent(request Request) transaction.StepIntent { return intent(stepPullImage, "pull and verify backend image", struct { ImageReference string `json:"imageReference"` @@ -361,6 +422,7 @@ func pullIntent(request Request) transaction.StepIntent { }{request.ImageReference, request.ExpectedImageDigest, request.Platform}) } +// loadIntent 构造从归档加载并校验后端镜像步骤的持久化意图。 func loadIntent(request Request) transaction.StepIntent { return intent(stepLoadImage, "load and verify backend image", struct { ArchivePath string `json:"archivePath"` @@ -370,6 +432,7 @@ func loadIntent(request Request) transaction.StepIntent { }{request.ArchivePath, request.ImageReference, request.ExpectedImageDigest, request.Platform}) } +// createIntent 构造创建非活动后端容器步骤的持久化意图,imageID 为已核对镜像的标识。 func createIntent(request Request, imageID string) transaction.StepIntent { return intent(stepCreateContainer, "create inactive backend container", struct { Spec containerengine.ContainerSpec `json:"spec"` @@ -377,18 +440,21 @@ func createIntent(request Request, imageID string) transaction.StepIntent { }{containerSpec(request), imageID}) } +// removeIntent 构造移除非活动后端容器步骤的持久化意图。 func removeIntent(request Request) transaction.StepIntent { return intent(stepRemoveContainer, "remove inactive backend container", struct { ContainerName string `json:"containerName"` }{request.ContainerName}) } +// startIntent 构造启动非活动后端容器步骤的持久化意图。 func startIntent(request Request) transaction.StepIntent { return intent(stepStartContainer, "start inactive backend container", struct { ContainerName string `json:"containerName"` }{request.ContainerName}) } +// healthIntent 构造等待后端 Actuator 健康步骤的持久化意图。 func healthIntent(request Request) transaction.StepIntent { return intent(stepCheckHealth, "wait for backend Actuator health", struct { ContainerName string `json:"containerName"` @@ -397,6 +463,7 @@ func healthIntent(request Request) transaction.StepIntent { }{request.ContainerName, request.HealthEndpoint, healthTimeout}) } +// intent 将任意值序列化为 JSON 后封装成事务步骤意图。序列化失败视为内部错误并直接 panic。 func intent(key, name string, value any) transaction.StepIntent { payload, err := json.Marshal(value) if err != nil { @@ -405,6 +472,9 @@ func intent(key, name string, value any) transaction.StepIntent { return transaction.StepIntent{Key: key, Name: name, Intent: payload} } +// imageMatches 判断已核对镜像是否与期望摘要及期望平台完全匹配。 +// 当镜像平台与期望平台不一致时直接返回 false;否则依据镜像的描述符摘要或仓库摘要 +// 中是否存在与期望摘要相等的证据来判定匹配。 func imageMatches(image containerengine.Image, expectedDigest string, expectedPlatform containerengine.Platform) (bool, error) { expected, err := opencontainersdigest.Parse(expectedDigest) if err != nil { @@ -438,6 +508,7 @@ func imageMatches(image containerengine.Image, expectedDigest string, expectedPl return false, nil } +// resultJSON 将任意值序列化为 JSON RawMessage。序列化失败视为内部错误并直接 panic。 func resultJSON(value any) json.RawMessage { payload, err := json.Marshal(value) if err != nil { diff --git a/internal/backendexecutor/executor_test.go b/internal/backendexecutor/executor_test.go index d9b7a88..e4b0f4e 100644 --- a/internal/backendexecutor/executor_test.go +++ b/internal/backendexecutor/executor_test.go @@ -19,11 +19,16 @@ import ( ) const ( - testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - testRepository = "harbor.ymswell.asia/ymswell/glory-ymswell" + // testDigest 测试中使用的固定镜像清单摘要。 + testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + // testRepository 测试中使用的镜像仓库前缀。 + testRepository = "harbor.ymswell.asia/ymswell/glory-ymswell" + // healthyResponse 测试中健康检查端点返回的固定 UP 响应体。 healthyResponse = `{"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"},"ping":{"status":"UP"},"redis":{"status":"UP"}}}` ) +// TestExecutorPreservesOpaqueImageTagsAndReachesSwitching 验证执行器对不透明镜像标签保持原样传递, +// 并能使事务推进到 StateSwitching;同时验证在 StateSwitching 状态重复执行不会产生额外引擎调用。 func TestExecutorPreservesOpaqueImageTagsAndReachesSwitching(t *testing.T) { tags := []string{ "20260814-093609-d7ed70f0-v1.1.8.1", @@ -80,6 +85,8 @@ func TestExecutorPreservesOpaqueImageTagsAndReachesSwitching(t *testing.T) { } } +// TestExecutorRecoversRecordedCreateIntentWithoutRepeatingCreate 验证在 crash 窗口内已记录创建步骤意图后, +// 恢复执行不会重复创建容器,而是直接复用已完成的创建步骤并最终进入 StateSwitching。 func TestExecutorRecoversRecordedCreateIntentWithoutRepeatingCreate(t *testing.T) { ctx := context.Background() store, coordinator := testTransactionKernel(t) @@ -125,6 +132,7 @@ func TestExecutorRecoversRecordedCreateIntentWithoutRepeatingCreate(t *testing.T } } +// TestExecutorMarksValidationFailureTerminal 验证请求校验失败时事务被置为 StateFailed 终态。 func TestExecutorMarksValidationFailureTerminal(t *testing.T) { ctx := context.Background() store, coordinator := testTransactionKernel(t) @@ -143,6 +151,8 @@ func TestExecutorMarksValidationFailureTerminal(t *testing.T) { } } +// TestExecutorReplacesInactiveContainerWithConflictingImage 验证当存在镜像标识冲突的非活动容器时, +// 执行器会移除该容器并重新创建,最终进入 StateSwitching。 func TestExecutorReplacesInactiveContainerWithConflictingImage(t *testing.T) { ctx := context.Background() store, coordinator := testTransactionKernel(t) @@ -170,6 +180,8 @@ func TestExecutorReplacesInactiveContainerWithConflictingImage(t *testing.T) { } } +// TestExecutorRejectsChangedRecoveryRequestWithoutChangingState 验证恢复执行时若请求参数被改动, +// 执行器会因持久化意图冲突而拒绝,且不改变事务的 StateStarting 状态。 func TestExecutorRejectsChangedRecoveryRequestWithoutChangingState(t *testing.T) { ctx := context.Background() store, coordinator := testTransactionKernel(t) @@ -198,6 +210,8 @@ func TestExecutorRejectsChangedRecoveryRequestWithoutChangingState(t *testing.T) } } +// TestImageMatchesRequiresDigestEvidenceAndExactPlatform 验证镜像匹配逻辑要求存在摘要证据且平台完全一致, +// 镜像 ID 单独存在不足以满足清单摘要匹配,平台不一致时不得判定匹配。 func TestImageMatchesRequiresDigestEvidenceAndExactPlatform(t *testing.T) { t.Parallel() platform := containerengine.Platform{OS: "linux", Architecture: "arm64"} @@ -222,6 +236,7 @@ func TestImageMatchesRequiresDigestEvidenceAndExactPlatform(t *testing.T) { } } +// testRequest 构造一个合法的测试请求,并在临时目录中写入镜像归档与后端配置文件。 func testRequest(t *testing.T, imageReference string) Request { t.Helper() directory := t.TempDir() @@ -253,6 +268,7 @@ func testRequest(t *testing.T, imageReference string) Request { } } +// testTransactionKernel 创建测试用的事务存储与协调器,并在测试结束时自动关闭存储。 func testTransactionKernel(t *testing.T) (*transaction.Store, *transaction.Coordinator) { t.Helper() store, err := transaction.OpenStore(context.Background(), filepath.Join(t.TempDir(), "transactions.db")) @@ -267,6 +283,7 @@ func testTransactionKernel(t *testing.T) (*transaction.Store, *transaction.Coord return store, coordinator } +// testExecutor 使用固定响应体构造健康检查 HTTP 客户端并创建后端执行器。 func testExecutor(t *testing.T, store *transaction.Store, coordinator *transaction.Coordinator, engine containerengine.Engine, body string) *Executor { t.Helper() client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -279,6 +296,7 @@ func testExecutor(t *testing.T, store *transaction.Store, coordinator *transacti return executor } +// createTransaction 以给定后缀创建一个新的后端测试事务并返回其记录。 func createTransaction(t *testing.T, store *transaction.Store, suffix string) transaction.Transaction { t.Helper() record, _, err := store.CreateTransaction(context.Background(), transaction.CreateRequest{ @@ -293,6 +311,7 @@ func createTransaction(t *testing.T, store *transaction.Store, suffix string) tr return record } +// transitionToPrepared 将指定事务依次推进到 StateValidating 与 StatePrepared 状态。 func transitionToPrepared(t *testing.T, store *transaction.Store, transactionID string) { t.Helper() ctx := context.Background() @@ -304,12 +323,15 @@ func transitionToPrepared(t *testing.T, store *transaction.Store, transactionID } } +// roundTripFunc http.RoundTripper 的函数式适配器,用于在测试中固定 HTTP 响应。 type roundTripFunc func(*http.Request) (*http.Response, error) +// RoundTrip 实现 http.RoundTripper 接口,直接调用底层函数。 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } +// engineCalls 记录 fakeEngine 各方法被调用的次数,用于断言执行路径。 type engineCalls struct { ping int load int @@ -319,12 +341,19 @@ type engineCalls struct { remove int } +// fakeEngine containerengine.Engine 的内存实现,用于测试中模拟容器引擎行为。 type fakeEngine struct { - mu sync.Mutex - request Request - loadedImage containerengine.Image + // mu 保护下方字段的并发访问。 + mu sync.Mutex + // request 构造该引擎时使用的请求参数。 + request Request + // loadedImage 引擎加载后提供的镜像。 + loadedImage containerengine.Image + // imageAvailable 表示镜像当前是否可用。 imageAvailable bool - containers map[string]containerengine.Container + // containers 引擎维护的容器表,键为容器名称。 + containers map[string]containerengine.Container + // lastCreateSpec 记录最近一次创建容器所用的规格。 lastCreateSpec containerengine.ContainerSpec pingCalls int loadCalls int @@ -334,6 +363,7 @@ type fakeEngine struct { removeCalls int } +// newFakeEngine 构造一个已按请求平台预置镜像的 fakeEngine。 func newFakeEngine(request Request) *fakeEngine { return &fakeEngine{ request: request, @@ -346,6 +376,7 @@ func newFakeEngine(request Request) *fakeEngine { } } +// Ping 实现容器引擎的 Ping,记录调用次数并始终返回成功。 func (e *fakeEngine) Ping(context.Context) error { e.mu.Lock() defer e.mu.Unlock() @@ -353,6 +384,7 @@ func (e *fakeEngine) Ping(context.Context) error { return nil } +// LoadImage 实现容器引擎的 LoadImage,读取输入流后标记镜像可用并记录调用次数。 func (e *fakeEngine) LoadImage(_ context.Context, input io.Reader) error { e.mu.Lock() defer e.mu.Unlock() @@ -364,6 +396,7 @@ func (e *fakeEngine) LoadImage(_ context.Context, input io.Reader) error { return nil } +// PullImage 实现容器引擎的 PullImage,标记镜像可用并记录调用次数。 func (e *fakeEngine) PullImage(context.Context, string) error { e.mu.Lock() defer e.mu.Unlock() @@ -372,6 +405,7 @@ func (e *fakeEngine) PullImage(context.Context, string) error { return nil } +// InspectImage 实现容器引擎的 InspectImage,镜像不可用时返回 ErrNotFound,否则返回已加载镜像。 func (e *fakeEngine) InspectImage(context.Context, string) (containerengine.Image, error) { e.mu.Lock() defer e.mu.Unlock() @@ -382,6 +416,7 @@ func (e *fakeEngine) InspectImage(context.Context, string) (containerengine.Imag return e.loadedImage, nil } +// CreateContainer 实现容器引擎的 CreateContainer,记录规格并创建容器,同名时返回错误。 func (e *fakeEngine) CreateContainer(_ context.Context, spec containerengine.ContainerSpec) (containerengine.Container, error) { e.mu.Lock() defer e.mu.Unlock() @@ -395,6 +430,7 @@ func (e *fakeEngine) CreateContainer(_ context.Context, spec containerengine.Con return record, nil } +// StartContainer 实现容器引擎的 StartContainer,将指定容器标记为运行状态,不存在时返回 ErrNotFound。 func (e *fakeEngine) StartContainer(_ context.Context, name string) error { e.mu.Lock() defer e.mu.Unlock() @@ -409,10 +445,12 @@ func (e *fakeEngine) StartContainer(_ context.Context, name string) error { return nil } +// ContainerLogs 实现容器引擎的 ContainerLogs,返回空的日志读取器。 func (e *fakeEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil } +// StopContainer 实现容器引擎的 StopContainer,将指定容器标记为已退出状态,不存在时返回 ErrNotFound。 func (e *fakeEngine) StopContainer(_ context.Context, name string) error { e.mu.Lock() defer e.mu.Unlock() @@ -426,6 +464,7 @@ func (e *fakeEngine) StopContainer(_ context.Context, name string) error { return nil } +// InspectContainer 实现容器引擎的 InspectContainer,返回容器记录,不存在时返回 ErrNotFound。 func (e *fakeEngine) InspectContainer(_ context.Context, name string) (containerengine.Container, error) { e.mu.Lock() defer e.mu.Unlock() @@ -436,6 +475,7 @@ func (e *fakeEngine) InspectContainer(_ context.Context, name string) (container return record, nil } +// RemoveContainer 实现容器引擎的 RemoveContainer,删除指定容器并记录调用次数,不存在时返回 ErrNotFound。 func (e *fakeEngine) RemoveContainer(_ context.Context, name string, _ bool) error { e.mu.Lock() defer e.mu.Unlock() @@ -447,8 +487,10 @@ func (e *fakeEngine) RemoveContainer(_ context.Context, name string, _ bool) err return nil } +// Close 实现容器引擎的 Close,不做任何清理并返回 nil。 func (e *fakeEngine) Close() error { return nil } +// containerFromSpec 根据容器规格构造一条容器记录,running 指定其初始运行状态。 func (e *fakeEngine) containerFromSpec(spec containerengine.ContainerSpec, running bool) containerengine.Container { return containerengine.Container{ ID: "container-id-" + spec.Name, @@ -467,6 +509,7 @@ func (e *fakeEngine) containerFromSpec(spec containerengine.ContainerSpec, runni } } +// callCounts 返回 fakeEngine 各方法当前的调用次数快照。 func (e *fakeEngine) callCounts() engineCalls { return engineCalls{ ping: e.pingCalls, @@ -478,4 +521,5 @@ func (e *fakeEngine) callCounts() engineCalls { } } +// 编译期断言确保 fakeEngine 实现 containerengine.Engine 接口。 var _ containerengine.Engine = (*fakeEngine)(nil) diff --git a/internal/backendexecutor/operations.go b/internal/backendexecutor/operations.go index 06f9638..0033780 100644 --- a/internal/backendexecutor/operations.go +++ b/internal/backendexecutor/operations.go @@ -15,25 +15,39 @@ import ( "yms-daemon/internal/transaction" ) +// loadImageOperation 表示“从本地归档加载并校验后端镜像”的事务操作。 type loadImageOperation struct { - engine containerengine.Engine - archivePath string + // engine 执行加载操作的容器引擎。 + engine containerengine.Engine + // archivePath 本地镜像归档的绝对路径。 + archivePath string + // imageReference 镜像加载后的目标精确引用。 imageReference string + // expectedDigest 加载镜像后期望的清单摘要。 expectedDigest string - platform containerengine.Platform + // platform 镜像的期望平台。 + platform containerengine.Platform } +// pullImageOperation 表示“从远程仓库拉取并校验后端镜像”的事务操作。 type pullImageOperation struct { - engine containerengine.Engine + // engine 执行拉取操作的容器引擎。 + engine containerengine.Engine + // imageReference 待拉取的镜像精确引用。 imageReference string + // expectedDigest 拉取镜像后期望的清单摘要。 expectedDigest string - platform containerengine.Platform + // platform 镜像的期望平台。 + platform containerengine.Platform } +// Apply 调用容器引擎按 imageReference 拉取后端镜像。 func (o *pullImageOperation) Apply(ctx context.Context) error { return o.engine.PullImage(ctx, o.imageReference) } +// Inspect 检查拉取操作是否已生效:镜像不存在时为未应用;存在时依据摘要与平台判定, +// 匹配则返回已应用,否则返回未应用。无法判定或解析失败时返回错误。 func (o *pullImageOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { image, err := o.engine.InspectImage(ctx, o.imageReference) if errors.Is(err, containerengine.ErrNotFound) { @@ -53,6 +67,7 @@ func (o *pullImageOperation) Inspect(ctx context.Context) (transaction.Inspectio return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } +// Apply 打开本地镜像归档并调用容器引擎加载后端镜像。 func (o *loadImageOperation) Apply(ctx context.Context) error { archive, err := os.Open(o.archivePath) if err != nil { @@ -62,6 +77,8 @@ func (o *loadImageOperation) Apply(ctx context.Context) error { return o.engine.LoadImage(ctx, archive) } +// Inspect 检查加载操作是否已生效:镜像不存在时为未应用;存在时依据摘要与平台判定, +// 匹配则返回已应用,否则返回未应用。结果携带镜像标识、仓库摘要、描述符摘要与平台信息。 func (o *loadImageOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { image, err := o.engine.InspectImage(ctx, o.imageReference) if errors.Is(err, containerengine.ErrNotFound) { @@ -86,17 +103,25 @@ func (o *loadImageOperation) Inspect(ctx context.Context) (transaction.Inspectio return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } +// createContainerOperation 表示“创建非活动后端容器”的事务操作。 type createContainerOperation struct { - engine containerengine.Engine - expectedImage containerengine.Image - spec containerengine.ContainerSpec -} - -type removeContainerOperation struct { + // engine 执行创建操作的容器引擎。 engine containerengine.Engine - name string + // expectedImage 创建容器时期望使用的镜像。 + expectedImage containerengine.Image + // spec 待创建容器的完整规格。 + spec containerengine.ContainerSpec } +// removeContainerOperation 表示“移除非活动后端容器”的事务操作。 +type removeContainerOperation struct { + // engine 执行移除操作的容器引擎。 + engine containerengine.Engine + // name 待移除容器的名称。 + name string +} + +// Apply 强制移除指定名称的非活动容器,容器不存在时视为成功。 func (o *removeContainerOperation) Apply(ctx context.Context) error { err := o.engine.RemoveContainer(ctx, o.name, true) if errors.Is(err, containerengine.ErrNotFound) { @@ -105,6 +130,7 @@ func (o *removeContainerOperation) Apply(ctx context.Context) error { return err } +// Inspect 检查移除操作是否已生效:容器不存在即为已应用,存在则为未应用并返回容器信息。 func (o *removeContainerOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { record, err := o.engine.InspectContainer(ctx, o.name) if errors.Is(err, containerengine.ErrNotFound) { @@ -116,11 +142,14 @@ func (o *removeContainerOperation) Inspect(ctx context.Context) (transaction.Ins return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: containerResult(record)}, nil } +// Apply 调用容器引擎按规格创建非活动后端容器。 func (o *createContainerOperation) Apply(ctx context.Context) error { _, err := o.engine.CreateContainer(ctx, o.spec) return err } +// Inspect 检查创建操作是否已生效:容器不存在时为未应用;存在时若与期望镜像及规格完全匹配则为已应用, +// 否则为未知状态(存在冲突),以便上层据此重放或报错。 func (o *createContainerOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { record, err := o.engine.InspectContainer(ctx, o.spec.Name) if errors.Is(err, containerengine.ErrNotFound) { @@ -136,15 +165,21 @@ func (o *createContainerOperation) Inspect(ctx context.Context) (transaction.Ins return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } +// startContainerOperation 表示“启动非活动后端容器”的事务操作。 type startContainerOperation struct { + // engine 执行启动操作的容器引擎。 engine containerengine.Engine - name string + // name 待启动容器的名称。 + name string } +// Apply 调用容器引擎启动指定名称的后端容器。 func (o *startContainerOperation) Apply(ctx context.Context) error { return o.engine.StartContainer(ctx, o.name) } +// Inspect 检查启动操作是否已生效:容器不存在时为未应用;存在且处于运行且非 Dead 状态时为已应用, +// 否则为未应用。结果携带容器运行状态信息。 func (o *startContainerOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { record, err := o.engine.InspectContainer(ctx, o.name) if errors.Is(err, containerengine.ErrNotFound) { @@ -160,18 +195,28 @@ func (o *startContainerOperation) Inspect(ctx context.Context) (transaction.Insp return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil } +// healthOperation 表示“等待后端 Actuator 健康”的事务操作。 type healthOperation struct { - engine containerengine.Engine - checker *healthcheck.ActuatorChecker - name string + // engine 用于查询容器运行状态。 + engine containerengine.Engine + // checker Actuator 健康检查器。 + checker *healthcheck.ActuatorChecker + // name 待检查容器的名称。 + name string + // endpoint 健康检查的 HTTP 端点。 endpoint string - timeout time.Duration + // timeout 等待健康的整体超时时间。 + timeout time.Duration - mu sync.Mutex + // mu 保护 confirmed 与 confirmedReport 的并发访问。 + mu sync.Mutex + // confirmedReport 已确认的健康报告。 confirmedReport healthcheck.ActuatorReport - confirmed bool + // confirmed 表示健康检查结果是否已经确认。 + confirmed bool } +// Apply 调用健康检查器等待后端健康,成功后加锁保存已确认的健康报告并标记为已确认。 func (o *healthOperation) Apply(ctx context.Context) error { report, err := o.checker.Wait(ctx, o.endpoint, o.timeout, o.running) if err != nil { @@ -184,6 +229,8 @@ func (o *healthOperation) Apply(ctx context.Context) error { return nil } +// Inspect 检查健康操作是否已生效:若先前已确认则直接返回已确认结果, +// 否则执行一次即时健康检查并返回检查结论。 func (o *healthOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { o.mu.Lock() if o.confirmed { @@ -196,6 +243,7 @@ func (o *healthOperation) Inspect(ctx context.Context) (transaction.Inspection, return healthInspection(report, ready, err) } +// running 查询后端容器是否处于运行且非 Dead 状态,容器不存在时返回 false 且无错误。 func (o *healthOperation) running(ctx context.Context) (bool, error) { record, err := o.engine.InspectContainer(ctx, o.name) if errors.Is(err, containerengine.ErrNotFound) { @@ -207,6 +255,8 @@ func (o *healthOperation) running(ctx context.Context) (bool, error) { return record.Running && !record.Dead, nil } +// healthInspection 将健康检查结果转换为事务检查结论:工作负载停止、检查出错或未就绪时均为未应用, +// 仅当 ready 为 true 且无错误时才判定为已应用。结果始终携带健康报告的 JSON 序列化。 func healthInspection(report healthcheck.ActuatorReport, ready bool, err error) (transaction.Inspection, error) { result := resultJSON(report) if errors.Is(err, healthcheck.ErrWorkloadStopped) { @@ -221,6 +271,8 @@ func healthInspection(report healthcheck.ActuatorReport, ready bool, err error) return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } +// containerMatches 判断实际容器记录是否与期望镜像标识及期望规格完全匹配, +// 包括镜像、网络模式、重启策略、用户、停止超时以及环境变量与挂载集合的逐项包含关系。 func containerMatches(record containerengine.Container, expectedImageID string, spec containerengine.ContainerSpec) bool { if record.ImageID != expectedImageID || record.NetworkMode != spec.NetworkMode || record.RestartPolicy != spec.RestartPolicy || record.User != spec.User || record.StopTimeoutSeconds != spec.StopTimeoutSeconds { return false @@ -238,6 +290,7 @@ func containerMatches(record containerengine.Container, expectedImageID string, return true } +// containerResult 将容器记录的标识、镜像、运行、Dead 与状态字段序列化为 JSON RawMessage。 func containerResult(record containerengine.Container) json.RawMessage { return resultJSON(struct { ID string `json:"id"` @@ -248,6 +301,7 @@ func containerResult(record containerengine.Container) json.RawMessage { }{record.ID, record.ImageID, record.Running, record.Dead, record.Status}) } +// 以下编译期断言确保各操作类型均实现 transaction.Operation 接口。 var _ transaction.Operation = (*loadImageOperation)(nil) var _ transaction.Operation = (*pullImageOperation)(nil) var _ transaction.Operation = (*removeContainerOperation)(nil) diff --git a/internal/backendupdate/container.go b/internal/backendupdate/container.go index 743f1f0..1a3cf4f 100644 --- a/internal/backendupdate/container.go +++ b/internal/backendupdate/container.go @@ -20,27 +20,48 @@ import ( "yms-daemon/internal/transaction" ) +// inputTypeContainerImage 标识以容器镜像方式执行后端更新的输入类型。 const inputTypeContainerImage = "container-image" +// persistedContainerRequest 容器后端更新请求的持久化形态,以 JSON 存入事务 +// 记录。事务在提交、重试或补偿时依赖这些字段重建执行上下文。 type persistedContainerRequest struct { - InputType string `json:"inputType"` - ImageReference string `json:"imageReference"` - ImmutableReference string `json:"immutableReference"` - ImageDigest string `json:"imageDigest"` - Platform containerengine.Platform `json:"platform"` - TargetPort int `json:"targetPort"` - TargetContainer string `json:"targetContainer"` - PreviousPort int `json:"previousPort"` - PreviousContainer string `json:"previousContainer"` - TargetHealthEndpoint string `json:"targetHealthEndpoint"` - StartLog bool `json:"startLog"` - GatewayBeforePath string `json:"gatewayBeforePath"` - GatewayAfterPath string `json:"gatewayAfterPath"` - GatewayReceiptPath string `json:"gatewayReceiptPath"` + // InputType 标识输入来源类型。 + InputType string `json:"inputType"` + // ImageReference 用户提供的镜像引用(带标签)。 + ImageReference string `json:"imageReference"` + // ImmutableReference 解析出的不可变镜像引用(带摘要)。 + ImmutableReference string `json:"immutableReference"` + // ImageDigest 解析出的镜像仓库摘要。 + ImageDigest string `json:"imageDigest"` + // Platform 镜像的精确平台信息。 + Platform containerengine.Platform `json:"platform"` + // TargetPort 本次更新要切换到的目标端口。 + TargetPort int `json:"targetPort"` + // TargetContainer 目标槽位的容器名。 + TargetContainer string `json:"targetContainer"` + // PreviousPort 更新前 Nginx 上游指向的端口。 + PreviousPort int `json:"previousPort"` + // PreviousContainer 更新前活动槽位的容器名,首次安装时为空。 + PreviousContainer string `json:"previousContainer"` + // TargetHealthEndpoint 目标容器的健康检查端点。 + TargetHealthEndpoint string `json:"targetHealthEndpoint"` + // StartLog 表示是否在启动阶段转发容器日志。 + StartLog bool `json:"startLog"` + // GatewayBeforePath 切换前 Nginx 配置快照的文件路径。 + GatewayBeforePath string `json:"gatewayBeforePath"` + // GatewayAfterPath 切换后 Nginx 配置快照的文件路径。 + GatewayAfterPath string `json:"gatewayAfterPath"` + // GatewayReceiptPath Nginx 切换成功后的回执文件路径。 + GatewayReceiptPath string `json:"gatewayReceiptPath"` } -// UpdateContainerImage pulls one development image, freezes its repository -// digest, and updates the inactive Docker backend slot. +// UpdateContainerImage 拉取一个开发镜像,冻结其仓库摘要,并更新非活动的 Docker +// 后端槽位。 +// +// 参数 imageReference 是必须带标签且不含多余空白的精确镜像引用;startLog 表示 +// 是否转发容器启动日志;report 用于回传实时进度,可为 nil。返回值为本次更新 +// 对应的事务记录以及错误。若已存在同镜像的活动事务则复用续跑。 func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, startLog bool, report ProgressReporter) (transaction.Transaction, error) { if u.containerExecutor == nil || u.engine == nil { return transaction.Transaction{}, errors.New("container backend updater is not configured") @@ -162,11 +183,13 @@ func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference strin return u.runContainerUpdate(ctx, record, request, created, report) } -// resolveContainerSlots reconciles the committed deployment, gateway and both -// exact container names before selecting a target. A fresh installation has no -// deployment row, no committed container transaction history and no slot -// containers. It starts on the non-routed slot so traffic is exposed only after -// health passes. +// resolveContainerSlots 在选择目标槽位前,核对已提交的部署、网关以及两个精确 +// 容器名是否一致。全新安装没有部署记录、没有已提交的容器事务历史,也没有槽位 +// 容器,会从非路由槽位启动,这样只有健康检查通过后才会暴露流量。 +// +// 返回值依次为目标端口、目标槽位以及上一容器名。activePort 为当前路由端口, +// activeSlot 为当前路由槽位,deployment 为已提交的部署记录,hasDeployment 与 +// hasHistory 分别表示是否存在部署记录与容器事务历史。 func (u *Updater) resolveContainerSlots( ctx context.Context, activePort int, @@ -224,12 +247,19 @@ func (u *Updater) resolveContainerSlots( return inactivePort, inactiveSlot, "", nil } +// resolvedImage 拉取并解析后得到的镜像信息。 type resolvedImage struct { + // ImmutableReference 带摘要的不可变镜像引用。 ImmutableReference string - Digest string - Platform containerengine.Platform + // Digest 镜像仓库摘要。 + Digest string + // Platform 镜像的精确平台信息。 + Platform containerengine.Platform } +// pullAndResolveImage 解析镜像引用、确保其为带标签引用,拉取镜像并检查其平台 +// 信息,随后从仓库摘要中解析出唯一的不可变引用。若仓库存在多个匹配摘要则报错, +// 以保证后续部署所用的引用是确定且唯一的。 func (u *Updater) pullAndResolveImage(ctx context.Context, imageReference string) (resolvedImage, error) { named, err := reference.ParseNormalizedNamed(imageReference) if err != nil { @@ -276,6 +306,9 @@ func (u *Updater) pullAndResolveImage(ctx context.Context, imageReference string return resolvedImage{}, errors.New("repository digest resolution produced no result") } +// runContainerUpdate 根据事务当前状态执行容器后端更新的核心流程:启动目标容器, +// 再执行切换与提交。created 表示本调用是否新建了事务;report 用于回传进度。执行 +// 失败时若事务尚未进入失败态则触发容器补偿。 func (u *Updater) runContainerUpdate(ctx context.Context, record transaction.Transaction, request persistedContainerRequest, created bool, report ProgressReporter) (transaction.Transaction, error) { message := "Resuming container backend transaction " + record.ID if created { @@ -326,6 +359,9 @@ func (u *Updater) runContainerUpdate(ctx context.Context, record transaction.Tra return u.store.Transaction(ctx, record.ID) } +// switchAndCommitContainer 执行容器后端的流量切换与提交:切换 Nginx 上游到目标 +// 端口、进入排空阶段、确认目标容器运行且镜像引用匹配,最后提交容器部署记录。 +// 切换失败时触发容器补偿。 func (u *Updater) switchAndCommitContainer(ctx context.Context, transactionID string, request persistedContainerRequest, report ProgressReporter) error { before, err := readGatewaySnapshot(request.GatewayBeforePath, request.PreviousPort) if err != nil { @@ -399,6 +435,9 @@ func (u *Updater) switchAndCommitContainer(ctx context.Context, transactionID st } } +// rollbackContainer 执行容器后端补偿:恢复 Nginx 上游、停止被补偿的目标容器,并 +// 将事务迁入 RolledBack 状态。cause 为触发补偿的原始错误,会与补偿过程中的错误 +// 合并后返回。 func (u *Updater) rollbackContainer(ctx context.Context, transactionID string, request persistedContainerRequest, before hostnginx.Snapshot, after hostnginx.Snapshot, cause error) error { record, err := u.store.Transaction(ctx, transactionID) if err != nil { @@ -420,11 +459,15 @@ func (u *Updater) rollbackContainer(ctx context.Context, transactionID string, r return errors.Join(cause, transitionErr) } +// containerStopOperation 停止某个后端容器的事务操作。 type containerStopOperation struct { + // engine 容器引擎。 engine containerengine.Engine - name string + // name 待停止的容器名。 + name string } +// Apply 停止指定容器,若容器不存在则视为已满足(幂等成功)。 func (o *containerStopOperation) Apply(ctx context.Context) error { err := o.engine.StopContainer(ctx, o.name) if errors.Is(err, containerengine.ErrNotFound) { @@ -433,6 +476,7 @@ func (o *containerStopOperation) Apply(ctx context.Context) error { return err } +// Inspect 检查容器状态:不存在或已停止视为已应用,仍运行视为未应用。 func (o *containerStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { record, err := o.engine.InspectContainer(ctx, o.name) if errors.Is(err, containerengine.ErrNotFound) { @@ -448,6 +492,8 @@ func (o *containerStopOperation) Inspect(ctx context.Context) (transaction.Inspe return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil } +// containerGatewaySwitchIntent 构造容器后端 Nginx 上游切换步骤的意图,记录切换 +// 前后的端口。 func containerGatewaySwitchIntent(request persistedContainerRequest) transaction.StepIntent { return stepIntent("backend.container.gateway.switch", "switch host Nginx to container backend", struct { BeforePort int `json:"beforePort"` @@ -455,24 +501,30 @@ func containerGatewaySwitchIntent(request persistedContainerRequest) transaction }{request.PreviousPort, request.TargetPort}) } +// containerGatewayRestoreIntent 构造容器后端失败后恢复 Nginx 上游步骤的意图,记录 +// 需恢复到的端口。 func containerGatewayRestoreIntent(request persistedContainerRequest) transaction.StepIntent { return stepIntent("backend.container.gateway.restore", "restore host Nginx after container backend failure", struct { Port int `json:"port"` }{request.PreviousPort}) } +// stopPreviousContainerIntent 构造排空后停止前一容器步骤的意图,记录容器名。 func stopPreviousContainerIntent(request persistedContainerRequest) transaction.StepIntent { return stepIntent("backend.previous-container.stop", "stop previous backend container after drain", struct { ContainerName string `json:"containerName"` }{request.PreviousContainer}) } +// stopTargetContainerIntent 构造停止被补偿容器步骤的意图,记录容器名。 func stopTargetContainerIntent(request persistedContainerRequest) transaction.StepIntent { return stepIntent("backend.target-container.stop", "stop compensated backend container", struct { ContainerName string `json:"containerName"` }{request.TargetContainer}) } +// decodeContainerRequest 将持久化的容器后端更新请求 JSON 反序列化到目标结构体, +// 并禁止出现未知字段。 func decodeContainerRequest(content json.RawMessage, request *persistedContainerRequest) error { decoder := json.NewDecoder(bytes.NewReader(content)) decoder.DisallowUnknownFields() diff --git a/internal/backendupdate/container_test.go b/internal/backendupdate/container_test.go index a3140bf..ab026dc 100644 --- a/internal/backendupdate/container_test.go +++ b/internal/backendupdate/container_test.go @@ -20,9 +20,14 @@ import ( "yms-daemon/internal/transaction" ) +// containerTestImage 容器更新测试使用的镜像引用,指向 harbor 仓库的一个具体 tag。 const containerTestImage = "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1" + +// containerTestDigest 容器更新测试使用的镜像仓库摘要,用于与 containerTestImage 组合成不可变引用。 const containerTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +// TestContainerUpdaterPullsSwitchesAndStopsPreviousSlot 验证在已存在运行中的 backend-8080 容器时, +// 更新镜像会切换到非活跃槽位 backend-8081、提交部署并停止旧容器 backend-8080。 func TestContainerUpdaterPullsSwitchesAndStopsPreviousSlot(t *testing.T) { ctx := context.Background() updater, store, engine, gateway := newContainerUpdaterFixture(t, map[string]containerengine.Container{ @@ -48,6 +53,9 @@ func TestContainerUpdaterPullsSwitchesAndStopsPreviousSlot(t *testing.T) { assertCommittedContainerDeployment(t, store, record.ID, 8081, "backend-8081", "container-id-backend-8081") } +// TestContainerUpdaterFirstInstallCreatesInactiveSlotBeforeSwitch 验证全新安装场景: +// 目标容器 backend-8081 必须先在非路由槽位创建并通过健康检查,之后才切换网关; +// 整个过程不得进入旧容器排水流程,也不得停止任何旧容器。 func TestContainerUpdaterFirstInstallCreatesInactiveSlotBeforeSwitch(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() @@ -92,6 +100,8 @@ func TestContainerUpdaterFirstInstallCreatesInactiveSlotBeforeSwitch(t *testing. assertCommittedContainerDeployment(t, store, record.ID, 8081, "backend-8081", "container-id-backend-8081") } +// TestContainerUpdaterRejectsMissingActiveWithPresentInactive 验证当活跃容器 backend-8080 缺失 +// 而非活跃容器 backend-8081 仍在运行时,更新会被拒绝并返回明确的错误信息。 func TestContainerUpdaterRejectsMissingActiveWithPresentInactive(t *testing.T) { updater, _, _, _ := newContainerUpdaterFixture(t, map[string]containerengine.Container{ "backend-8081": { @@ -105,6 +115,9 @@ func TestContainerUpdaterRejectsMissingActiveWithPresentInactive(t *testing.T) { } } +// TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage 验证更新失败进入回滚后的恢复链路: +// 首次更新因网关切换失败而进入回滚并停止目标容器,再次调用恢复同一回滚直至完成, +// 最后重试同一镜像能够成功切换并提交。 func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T) { ctx := context.Background() updater, _, engine, gateway := newContainerUpdaterFixture(t, map[string]containerengine.Container{}, 0) @@ -140,6 +153,8 @@ func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T } } +// TestContainerUpdaterRejectsMissingCommittedContainer 验证当已提交部署记录中的活跃容器 backend-8080 丢失时, +// 更新会将其识别为部署漂移并拒绝继续。 func TestContainerUpdaterRejectsMissingCommittedContainer(t *testing.T) { ctx := context.Background() updater, store, _, _ := newContainerUpdaterFixture(t, map[string]containerengine.Container{}, 0) @@ -175,6 +190,8 @@ func TestContainerUpdaterRejectsMissingCommittedContainer(t *testing.T) { } } +// newContainerUpdaterFixture 构造容器更新器的测试夹具,返回更新器、事务存储、假容器引擎和内存网关。 +// containers 指定引擎初始存在的容器;drain 指定旧容器停止前的排水等待时长。 func newContainerUpdaterFixture( t *testing.T, containers map[string]containerengine.Container, @@ -242,6 +259,8 @@ func newContainerUpdaterFixture( return updater, store, engine, gateway } +// assertCreatedContainerSpec 校验传入容器引擎的创建规格,确认目标容器名称、镜像引用、运行时约束、 +// 环境变量和挂载点均符合容器更新契约。 func assertCreatedContainerSpec(t *testing.T, request containerengine.ContainerSpec) { t.Helper() if request.Name != "backend-8081" || request.ImageReference != "harbor.ymswell.asia/ymswell/glory-ymswell@"+containerTestDigest { @@ -258,6 +277,8 @@ func assertCreatedContainerSpec(t *testing.T, request containerengine.ContainerS } } +// assertCommittedContainerDeployment 校验事务存储中已提交的后端容器部署记录, +// 确认其活跃端口、容器名称、容器 ID、镜像摘要和事务 ID 均与预期一致。 func assertCommittedContainerDeployment( t *testing.T, store *transaction.Store, @@ -276,6 +297,7 @@ func assertCommittedContainerDeployment( } } +// serverConfiguration8080 测试用的 host Nginx 配置片段,其活跃后端端口为 8080。 const serverConfiguration8080 = `http { upstream yms-server { # yms-update managed upstream begin @@ -286,12 +308,17 @@ const serverConfiguration8080 = `http { } ` +// containerUpdateRoundTripFunc 将普通函数适配为 http.RoundTripper, +// 使测试可以用自定义逻辑响应后端的健康检查请求。 type containerUpdateRoundTripFunc func(*http.Request) (*http.Response, error) +// RoundTrip 实现 http.RoundTripper 接口,直接委托给底层函数处理请求。 func (function containerUpdateRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return function(request) } +// containerUpdateEngine 容器引擎的测试替身,在内存中模拟镜像与容器的生命周期, +// 并记录停止、创建和健康检查等交互,供断言验证更新行为。 type containerUpdateEngine struct { image containerengine.Image containers map[string]containerengine.Container @@ -300,12 +327,21 @@ type containerUpdateEngine struct { healthChecks int } -func (e *containerUpdateEngine) Ping(context.Context) error { return nil } -func (e *containerUpdateEngine) PullImage(context.Context, string) error { return nil } +// Ping 返回 nil,模拟引擎连通性检查始终成功。 +func (e *containerUpdateEngine) Ping(context.Context) error { return nil } + +// PullImage 返回 nil,模拟镜像拉取始终成功。 +func (e *containerUpdateEngine) PullImage(context.Context, string) error { return nil } + +// LoadImage 返回 nil,模拟从流加载镜像始终成功。 func (e *containerUpdateEngine) LoadImage(context.Context, io.Reader) error { return nil } + +// InspectImage 返回夹具预设的镜像信息,用于冻结镜像的平台与仓库摘要。 func (e *containerUpdateEngine) InspectImage(context.Context, string) (containerengine.Image, error) { return e.image, nil } + +// CreateContainer 依据规格在内存中登记一个新容器,记录最后一次创建规格并返回该容器。 func (e *containerUpdateEngine) CreateContainer(_ context.Context, spec containerengine.ContainerSpec) (containerengine.Container, error) { e.lastCreateSpec = spec record := containerengine.Container{ @@ -326,6 +362,8 @@ func (e *containerUpdateEngine) CreateContainer(_ context.Context, spec containe e.containers[spec.Name] = record return record, nil } + +// StartContainer 将指定容器标记为运行中;若容器不存在则返回 containerengine.ErrNotFound。 func (e *containerUpdateEngine) StartContainer(_ context.Context, name string) error { record, found := e.containers[name] if !found { @@ -337,9 +375,12 @@ func (e *containerUpdateEngine) StartContainer(_ context.Context, name string) e return nil } +// ContainerLogs 返回空的日志流,模拟容器日志读取。 func (e *containerUpdateEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil } + +// StopContainer 记录被停止的容器名称并将该容器标记为已退出;若容器不存在则返回 containerengine.ErrNotFound。 func (e *containerUpdateEngine) StopContainer(_ context.Context, name string) error { e.stopped = append(e.stopped, name) record, found := e.containers[name] @@ -351,6 +392,8 @@ func (e *containerUpdateEngine) StopContainer(_ context.Context, name string) er e.containers[name] = record return nil } + +// InspectContainer 返回内存中的指定容器;若容器不存在则返回 containerengine.ErrNotFound。 func (e *containerUpdateEngine) InspectContainer(_ context.Context, name string) (containerengine.Container, error) { record, found := e.containers[name] if !found { @@ -358,6 +401,8 @@ func (e *containerUpdateEngine) InspectContainer(_ context.Context, name string) } return record, nil } + +// RemoveContainer 从内存中删除指定容器;若容器不存在则返回 containerengine.ErrNotFound。 func (e *containerUpdateEngine) RemoveContainer(_ context.Context, name string, _ bool) error { if _, found := e.containers[name]; !found { return containerengine.ErrNotFound @@ -365,6 +410,8 @@ func (e *containerUpdateEngine) RemoveContainer(_ context.Context, name string, delete(e.containers, name) return nil } + +// Close 返回 nil,模拟引擎关闭无副作用。 func (e *containerUpdateEngine) Close() error { return nil } var _ containerengine.Engine = (*containerUpdateEngine)(nil) diff --git a/internal/backendupdate/intents.go b/internal/backendupdate/intents.go index d175867..41b3683 100644 --- a/internal/backendupdate/intents.go +++ b/internal/backendupdate/intents.go @@ -7,6 +7,8 @@ import ( "yms-daemon/internal/transaction" ) +// gatewaySwitchIntent 构造原生后端 Nginx 上游切换步骤的意图,记录切换前、 +// 切换后各自的端口与配置摘要,供审计与回放时确认两侧快照一致。 func gatewaySwitchIntent(request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot) transaction.StepIntent { return stepIntent("backend.gateway.switch", "switch host Nginx backend upstream", struct { BeforePort int `json:"beforePort"` @@ -16,6 +18,8 @@ func gatewaySwitchIntent(request persistedRequest, before hostnginx.Snapshot, af }{before.ActivePort, snapshotDigest(before), after.ActivePort, snapshotDigest(after)}) } +// activeLinkIntent 构造替换原生后端兼容性链接步骤的意图,记录链接路径、 +// 更新前状态以及即将指向的安装路径。 func activeLinkIntent(request persistedRequest, installedPath string) transaction.StepIntent { return stepIntent("backend.native.active-link", "replace backend compatibility link", struct { Path string `json:"path"` @@ -24,12 +28,15 @@ func activeLinkIntent(request persistedRequest, installedPath string) transactio }{request.ActiveJARPath, request.ActiveJARBefore, installedPath}) } +// stopPreviousUnitIntent 构造停止前一原生后端单元的步骤意图,记录待停止单元名。 func stopPreviousUnitIntent(request persistedRequest) transaction.StepIntent { return stepIntent("backend.previous-unit.stop", "stop previous backend unit after drain", struct { Unit string `json:"unit"` }{request.PreviousUnit}) } +// activeLinkRestoreIntent 构造恢复原生后端兼容性链接步骤的意图,记录链接 +// 路径及其更新前状态,供补偿流程回滚。 func activeLinkRestoreIntent(request persistedRequest) transaction.StepIntent { return stepIntent("backend.native.active-link.restore", "restore backend compatibility path", struct { Path string `json:"path"` @@ -37,18 +44,22 @@ func activeLinkRestoreIntent(request persistedRequest) transaction.StepIntent { }{request.ActiveJARPath, request.ActiveJARBefore}) } +// gatewayRestoreIntent 构造恢复原生后端 Nginx 上游步骤的意图,记录需恢复到的端口。 func gatewayRestoreIntent(request persistedRequest) transaction.StepIntent { return stepIntent("backend.gateway.restore", "restore host Nginx backend upstream", struct { Port int `json:"port"` }{request.PreviousGatewayPort}) } +// stopTargetUnitIntent 构造停止被补偿的原生后端目标单元步骤的意图,记录待停止单元名。 func stopTargetUnitIntent(request persistedRequest) transaction.StepIntent { return stepIntent("backend.target-unit.stop", "stop compensated backend target unit", struct { Unit string `json:"unit"` }{request.TargetUnit}) } +// restoreTargetSlotIntent 构造恢复被补偿的原生后端目标槽位步骤的意图,记录 +// 槽位链接路径、其安装路径以及更新前的链接目标。 func restoreTargetSlotIntent(request persistedRequest, installedPath string) transaction.StepIntent { return stepIntent("backend.target-slot.restore", "restore compensated backend target slot", struct { Path string `json:"path"` @@ -57,6 +68,8 @@ func restoreTargetSlotIntent(request persistedRequest, installedPath string) tra }{request.TargetSlotJAR, installedPath, request.PreviousSlotTarget}) } +// stepIntent 以键名、显示名和任意值构造一个事务步骤意图。意图值会被序列化为 +// JSON;若序列化失败则直接 panic,因为该失败属于程序缺陷而非运行期错误。 func stepIntent(key string, name string, value any) transaction.StepIntent { content, err := json.Marshal(value) if err != nil { diff --git a/internal/backendupdate/model.go b/internal/backendupdate/model.go index faf791a..2b766f4 100644 --- a/internal/backendupdate/model.go +++ b/internal/backendupdate/model.go @@ -2,57 +2,106 @@ package backendupdate import "yms-daemon/internal/filestore" +// updateInput 一次原生后端更新(或重启)在进入事务前的内存态输入,由上层 +// 入口(UpdateRepack、UpdateNativeJAR、Restart)组装后交给 Updater.update 执行。 +// 其中持久化相关字段最终会编码进 persistedRequest 存入事务存储。 type updateInput struct { - IdempotencyKey string - InputType string - SourcePath string - SourceSHA256 string - CustomerCode string - VersionID string - ArtifactID int64 + // IdempotencyKey 本次更新的幂等键,用于跨进程/跨调用识别同一逻辑更新。 + IdempotencyKey string + // InputType 标识本次输入的来源类型,取值为 daemonapi 中定义的输入类型常量。 + InputType string + // SourcePath 待部署构件在宿主机上的源路径。 + SourcePath string + // SourceSHA256 源构件的 SHA-256 摘要,参与幂等识别与一致性校验。 + SourceSHA256 string + // CustomerCode 构件所属客户编码,仅 repack 输入携带。 + CustomerCode string + // VersionID 构件所属版本标识,仅 repack 输入携带。 + VersionID string + // ArtifactID 构件标识,仅 repack 输入携带。 + ArtifactID int64 + // ArtifactFileName 构件文件名。 ArtifactFileName string + // ArtifactIdentity 构件的尺寸与摘要身份信息,用于校验落盘结果。 ArtifactIdentity filestore.Identity - ReleasePath string - Materialize func(string) error + // ReleasePath 构件在 release 目录下的相对安装路径。 + ReleasePath string + // Materialize 将构件物化到指定目标路径,通常为解压或复制实现。 + Materialize func(string) error } +// persistedRequest 原生后端更新请求的持久化形态,以 JSON 存入事务记录。 +// 事务在提交、重试或补偿时都依赖这些字段重建执行所需的完整上下文。 type persistedRequest struct { - InputType string `json:"inputType"` - SourcePath string `json:"sourcePath"` - SourceSHA256 string `json:"sourceSHA256"` - CustomerCode string `json:"customerCode,omitempty"` - VersionID string `json:"versionId,omitempty"` - ArtifactID int64 `json:"artifactId,omitempty"` - ArtifactFileName string `json:"artifactFileName"` - ArtifactPath string `json:"artifactPath"` - ArtifactIdentity filestore.Identity `json:"artifactIdentity"` - ReleasePath string `json:"releasePath"` - TargetPort int `json:"targetPort"` - TargetUnit string `json:"targetUnit"` - TargetSlotJAR string `json:"targetSlotJar"` - TargetHealthEndpoint string `json:"targetHealthEndpoint"` - PreviousSlotTarget string `json:"previousSlotTarget"` - PreviousGatewayPort int `json:"previousGatewayPort"` - PreviousUnit string `json:"previousUnit"` - GatewayBeforePath string `json:"gatewayBeforePath"` - GatewayAfterPath string `json:"gatewayAfterPath"` - GatewayReceiptPath string `json:"gatewayReceiptPath"` - ActiveJARPath string `json:"activeJarPath"` - ActiveJARBefore pathState `json:"activeJarBefore"` + // InputType 标识输入来源类型。 + InputType string `json:"inputType"` + // SourcePath 构件源路径。 + SourcePath string `json:"sourcePath"` + // SourceSHA256 构件 SHA-256 摘要。 + SourceSHA256 string `json:"sourceSHA256"` + // CustomerCode 客户编码,为空时省略。 + CustomerCode string `json:"customerCode,omitempty"` + // VersionID 版本标识,为空时省略。 + VersionID string `json:"versionId,omitempty"` + // ArtifactID 构件标识,为零时省略。 + ArtifactID int64 `json:"artifactId,omitempty"` + // ArtifactFileName 构件文件名。 + ArtifactFileName string `json:"artifactFileName"` + // ArtifactPath 构件在事务工作目录中的物化路径。 + ArtifactPath string `json:"artifactPath"` + // ArtifactIdentity 构件尺寸与摘要身份信息。 + ArtifactIdentity filestore.Identity `json:"artifactIdentity"` + // ReleasePath 构件在 release 目录下的相对安装路径。 + ReleasePath string `json:"releasePath"` + // TargetPort 本次更新要切换到的目标后端端口。 + TargetPort int `json:"targetPort"` + // TargetUnit 目标端口对应的 systemd 单元名。 + TargetUnit string `json:"targetUnit"` + // TargetSlotJAR 目标槽位的 JAR 链接路径。 + TargetSlotJAR string `json:"targetSlotJar"` + // TargetHealthEndpoint 目标槽位的健康检查端点。 + TargetHealthEndpoint string `json:"targetHealthEndpoint"` + // PreviousSlotTarget 目标槽位链接在更新前指向的目标,用于补偿恢复。 + PreviousSlotTarget string `json:"previousSlotTarget"` + // PreviousGatewayPort 更新前 Nginx 上游指向的端口。 + PreviousGatewayPort int `json:"previousGatewayPort"` + // PreviousUnit 更新前实际运行的后端单元名。 + PreviousUnit string `json:"previousUnit"` + // GatewayBeforePath 切换前 Nginx 配置快照的文件路径。 + GatewayBeforePath string `json:"gatewayBeforePath"` + // GatewayAfterPath 切换后 Nginx 配置快照的文件路径。 + GatewayAfterPath string `json:"gatewayAfterPath"` + // GatewayReceiptPath Nginx 切换成功后的回执文件路径。 + GatewayReceiptPath string `json:"gatewayReceiptPath"` + // ActiveJARPath 兼容性 JAR 链接路径。 + ActiveJARPath string `json:"activeJarPath"` + // ActiveJARBefore 兼容性 JAR 链接更新前的状态快照,用于补偿恢复。 + ActiveJARBefore pathState `json:"activeJarBefore"` } +// pathKind 描述文件系统路径的状态种类。 type pathKind string const ( - pathKindAbsent pathKind = "absent" + // pathKindAbsent 表示路径应不存在。 + pathKindAbsent pathKind = "absent" + // pathKindRegular 表示路径应是普通文件。 pathKindRegular pathKind = "regular" + // pathKindSymlink 表示路径应是符号链接。 pathKindSymlink pathKind = "symlink" ) +// pathState 文件系统路径在某个时刻的状态快照,同时作为“期望状态”与 +// “历史状态”使用。applyPathState 依据 Kind 决定如何把路径调整到该状态。 type pathState struct { - Kind pathKind `json:"kind"` - Target string `json:"target,omitempty"` - BackupPath string `json:"backupPath,omitempty"` - Identity filestore.Identity `json:"identity,omitempty"` - Mode uint32 `json:"mode,omitempty"` + // Kind 路径状态种类,决定其它字段的取值。 + Kind pathKind `json:"kind"` + // Target 符号链接目标路径,仅 Kind 为 pathKindSymlink 时有效。 + Target string `json:"target,omitempty"` + // BackupPath 普通文件快照的备份路径,仅 Kind 为 pathKindRegular 时有效。 + BackupPath string `json:"backupPath,omitempty"` + // Identity 普通文件快照的尺寸与摘要身份信息。 + Identity filestore.Identity `json:"identity,omitempty"` + // Mode 普通文件的权限位,仅 Kind 为 pathKindRegular 时有效。 + Mode uint32 `json:"mode,omitempty"` } diff --git a/internal/backendupdate/operations.go b/internal/backendupdate/operations.go index e40e35d..9988bf2 100644 --- a/internal/backendupdate/operations.go +++ b/internal/backendupdate/operations.go @@ -20,13 +20,21 @@ import ( "yms-daemon/internal/transaction" ) +// gatewayOperation 切换宿主 Nginx 后端上游的事务操作。Apply 应用 after 快照 +// 并写入回执,Inspect 通过对比当前配置与 before/after 快照判断执行状态。 type gatewayOperation struct { - controller gatewayController - before hostnginx.Snapshot - after hostnginx.Snapshot + // controller 宿主 Nginx 配置的读取与应用控制器。 + controller gatewayController + // before 切换前的 Nginx 配置快照。 + before hostnginx.Snapshot + // after 切换后的 Nginx 配置快照。 + after hostnginx.Snapshot + // receiptPath 切换成功后写入回执摘要的文件路径。 receiptPath string } +// Apply 应用 after 快照到宿主 Nginx,并在成功后写入摘要回执文件。 +// 回执文件用于 Inspect 区分“已应用”与“应用内容不一致”等状态。 func (o *gatewayOperation) Apply(ctx context.Context) error { if err := o.controller.Apply(ctx, o.after); err != nil { return err @@ -34,6 +42,9 @@ func (o *gatewayOperation) Apply(ctx context.Context) error { return writeImmutableFile(o.receiptPath, []byte(snapshotDigest(o.after)), 0o600) } +// Inspect 读取当前 Nginx 配置并与 after/before 快照对比,返回该操作是否已应用、 +// 未应用或状态未知。对比同时校验端口与配置内容;回执文件的存在与摘要一致用于 +// 强化“已应用”判定。 func (o *gatewayOperation) Inspect(context.Context) (transaction.Inspection, error) { current, err := o.controller.Read() if err != nil { @@ -58,16 +69,23 @@ func (o *gatewayOperation) Inspect(context.Context) (transaction.Inspection, err return transaction.Inspection{Status: transaction.InspectionUnknown, Result: gatewayResult(current)}, nil } +// pathOperation 将某文件系统路径调整到期望状态的事务操作。before 保存操作前 +// 的路径状态,desired 保存期望达到的路径状态,Inspect 据此判断执行结果。 type pathOperation struct { - path string - before pathState + // path 被操作的路径。 + path string + // before 操作前的路径状态,用于判断“未应用”。 + before pathState + // desired 期望达到的路径状态,用于判断“已应用”。 desired pathState } +// Apply 将目标路径调整到 desired 状态。 func (o *pathOperation) Apply(context.Context) error { return applyPathState(o.path, o.desired) } +// Inspect 判断路径当前是否已匹配 desired 或仍匹配 before,返回相应的执行状态。 func (o *pathOperation) Inspect(context.Context) (transaction.Inspection, error) { desired, err := pathMatches(o.path, o.desired) if err != nil { @@ -86,15 +104,22 @@ func (o *pathOperation) Inspect(context.Context) (transaction.Inspection, error) return transaction.Inspection{Status: transaction.InspectionUnknown}, nil } +// unitStopOperation 停止某个 systemd 单元的事务操作。 type unitStopOperation struct { + // units systemd 管理器。 units systemd.Manager - name string + // name 待停止的单元名。 + name string } +// Apply 停止指定 systemd 单元。 func (o *unitStopOperation) Apply(ctx context.Context) error { return o.units.Stop(ctx, o.name) } +// Inspect 查询单元状态并判断是否已停止。inactive 与 failed 均视为已停止,因为 +// systemd 可能把成功停止的遗留服务标记为 failed(其追踪的 JVM 以 SIGTERM 退出, +// 状态码 143),二者都表示已无活动进程,即本步骤所需结果。 func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { unit, err := o.units.Inspect(ctx, o.name) if err != nil { @@ -116,6 +141,9 @@ func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil } +// snapshotPath 检查指定路径并把其当前状态快照为 pathState。若路径不存在则返回 +// absent 状态;若为符号链接则记录其目标;若为普通文件则复制一份备份并记录其 +// 尺寸与摘要身份,便于后续校验与恢复。 func snapshotPath(path string, backupPath string) (pathState, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { @@ -141,6 +169,9 @@ func snapshotPath(path string, backupPath string) (pathState, error) { return pathState{Kind: pathKindRegular, BackupPath: backupPath, Identity: identity, Mode: uint32(info.Mode().Perm())}, nil } +// applyPathState 将目标路径调整到指定状态:absent 则删除,symlink 则原子地替换 +// 为指向目标的新链接,regular 则校验备份后原子地恢复为普通文件。父目录必须是 +// 直接目录。所有变更完成后都会同步父目录以确保持久化。 func applyPathState(path string, state pathState) error { parent := filepath.Dir(path) info, err := os.Lstat(parent) @@ -179,6 +210,9 @@ func applyPathState(path string, state pathState) error { } } +// pathMatches 判断路径当前是否匹配指定状态:absent 状态要求路径不存在,symlink +// 状态要求为指向相同目标的符号链接,regular 状态要求为权限位与身份摘要都一致的 +// 普通文件。 func pathMatches(path string, state pathState) (bool, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { @@ -209,6 +243,8 @@ func pathMatches(path string, state pathState) (bool, error) { } } +// copyFileSnapshot 将源文件复制到目标路径并返回其尺寸与 SHA-256 身份信息。 +// 复制采用先写临时文件、同步、再原子重命名的流程,并同步父目录,保证落盘一致。 func copyFileSnapshot(sourcePath string, destinationPath string, mode os.FileMode) (filestore.Identity, error) { if err := os.MkdirAll(filepath.Dir(destinationPath), 0o750); err != nil { return filestore.Identity{}, err @@ -250,6 +286,8 @@ func copyFileSnapshot(sourcePath string, destinationPath string, mode os.FileMod return filestore.Identity{Size: size, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil } +// copyFileAtomic 原子地将源文件复制到目标路径:先写入同目录临时文件,同步并 +// 关闭后再重命名,最后同步父目录。用于普通文件路径状态的恢复。 func copyFileAtomic(sourcePath string, destinationPath string, mode os.FileMode) error { source, err := os.Open(sourcePath) if err != nil { @@ -284,6 +322,9 @@ func copyFileAtomic(sourcePath string, destinationPath string, mode os.FileMode) return syncDirectory(parent) } +// writeImmutableFile 以排他方式写入不可变文件:若路径已存在则要求其内容与待写 +// 内容完全一致,否则报错;若不存在则以指定权限原子地创建并同步。写入失败时会 +// 清理残留文件。 func writeImmutableFile(path string, content []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err @@ -326,6 +367,8 @@ func writeImmutableFile(path string, content []byte, mode os.FileMode) error { return nil } +// readGatewaySnapshot 读取持久化的 Nginx 配置快照文件,并校验其中活动端口与 +// 期望端口一致,返回快照内容与端口。 func readGatewaySnapshot(path string, port int) (hostnginx.Snapshot, error) { content, err := os.ReadFile(path) if err != nil { @@ -341,6 +384,8 @@ func readGatewaySnapshot(path string, port int) (hostnginx.Snapshot, error) { return hostnginx.Snapshot{Content: content, ActivePort: port}, nil } +// verifyFileIdentity 校验文件的实际尺寸与 SHA-256 摘要是否与给定身份一致,任何 +// 不一致都返回错误。 func verifyFileIdentity(path string, identity filestore.Identity) error { if err := identity.Validate(); err != nil { return err @@ -361,11 +406,14 @@ func verifyFileIdentity(path string, identity filestore.Identity) error { return nil } +// snapshotDigest 计算 Nginx 快照内容的 SHA-256 摘要(十六进制字符串),用于 +// 回执与意图中的一致性标识。 func snapshotDigest(snapshot hostnginx.Snapshot) string { digest := sha256.Sum256(snapshot.Content) return hex.EncodeToString(digest[:]) } +// gatewayResult 将 Nginx 快照的活动端口与内容摘要序列化为事务检查结果的 JSON。 func gatewayResult(snapshot hostnginx.Snapshot) json.RawMessage { result, _ := json.Marshal(struct { ActivePort int `json:"activePort"` @@ -374,6 +422,7 @@ func gatewayResult(snapshot hostnginx.Snapshot) json.RawMessage { return result } +// pathResult 将路径及其状态序列化为事务检查结果的 JSON。 func pathResult(path string, state pathState) json.RawMessage { result, _ := json.Marshal(struct { Path string `json:"path"` @@ -382,6 +431,7 @@ func pathResult(path string, state pathState) json.RawMessage { return result } +// syncDirectory 打开目录并执行 fsync,将目录项变更持久化到磁盘。 func syncDirectory(directory string) error { file, err := os.Open(directory) if err != nil { diff --git a/internal/backendupdate/operations_test.go b/internal/backendupdate/operations_test.go index 4f6efa5..7e7b6ea 100644 --- a/internal/backendupdate/operations_test.go +++ b/internal/backendupdate/operations_test.go @@ -10,6 +10,8 @@ import ( "yms-daemon/internal/transaction" ) +// TestPathOperationReplacesRegularCompatibilityJarAndRestoresIt 验证 pathOperation 能把普通文件形态的 +// 兼容 JAR 替换为目标软链接,随后又能依据快照恢复为原始普通文件内容。 func TestPathOperationReplacesRegularCompatibilityJarAndRestoresIt(t *testing.T) { root := t.TempDir() activePath := filepath.Join(root, "glory-soft-yms.jar") @@ -47,6 +49,8 @@ func TestPathOperationReplacesRegularCompatibilityJarAndRestoresIt(t *testing.T) } } +// TestResolveCurrentUnitSupportsFirstLegacyMigrationAndTemplateRotation 验证 resolveCurrentUnit 在 +// 首次从旧版单元迁移和模板单元轮换两种场景下,都能选出当前真正运行的单元。 func TestResolveCurrentUnitSupportsFirstLegacyMigrationAndTemplateRotation(t *testing.T) { tests := []struct { name string @@ -72,6 +76,8 @@ func TestResolveCurrentUnitSupportsFirstLegacyMigrationAndTemplateRotation(t *te } } +// TestUnitStopOperationAcceptsSystemdFailedAsStopped 验证 unitStopOperation 的 Inspect 把 systemd 的 +// failed 状态视为已停止(InspectionApplied),因为旧服务在 JVM 以 SIGTERM 退出后常表现为 failed。 func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { units := &unitStateManager{units: map[string]systemd.Unit{ legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "failed"}, @@ -86,14 +92,19 @@ func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { } } +// unitStateManager systemd 单元管理器的只读测试替身,仅返回预先配置的单元状态, +// 用于隔离 resolveCurrentUnit 与 unitStopOperation 的检查逻辑。 type unitStateManager struct { units map[string]systemd.Unit } +// Inspect 返回指定名称的单元状态。 func (m *unitStateManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { return m.units[name], nil } +// Start 空实现,测试中不涉及启动单元。 func (m *unitStateManager) Start(context.Context, string) error { return nil } +// Stop 空实现,测试中不涉及停止单元。 func (m *unitStateManager) Stop(context.Context, string) error { return nil } diff --git a/internal/backendupdate/progress.go b/internal/backendupdate/progress.go index 18a371f..4bf0a93 100644 --- a/internal/backendupdate/progress.go +++ b/internal/backendupdate/progress.go @@ -2,16 +2,23 @@ package backendupdate import "yms-daemon/internal/transaction" -// Progress is one live update event sent to the local CLI. It is observational -// only: delivery failure must not change the persisted update transaction. +// Progress 一次更新过程中的单条实时进度事件,用于回传给本地 CLI。 +// 它仅用于观测反馈:即使投递失败也不得改变已持久化的更新事务状态。 type Progress struct { + // TransactionID 事件所属的事务标识。 TransactionID string - State transaction.State - Message string + // State 事件发生时的事务状态。 + State transaction.State + // Message 面向用户的进度描述文本。 + Message string } +// ProgressReporter 进度事件回调,接收一次更新过程中的单条进度。 +// 实现必须可安全地并发调用,且失败不应影响更新主流程。 type ProgressReporter func(Progress) +// operationLabel 根据输入类型返回本次操作的中文可读标签:重启类输入返回 +// "restart",其余输入返回 "update"。该标签用于拼装进度与提交消息。 func operationLabel(inputType string) string { if inputType == inputTypeCurrentRelease { return "restart" diff --git a/internal/backendupdate/restart.go b/internal/backendupdate/restart.go index 7f98633..d2cb3ff 100644 --- a/internal/backendupdate/restart.go +++ b/internal/backendupdate/restart.go @@ -13,8 +13,13 @@ import ( "yms-daemon/internal/updatepackage" ) -// Restart performs a zero-downtime rotation with the exact release currently -// exposed by the compatibility JAR path. +// Restart 使用兼容性 JAR 链接当前指向的精确发行版执行一次零停机轮换:它把 +// 当前运行中的发行版部署到非活动槽位,然后切换流量并停止前一单元,从而在 +// 不更换版本的前提下完成一次重启。 +// +// 参数 ctx 用于控制整个更新过程的取消;report 用于回传实时进度,可为 nil。 +// 返回值为本次重启对应的事务记录以及错误。若后端类型为容器,则直接返回错误; +// 若已存在相关活动事务,则尝试复用并续跑。 func (u *Updater) Restart(ctx context.Context, report ProgressReporter) (transaction.Transaction, error) { if u.config.Backend.Type == deploymentconfig.BackendTypeContainer { return transaction.Transaction{}, errors.New("restart is not implemented for container backend") @@ -67,6 +72,8 @@ func (u *Updater) Restart(ctx context.Context, report ProgressReporter) (transac }, report) } +// currentReleaseSource 解析兼容性 JAR 链接实际指向的源文件路径。若链接为 +// 直接普通文件则原样返回;若为符号链接则要求其为绝对目标并解析到最终文件。 func currentReleaseSource(activeJAR string) (string, error) { info, err := os.Lstat(activeJAR) if err != nil { @@ -92,6 +99,8 @@ func currentReleaseSource(activeJAR string) (string, error) { return resolved, nil } +// restartReleasePath 计算当前 JAR 在 release 目录下的相对安装路径。若当前 JAR +// 位于 release 目录内,则返回其相对路径;否则落到 direct 目录下的规范路径。 func (u *Updater) restartReleasePath(jar updatepackage.DirectNativeJAR) (string, error) { resolvedReleaseDir, err := filepath.EvalSymlinks(u.config.Backend.ReleaseDir) if err != nil { @@ -107,6 +116,8 @@ func (u *Updater) restartReleasePath(jar updatepackage.DirectNativeJAR) (string, return filepath.Join("direct", jar.SHA256[:directReleaseDigestLength], jar.FileName), nil } +// persistedRestartInput 由已持久化的重启请求重建 updateInput,供续跑已存在的 +// 重启事务使用。其中物化函数从 restartMaterializer 得到。 func (u *Updater) persistedRestartInput(request persistedRequest, idempotencyKey string) (updateInput, error) { materialize, err := u.restartMaterializer(request) if err != nil { @@ -124,6 +135,9 @@ func (u *Updater) persistedRestartInput(request persistedRequest, idempotencyKey }, nil } +// restartMaterializer 为续跑的重启事务重建构件物化函数。若已物化的构件仍存在 +// 且为直接普通文件,则返回一个表示“恢复期间构件消失”的失败函数;否则尝试从 +// 源路径或 release 目录重新定位与身份匹配的 JAR 并提供其复制函数。 func (u *Updater) restartMaterializer(request persistedRequest) (func(string) error, error) { if info, err := os.Lstat(request.ArtifactPath); err == nil { if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { diff --git a/internal/backendupdate/restart_test.go b/internal/backendupdate/restart_test.go index 0748f70..d553d40 100644 --- a/internal/backendupdate/restart_test.go +++ b/internal/backendupdate/restart_test.go @@ -11,6 +11,8 @@ import ( "yms-daemon/internal/transaction" ) +// TestRestartRejectsUnfinishedBackendUpdate 验证当存在未完成的后端更新事务时,Restart 会被拒绝, +// 并返回 transaction.ErrActiveExists 及该活跃事务的记录。 func TestRestartRejectsUnfinishedBackendUpdate(t *testing.T) { ctx := context.Background() store, err := transaction.OpenStore(ctx, filepath.Join(t.TempDir(), "transactions.db")) diff --git a/internal/backendupdate/updater.go b/internal/backendupdate/updater.go index 4c40eec..0132d4f 100644 --- a/internal/backendupdate/updater.go +++ b/internal/backendupdate/updater.go @@ -1,4 +1,6 @@ -// Package backendupdate orchestrates one native backend update through commit or compensation. +// Package backendupdate 负责在单台服务器上编排一次原生后端(或容器后端)更新, +// 覆盖从构件准备、切换流量到提交或补偿的完整事务流程。它通过 transaction 包维护 +// 可恢复的更新事务,并在失败时执行补偿以回滚到更新前的状态。 package backendupdate import ( @@ -27,49 +29,86 @@ import ( ) const ( - serviceBackend = "backend" - sourceLocalCLI = "local-cli" - drainDuration = 5 * time.Second + // serviceBackend 本更新服务在事务中的服务标识名。 + serviceBackend = "backend" + // sourceLocalCLI 事务来源标识,表示更新由本地命令行发起。 + sourceLocalCLI = "local-cli" + // drainDuration 切换后旧单元/旧容器的默认排空时长。 + drainDuration = 5 * time.Second + // directReleaseDigestLength 直接部署构件在 release 目录下的摘要路径截取长度。 directReleaseDigestLength = 12 - inputTypeCurrentRelease = "current-native-release" - legacyUnit8080 = "yms.service" - legacyUnit8081 = "ymsback.service" + // inputTypeCurrentRelease 标识以当前运行发行版执行重启的输入类型。 + inputTypeCurrentRelease = "current-native-release" + // legacyUnit8080 端口 8080 对应的遗留 systemd 单元名。 + legacyUnit8080 = "yms.service" + // legacyUnit8081 端口 8081 对应的遗留 systemd 单元名。 + legacyUnit8081 = "ymsback.service" ) -// Updater executes the current native backend contract on one server. +// Updater 在单台服务器上执行当前原生(或容器)后端契约。它持有部署配置、事务 +// 存储、协调器以及各类执行器,是后端更新流程的核心入口与状态载体。 type Updater struct { - config deploymentconfig.Config - workRoot string - store *transaction.Store - coordinator *transaction.Coordinator - releaseStore *filestore.Store - units systemd.Manager - gateway gatewayController - executor nativeExecutor - containerExecutor containerExecutor - engine containerengine.Engine + // config 部署配置,描述后端类型、槽位与路径等信息。 + config deploymentconfig.Config + // workRoot 事务工作目录的根路径。 + workRoot string + // store 事务存储,用于创建、读取与推进更新事务。 + store *transaction.Store + // coordinator 事务协调器,用于以可恢复方式执行单个步骤。 + coordinator *transaction.Coordinator + // releaseStore 发行版文件存储,供执行器使用。 + releaseStore *filestore.Store + // units systemd 管理器,用于检查与停止后端单元。 + units systemd.Manager + // gateway 宿主 Nginx 配置控制器,用于读取与应用上游配置。 + gateway gatewayController + // executor 原生后端执行器,负责运行与健康检查原生后端。 + executor nativeExecutor + // containerExecutor 容器后端执行器,负责运行与健康检查容器后端。 + containerExecutor containerExecutor + // engine 容器引擎,仅在容器后端更新时使用。 + engine containerengine.Engine + // containerConfigSource 容器后端配置文件在宿主机上的源路径。 containerConfigSource string + // containerConfigTarget 容器后端配置文件在容器内的目标路径。 containerConfigTarget string - containerTmpSource string - containerTmpTarget string - logger *slog.Logger - drain time.Duration + // containerTmpSource 容器后端临时目录在宿主机上的源路径。 + containerTmpSource string + // containerTmpTarget 容器后端临时目录在容器内的目标路径。 + containerTmpTarget string + // logger 结构化日志记录器。 + logger *slog.Logger + // drain 切换后旧单元/旧容器的排空时长。 + drain time.Duration } +// gatewayController 抽象宿主 Nginx 配置的读取与应用,便于测试与替换实现。 type gatewayController interface { + // Read 返回当前宿主 Nginx 配置快照。 Read() (hostnginx.Snapshot, error) + // Apply 将给定快照应用到宿主 Nginx。 Apply(context.Context, hostnginx.Snapshot) error } +// nativeExecutor 抽象原生后端执行器的运行能力。 type nativeExecutor interface { + // Run 执行原生后端运行流程,request 携带构件与目标槽位等信息。 Run(context.Context, string, nativebackendexecutor.Request) error } +// containerExecutor 抽象容器后端执行器的运行能力。 type containerExecutor interface { + // Run 执行容器后端运行流程,request 携带镜像与容器参数等信息。 Run(context.Context, string, backendexecutor.Request) error } -// New creates the complete native backend update orchestrator. +// New 创建完整的原生后端更新编排器。 +// +// 参数 config 为部署配置且必须校验通过且后端类型为 native;workRoot 必须是干净 +// 的绝对路径;store 与 coordinator 提供事务能力;releaseStore 提供发行版存储; +// units 提供 systemd 管理;gateway 提供 Nginx 控制;httpClient 供执行器进行健康 +// 检查;logger 可为 nil,缺省使用默认日志器。返回构造完成的 Updater,若参数非法 +// 或执行器创建失败则返回错误。 func New( config deploymentconfig.Config, workRoot string, @@ -114,7 +153,10 @@ func New( }, nil } -// NewContainer creates the Docker standalone backend update orchestrator. +// NewContainer 创建 Docker 独立容器后端更新编排器。 +// +// 参数与 New 类似,但要求后端类型为 container 且 daemon 环境为 dev,engine 提供 +// 容器引擎能力。返回构造完成的 Updater,若参数非法或执行器创建失败则返回错误。 func NewContainer( config deploymentconfig.Config, workRoot string, @@ -157,7 +199,10 @@ func NewContainer( }, nil } -// UpdateRepack applies one repack ZIP selected by an absolute local path. +// UpdateRepack 应用由绝对本地路径指定的 repack ZIP 更新。 +// +// 参数 packagePath 是 repack ZIP 的绝对路径;report 用于回传进度,可为 nil。 +// 返回本次更新对应的事务记录以及错误。容器后端不支持该更新方式。 func (u *Updater) UpdateRepack(ctx context.Context, packagePath string, report ProgressReporter) (transaction.Transaction, error) { if u.config.Backend.Type == deploymentconfig.BackendTypeContainer { return transaction.Transaction{}, errors.New("repack ZIP update is not implemented for container backend") @@ -184,7 +229,10 @@ func (u *Updater) UpdateRepack(ctx context.Context, packagePath string, report P }, report) } -// UpdateNativeJAR applies one JAR copied directly to the server. +// UpdateNativeJAR 应用一个直接复制到服务器的 JAR 更新。 +// +// 参数 jarPath 是 JAR 的本地路径;report 用于回传进度,可为 nil。返回本次更新 +// 对应的事务记录以及错误。仅原生后端支持该更新方式。 func (u *Updater) UpdateNativeJAR(ctx context.Context, jarPath string, report ProgressReporter) (transaction.Transaction, error) { if u.config.Backend.Type == deploymentconfig.BackendTypeContainer { return transaction.Transaction{}, errors.New("--native-jar requires backend.type = native") @@ -207,6 +255,8 @@ func (u *Updater) UpdateNativeJAR(ctx context.Context, jarPath string, report Pr }, report) } +// update 原生后端更新(含重启)的统一执行入口:创建或恢复事务、物化构件、 +// 运行执行器、再切换与提交。input 描述本次更新输入,report 用于回传进度。 func (u *Updater) update(ctx context.Context, input updateInput, report ProgressReporter) (transaction.Transaction, error) { existing, request, created, err := u.createOrResume(ctx, input) if err != nil { @@ -269,6 +319,8 @@ func (u *Updater) update(ctx context.Context, input updateInput, report Progress return u.store.Transaction(ctx, existing.ID) } +// terminalResult 根据终态事务记录返回结果:已提交则正常返回,否则返回带有状态 +// 信息的错误。 func terminalResult(record transaction.Transaction) (transaction.Transaction, error) { if record.State == transaction.StateCommitted { return record, nil @@ -276,6 +328,11 @@ func terminalResult(record transaction.Transaction) (transaction.Transaction, er return record, fmt.Errorf("backend update transaction %s is terminal in state %s", record.ID, record.State) } +// createOrResume 为给定更新输入创建新事务,或在幂等键命中时恢复已存在事务。 +// +// 该函数先读取当前 Nginx 快照以确定目标端口与槽位,再在事务工作目录下固化网关 +// 快照与兼容性链接备份,最后创建持久化请求并写入事务存储。返回值为事务记录、 +// 持久化请求、是否新建以及错误。 func (u *Updater) createOrResume(ctx context.Context, input updateInput) (transaction.Transaction, persistedRequest, bool, error) { gatewayBefore, err := u.gateway.Read() if err != nil { @@ -372,6 +429,9 @@ func (u *Updater) createOrResume(ctx context.Context, input updateInput) (transa return record, persisted, false, nil } +// switchAndCommit 执行原生后端更新的切换与提交:切换 Nginx 上游到目标端口、更新 +// 兼容性 JAR 链接、排空并停止前一单元,最终把事务迁入 Committed 状态。任何切换 +// 阶段失败都会触发补偿。 func (u *Updater) switchAndCommit(ctx context.Context, transactionID string, request persistedRequest, report ProgressReporter) error { operation := operationLabel(request.InputType) before, err := readGatewaySnapshot(request.GatewayBeforePath, request.PreviousGatewayPort) @@ -440,12 +500,16 @@ func (u *Updater) switchAndCommit(ctx context.Context, transactionID string, req } } +// reportProgress 在 report 非空时向其投递一条进度事件,report 为 nil 时静默忽略。 func reportProgress(report ProgressReporter, progress Progress) { if report != nil { report(progress) } } +// rollbackAfterPreparation 在切换阶段失败后执行原生后端补偿:恢复兼容性链接、 +// 恢复 Nginx 上游、停止目标单元并恢复目标槽位,最后迁入 RolledBack 状态。cause +// 为触发补偿的原始错误,会与补偿过程中的错误合并返回。 func (u *Updater) rollbackAfterPreparation(ctx context.Context, transactionID string, request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot, cause error) error { record, readErr := u.store.Transaction(ctx, transactionID) if readErr != nil { @@ -490,6 +554,8 @@ func (u *Updater) rollbackAfterPreparation(ctx context.Context, transactionID st return errors.Join(cause, transitionErr) } +// resolveCurrentUnit 确定当前活动端口实际运行的后端单元名。它同时检查配置单元与 +// 遗留单元,要求二者恰好一个在运行,并返回运行中的那个。 func (u *Updater) resolveCurrentUnit(ctx context.Context, port int, configuredUnit string) (string, error) { legacyUnit, err := legacyUnitForPort(port) if err != nil { @@ -514,16 +580,20 @@ func (u *Updater) resolveCurrentUnit(ctx context.Context, port int, configuredUn return legacyUnit, nil } +// fail 把事务迁入 Failed 状态并返回带错误的事务记录。 func (u *Updater) fail(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) { _, transitionErr := u.store.Transition(ctx, transactionID, transaction.StateFailed, cause.Error()) return u.currentWithError(ctx, transactionID, errors.Join(cause, transitionErr)) } +// currentWithError 读取事务当前记录并把给定错误与读取错误合并返回,便于调用方在 +// 出错时仍拿到最新事务状态。 func (u *Updater) currentWithError(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) { record, err := u.store.Transaction(ctx, transactionID) return record, errors.Join(cause, err) } +// otherPort 返回给定后端端口的对侧端口:8080 与 8081 互换。 func otherPort(port int) int { if port == deploymentconfig.BackendPort8080 { return deploymentconfig.BackendPort8081 @@ -531,6 +601,8 @@ func otherPort(port int) int { return deploymentconfig.BackendPort8080 } +// legacyUnitForPort 返回给定后端端口对应的遗留 systemd 单元名,仅支持 8080 与 +// 8081 两个端口。 func legacyUnitForPort(port int) (string, error) { switch port { case deploymentconfig.BackendPort8080: @@ -542,10 +614,13 @@ func legacyUnitForPort(port int) (string, error) { } } +// unitRunning 判断单元是否处于运行状态:既非 inactive 也非 failed 即视为运行。 func unitRunning(unit systemd.Unit) bool { return unit.ActiveState != "inactive" && unit.ActiveState != "failed" } +// readOptionalSymlink 读取指定路径的符号链接目标;若路径不存在则返回空字符串, +// 若路径存在但不是符号链接则报错。 func readOptionalSymlink(path string) (string, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { @@ -564,6 +639,8 @@ func readOptionalSymlink(path string) (string, error) { return target, nil } +// ensureTransactionArtifact 确保构件已物化到事务工作路径:若已存在则校验其为直接 +// 普通文件且身份匹配,否则调用 input.Materialize 进行物化。 func ensureTransactionArtifact(input updateInput, request persistedRequest) error { info, err := os.Lstat(request.ArtifactPath) if errors.Is(err, os.ErrNotExist) { @@ -578,6 +655,8 @@ func ensureTransactionArtifact(input updateInput, request persistedRequest) erro return verifyFileIdentity(request.ArtifactPath, request.ArtifactIdentity) } +// decodePersistedRequest 将持久化的原生后端更新请求 JSON 反序列化到目标结构体, +// 并禁止出现未知字段。 func decodePersistedRequest(content json.RawMessage, request *persistedRequest) error { decoder := json.NewDecoder(bytes.NewReader(content)) decoder.DisallowUnknownFields() @@ -587,6 +666,7 @@ func decodePersistedRequest(content json.RawMessage, request *persistedRequest) return nil } +// waitContext 等待指定时长,或直到 ctx 被取消。返回 ctx 取消错误或 nil。 func waitContext(ctx context.Context, duration time.Duration) error { timer := time.NewTimer(duration) defer timer.Stop() diff --git a/internal/backendupdate/updater_test.go b/internal/backendupdate/updater_test.go index f7cd97e..4bc1d5d 100644 --- a/internal/backendupdate/updater_test.go +++ b/internal/backendupdate/updater_test.go @@ -20,14 +20,21 @@ import ( "yms-daemon/internal/updatepackage" ) +// TestUpdaterMigratesLegacyBackendAndCommits 验证通过 repack 包更新时,能把仍在运行的旧版后端单元 +// (ymsback.service)迁移到模板单元并完成提交。 func TestUpdaterMigratesLegacyBackendAndCommits(t *testing.T) { testUpdaterMigratesLegacyBackendAndCommits(t, false) } +// TestUpdaterCommitsDirectNativeJAR 验证直接安装 native JAR 时能完成提交, +// 并进一步验证重启事务的创建、恢复与槽位旋转。 func TestUpdaterCommitsDirectNativeJAR(t *testing.T) { testUpdaterMigratesLegacyBackendAndCommits(t, true) } +// testUpdaterMigratesLegacyBackendAndCommits 上述两个测试共用的驱动函数。 +// 当 direct 为 false 时走 repack 更新路径,为 true 时走直接 JAR 安装路径, +// 并额外验证重启事务的创建、恢复以及槽位旋转行为。 func testUpdaterMigratesLegacyBackendAndCommits(t *testing.T, direct bool) { t.Helper() ctx := context.Background() @@ -164,6 +171,7 @@ func testUpdaterMigratesLegacyBackendAndCommits(t *testing.T, direct bool) { } } +// serverConfiguration8081 测试用的 host Nginx 配置片段,其活跃后端端口为 8081。 const serverConfiguration8081 = `http { upstream yms-server { # yms-update managed upstream begin @@ -174,16 +182,20 @@ const serverConfiguration8081 = `http { } ` +// memoryGateway 网关控制器的测试替身,在内存中保存 host Nginx 快照, +// 记录 Apply 调用次数,并支持在应用前注入失败以模拟切换故障。 type memoryGateway struct { snapshot hostnginx.Snapshot applyCount int beforeApply func(hostnginx.Snapshot) error } +// Read 返回当前保存的网关快照副本。 func (g *memoryGateway) Read() (hostnginx.Snapshot, error) { return hostnginx.Snapshot{Content: append([]byte(nil), g.snapshot.Content...), ActivePort: g.snapshot.ActivePort}, nil } +// Apply 在 beforeApply 钩子通过后,将给定快照保存为当前状态并累计应用次数。 func (g *memoryGateway) Apply(_ context.Context, snapshot hostnginx.Snapshot) error { if g.beforeApply != nil { if err := g.beforeApply(snapshot); err != nil { @@ -195,15 +207,19 @@ func (g *memoryGateway) Apply(_ context.Context, snapshot hostnginx.Snapshot) er return nil } +// updateUnitManager systemd 单元管理器的测试替身,在内存中维护单元状态, +// 并记录最近一次被停止的单元名称。 type updateUnitManager struct { units map[string]systemd.Unit stopped string } +// Inspect 返回指定名称的单元状态。 func (m *updateUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { return m.units[name], nil } +// Start 将指定单元置为 active。 func (m *updateUnitManager) Start(_ context.Context, name string) error { unit := m.units[name] unit.ActiveState = "active" @@ -211,6 +227,7 @@ func (m *updateUnitManager) Start(_ context.Context, name string) error { return nil } +// Stop 将指定单元置为 failed 并记录其名称。 func (m *updateUnitManager) Stop(_ context.Context, name string) error { unit := m.units[name] unit.ActiveState = "failed" @@ -219,12 +236,16 @@ func (m *updateUnitManager) Stop(_ context.Context, name string) error { return nil } +// preparingExecutor 原生后端执行器的测试替身:它把制品写入发布目录、 +// 建立槽位软链接、启动目标单元并推进事务状态,模拟执行器在真实环境中的工作。 type preparingExecutor struct { store *transaction.Store releaseDir string units *updateUnitManager } +// Run 将请求中的制品落盘到发布目录,创建槽位软链接,启动目标单元, +// 并把事务依次推进到 Starting 状态,用于驱动后续切换与提交逻辑。 func (e *preparingExecutor) Run(ctx context.Context, transactionID string, request nativebackendexecutor.Request) error { if err := os.MkdirAll(e.releaseDir, 0o750); err != nil { return err @@ -259,6 +280,8 @@ func (e *preparingExecutor) Run(ctx context.Context, transactionID string, reque return nil } +// writeNativeBackendPackage 生成一个包含 artifact-selection.json 清单和 +// glory-soft-yms-test.jar 制品的 repack ZIP 包,返回其路径。 func writeNativeBackendPackage(t *testing.T, jar []byte) string { t.Helper() digest := sha256.Sum256(jar) @@ -314,6 +337,8 @@ func writeNativeBackendPackage(t *testing.T, jar []byte) string { return packagePath } +// writeDirectBackendJAR 生成一个包含 BOOT-INF/classes/application.properties 的直接 native JAR, +// 返回其路径,供直接安装更新路径使用。 func writeDirectBackendJAR(t *testing.T, content []byte) string { t.Helper() jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") diff --git a/internal/containerengine/engine.go b/internal/containerengine/engine.go index 3b35fbc..f858b1b 100644 --- a/internal/containerengine/engine.go +++ b/internal/containerengine/engine.go @@ -1,4 +1,7 @@ -// Package containerengine defines the container runtime boundary used by update executors. +// Package containerengine 定义更新执行器所依赖的容器运行时边界。 +// +// 该包只声明容器运行时所必需的最小抽象,包括引擎接口、镜像、容器、 +// 挂载等数据结构,不包含任何具体实现细节;具体实现见同包的 moby.go。 package containerengine import ( @@ -7,81 +10,134 @@ import ( "io" ) +// ErrNotFound 表示在容器引擎中找不到目标对象(镜像或容器)。 var ErrNotFound = errors.New("container engine object not found") -// Platform is an explicit OCI operating system and CPU platform. +// Platform 描述一个显式的 OCI 操作系统与 CPU 平台。 type Platform struct { - OS string + // OS 表示操作系统名称,例如 linux。 + OS string + // Architecture 表示 CPU 架构,例如 amd64。 Architecture string - Variant string + // Variant 表示架构变体,例如 v8,可为空字符串。 + Variant string } -// Image is the immutable image information returned by the engine. +// Image 引擎返回的不可变镜像信息。 type Image struct { - ID string - RepoDigests []string + // ID 表示镜像的唯一标识。 + ID string + // RepoDigests 表示镜像在仓库中的内容摘要列表。 + RepoDigests []string + // DescriptorDigest 表示 OCI 描述符摘要,不可用时为空字符串。 DescriptorDigest string - Platform Platform + // Platform 表示镜像的目标操作系统与 CPU 平台。 + Platform Platform } -// RestartPolicy is passed to the engine without an implicit default. +// RestartPolicy 传递给引擎的重启策略,引擎不会为其附加隐式默认值。 type RestartPolicy struct { - Name string + // Name 表示策略名称,例如 always、on-failure。 + Name string + // MaximumRetryCount 表示策略为 on-failure 时的最大重试次数。 MaximumRetryCount int } -// Mount is one explicit container mount. +// Mount 表示一个显式的容器挂载。 type Mount struct { - Type string - Source string - Target string + // Type 表示挂载类型,例如 bind、volume。 + Type string + // Source 表示宿主机上的源路径或卷名。 + Source string + // Target 表示容器内的目标路径。 + Target string + // ReadOnly 表示挂载是否为只读。 ReadOnly bool } -// ContainerSpec contains every property controlled by the backend executor. +// ContainerSpec 包含后端执行器控制的容器全部属性。 type ContainerSpec struct { - Name string - ImageReference string - Platform Platform - Environment []string - Labels map[string]string - NetworkMode string - RestartPolicy RestartPolicy - Mounts []Mount - User string + // Name 表示容器名称。 + Name string + // ImageReference 表示要使用的镜像引用。 + ImageReference string + // Platform 表示容器运行的目标平台。 + Platform Platform + // Environment 表示以 KEY=VALUE 形式给出的环境变量列表。 + Environment []string + // Labels 表示附加到容器的键值标签。 + Labels map[string]string + // NetworkMode 表示容器的网络模式。 + NetworkMode string + // RestartPolicy 表示容器的重启策略。 + RestartPolicy RestartPolicy + // Mounts 表示容器的挂载列表。 + Mounts []Mount + // User 表示容器内的运行用户。 + User string + // StopTimeoutSeconds 表示停止容器的超时秒数。 StopTimeoutSeconds int } -// Container is the runtime state required for idempotent inspection. +// Container 执行幂等检查所需的容器运行时状态。 type Container struct { - ID string - Name string - ImageID string - ImageReference string - Platform string - Running bool - Dead bool - Status string - Environment []string - Labels map[string]string - NetworkMode string - RestartPolicy RestartPolicy - Mounts []Mount - User string + // ID 表示容器标识。 + ID string + // Name 表示容器名称。 + Name string + // ImageID 表示容器所基于的镜像标识。 + ImageID string + // ImageReference 表示创建容器时使用的镜像引用。 + ImageReference string + // Platform 表示容器的运行平台。 + Platform string + // Running 表示容器是否正在运行。 + Running bool + // Dead 表示容器是否已经停止且不可重启。 + Dead bool + // Status 表示容器的原始状态字符串。 + Status string + // Environment 表示容器的环境变量列表。 + Environment []string + // Labels 表示容器的键值标签。 + Labels map[string]string + // NetworkMode 表示容器的网络模式。 + NetworkMode string + // RestartPolicy 表示容器的重启策略。 + RestartPolicy RestartPolicy + // Mounts 表示容器的挂载列表。 + Mounts []Mount + // User 表示容器内的运行用户。 + User string + // StopTimeoutSeconds 表示停止容器的超时秒数。 StopTimeoutSeconds int } -// Engine is the smallest container runtime API required by an update executor. +// Engine 更新执行器所需的最小容器运行时 API。 +// +// 所有方法都通过 context 传递取消与超时控制; +// 各方法的具体行为由实现决定,详见 MobyEngine。 type Engine interface { + // Ping 探测引擎是否可达。 Ping(context.Context) error + // PullImage 从远端仓库拉取指定镜像。 PullImage(context.Context, string) error + // LoadImage 从归档读取流加载镜像。 LoadImage(context.Context, io.Reader) error + // InspectImage 检查镜像并返回其不可变信息。 InspectImage(context.Context, string) (Image, error) + // CreateContainer 依据规格创建容器并返回其状态。 CreateContainer(context.Context, ContainerSpec) (Container, error) + // StartContainer 启动指定容器。 StartContainer(context.Context, string) error + // ContainerLogs 读取指定容器的日志流。 ContainerLogs(context.Context, string) (io.ReadCloser, error) + // StopContainer 停止指定容器。 StopContainer(context.Context, string) error + // InspectContainer 检查容器并返回其运行时状态。 InspectContainer(context.Context, string) (Container, error) + // RemoveContainer 移除指定容器,force 决定是否强制移除。 RemoveContainer(context.Context, string, bool) error + // Close 释放引擎底层资源。 Close() error } diff --git a/internal/containerengine/moby.go b/internal/containerengine/moby.go index 82f6c01..184bfad 100644 --- a/internal/containerengine/moby.go +++ b/internal/containerengine/moby.go @@ -17,13 +17,15 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) -// MobyEngine adapts the official Docker Engine Go client. +// MobyEngine 适配官方 Docker Engine Go 客户端,实现 Engine 接口。 type MobyEngine struct { + // client 底层 Docker Engine 客户端。 client *client.Client } -// NewMobyEngine creates a client from Docker's documented environment variables. -// API negotiation remains enabled, including when DOCKER_HOST selects a non-default socket. +// NewMobyEngine 依据 Docker 官方文档定义的环境变量创建客户端。 +// 即使 DOCKER_HOST 指向非默认 socket,API 版本协商仍然保持启用。 +// 创建失败时返回包装后的错误。 func NewMobyEngine() (*MobyEngine, error) { apiClient, err := client.New(client.FromEnv) if err != nil { @@ -32,6 +34,7 @@ func NewMobyEngine() (*MobyEngine, error) { return &MobyEngine{client: apiClient}, nil } +// Ping 探测 Docker Engine 是否可达,不可达时返回包装后的错误。 func (e *MobyEngine) Ping(ctx context.Context) error { if _, err := e.client.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil { return fmt.Errorf("ping Docker Engine: %w", err) @@ -39,6 +42,8 @@ func (e *MobyEngine) Ping(ctx context.Context) error { return nil } +// PullImage 从远端仓库拉取指定镜像引用。 +// 拉取完成后逐条读取并校验引擎返回的 JSON 流,确保过程中无错误。 func (e *MobyEngine) PullImage(ctx context.Context, imageReference string) error { response, err := e.client.ImagePull(ctx, imageReference, client.ImagePullOptions{}) if err != nil { @@ -51,6 +56,8 @@ func (e *MobyEngine) PullImage(ctx context.Context, imageReference string) error return nil } +// LoadImage 从归档读取流加载镜像。 +// input 为空时返回错误;加载完成后同样校验引擎返回的 JSON 流。 func (e *MobyEngine) LoadImage(ctx context.Context, input io.Reader) error { if input == nil { return errors.New("image archive reader is required") @@ -66,6 +73,8 @@ func (e *MobyEngine) LoadImage(ctx context.Context, input io.Reader) error { return nil } +// decodeImageLoadResponse 逐条解码 Docker 引擎的 JSON 消息流。 +// 读到 EOF 表示成功;遇到消息中的错误字段时立即返回该错误。 func decodeImageLoadResponse(input io.Reader) error { decoder := json.NewDecoder(input) for { @@ -82,6 +91,8 @@ func decodeImageLoadResponse(input io.Reader) error { } } +// InspectImage 检查指定镜像引用并返回不可变镜像信息。 +// 找不到镜像时返回的错误会包装 ErrNotFound。 func (e *MobyEngine) InspectImage(ctx context.Context, reference string) (Image, error) { response, err := e.client.ImageInspect(ctx, reference) if err != nil { @@ -103,6 +114,8 @@ func (e *MobyEngine) InspectImage(ctx context.Context, reference string) (Image, }, nil } +// CreateContainer 依据给定规格创建容器。 +// 成功创建后立即通过 InspectContainer 返回容器的完整运行时状态。 func (e *MobyEngine) CreateContainer(ctx context.Context, spec ContainerSpec) (Container, error) { apiMounts := make([]mount.Mount, 0, len(spec.Mounts)) for _, item := range spec.Mounts { @@ -143,6 +156,7 @@ func (e *MobyEngine) CreateContainer(ctx context.Context, spec ContainerSpec) (C return e.InspectContainer(ctx, result.ID) } +// StartContainer 启动指定容器,参数可为容器 ID 或名称。 func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error { if _, err := e.client.ContainerStart(ctx, idOrName, client.ContainerStartOptions{}); err != nil { return engineError("start container", err) @@ -150,6 +164,8 @@ func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error return nil } +// ContainerLogs 读取指定容器的 stdout 与 stderr 日志, +// 将引擎的多路复用流解码后合并为一个只读流返回。 func (e *MobyEngine) ContainerLogs(ctx context.Context, idOrName string) (io.ReadCloser, error) { stream, err := e.client.ContainerLogs(ctx, idOrName, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "all"}) if err != nil { @@ -163,6 +179,7 @@ func (e *MobyEngine) ContainerLogs(ctx context.Context, idOrName string) (io.Rea return io.NopCloser(bytes.NewReader(output.Bytes())), nil } +// StopContainer 停止指定容器,参数可为容器 ID 或名称。 func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string) error { if _, err := e.client.ContainerStop(ctx, idOrName, client.ContainerStopOptions{}); err != nil { return engineError("stop container", err) @@ -170,6 +187,8 @@ func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string) error { return nil } +// InspectContainer 检查指定容器并返回其运行时状态。 +// 各字段按引擎返回的嵌套结构逐层提取,缺失的可选部分保持零值。 func (e *MobyEngine) InspectContainer(ctx context.Context, idOrName string) (Container, error) { result, err := e.client.ContainerInspect(ctx, idOrName, client.ContainerInspectOptions{}) if err != nil { @@ -214,6 +233,7 @@ func (e *MobyEngine) InspectContainer(ctx context.Context, idOrName string) (Con return record, nil } +// RemoveContainer 移除指定容器,force 决定是否强制移除正在运行的容器。 func (e *MobyEngine) RemoveContainer(ctx context.Context, idOrName string, force bool) error { _, err := e.client.ContainerRemove(ctx, idOrName, client.ContainerRemoveOptions{Force: force}) if err != nil { @@ -222,10 +242,13 @@ func (e *MobyEngine) RemoveContainer(ctx context.Context, idOrName string, force return nil } +// Close 关闭底层 Docker Engine 客户端连接。 func (e *MobyEngine) Close() error { return e.client.Close() } +// engineError 包装引擎操作错误。 +// 当底层错误属于“未找到”类别时,额外包装 ErrNotFound,以便调用方通过 errors.Is 识别。 func engineError(action string, err error) error { if cerrdefs.IsNotFound(err) { return fmt.Errorf("%s: %w: %v", action, ErrNotFound, err) @@ -233,6 +256,8 @@ func engineError(action string, err error) error { return fmt.Errorf("%s: %w", action, err) } +// cloneMap 浅拷贝一个字符串映射,避免调用方后续修改影响内部数据。 +// 若 source 为 nil 则返回 nil。 func cloneMap(source map[string]string) map[string]string { if source == nil { return nil diff --git a/internal/containerengine/moby_test.go b/internal/containerengine/moby_test.go index 864bf81..c15d28e 100644 --- a/internal/containerengine/moby_test.go +++ b/internal/containerengine/moby_test.go @@ -5,6 +5,7 @@ import ( "testing" ) +// TestDecodeImageLoadResponseConsumesCompleteSuccessStream 验证成功流中的多条消息能被完整消费且不产生错误。 func TestDecodeImageLoadResponseConsumesCompleteSuccessStream(t *testing.T) { t.Parallel() input := strings.NewReader("{\"stream\":\"Loaded image: repository:20260814-093609-d7ed70f0-v1.1.8.1\\n\"}\n" + @@ -14,6 +15,7 @@ func TestDecodeImageLoadResponseConsumesCompleteSuccessStream(t *testing.T) { } } +// TestDecodeImageLoadResponseReturnsStreamError 验证包含错误详情字段的消息会被解析为对应错误返回。 func TestDecodeImageLoadResponseReturnsStreamError(t *testing.T) { t.Parallel() err := decodeImageLoadResponse(strings.NewReader(`{"errorDetail":{"code":500,"message":"load failed"}}`)) @@ -22,6 +24,7 @@ func TestDecodeImageLoadResponseReturnsStreamError(t *testing.T) { } } +// TestDecodeImageLoadResponseRejectsMalformedJSON 验证格式错误的 JSON 输入会返回解码错误。 func TestDecodeImageLoadResponseRejectsMalformedJSON(t *testing.T) { t.Parallel() err := decodeImageLoadResponse(strings.NewReader(`{"stream":`)) diff --git a/internal/daemonapi/protocol.go b/internal/daemonapi/protocol.go index d4228a1..8c057cd 100644 --- a/internal/daemonapi/protocol.go +++ b/internal/daemonapi/protocol.go @@ -1,29 +1,53 @@ -// Package daemonapi defines the local Unix Socket request and response protocol. +// Package daemonapi 定义了守护进程服务与命令行客户端之间通过本地 Unix Socket 传输的请求与响应协议。 +// 该包只承载协议相关的常量与数据结构,不包含任何网络收发逻辑, +// 供 daemonclient 与 daemonserver 双方共同引用,从而保证两侧对操作类型、输入类型以及响应类型的理解保持一致。 package daemonapi const ( - OperationUpdate = "update" - OperationRestart = "restart" - InputTypeRepackZIP = "repack-zip" - InputTypeNativeJAR = "native-jar" + // OperationUpdate 表示更新操作,即用新的后端制品替换当前运行的后端。 + OperationUpdate = "update" + // OperationRestart 表示重启操作,即不更换制品、仅重启当前后端服务。 + OperationRestart = "restart" + // InputTypeRepackZIP 表示输入类型为重新打包后的 ZIP 包。 + InputTypeRepackZIP = "repack-zip" + // InputTypeNativeJAR 表示输入类型为原生后端 JAR 文件。 + InputTypeNativeJAR = "native-jar" + // InputTypeContainerImage 表示输入类型为容器镜像引用。 InputTypeContainerImage = "container-image" - ResponseProgress = "progress" - ResponseResult = "result" + // ResponseProgress 表示响应为进度事件,用于在操作过程中向客户端流式推送中间状态。 + ResponseProgress = "progress" + // ResponseResult 表示响应为最终结果,代表一次操作已经结束,并携带终态与错误信息。 + ResponseResult = "result" ) +// Request 表示客户端通过 Unix Socket 提交给守护进程的一次请求。 +// 各字段按操作类型选择性填充,未使用的字段保持空值。 type Request struct { - Operation string `json:"operation"` - Service string `json:"service"` - InputType string `json:"inputType"` - File string `json:"file"` + // Operation 表示要执行的操作,取值为 OperationUpdate 或 OperationRestart。 + Operation string `json:"operation"` + // Service 表示操作作用的目标服务,当前仅接受 backend。 + Service string `json:"service"` + // InputType 表示更新操作的输入类型,仅在 Operation 为 OperationUpdate 时有意义。 + InputType string `json:"inputType"` + // File 表示更新制品的本地绝对路径,输入类型为 InputTypeRepackZIP 或 InputTypeNativeJAR 时使用。 + File string `json:"file"` + // ImageReference 表示容器镜像引用,输入类型为 InputTypeContainerImage 时使用。 ImageReference string `json:"imageReference"` - StartLog bool `json:"startLog"` + // StartLog 表示容器更新完成后是否输出容器启动日志,仅对容器镜像输入生效。 + StartLog bool `json:"startLog"` } +// Response 表示守护进程返回给客户端的一次响应。 +// 响应分为进度事件与最终结果两类,由 Kind 字段区分。 type Response struct { - Kind string `json:"kind"` + // Kind 表示响应类型,取值为 ResponseProgress 或 ResponseResult。 + Kind string `json:"kind"` + // TransactionID 表示本次操作对应的事务标识,仅在已创建事务后返回。 TransactionID string `json:"transactionId,omitempty"` - State string `json:"state,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` + // State 表示事务当前所处状态,用于进度事件与最终结果的展示。 + State string `json:"state,omitempty"` + // Message 表示进度事件的描述信息,最终结果通常不携带该字段。 + Message string `json:"message,omitempty"` + // Error 表示操作失败时的错误信息,为空说明操作执行成功。 + Error string `json:"error,omitempty"` } diff --git a/internal/daemonclient/client.go b/internal/daemonclient/client.go index decd510..195240d 100644 --- a/internal/daemonclient/client.go +++ b/internal/daemonclient/client.go @@ -1,4 +1,6 @@ -// Package daemonclient submits local CLI requests to the running daemon service. +// Package daemonclient 提供了命令行客户端向正在运行的守护进程服务提交请求的能力。 +// 它负责把调用方参数封装成 daemonapi.Request、通过 Unix Socket 发送请求并解析流式响应, +// 将进度事件通过回调逐条透传给调用方,最终返回携带终态的最终结果。 package daemonclient import ( @@ -12,6 +14,11 @@ import ( "yms-daemon/internal/daemonapi" ) +// Update 向守护进程提交一次后端更新请求,输入类型由 inputType 指定。 +// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名, +// file 本地制品文件的绝对路径,progress 在非空时会在每收到一条进度事件时被调用一次。 +// 返回的 daemonapi.Response 为最终结果;当路径校验失败、连接失败或守护进程返回错误时返回 error。 +// 该函数不修改传入文件,可并发调用,每次调用都会建立独立的连接。 func Update(ctx context.Context, socketPath string, service string, inputType string, file string, progress func(daemonapi.Response)) (daemonapi.Response, error) { if !filepath.IsAbs(socketPath) || !filepath.IsAbs(file) { return daemonapi.Response{}, errors.New("daemon socket and update file paths must be absolute") @@ -20,6 +27,11 @@ func Update(ctx context.Context, socketPath string, service string, inputType st return submit(ctx, socketPath, request, progress) } +// UpdateContainerImage 向守护进程提交一次基于容器镜像的后端更新请求。 +// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名, +// imageReference 必填的容器镜像引用,startLog 控制更新完成后是否输出容器启动日志, +// progress 在非空时会在每收到一条进度事件时被调用一次。 +// 返回的 daemonapi.Response 为最终结果;当路径非法、镜像引用为空、连接失败或守护进程返回错误时返回 error。 func UpdateContainerImage(ctx context.Context, socketPath string, service string, imageReference string, startLog bool, progress func(daemonapi.Response)) (daemonapi.Response, error) { if !filepath.IsAbs(socketPath) { return daemonapi.Response{}, errors.New("daemon socket path must be absolute") @@ -31,6 +43,10 @@ func UpdateContainerImage(ctx context.Context, socketPath string, service string return submit(ctx, socketPath, request, progress) } +// Restart 向守护进程提交一次后端重启请求,不更换任何制品。 +// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名, +// progress 在非空时会在每收到一条进度事件时被调用一次。 +// 返回的 daemonapi.Response 为最终结果;当路径非法、连接失败或守护进程返回错误时返回 error。 func Restart(ctx context.Context, socketPath string, service string, progress func(daemonapi.Response)) (daemonapi.Response, error) { if !filepath.IsAbs(socketPath) { return daemonapi.Response{}, errors.New("daemon socket path must be absolute") @@ -39,6 +55,10 @@ func Restart(ctx context.Context, socketPath string, service string, progress fu return submit(ctx, socketPath, request, progress) } +// submit 通过 Unix Socket 把 request 发送给守护进程并读取响应流,供本包各公开函数复用。 +// 它会以换行分隔的 JSON 编码请求,写完后关闭写端以向对端发送结束信号, +// 随后循环解码响应:进度事件通过 progress 回调透传,最终结果则返回给调用方。 +// 当连接失败、编解码失败、对端返回未知响应类型或最终结果携带错误时返回 error。 func submit(ctx context.Context, socketPath string, request daemonapi.Request, progress func(daemonapi.Response)) (daemonapi.Response, error) { dialer := net.Dialer{} connection, err := dialer.DialContext(ctx, "unix", socketPath) diff --git a/internal/daemonserver/server.go b/internal/daemonserver/server.go index 7c04d24..97aed37 100644 --- a/internal/daemonserver/server.go +++ b/internal/daemonserver/server.go @@ -1,4 +1,6 @@ -// Package daemonserver accepts local CLI requests over the frozen Unix Socket. +// Package daemonserver 通过固定的本地 Unix Socket 接受命令行客户端请求,并将其分派给事务编排器执行。 +// 服务端负责监听并校验请求、把请求映射到后端更新或重启动作、将执行进度流式写回客户端, +// 最终返回携带事务终态与错误信息的结果。 package daemonserver import ( @@ -20,22 +22,36 @@ import ( "yms-daemon/internal/transaction" ) +// maximumRequestBytes 单次请求体的最大字节数,超过该上限的请求会被拒绝,以防内存被异常输入耗尽。 const maximumRequestBytes = 1 << 20 +// backendUpdater 抽象了后端更新与重启的具体实现,是 Server 与事务编排器之间的解耦接口。 +// 它由 backendupdate 包在运行时注入,便于在测试中用轻量替身替换。 type backendUpdater interface { + // UpdateRepack 使用重新打包的 ZIP 制品执行后端更新,file 为制品绝对路径。 UpdateRepack(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error) + // UpdateNativeJAR 使用原生后端 JAR 文件执行后端更新,file 为制品绝对路径。 UpdateNativeJAR(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error) + // UpdateContainerImage 使用容器镜像执行后端更新,imageReference 为镜像引用,startLog 控制是否输出启动日志。 UpdateContainerImage(context.Context, string, bool, backendupdate.ProgressReporter) (transaction.Transaction, error) + // Restart 重启后端服务而不更换制品。 Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error) } -// Server owns the local Unix Socket and dispatches requests to the transaction orchestrator. +// Server 持有本地 Unix Socket 并把收到的请求分派给事务编排器。 +// 它负责连接的生命周期管理、请求解码与校验、执行进度回写以及最终结果的返回。 type Server struct { + // socketPath 守护进程监听的 Unix Socket 绝对路径。 socketPath string - updater backendUpdater - logger *slog.Logger + // updater 后端更新与重启动作的实际执行者。 + updater backendUpdater + // logger 用于记录服务运行与请求处理过程中的日志。 + logger *slog.Logger } +// New 构造一个 Server 实例并校验入参。 +// socketPath 必须是规范化的绝对路径,updater 不能为空,logger 为空时回退为默认日志器。 +// 当路径非法或 updater 为空时返回 error。 func New(socketPath string, updater backendUpdater, logger *slog.Logger) (*Server, error) { if !filepath.IsAbs(socketPath) || filepath.Clean(socketPath) != socketPath { return nil, errors.New("daemon Unix Socket path must be a clean absolute path") @@ -49,7 +65,10 @@ func New(socketPath string, updater backendUpdater, logger *slog.Logger) (*Serve return &Server{socketPath: socketPath, updater: updater, logger: logger}, nil } -// Serve listens until ctx is canceled. Each accepted request owns its response connection. +// Serve 监听 Unix Socket 直到 ctx 被取消,返回前会等待所有连接处理完成并清理 Socket 文件。 +// 它先准备 Socket 目录并移除残留的旧 Socket,然后以 0o600 权限监听; +// 每个被接受的连接都由独立的 goroutine 处理,连接在响应写完后由 handle 关闭。 +// 当监听或准备 Socket 路径失败时返回 error,ctx 取消后正常返回 nil。 func (s *Server) Serve(ctx context.Context) error { if err := prepareSocketPath(s.socketPath); err != nil { return err @@ -90,6 +109,11 @@ func (s *Server) Serve(ctx context.Context) error { } } +// handle 处理单个已接受的连接:读取并校验请求、执行对应操作、把进度与结果写回客户端。 +// 它限制请求读取时限为 10 秒,仅接受 service 为 backend 的请求; +// 对于 update 操作会按 inputType 分派到不同更新实现,对 restart 操作校验其不携带多余输入。 +// 执行期间产生的进度通过 report 回调实时写回,一旦写回失败便停止后续进度发送, +// 最终无论成功失败都会写入一条 ResponseResult 响应并记录日志。 func (s *Server) handle(ctx context.Context, connection net.Conn) { defer connection.Close() _ = connection.SetReadDeadline(time.Now().Add(10 * time.Second)) @@ -168,10 +192,14 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) { } } +// writeResponse 把一条响应以换行分隔的 JSON 编码写入连接,失败时返回底层写入错误。 func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response) error { return json.NewEncoder(connection).Encode(response) } +// decodeRequest 从 reader 中解码并校验一条客户端请求。 +// 它限制请求体大小不超过 maximumRequestBytes,禁止未知字段,并要求请求只能包含一个 JSON 值, +// 且所有字符串字段不得带有前后空白字符;任一条不满足时返回 error。 func decodeRequest(reader io.Reader) (daemonapi.Request, error) { limited := io.LimitReader(reader, maximumRequestBytes+1) decoder := json.NewDecoder(limited) @@ -193,6 +221,8 @@ func decodeRequest(reader io.Reader) (daemonapi.Request, error) { return request, nil } +// prepareSocketPath 在监听前准备 Socket 路径:创建父目录,若目标位置已被占用则校验其是否为 Socket, +// 若是残留 Socket 则移除,若被非 Socket 文件占用则返回 error。 func prepareSocketPath(socketPath string) error { if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { return fmt.Errorf("create daemon Unix Socket directory: %w", err) diff --git a/internal/daemonserver/server_test.go b/internal/daemonserver/server_test.go index 11f3f48..46436f2 100644 --- a/internal/daemonserver/server_test.go +++ b/internal/daemonserver/server_test.go @@ -17,6 +17,8 @@ import ( "yms-daemon/internal/transaction" ) +// TestServerAcceptsBackendUpdateThroughUnixSocket 验证服务端能通过 Unix Socket 接收后端 ZIP 更新请求, +// 正确分派到更新器,并向客户端返回事务终态以及流式的更新进度事件。 func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) { socketPath := shortSocketPath(t) updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-01", State: transaction.StateCommitted}} @@ -58,6 +60,8 @@ func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) { } } +// TestServerAcceptsDirectNativeJARThroughUnixSocket 验证服务端能接收原生后端 JAR 直接更新请求, +// 并将制品路径与输入类型正确分派到更新器。 func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) { socketPath := shortSocketPath(t) updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-direct-01", State: transaction.StateCommitted}} @@ -84,6 +88,8 @@ func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) { } } +// TestServerAcceptsContainerImageThroughUnixSocket 验证服务端能接收容器镜像更新请求, +// 并把镜像引用作为制品路径分派到更新器。 func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) { socketPath := shortSocketPath(t) updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-container-01", State: transaction.StateCommitted}} @@ -107,6 +113,8 @@ func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) { } } +// TestServerReturnsTransactionFailure 验证更新器返回错误时,服务端会在最终结果中携带事务标识、 +// 回滚终态以及错误信息,客户端据此返回错误。 func TestServerReturnsTransactionFailure(t *testing.T) { socketPath := shortSocketPath(t) updater := &fakeUpdater{ @@ -130,6 +138,8 @@ func TestServerReturnsTransactionFailure(t *testing.T) { } } +// TestServerAcceptsBackendRestartThroughUnixSocket 验证服务端能接收后端重启请求, +// 并返回事务终态与流式的重启进度事件。 func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) { socketPath := shortSocketPath(t) updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-restart-01", State: transaction.StateCommitted}} @@ -158,6 +168,8 @@ func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) { } } +// shortSocketPath 创建一个短目录并返回其下的 daemon.sock 路径,用于规避 Unix Socket 路径长度上限。 +// 目录会在测试结束时自动清理。 func shortSocketPath(t *testing.T) string { t.Helper() directory, err := os.MkdirTemp("", "yd-") @@ -168,6 +180,9 @@ func shortSocketPath(t *testing.T) string { return filepath.Join(directory, "daemon.sock") } +// waitForSocket 轮询等待 Unix Socket 文件出现,用于在测试中同步服务端就绪状态。 +// 若服务端在 Socket 创建前就退出且原因为权限不足则跳过测试,否则视为失败; +// 超过 3 秒仍未见 Socket 也视为失败。 func waitForSocket(t *testing.T, socketPath string, serveResult <-chan error) { t.Helper() deadline := time.Now().Add(3 * time.Second) @@ -189,14 +204,21 @@ func waitForSocket(t *testing.T, socketPath string, serveResult <-chan error) { t.Fatalf("daemon Unix Socket was not created: %s", socketPath) } +// fakeUpdater backendUpdater 接口的测试替身,记录被分派的参数并返回预设结果。 type fakeUpdater struct { - record transaction.Transaction - err error - file string + // record 各更新方法返回的预设事务。 + record transaction.Transaction + // err 各更新方法返回的预设错误。 + err error + // file 记录最近一次分派收到的制品路径或镜像引用。 + file string + // inputType 记录最近一次分派收到的输入类型。 inputType string + // operation 记录最近一次分派收到的操作类型。 operation string } +// UpdateRepack 记录分派参数并上报一条更新进度,返回预设事务与错误。 func (u *fakeUpdater) UpdateRepack(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) { u.file = file u.inputType = daemonapi.InputTypeRepackZIP @@ -207,6 +229,7 @@ func (u *fakeUpdater) UpdateRepack(_ context.Context, file string, report backen return u.record, u.err } +// UpdateNativeJAR 记录分派参数并上报一条更新进度,返回预设事务与错误。 func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) { u.file = file u.inputType = daemonapi.InputTypeNativeJAR @@ -217,6 +240,7 @@ func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report bac return u.record, u.err } +// UpdateContainerImage 记录分派参数并上报一条更新进度,返回预设事务与错误。 func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference string, _ bool, report backendupdate.ProgressReporter) (transaction.Transaction, error) { u.file = imageReference u.inputType = daemonapi.InputTypeContainerImage @@ -227,6 +251,7 @@ func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference str return u.record, u.err } +// Restart 记录重启操作并上报一条重启进度,返回预设事务与错误。 func (u *fakeUpdater) Restart(_ context.Context, report backendupdate.ProgressReporter) (transaction.Transaction, error) { u.operation = daemonapi.OperationRestart if report != nil { diff --git a/internal/deploymentconfig/config.go b/internal/deploymentconfig/config.go index 8475c5b..78912a0 100644 --- a/internal/deploymentconfig/config.go +++ b/internal/deploymentconfig/config.go @@ -1,5 +1,6 @@ -// Package deploymentconfig loads the daemon's explicit local deployment configuration. -// It never infers deployment type, executable paths, unit names, slot paths, or endpoints. +// Package deploymentconfig 负责加载并严格校验 daemon 显式给出的本地部署配置。 +// 该包绝不推断部署类型、可执行文件路径、systemd 单元名称、槽位路径或健康端点, +// 上述部署契约必须全部由配置文件显式提供,缺失或与内置契约不符都会返回错误。 package deploymentconfig import ( @@ -16,77 +17,118 @@ import ( ) const ( - // DefaultPath is the only default location used by the daemon entrypoint. + // DefaultPath daemon 入口唯一使用的默认配置路径。 DefaultPath = "/etc/yms-daemon/yms-daemon.toml" - EnvironmentDev = "dev" + // EnvironmentDev 表示开发环境。 + EnvironmentDev = "dev" + // EnvironmentProd 表示生产环境。 EnvironmentProd = "prod" - BackendTypeNative = "native" + // BackendTypeNative 表示原生后端运行时(systemd 单元 + JAR 文件)。 + BackendTypeNative = "native" + // BackendTypeContainer 表示容器后端运行时。 BackendTypeContainer = "container" + // BackendPort8080 蓝绿发布中 8080 槽位的端口号。 BackendPort8080 = 8080 + // BackendPort8081 蓝绿发布中 8081 槽位的端口号。 BackendPort8081 = 8081 ) const ( - nativeReleaseDir = "/home/yms/lib/releases" - nativeActiveJAR = "/home/yms/lib/glory-soft-yms.jar" - nativeUnit8080 = "yms-backend@8080.service" - nativeUnit8081 = "yms-backend@8081.service" - nativeSlotJAR8080 = "/home/yms/lib/glory-soft-yms-8080.jar" - nativeSlotJAR8081 = "/home/yms/lib/glory-soft-yms-8081.jar" + // nativeReleaseDir 原生后端发布包(JAR)的固定存放目录。 + nativeReleaseDir = "/home/yms/lib/releases" + // nativeActiveJAR 原生后端当前生效的主 JAR 路径。 + nativeActiveJAR = "/home/yms/lib/glory-soft-yms.jar" + // nativeUnit8080 8080 槽位对应的 systemd 服务单元名称。 + nativeUnit8080 = "yms-backend@8080.service" + // nativeUnit8081 8081 槽位对应的 systemd 服务单元名称。 + nativeUnit8081 = "yms-backend@8081.service" + // nativeSlotJAR8080 8080 槽位对应的 JAR 文件路径。 + nativeSlotJAR8080 = "/home/yms/lib/glory-soft-yms-8080.jar" + // nativeSlotJAR8081 8081 槽位对应的 JAR 文件路径。 + nativeSlotJAR8081 = "/home/yms/lib/glory-soft-yms-8081.jar" + // nativeHealthURL8080 8080 槽位的健康检查端点地址。 nativeHealthURL8080 = "http://127.0.0.1:8080/yms/actuator/health" + // nativeHealthURL8081 8081 槽位的健康检查端点地址。 nativeHealthURL8081 = "http://127.0.0.1:8081/yms/actuator/health" - containerName8080 = "backend-8080" - containerName8081 = "backend-8081" + // containerName8080 容器后端 8080 槽位的容器名称。 + containerName8080 = "backend-8080" + // containerName8081 容器后端 8081 槽位的容器名称。 + containerName8081 = "backend-8081" ) const ( - ContainerConfigSource = "/home/yms/conf/yms.yaml" - ContainerConfigTarget = "/app/config/yms.yaml" - ContainerTmpSource = "/home/yms/tmp" - ContainerTmpTarget = "/home/yms/tmp" - ContainerPortEnvironment = "SERVER_PORT" + // ContainerConfigSource 容器后端配置在宿主侧的源路径。 + ContainerConfigSource = "/home/yms/conf/yms.yaml" + // ContainerConfigTarget 容器后端配置在容器内的目标路径。 + ContainerConfigTarget = "/app/config/yms.yaml" + // ContainerTmpSource 容器后端临时目录在宿主侧的源路径。 + ContainerTmpSource = "/home/yms/tmp" + // ContainerTmpTarget 容器后端临时目录在容器内的目标路径。 + ContainerTmpTarget = "/home/yms/tmp" + // ContainerPortEnvironment 用于向容器传递后端端口的环境变量名。 + ContainerPortEnvironment = "SERVER_PORT" + // ContainerConfigEnvironment 用于向容器传递 Spring 配置位置的环境变量名。 ContainerConfigEnvironment = "SPRING_CONFIG_LOCATION" - ContainerConfigLocation = "file:/app/config/yms.yaml" + // ContainerConfigLocation 容器内 Spring 配置文件的 file: 地址。 + ContainerConfigLocation = "file:/app/config/yms.yaml" ) -// Config is the complete local deployment configuration currently understood by the daemon. +// Config daemon 当前能够理解的完整本地部署配置。 +// 其结构与 TOML 文件中的 daemon、backend 两张顶层表一一对应,加载时做严格解码与校验。 type Config struct { - Daemon Daemon `toml:"daemon"` + // Daemon 记录与环境相关、与具体运行时无关的机器级配置。 + Daemon Daemon `toml:"daemon"` + // Backend 记录显式选定的后端运行时及其蓝绿槽位。 Backend Backend `toml:"backend"` } -// Daemon contains machine-wide behavior that is independent of a component runtime. +// Daemon 描述与具体组件运行时无关的机器级行为。 type Daemon struct { + // Environment 必须是 EnvironmentDev 或 EnvironmentProd 之一。 Environment string `toml:"environment"` } -// Backend describes the explicitly selected backend runtime and its blue/green slots. +// Backend 描述显式选定的后端运行时类型及其蓝绿槽位。 type Backend struct { - Type string `toml:"type"` - ReleaseDir string `toml:"release_dir"` - ActiveJAR string `toml:"active_jar"` - SystemctlPath string `toml:"systemctl_path"` - Slot BackendSlots `toml:"slot"` + // Type 必须是 BackendTypeNative 或 BackendTypeContainer 之一。 + Type string `toml:"type"` + // ReleaseDir 仅原生后端使用,必须是 nativeReleaseDir 对应的绝对路径。 + ReleaseDir string `toml:"release_dir"` + // ActiveJAR 仅原生后端使用,必须是 nativeActiveJAR 对应的绝对路径。 + ActiveJAR string `toml:"active_jar"` + // SystemctlPath systemctl 可执行文件的绝对路径。 + SystemctlPath string `toml:"systemctl_path"` + // Slot 包含 8080、8081 两个受支持槽位的精确取值。 + Slot BackendSlots `toml:"slot"` } -// BackendSlots lists the only backend ports supported by the current deployment contract. +// BackendSlots 列出当前部署契约唯一支持的两个后端端口槽位。 type BackendSlots struct { + // Port8080 8080 端口的槽位配置。 Port8080 BackendSlot `toml:"8080"` + // Port8081 8081 端口的槽位配置。 Port8081 BackendSlot `toml:"8081"` } -// BackendSlot contains exact values for one native or container backend slot. +// BackendSlot 包含一个原生或容器后端槽位的精确取值。 +// 不同运行时类型下使用的字段不同:原生后端使用 Unit、JAR,容器后端使用 ContainerName。 type BackendSlot struct { - Unit string `toml:"unit"` - JAR string `toml:"jar"` - ContainerName string `toml:"container_name"` + // Unit 原生后端槽位对应的 systemd 服务单元名称。 + Unit string `toml:"unit"` + // JAR 原生后端槽位对应的 JAR 文件绝对路径。 + JAR string `toml:"jar"` + // ContainerName 容器后端槽位对应的容器名称。 + ContainerName string `toml:"container_name"` + // HealthEndpoint 槽位健康检查的 URL 地址。 HealthEndpoint string `toml:"health_endpoint"` } -// Load opens path, performs strict TOML decoding, and validates the selected backend contract. +// Load 打开 path 指向的部署配置文件,进行严格的 TOML 解码与契约校验,返回解析后的 Config。 +// 它要求 path 是绝对路径且为普通文件,解码时禁用未知字段(任何大小写、拼写或层级不一致的 +// 键都会被拒绝),解码后调用 Validate 精确校验取值。任一步失败都会返回带上下文的 error。 func Load(path string) (Config, error) { if err := validateAbsolutePath("deployment configuration", path); err != nil { return Config{}, err @@ -124,6 +166,8 @@ func Load(path string) (Config, error) { return config, nil } +// validateExactDocumentKeys 对原始 TOML 文档做逐层键名校验,确保 daemon、backend 及 +// backend.slot 下每一张表都只包含契约允许的确切字段,任何未知字段(大小写、拼写或层级不符)都会被拒绝。 func validateExactDocumentKeys(document []byte) error { var root map[string]any if err := toml.Unmarshal(document, &root); err != nil { @@ -165,6 +209,8 @@ func validateExactDocumentKeys(document []byte) error { return nil } +// exactTable 从 table 中取出名为 key 的子表并返回;若 key 不存在或对应值不是 TOML 表, +// 则返回错误。parent 用于拼出带前缀的完整字段名,以生成可读的错误信息。 func exactTable(table map[string]any, parent string, key string) (map[string]any, error) { value, found := table[key] field := key @@ -181,6 +227,8 @@ func exactTable(table map[string]any, parent string, key string) (map[string]any return nested, nil } +// rejectUnknownKeys 检查 table 是否只包含 allowed 中列出的键;存在其他键时,按字典序 +// 返回第一个未知键的拒绝错误。parent 用于拼出带前缀的完整字段名。 func rejectUnknownKeys(table map[string]any, parent string, allowed ...string) error { known := make(map[string]struct{}, len(allowed)) for _, key := range allowed { @@ -204,7 +252,9 @@ func rejectUnknownKeys(table map[string]any, parent string, allowed ...string) e return nil } -// Validate rejects incomplete or altered local deployment identifiers. +// Validate 拒绝不完整或与内置部署契约不一致的本地部署标识符。 +// 它依次校验 daemon.environment、backend.systemctl_path 的绝对路径约束,再按 backend.type +// 分支精确校验原生或容器槽位的 unit、jar、container_name、health_endpoint 等字段。 func (c Config) Validate() error { switch c.Daemon.Environment { case EnvironmentDev, EnvironmentProd: @@ -250,7 +300,8 @@ func (c Config) Validate() error { return nil } -// Slot returns the exact configuration for one supported backend port. +// SlotForPort 返回 port 对应的后端槽位配置,仅支持 BackendPort8080 或 BackendPort8081, +// 其他端口返回带端口号的错误。 func (b Backend) SlotForPort(port int) (BackendSlot, error) { switch port { case BackendPort8080: @@ -262,6 +313,8 @@ func (b Backend) SlotForPort(port int) (BackendSlot, error) { } } +// validateNativeSlot 校验原生槽位 slot 的 Unit、JAR、HealthEndpoint 是否分别等于 +// unit、jar、endpoint,任一不符即返回带 field 前缀的错误。 func validateNativeSlot(field string, slot BackendSlot, unit string, jar string, endpoint string) error { if slot.Unit != unit { return fmt.Errorf("%s.unit must be %q", field, unit) @@ -275,6 +328,8 @@ func validateNativeSlot(field string, slot BackendSlot, unit string, jar string, return nil } +// validateContainerSlot 校验容器槽位 slot 的 ContainerName、HealthEndpoint 是否分别等于 +// containerName、endpoint,任一不符即返回带 field 前缀的错误。 func validateContainerSlot(field string, slot BackendSlot, containerName string, endpoint string) error { if slot.ContainerName != containerName { return fmt.Errorf("%s.container_name must be %q", field, containerName) @@ -285,6 +340,8 @@ func validateContainerSlot(field string, slot BackendSlot, containerName string, return nil } +// validateAbsolutePath 校验 value 是一个非空、无首尾空白、经过 Clean 且不含 NUL 字节的绝对路径, +// 用于保证配置中的路径标识符不会被意外篡改。field 用于拼出错误信息。 func validateAbsolutePath(field string, value string) error { if value == "" { return fmt.Errorf("%s is required", field) diff --git a/internal/deploymentconfig/config_test.go b/internal/deploymentconfig/config_test.go index dea0925..ace018f 100644 --- a/internal/deploymentconfig/config_test.go +++ b/internal/deploymentconfig/config_test.go @@ -7,6 +7,7 @@ import ( "testing" ) +// validNativeConfig 一份满足原生后端部署契约的示例 TOML 配置,供各测试用例共享。 const validNativeConfig = `[daemon] environment = "dev" @@ -27,6 +28,7 @@ jar = "/home/yms/lib/glory-soft-yms-8081.jar" health_endpoint = "http://127.0.0.1:8081/yms/actuator/health" ` +// validContainerConfig 一份满足容器后端部署契约的示例 TOML 配置,供各测试用例共享。 const validContainerConfig = `[daemon] environment = "dev" @@ -43,6 +45,8 @@ container_name = "backend-8081" health_endpoint = "http://127.0.0.1:8081/yms/actuator/health" ` +// TestLoadValidNativeConfiguration 验证合法原生配置能被完整加载, +// 且解析出的 daemon、backend 与 8080/8081 槽位取值都与契约常量精确一致。 func TestLoadValidNativeConfiguration(t *testing.T) { path := writeConfig(t, validNativeConfig) @@ -72,6 +76,8 @@ func TestLoadValidNativeConfiguration(t *testing.T) { } } +// TestLoadValidContainerConfiguration 验证合法容器配置能被加载, +// 且两个槽位的 container_name 取值与契约精确一致。 func TestLoadValidContainerConfiguration(t *testing.T) { config, err := Load(writeConfig(t, validContainerConfig)) if err != nil { @@ -85,6 +91,8 @@ func TestLoadValidContainerConfiguration(t *testing.T) { } } +// TestLoadRejectsChangedContainerName 验证容器槽位的 container_name 被篡改时会被拒绝, +// 且错误信息精确指向 backend.slot.8081.container_name 字段。 func TestLoadRejectsChangedContainerName(t *testing.T) { content := strings.Replace(validContainerConfig, `container_name = "backend-8081"`, `container_name = "backend"`, 1) _, err := Load(writeConfig(t, content)) @@ -93,6 +101,8 @@ func TestLoadRejectsChangedContainerName(t *testing.T) { } } +// TestPackagedNativeConfigurationMatchesContract 验证随发行包打包的原生部署配置 +// 与代码内置契约一致,确保打包产物不会被意外改坏。 func TestPackagedNativeConfigurationMatchesContract(t *testing.T) { path, err := filepath.Abs(filepath.Join("..", "..", "packaging", "etc", "yms-daemon", "yms-daemon.toml")) if err != nil { @@ -103,6 +113,8 @@ func TestPackagedNativeConfigurationMatchesContract(t *testing.T) { } } +// TestLoadRejectsUnknownField 验证配置中出现的未知字段(大小写不一致)会被拒绝, +// 且错误信息精确包含该字段名 SystemctlPath。 func TestLoadRejectsUnknownField(t *testing.T) { content := strings.Replace(validNativeConfig, "systemctl_path = \"/bin/systemctl\"", "systemctl_path = \"/bin/systemctl\"\nSystemctlPath = \"/usr/bin/systemctl\"", 1) _, err := Load(writeConfig(t, content)) @@ -111,6 +123,8 @@ func TestLoadRejectsUnknownField(t *testing.T) { } } +// TestLoadAcceptsProductionEnvironment 验证 environment 取值为 prod 时能被接受, +// 并正确解析到 EnvironmentProd。 func TestLoadAcceptsProductionEnvironment(t *testing.T) { content := strings.Replace(validNativeConfig, `environment = "dev"`, `environment = "prod"`, 1) config, err := Load(writeConfig(t, content)) @@ -122,6 +136,8 @@ func TestLoadAcceptsProductionEnvironment(t *testing.T) { } } +// TestLoadRejectsMissingDaemonTable 验证缺失 daemon 顶层表时会被拒绝, +// 且错误信息提示 daemon 表是必需的。 func TestLoadRejectsMissingDaemonTable(t *testing.T) { content := strings.Replace(validNativeConfig, "[daemon]\nenvironment = \"dev\"\n\n", "", 1) _, err := Load(writeConfig(t, content)) @@ -130,6 +146,8 @@ func TestLoadRejectsMissingDaemonTable(t *testing.T) { } } +// TestLoadRejectsChangedEnvironment 验证 environment 被篡改为非法取值时会被拒绝, +// 且错误信息精确指向 daemon.environment 字段。 func TestLoadRejectsChangedEnvironment(t *testing.T) { content := strings.Replace(validNativeConfig, `environment = "dev"`, `environment = "development"`, 1) _, err := Load(writeConfig(t, content)) @@ -138,6 +156,8 @@ func TestLoadRejectsChangedEnvironment(t *testing.T) { } } +// TestLoadRejectsUnknownDaemonField 验证 daemon 表中出现的未知字段(大小写不一致) +// 会被拒绝,且错误信息精确包含该字段名 daemon.Environment。 func TestLoadRejectsUnknownDaemonField(t *testing.T) { content := strings.Replace(validNativeConfig, `environment = "dev"`, "environment = \"dev\"\nEnvironment = \"dev\"", 1) _, err := Load(writeConfig(t, content)) @@ -146,6 +166,8 @@ func TestLoadRejectsUnknownDaemonField(t *testing.T) { } } +// TestLoadRejectsWrongTableCase 验证表名大小写不一致([Backend])时会被拒绝, +// 且错误信息精确包含该表名 Backend。 func TestLoadRejectsWrongTableCase(t *testing.T) { content := strings.Replace(validNativeConfig, "[backend]", "[Backend]", 1) _, err := Load(writeConfig(t, content)) @@ -154,6 +176,8 @@ func TestLoadRejectsWrongTableCase(t *testing.T) { } } +// TestLoadRejectsMissingNativeField 验证原生配置缺失 active_jar 字段时会被拒绝, +// 且错误信息精确指向 backend.active_jar 字段。 func TestLoadRejectsMissingNativeField(t *testing.T) { content := strings.Replace(validNativeConfig, "active_jar = \"/home/yms/lib/glory-soft-yms.jar\"\n", "", 1) _, err := Load(writeConfig(t, content)) @@ -162,6 +186,8 @@ func TestLoadRejectsMissingNativeField(t *testing.T) { } } +// TestLoadRejectsUnknownSlot 验证配置中出现未受支持的槽位(9090)时会被拒绝, +// 且错误信息精确包含该端口号 9090。 func TestLoadRejectsUnknownSlot(t *testing.T) { content := validNativeConfig + ` [backend.slot.9090] @@ -175,6 +201,8 @@ health_endpoint = "http://127.0.0.1:9090/yms/actuator/health" } } +// TestLoadRejectsChangedSlotEndpoint 验证槽位健康端点被篡改时会被拒绝, +// 且错误信息精确指向 backend.slot.8080.health_endpoint 字段。 func TestLoadRejectsChangedSlotEndpoint(t *testing.T) { content := strings.Replace(validNativeConfig, nativeHealthURL8080, nativeHealthURL8081, 1) _, err := Load(writeConfig(t, content)) @@ -183,6 +211,8 @@ func TestLoadRejectsChangedSlotEndpoint(t *testing.T) { } } +// TestLoadAcceptsExplicitUsrBinSystemctlPath 验证显式指定的 /usr/bin/systemctl 路径 +// 能被接受并原样解析,说明 systemctl_path 只要求绝对路径而不绑定单一取值。 func TestLoadAcceptsExplicitUsrBinSystemctlPath(t *testing.T) { content := strings.Replace(validNativeConfig, "/bin/systemctl", "/usr/bin/systemctl", 1) config, err := Load(writeConfig(t, content)) @@ -194,6 +224,8 @@ func TestLoadAcceptsExplicitUsrBinSystemctlPath(t *testing.T) { } } +// TestLoadRejectsChangedBackendTypeCase 验证 backend.type 大小写不一致(Native)时会被拒绝, +// 且错误信息精确指向 backend.type 字段。 func TestLoadRejectsChangedBackendTypeCase(t *testing.T) { content := strings.Replace(validNativeConfig, `type = "native"`, `type = "Native"`, 1) _, err := Load(writeConfig(t, content)) @@ -202,6 +234,8 @@ func TestLoadRejectsChangedBackendTypeCase(t *testing.T) { } } +// TestLoadRejectsDuplicateField 验证 TOML 中重复出现的字段会被拒绝, +// 确保配置没有歧义。 func TestLoadRejectsDuplicateField(t *testing.T) { content := strings.Replace(validNativeConfig, "type = \"native\"", "type = \"native\"\ntype = \"native\"", 1) _, err := Load(writeConfig(t, content)) @@ -210,6 +244,8 @@ func TestLoadRejectsDuplicateField(t *testing.T) { } } +// TestLoadRequiresAbsoluteConfigurationPath 验证 Load 要求传入绝对路径, +// 相对路径会被拒绝且错误信息提示 absolute path。 func TestLoadRequiresAbsoluteConfigurationPath(t *testing.T) { _, err := Load("yms-daemon.toml") if err == nil || !strings.Contains(err.Error(), "absolute path") { @@ -217,6 +253,7 @@ func TestLoadRequiresAbsoluteConfigurationPath(t *testing.T) { } } +// TestSlotForPortRejectsUnsupportedPort 验证 SlotForPort 对未受支持端口返回错误。 func TestSlotForPortRejectsUnsupportedPort(t *testing.T) { config, err := Load(writeConfig(t, validNativeConfig)) if err != nil { @@ -227,6 +264,8 @@ func TestSlotForPortRejectsUnsupportedPort(t *testing.T) { } } +// writeConfig 将 content 写入临时目录下的 yms-daemon.toml 并返回其绝对路径, +// 供 Load 测试使用。写入失败会直接终止测试。 func writeConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "yms-daemon.toml") diff --git a/internal/filestore/store.go b/internal/filestore/store.go index 24153d5..b2075ac 100644 --- a/internal/filestore/store.go +++ b/internal/filestore/store.go @@ -1,3 +1,5 @@ +// Package filestore 在一个由 daemon 独占管理的本地根目录中保存不可变文件, +// 通过内容哈希校验、硬链接原子提交与符号链接逃逸防护,保证已发布文件与其声明身份严格一致。 package filestore import ( @@ -12,33 +14,41 @@ import ( "path/filepath" ) +// ErrDestinationConflict 表示目标位置已存在内容不同的文件,禁止覆盖。 var ErrDestinationConflict = errors.New("destination already exists with different content") -// Identity 是不可变文件在进入事务目录前必须满足的身份。 +// Identity 不可变文件在进入事务目录前必须满足的身份,由精确大小与 SHA-256 摘要组成。 type Identity struct { - Size int64 + // Size 期望的精确字节数,必须为非负。 + Size int64 + // SHA256 期望的十六进制 SHA-256 摘要,必须为 64 个十六进制字符。 SHA256 string } -// Validate checks the exact immutable file identity without reading a file. +// Validate 校验 Identity 的大小与 SHA-256 格式是否合法,不读取任何文件。 func (i Identity) Validate() error { _, err := validateIdentity(i) return err } -// File 是一次原子提交的结果。 +// File 一次原子提交的返回结果。 type File struct { - Path string + // Path 最终文件在存储根目录下的绝对路径。 + Path string + // Identity 最终文件满足的精确身份。 Identity Identity - Reused bool + // Reused 为 true 表示目标位置已存在身份相同的文件,本次提交未创建新文件。 + Reused bool } -// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件。 +// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件,并保证所有写入都经过身份校验。 type Store struct { + // root 解析符号链接后的规范根目录绝对路径,所有目标路径都必须落在其中。 root string } -// New 创建文件存储并固定其规范根目录。 +// New 创建文件存储并固定其规范根目录。root 必须非空,最终会转换为绝对路径、 +// 创建目录并解析符号链接,确保后续操作都基于稳定且真实存在的根目录。 func New(root string) (*Store, error) { if root == "" { return nil, errors.New("file store root is required") @@ -57,8 +67,10 @@ func New(root string) (*Store, error) { return &Store{root: resolvedRoot}, nil } -// Commit 把内容写入同目录临时文件,校验后通过硬链接原子创建最终文件。 -// 最终文件已经存在且身份相同时按幂等成功处理,内容不同时拒绝覆盖。 +// Commit 把 source 的内容写入目标目录内的临时文件,校验其大小与 SHA-256 与 expected 一致后, +// 通过硬链接原子地创建 relativePath 指向的最终文件。若最终文件已存在且身份相同,则按幂等 +// 成功返回 Reused 为 true 的结果;若身份不同则返回 ErrDestinationConflict,拒绝覆盖。 +// 写入过程会同步临时文件与父目录,确保中途失败不会留下已发布的半成品文件。 func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) (File, error) { if source == nil { return File{}, errors.New("file source is required") @@ -145,8 +157,9 @@ func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) return File{Path: target, Identity: expected}, nil } -// Inspect verifies an immutable destination without changing it. -// found=false means that the exact destination does not exist. +// Inspect 在不做任何修改的情况下校验 relativePath 指向的不可变目标是否与 expected 一致。 +// 返回的 found 为 false 表示该目标尚不存在(或所在目录尚不存在);目标存在但身份不符时 +// 返回 ErrDestinationConflict。 func (s *Store) Inspect(relativePath string, expected Identity) (file File, found bool, err error) { expectedDigest, err := validateIdentity(expected) if err != nil { @@ -168,6 +181,8 @@ func (s *Store) Inspect(relativePath string, expected Identity) (file File, foun return verifyExisting(target, expected, expectedDigest) } +// destination 将 relativePath 规范化后拼接到存储根目录,返回最终绝对路径。 +// 它拒绝空路径、非本地相对路径(如 ../、绝对路径或含 .. 的越界路径)。 func (s *Store) destination(relativePath string) (string, error) { if !filepath.IsLocal(relativePath) || relativePath == "." { return "", fmt.Errorf("file store path is not a local relative path: %q", relativePath) @@ -175,6 +190,8 @@ func (s *Store) destination(relativePath string) (string, error) { return filepath.Join(s.root, filepath.Clean(relativePath)), nil } +// verifyParent 解析 parent 的符号链接后,确认其仍位于存储根目录之内, +// 防止通过符号链接把文件写出根目录之外。 func (s *Store) verifyParent(parent string) error { resolvedParent, err := filepath.EvalSymlinks(parent) if err != nil { @@ -190,6 +207,8 @@ func (s *Store) verifyParent(parent string) error { return nil } +// validateIdentity 校验 identity 的大小非负、SHA-256 为合法 64 位十六进制字符串, +// 并返回解码后的摘要字节切片供后续比较使用。 func validateIdentity(identity Identity) ([]byte, error) { if identity.Size < 0 { return nil, errors.New("expected file size must not be negative") @@ -201,6 +220,9 @@ func validateIdentity(identity Identity) ([]byte, error) { return digest, nil } +// verifyExisting 检查 path 处是否已存在目标文件:不存在时返回 found=false; +// 存在但不是普通文件(或为符号链接)、大小不符、摘要不符时分别返回错误或 +// ErrDestinationConflict;完全一致时返回 Reused 为 true 的 File。 func verifyExisting(path string, expected Identity, expectedDigest []byte) (File, bool, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { @@ -231,10 +253,12 @@ func verifyExisting(path string, expected Identity, expectedDigest []byte) (File return File{Path: path, Identity: expected, Reused: true}, true, nil } +// sameDigest 使用常数时间比较判断实际摘要与期望摘要是否一致,避免时序侧信道。 func sameDigest(actual hash.Hash, expected []byte) bool { return subtle.ConstantTimeCompare(actual.Sum(nil), expected) == 1 } +// syncDirectory 打开 path 指向的目录并调用 Sync 将其刷入磁盘,保证目录项变更持久化。 func syncDirectory(path string) error { directory, err := os.Open(path) if err != nil { diff --git a/internal/filestore/store_test.go b/internal/filestore/store_test.go index d39307b..8936290 100644 --- a/internal/filestore/store_test.go +++ b/internal/filestore/store_test.go @@ -10,6 +10,8 @@ import ( "testing" ) +// TestStoreCommitsAndReusesImmutableFile 验证 Commit 首次写入会创建文件且 Reused 为 false, +// 第二次写入相同身份时按幂等返回 Reused 为 true 且路径一致。 func TestStoreCommitsAndReusesImmutableFile(t *testing.T) { t.Parallel() store, err := New(t.TempDir()) @@ -38,6 +40,8 @@ func TestStoreCommitsAndReusesImmutableFile(t *testing.T) { } } +// TestStoreInspectsCommittedIdentityWithoutMutation 验证 Inspect 能校验已提交文件的身份、 +// 正确处理不存在的文件或目录,并对身份不符返回 ErrDestinationConflict。 func TestStoreInspectsCommittedIdentityWithoutMutation(t *testing.T) { t.Parallel() store, err := New(t.TempDir()) @@ -67,6 +71,8 @@ func TestStoreInspectsCommittedIdentityWithoutMutation(t *testing.T) { } } +// TestStoreRejectsMismatchAndNeverPublishesInvalidFile 验证身份不符时 Commit 返回错误, +// 且目标路径上不会留下任何已发布的文件。 func TestStoreRejectsMismatchAndNeverPublishesInvalidFile(t *testing.T) { t.Parallel() root := t.TempDir() @@ -86,6 +92,7 @@ func TestStoreRejectsMismatchAndNeverPublishesInvalidFile(t *testing.T) { } } +// TestStoreRejectsNilSource 验证 Commit 传入 nil source 时会被拒绝。 func TestStoreRejectsNilSource(t *testing.T) { t.Parallel() store, err := New(t.TempDir()) @@ -97,6 +104,8 @@ func TestStoreRejectsNilSource(t *testing.T) { } } +// TestStoreRejectsExistingDifferentContentAndPathEscape 验证目标已存在不同内容时返回 +// ErrDestinationConflict,且通过 ../ 越界的相对路径会被拒绝。 func TestStoreRejectsExistingDifferentContentAndPathEscape(t *testing.T) { t.Parallel() root := t.TempDir() @@ -117,6 +126,8 @@ func TestStoreRejectsExistingDifferentContentAndPathEscape(t *testing.T) { } } +// TestStoreRejectsSymlinkedParentOutsideRoot 验证父目录为指向根目录之外的符号链接时, +// Commit 会被拒绝,防止文件通过符号链接写出存储根目录。 func TestStoreRejectsSymlinkedParentOutsideRoot(t *testing.T) { t.Parallel() root := t.TempDir() @@ -134,6 +145,7 @@ func TestStoreRejectsSymlinkedParentOutsideRoot(t *testing.T) { } } +// identityOf 根据 content 计算其 SHA-256 摘要并返回对应的 Identity,供测试构造期望身份。 func identityOf(content []byte) Identity { digest := sha256.Sum256(content) return Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} diff --git a/internal/healthcheck/actuator.go b/internal/healthcheck/actuator.go index 409dd85..98ff9db 100644 --- a/internal/healthcheck/actuator.go +++ b/internal/healthcheck/actuator.go @@ -1,3 +1,5 @@ +// Package healthcheck 提供针对 Spring Boot Actuator 健康端点的采样与等待能力, +// 供更新执行器在启动容器后确认工作负载是否就绪。 package healthcheck import ( @@ -11,23 +13,29 @@ import ( "time" ) +// maximumResponseBytes 限制 Actuator 响应体的最大读取字节数,防止异常大响应。 const maximumResponseBytes = 1 << 20 +// ErrWorkloadStopped 表示工作负载在变为健康之前已经停止。 var ErrWorkloadStopped = errors.New("workload stopped before becoming healthy") // RunningProbe 在每次健康采样前核对容器是否仍处于运行状态。 type RunningProbe func(context.Context) (bool, error) -// ActuatorReport 是一次成功健康采样的非敏感摘要。 +// ActuatorReport 一次成功健康采样的非敏感摘要。 type ActuatorReport struct { - Status string + // Status 表示顶层健康状态,例如 UP、DOWN。 + Status string + // Components 表示各子组件的健康状态映射。 Components map[string]string } // ActuatorChecker 等待 Spring Boot Actuator 顶层状态进入 UP。 // 它只采样健康状态,不负责重启容器。 type ActuatorChecker struct { - client *http.Client + // client 执行健康检查 HTTP 请求的客户端。 + client *http.Client + // interval 相邻两次采样之间的间隔。 interval time.Duration } @@ -88,6 +96,7 @@ func (c *ActuatorChecker) Wait(ctx context.Context, endpoint string, timeout tim } } +// validateEndpoint 校验 Actuator 端点必须是合法的 http URL 且包含主机部分。 func validateEndpoint(endpoint string) error { parsed, err := url.ParseRequestURI(endpoint) if err != nil || parsed.Scheme != "http" || parsed.Host == "" { @@ -96,6 +105,9 @@ func validateEndpoint(endpoint string) error { return nil } +// sample 执行一次完整健康采样:先核对容器仍在运行, +// 再向 Actuator 端点发起 GET 请求并解析返回的 JSON 状态。 +// 返回的报告在状态非 UP 时仍会携带实际采样到的状态信息。 func (c *ActuatorChecker) sample(ctx context.Context, endpoint string, running RunningProbe) (ActuatorReport, bool, error) { isRunning, err := running(ctx) if err != nil { @@ -140,11 +152,13 @@ func (c *ActuatorChecker) sample(ctx context.Context, endpoint string, running R return report, true, nil } +// actuatorPayload Actuator health 端点的顶层 JSON 结构。 type actuatorPayload struct { Status string `json:"status"` Components map[string]actuatorComponent `json:"components"` } +// actuatorComponent 健康检查组件的 JSON 结构,这里只关心其状态。 type actuatorComponent struct { Status string `json:"status"` } diff --git a/internal/healthcheck/actuator_test.go b/internal/healthcheck/actuator_test.go index c943c8c..68a6f29 100644 --- a/internal/healthcheck/actuator_test.go +++ b/internal/healthcheck/actuator_test.go @@ -11,8 +11,10 @@ import ( "time" ) +// healthyActuatorSample 与线上抓包结果一致的 Actuator 健康响应样例。 const healthyActuatorSample = `{"status":"UP","components":{"db":{"status":"UP","components":{"dorisDataSource":{"status":"UP"},"postgresqlDataSource":{"status":"UP"}}},"diskSpace":{"status":"UP"},"ping":{"status":"UP"},"redis":{"status":"UP"}}}` +// TestActuatorCheckerAcceptsConfirmedResponseShape 验证检查器能接受与真实抓包一致的健康响应并正确解析各组件状态。 func TestActuatorCheckerAcceptsConfirmedResponseShape(t *testing.T) { t.Parallel() client := newHTTPClient(func(request *http.Request) *http.Response { @@ -31,6 +33,7 @@ func TestActuatorCheckerAcceptsConfirmedResponseShape(t *testing.T) { } } +// TestActuatorCheckerChecksOnceForTransactionRecovery 验证事务恢复路径中单次采样即可确认已记录的健康步骤。 func TestActuatorCheckerChecksOnceForTransactionRecovery(t *testing.T) { t.Parallel() client := newHTTPClient(func(*http.Request) *http.Response { @@ -43,6 +46,7 @@ func TestActuatorCheckerChecksOnceForTransactionRecovery(t *testing.T) { } } +// TestActuatorCheckerSamplesReadinessWithoutRestarting 验证在服务暂未就绪时会持续采样直到就绪,且不会触发重启。 func TestActuatorCheckerSamplesReadinessWithoutRestarting(t *testing.T) { t.Parallel() var requests atomic.Int32 @@ -61,6 +65,7 @@ func TestActuatorCheckerSamplesReadinessWithoutRestarting(t *testing.T) { } } +// TestActuatorCheckerStopsImmediatelyWhenContainerStops 验证容器停止时等待立即以 ErrWorkloadStopped 失败。 func TestActuatorCheckerStopsImmediatelyWhenContainerStops(t *testing.T) { t.Parallel() checker := newTestChecker(t, http.DefaultClient) @@ -71,6 +76,7 @@ func TestActuatorCheckerStopsImmediatelyWhenContainerStops(t *testing.T) { } } +// TestActuatorCheckerHonorsOverallTimeout 验证整体超时到达后返回 DeadlineExceeded 错误。 func TestActuatorCheckerHonorsOverallTimeout(t *testing.T) { t.Parallel() client := newHTTPClient(func(*http.Request) *http.Response { @@ -83,6 +89,7 @@ func TestActuatorCheckerHonorsOverallTimeout(t *testing.T) { } } +// newTestChecker 以固定短间隔创建测试用检查器。 func newTestChecker(t *testing.T, client *http.Client) *ActuatorChecker { t.Helper() checker, err := NewActuatorChecker(client, 5*time.Millisecond) @@ -92,22 +99,27 @@ func newTestChecker(t *testing.T, client *http.Client) *ActuatorChecker { return checker } +// alwaysRunning 表示工作负载始终处于运行状态,用于大多数测试场景。 func alwaysRunning(context.Context) (bool, error) { return true, nil } +// roundTripFunc 将普通函数适配为 http.RoundTripper,便于在测试中模拟响应。 type roundTripFunc func(*http.Request) (*http.Response, error) +// RoundTrip 直接调用底层的处理函数返回响应。 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } +// newHTTPClient 返回一个使用给定处理函数生成响应的测试 HTTP 客户端。 func newHTTPClient(handle func(*http.Request) *http.Response) *http.Client { return &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { return handle(request), nil })} } +// response 构造一个带指定状态码与响应体的最小 HTTP 响应。 func response(statusCode int, body string) *http.Response { return &http.Response{ StatusCode: statusCode, diff --git a/internal/hostnginx/config.go b/internal/hostnginx/config.go index 06aa2fc..a87e973 100644 --- a/internal/hostnginx/config.go +++ b/internal/hostnginx/config.go @@ -1,4 +1,5 @@ -// Package hostnginx manages the exact backend upstream block used by the current host Nginx deployment. +// Package hostnginx 负责解析、渲染并校验当前宿主 Nginx 配置中被 daemon 管理的后端 upstream 块, +// 保证 upstream 中的后端服务器格式与端口取值精确符合部署契约。 package hostnginx import ( @@ -10,13 +11,18 @@ import ( ) const ( + // managedBegin 受管理 upstream 块的起始标记行。 managedBegin = "# yms-update managed upstream begin" - managedEnd = "# yms-update managed upstream end" - port8080 = 8080 - port8081 = 8081 + // managedEnd 受管理 upstream 块的结束标记行。 + managedEnd = "# yms-update managed upstream end" + // port8080 蓝绿发布中 8080 槽位的端口号。 + port8080 = 8080 + // port8081 蓝绿发布中 8081 槽位的端口号。 + port8081 = 8081 ) -// ActiveBackendPort reads the one uncommented backend server in the managed upstream block. +// ActiveBackendPort 从 content 的受管理 upstream 块中读取唯一一条未注释的后端服务器端口。 +// 若不存在活动服务器、存在多个活动服务器或块格式非法,均返回错误。 func ActiveBackendPort(content []byte) (int, error) { block, err := parseManagedBlock(content) if err != nil { @@ -37,7 +43,8 @@ func ActiveBackendPort(content []byte) (int, error) { return active, nil } -// RenderBackendPort returns a complete configuration with only activePort uncommented. +// RenderBackendPort 返回一份仅 activePort 未注释的完整配置,其余后端服务器行均被注释。 +// activePort 只允许 8080 或 8081,渲染结果会再经 ActiveBackendPort 校验后才返回。 func RenderBackendPort(content []byte, activePort int) ([]byte, error) { if activePort != port8080 && activePort != port8081 { return nil, fmt.Errorf("host Nginx backend port must be 8080 or 8081: %d", activePort) @@ -65,17 +72,27 @@ func RenderBackendPort(content []byte, activePort int) ([]byte, error) { return rendered, nil } +// managedBlock 受管理 upstream 块的解析结果,保留原始行供渲染时整体重建。 type managedBlock struct { - lines []string + // lines 配置文件的全部行,渲染时按行号直接改写并重新拼接。 + lines []string + // servers 块内解析出的后端服务器,按出现顺序排列。 servers []managedServer } +// managedServer 受管理 upstream 块内一条后端服务器行的解析结果。 type managedServer struct { - line int - port int + // line 服务器行在 lines 中的下标。 + line int + // port 服务器地址中的端口号,只允许 8080 或 8081。 + port int + // commented 为 true 表示该行以 "# server " 开头(当前未启用)。 commented bool } +// parseManagedBlock 解析 content 中由 managedBegin 与 managedEnd 标记的受管理 upstream 块。 +// 它要求存在且仅存在一对有序标记、块内恰好包含 8080 与 8081 两条后端服务器且无多余非空行, +// 任一约束不满足即返回错误。 func parseManagedBlock(content []byte) (managedBlock, error) { if len(content) == 0 { return managedBlock{}, errors.New("host Nginx configuration is empty") @@ -131,6 +148,9 @@ func parseManagedBlock(content []byte) (managedBlock, error) { return managedBlock{lines: lines, servers: servers}, nil } +// parseManagedServer 解析单行 line 是否为后端服务器行,返回解析结果与 found。 +// found 为 false 表示该行既非注释服务器也非活动服务器(如空行或其他行),由调用方继续处理; +// 若该行格式或端口不符合契约则返回错误。 func parseManagedServer(line string, lineIndex int) (managedServer, bool, error) { trimmed := strings.TrimSpace(line) commented := strings.HasPrefix(trimmed, "# server ") diff --git a/internal/hostnginx/config_test.go b/internal/hostnginx/config_test.go index cde7257..fe23fa5 100644 --- a/internal/hostnginx/config_test.go +++ b/internal/hostnginx/config_test.go @@ -6,6 +6,7 @@ import ( "testing" ) +// serverConfiguration 含受管理 upstream 块、8080 被注释、8081 活动的示例配置,供各测试共享。 const serverConfiguration = `http { upstream yms-server { # yms-update managed upstream begin @@ -16,6 +17,8 @@ const serverConfiguration = `http { } ` +// TestActiveBackendPortReadsExactManagedBlock 验证 ActiveBackendPort 能从示例配置中 +// 精确读取到活动端口 8081。 func TestActiveBackendPortReadsExactManagedBlock(t *testing.T) { port, err := ActiveBackendPort([]byte(serverConfiguration)) if err != nil || port != 8081 { @@ -23,6 +26,8 @@ func TestActiveBackendPortReadsExactManagedBlock(t *testing.T) { } } +// TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer 验证 RenderBackendPort +// 只切换一条服务器行,其余配置保持不变,且渲染结果的活动端口正确。 func TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer(t *testing.T) { rendered, err := RenderBackendPort([]byte(serverConfiguration), 8080) if err != nil { @@ -38,6 +43,8 @@ func TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer(t *testing. } } +// TestManagedBlockRejectsAmbiguousOrAlteredInput 验证受管理块在端口双活、端口缺失、 +// 选项被篡改或出现多余行等情况下都会被拒绝。 func TestManagedBlockRejectsAmbiguousOrAlteredInput(t *testing.T) { tests := map[string]string{ "both active": strings.Replace(serverConfiguration, "# server 10.11.1.117:8080", "server 10.11.1.117:8080", 1), diff --git a/internal/hostnginx/controller.go b/internal/hostnginx/controller.go index f0c7c64..89f4afc 100644 --- a/internal/hostnginx/controller.go +++ b/internal/hostnginx/controller.go @@ -10,55 +10,55 @@ import ( "strings" ) -// Snapshot is the complete host Nginx configuration before or after a switch. +// Snapshot 切换前后完整宿主 Nginx 配置的快照,用于恢复与补偿。 type Snapshot struct { - Content []byte + // Content 配置文件的完整字节内容。 + Content []byte + // ActivePort 内容中处于活动状态的后端端口。 ActivePort int } -// Controller validates, atomically replaces, and reloads the current host Nginx configuration. +// Controller 负责校验、原子替换并重载当前宿主 Nginx 配置。 type Controller struct { - configPath string - nginxExecutable string - systemctlPath string - nginxServiceName string - runner commandRunner + // configPath 宿主 Nginx 配置文件的绝对路径。 + configPath string + // nginxExecutable 用于配置校验的 Nginx 可执行文件绝对路径。 + nginxExecutable string + // runner 执行外部命令的抽象,便于测试时替换。 + runner commandRunner } -// NewController requires every external identifier to be supplied explicitly. -func NewController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string) (*Controller, error) { - return newController(configPath, nginxExecutable, systemctlPath, nginxServiceName, execRunner{}) +// NewController 要求显式提供配置文件和 Nginx 可执行文件路径。 +func NewController(configPath string, nginxExecutable string) (*Controller, error) { + return newController(configPath, nginxExecutable, execRunner{}) } -func newController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string, runner commandRunner) (*Controller, error) { +// newController 校验各参数后创建 Controller。configPath、nginxExecutable、systemctlPath 必须是 +// 干净绝对路径,nginxServiceName 必须非空且无首尾空白,runner 必须非空。 +func newController(configPath string, nginxExecutable string, runner commandRunner) (*Controller, error) { for _, entry := range []struct { name string value string }{ {"host Nginx configuration", configPath}, {"Nginx executable", nginxExecutable}, - {"systemctl executable", systemctlPath}, } { if !filepath.IsAbs(entry.value) || filepath.Clean(entry.value) != entry.value { return nil, fmt.Errorf("%s must be a clean absolute path", entry.name) } } - if nginxServiceName == "" || strings.TrimSpace(nginxServiceName) != nginxServiceName { - return nil, errors.New("exact Nginx systemd service name is required") - } if runner == nil { return nil, errors.New("host Nginx command runner is required") } return &Controller{ - configPath: configPath, - nginxExecutable: nginxExecutable, - systemctlPath: systemctlPath, - nginxServiceName: nginxServiceName, - runner: runner, + configPath: configPath, + nginxExecutable: nginxExecutable, + runner: runner, }, nil } -// Read returns the complete current configuration and its active backend port. +// Read 返回当前完整配置内容及其活动后端端口。它要求配置文件是直接存在的普通文件 +// (非符号链接),读取后调用 ActiveBackendPort 解析活动端口。 func (c *Controller) Read() (Snapshot, error) { info, err := os.Lstat(c.configPath) if err != nil { @@ -78,8 +78,9 @@ func (c *Controller) Read() (Snapshot, error) { return Snapshot{Content: content, ActivePort: port}, nil } -// Switch renders activePort from the current configuration, then validates and reloads Nginx. -// It returns the durable pre-switch snapshot required for compensation. +// Switch 基于当前配置渲染出 activePort 为活动的配置,校验后原子替换并重载 Nginx。 +// 若当前活动端口已等于 activePort 则直接返回当前快照,不做任何改动。 +// 返回的 Snapshot 是切换前的完整快照,供调用方在后续失败时补偿。 func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, error) { previous, err := c.Read() if err != nil { @@ -98,7 +99,8 @@ func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, erro return previous, nil } -// Restore atomically restores a previously persisted complete configuration and reloads Nginx. +// Restore 原子恢复一份先前持久化的完整配置并重载 Nginx。它校验快照非空、 +// 内容中的活动端口与快照元数据一致,且仅在与当前内容不同时才执行替换。 func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error { if len(snapshot.Content) == 0 { return errors.New("host Nginx restore snapshot is empty") @@ -120,7 +122,8 @@ func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error { return c.replaceValidateReload(ctx, snapshot.Content, current.Content) } -// Apply installs one previously persisted complete snapshot and reloads Nginx. +// Apply 安装一份先前持久化的完整配置快照并重载 Nginx。它校验快照非空、 +// 内容中的活动端口与快照元数据一致后直接替换当前配置。 func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error { if len(snapshot.Content) == 0 { return errors.New("host Nginx apply snapshot is empty") @@ -139,6 +142,8 @@ func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error { return c.replaceValidateReload(ctx, snapshot.Content, current.Content) } +// replaceValidateReload 先原子写入 desired,再用 nginx -t 校验,最后通过 nginx -s reload +// 平滑重载 Nginx。校验或重载失败时都会回滚到 rollback 内容并把补偿错误合并返回。 func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte, rollback []byte) error { if err := c.atomicWrite(desired); err != nil { return err @@ -149,27 +154,31 @@ func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte, c.restoreAfterFailure(ctx, rollback), ) } - if err := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName); err != nil { + if err := c.runner.Run(ctx, c.nginxExecutable, "-s", "reload"); err != nil { return errors.Join( - fmt.Errorf("reload host Nginx service %s: %w", c.nginxServiceName, err), + fmt.Errorf("reload host Nginx: %w", err), c.restoreAfterFailure(ctx, rollback), ) } return nil } +// restoreAfterFailure 在失败后把配置回滚为 content,并再次校验与重载 Nginx, +// 将校验与重载的错误合并返回。 func (c *Controller) restoreAfterFailure(ctx context.Context, content []byte) error { if err := c.atomicWrite(content); err != nil { return fmt.Errorf("restore host Nginx configuration after failure: %w", err) } validateErr := c.runner.Run(ctx, c.nginxExecutable, "-t") - reloadErr := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName) + reloadErr := c.runner.Run(ctx, c.nginxExecutable, "-s", "reload") return errors.Join( wrapError("validate restored host Nginx configuration", validateErr), wrapError("reload restored host Nginx configuration", reloadErr), ) } +// atomicWrite 通过同目录临时文件加 rename 的方式原子替换配置文件,并保留原文件权限、 +// 同步临时文件与父目录,确保替换持久且不会留下半成品。 func (c *Controller) atomicWrite(content []byte) error { info, err := os.Lstat(c.configPath) if err != nil { @@ -206,12 +215,15 @@ func (c *Controller) atomicWrite(content []byte) error { return syncDirectory(parent) } +// commandRunner 抽象外部命令执行,便于在测试中注入记录型运行器。 type commandRunner interface { Run(context.Context, string, ...string) error } +// execRunner commandRunner 的生产实现,通过 os/exec 执行真实外部命令。 type execRunner struct{} +// Run 执行 executable 及其参数,失败时把命令的标准输出与错误输出附加到错误信息中。 func (execRunner) Run(ctx context.Context, executable string, arguments ...string) error { output, err := exec.CommandContext(ctx, executable, arguments...).CombinedOutput() if err == nil { @@ -224,6 +236,7 @@ func (execRunner) Run(ctx context.Context, executable string, arguments ...strin return fmt.Errorf("%w: %s", err, detail) } +// wrapError 在 err 非空时为其附加 message 前缀并返回,err 为空则返回 nil。 func wrapError(message string, err error) error { if err == nil { return nil @@ -231,6 +244,8 @@ func wrapError(message string, err error) error { return fmt.Errorf("%s: %w", message, err) } +// syncDirectory 打开 directory 指向的目录并调用 Sync 将其刷入磁盘, +// 保证配置目录项变更持久化。 func syncDirectory(directory string) error { file, err := os.Open(directory) if err != nil { diff --git a/internal/hostnginx/controller_test.go b/internal/hostnginx/controller_test.go index 8a39382..402d7a0 100644 --- a/internal/hostnginx/controller_test.go +++ b/internal/hostnginx/controller_test.go @@ -9,10 +9,12 @@ import ( "testing" ) -func TestControllerSwitchesAndReloadsExactService(t *testing.T) { +// TestControllerSwitchesAndReloadsNginx 验证 Switch 能将活动端口切到 8080, +// 且实际执行了 nginx -t 与 nginx -s reload 命令。 +func TestControllerSwitchesAndReloadsNginx(t *testing.T) { configPath := writeNginxConfig(t, serverConfiguration) runner := &recordingRunner{} - controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + controller, err := newController(configPath, "/usr/sbin/nginx", runner) if err != nil { t.Fatalf("create host Nginx controller: %v", err) } @@ -30,17 +32,19 @@ func TestControllerSwitchesAndReloadsExactService(t *testing.T) { } wantCalls := [][]string{ {"/usr/sbin/nginx", "-t"}, - {"/bin/systemctl", "reload", "--", "nginx.service"}, + {"/usr/sbin/nginx", "-s", "reload"}, } if !slices.EqualFunc(runner.calls, wantCalls, slices.Equal) { t.Fatalf("unexpected host Nginx commands: %+v", runner.calls) } } +// TestControllerRestoresConfigurationWhenValidationFails 验证 nginx -t 校验失败时 Switch 返回错误, +// 并把配置回滚为原始内容,同时执行了校验、回滚校验与回滚重载三次命令。 func TestControllerRestoresConfigurationWhenValidationFails(t *testing.T) { configPath := writeNginxConfig(t, serverConfiguration) runner := &recordingRunner{errors: []error{errors.New("nginx test failed"), nil, nil}} - controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + controller, err := newController(configPath, "/usr/sbin/nginx", runner) if err != nil { t.Fatalf("create host Nginx controller: %v", err) } @@ -57,10 +61,12 @@ func TestControllerRestoresConfigurationWhenValidationFails(t *testing.T) { } } +// TestControllerRestoreUsesCompleteSnapshot 验证 Restore 能使用 Switch 返回的完整快照 +// 把配置恢复到切换前的原始内容与活动端口。 func TestControllerRestoreUsesCompleteSnapshot(t *testing.T) { configPath := writeNginxConfig(t, serverConfiguration) runner := &recordingRunner{} - controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + controller, err := newController(configPath, "/usr/sbin/nginx", runner) if err != nil { t.Fatalf("create host Nginx controller: %v", err) } @@ -77,6 +83,8 @@ func TestControllerRestoreUsesCompleteSnapshot(t *testing.T) { } } +// writeNginxConfig 将 content 写入临时目录下的 nginx.conf 并返回其绝对路径, +// 供 Controller 测试使用。写入失败会直接终止测试。 func writeNginxConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "nginx.conf") @@ -86,11 +94,15 @@ func writeNginxConfig(t *testing.T, content string) string { return path } +// recordingRunner commandRunner 的测试实现,记录每次调用并依次返回预设的错误。 type recordingRunner struct { - calls [][]string + // calls 记录每次执行的命令(含参数)序列。 + calls [][]string + // errors 依次返回的预设错误,取完后续调用返回 nil。 errors []error } +// Run 记录本次调用命令,并按 errors 中的顺序返回下一个预设错误。 func (r *recordingRunner) Run(_ context.Context, executable string, arguments ...string) error { call := append([]string{executable}, arguments...) r.calls = append(r.calls, call) diff --git a/internal/logging/logger.go b/internal/logging/logger.go index b66829a..6cf8bcc 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -1,3 +1,4 @@ +// Package logging 提供结构化文本日志的创建,同时写入控制台与本地文件。 package logging import ( @@ -11,11 +12,13 @@ import ( // New 创建同时写入 stdout 和本地文件的结构化文本日志。 // stdout 由 systemd/journald 收集,本地文件用于现场诊断和受控远程读取。 +// 返回的 closer 用于在退出前同步并关闭日志文件。 func New(filePath string) (*slog.Logger, io.Closer, error) { return NewWithConsole(os.Stdout, filePath) } // NewWithConsole 允许调用方指定控制台 writer,主要用于测试和嵌入运行。 +// console 与 filePath 均不能为空;日志级别固定为 Info。 func NewWithConsole(console io.Writer, filePath string) (*slog.Logger, io.Closer, error) { if console == nil { return nil, nil, errors.New("console writer is required") @@ -38,10 +41,12 @@ func NewWithConsole(console io.Writer, filePath string) (*slog.Logger, io.Closer return slog.New(handler), &syncFileCloser{file: file}, nil } +// syncFileCloser 负责在关闭时先同步再关闭日志文件。 type syncFileCloser struct { file *os.File } +// Close 同步并关闭日志文件,重复调用是安全的。 func (c *syncFileCloser) Close() error { if c == nil || c.file == nil { return nil diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 58800d7..66862ba 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -8,6 +8,7 @@ import ( "testing" ) +// TestLoggerWritesConsoleAndFile 验证日志同时写入控制台与文件,且包含消息与键值字段。 func TestLoggerWritesConsoleAndFile(t *testing.T) { t.Parallel() var console bytes.Buffer diff --git a/internal/nativebackendexecutor/executor.go b/internal/nativebackendexecutor/executor.go index 8ce57f0..2ec3586 100644 --- a/internal/nativebackendexecutor/executor.go +++ b/internal/nativebackendexecutor/executor.go @@ -1,5 +1,6 @@ -// Package nativebackendexecutor prepares and starts one explicitly configured native backend slot. -// Gateway switching is deliberately outside this package. +// Package nativebackendexecutor 负责准备并启动一个由外部显式配置的原生后端槽位。 +// 它把后端 JAR 安装到发布目录、把槽位软链接指向新版本、启动对应的 systemd 单元并等待健康检查通过, +// 从而把持久化的事务推进到 switching 状态。网关流量切换被刻意排除在本包职责之外。 package nativebackendexecutor import ( @@ -22,40 +23,66 @@ import ( ) const ( - healthPath = "/yms/actuator/health" - healthTimeout = 120 * time.Second - healthInterval = time.Second - stepInstallJar = "backend.native.jar.install" - stepBindSlot = "backend.native.slot.bind" - stepStartUnit = "backend.native.service.start" + // healthPath 健康检查端点必须精确匹配的 URL 路径。 + healthPath = "/yms/actuator/health" + // healthTimeout 等待原生后端 Actuator 健康检查就绪的最长时限。 + healthTimeout = 120 * time.Second + // healthInterval 健康检查器轮询端点时的间隔。 + healthInterval = time.Second + // stepInstallJar “安装不可变原生后端 JAR”事务步骤的持久化键。 + stepInstallJar = "backend.native.jar.install" + // stepBindSlot “绑定非活跃槽位软链接”事务步骤的持久化键。 + stepBindSlot = "backend.native.slot.bind" + // stepStartUnit “启动非活跃原生后端 systemd 单元”事务步骤的持久化键。 + stepStartUnit = "backend.native.service.start" + // stepCheckHealth “等待原生后端 Actuator 健康检查”事务步骤的持久化键。 stepCheckHealth = "backend.native.health" - stepStopUnit = "backend.native.service.stop" + // stepStopUnit “停止失败的非活跃原生后端 systemd 单元”事务步骤的持久化键。 + stepStopUnit = "backend.native.service.stop" + // stepRestoreSlot “恢复非活跃槽位软链接”事务步骤的持久化键。 stepRestoreSlot = "backend.native.slot.restore" - activeState = "active" - inactiveState = "inactive" - failedState = "failed" + // activeState systemd 单元的“活跃”运行状态标识。 + activeState = "active" + // inactiveState systemd 单元的“非活跃”运行状态标识。 + inactiveState = "inactive" + // failedState systemd 单元的“失败”运行状态标识。 + failedState = "failed" ) -// Request contains exact values from the immutable update request and local deployment configuration. -// UnitName, SlotJarPath and PreviousSlotTarget are opaque and are never derived from filenames or ports. +// Request 汇集了不可变更新请求中的精确值与本地部署配置。 +// 其中 UnitName、SlotJarPath 与 PreviousSlotTarget 都是不透明值,绝不从文件名或端口号推导得出。 type Request struct { - ArtifactPath string - ArtifactIdentity filestore.Identity - ReleasePath string - SlotJarPath string + // ArtifactPath 待安装后端 JAR 的绝对源路径。 + ArtifactPath string + // ArtifactIdentity 后端 JAR 的期望身份(大小与 SHA-256),用于校验与发布存储。 + ArtifactIdentity filestore.Identity + // ReleasePath 发布存储内的本地相对路径。 + ReleasePath string + // SlotJarPath 槽位软链接的绝对路径。 + SlotJarPath string + // PreviousSlotTarget 槽位软链接此前指向的绝对路径,首次部署时可为空。 PreviousSlotTarget string - UnitName string - Port int - HealthEndpoint string - Progress func(transaction.State, string) + // UnitName 非活跃后端对应的精确 systemd 单元名。 + UnitName string + // Port 后端监听的端口,只允许 8080 或 8081。 + Port int + // HealthEndpoint 健康检查的 HTTP URL,路径必须精确等于 healthPath。 + HealthEndpoint string + // Progress 可选的回调,用于把事务状态与进度消息上报给上层;为空时不回调。 + Progress func(transaction.State, string) } +// actuatorChecker 抽象了 Actuator 健康检查器,供 Executor 依赖注入使用。 +// 它把真实的 healthcheck.ActuatorChecker 与测试替身统一起来。 type actuatorChecker interface { + // Check 对给定端点执行一次健康检查并立即返回报告、就绪标志与错误。 Check(context.Context, string, healthcheck.RunningProbe) (healthcheck.ActuatorReport, bool, error) + // Wait 反复检查端点直到就绪或超过给定时限,并返回最终报告与错误。 Wait(context.Context, string, time.Duration, healthcheck.RunningProbe) (healthcheck.ActuatorReport, error) } -// Executor drives the persisted transaction up to SWITCHING after the inactive native backend is healthy. +// Executor 在非活跃原生后端健康之后,把持久化事务推进到 switching 状态。 +// 它负责校验、安装、绑定槽位、启动单元与健康检查,但不负责切换网关流量。 type Executor struct { store *transaction.Store coordinator *transaction.Coordinator @@ -64,6 +91,9 @@ type Executor struct { checker actuatorChecker } +// New 构造一个原生后端执行器。 +// store、coordinator、releaseStore、units 为 nil 时返回错误;httpClient 用于构造 Actuator 健康检查器。 +// 返回值是就绪可用的 *Executor;错误只在缺少必要依赖或健康检查器构造失败时非空。 func New(store *transaction.Store, coordinator *transaction.Coordinator, releaseStore *filestore.Store, units systemd.Manager, httpClient *http.Client) (*Executor, error) { if store == nil { return nil, errors.New("transaction store is required") @@ -84,7 +114,10 @@ func New(store *transaction.Store, coordinator *transaction.Coordinator, release return &Executor{store: store, coordinator: coordinator, releaseStore: releaseStore, units: units, checker: checker}, nil } -// Run resumes from the transaction's persisted state. It does not switch gateway traffic. +// Run 从事务持久化的状态恢复并继续推进,直到 switching 状态后返回。 +// ctx 用于取消与超时;transactionID 是待推进的事务 ID,空白时返回错误;request 携带不可变请求与部署配置。 +// 它通过协调器的排他执行保证同一事务不会并发运行,且不切换网关流量。 +// 返回值是执行过程中的错误;若事务已达到 switching 状态则返回 nil。 func (e *Executor) Run(ctx context.Context, transactionID string, request Request) error { if strings.TrimSpace(transactionID) == "" { return errors.New("transaction ID is required") @@ -94,6 +127,10 @@ func (e *Executor) Run(ctx context.Context, transactionID string, request Reques }) } +// run 在排他锁内的实际状态机,根据持久化事务状态执行对应步骤。 +// ctx 用于取消与超时;transactionID 定位事务;request 携带不可变请求与部署配置。 +// 它循环读取事务状态并推进:校验、安装与绑定、启动与健康检查、补偿恢复,直至 switching 或终止状态。 +// 返回值是推进过程中的错误;可恢复错误会被原样返回以便重试,不可恢复错误会写入 failed 状态。 func (e *Executor) run(ctx context.Context, transactionID string, request Request) error { for { record, err := e.store.Transaction(ctx, transactionID) @@ -161,6 +198,10 @@ func (e *Executor) run(ctx context.Context, transactionID string, request Reques } } +// validate 校验请求与部署前置条件。 +// ctx 用于取消;request 携带待校验的请求与配置。 +// 校验内容包括:请求字段合法性、JAR 源文件为普通文件且大小匹配、槽位软链接初始状态正确、 +// 以及目标 systemd 单元必须处于非活跃或失败状态。返回错误时不会改动任何文件或事务状态。 func (e *Executor) validate(ctx context.Context, request Request) error { if err := validateRequest(request); err != nil { return err @@ -188,6 +229,9 @@ func (e *Executor) validate(ctx context.Context, request Request) error { return nil } +// prepare 通过协调器执行安装 JAR 与绑定槽位两个步骤,具备幂等性。 +// ctx 用于取消;transactionID 定位事务;request 携带源路径、发布路径、身份与槽位配置。 +// 返回已安装 JAR 的绝对路径;错误来自任一事务步骤或发布存储检查失败。 func (e *Executor) prepare(ctx context.Context, transactionID string, request Request) (string, error) { installOperation := &installJarOperation{ store: e.releaseStore, @@ -217,6 +261,9 @@ func (e *Executor) prepare(ctx context.Context, transactionID string, request Re return installed.Path, nil } +// startAndCheck 通过协调器执行启动 systemd 单元与等待健康检查两个步骤。 +// ctx 用于取消;transactionID 定位事务;request 提供单元名、健康端点与进度回调。 +// 返回值是任一事务步骤的错误;健康检查成功后还会上报“状态为 UP”的进度。 func (e *Executor) startAndCheck(ctx context.Context, transactionID string, request Request) error { reportProgress(request, transaction.StateStarting, "Starting native backend unit "+request.UnitName) startOperation := &unitStartOperation{units: e.units, name: request.UnitName} @@ -238,12 +285,19 @@ func (e *Executor) startAndCheck(ctx context.Context, transactionID string, requ return err } +// reportProgress 在 request.Progress 非空时向调用方上报进度。 +// request 提供 Progress 回调;state 是当前事务状态;message 是进度描述。 +// 该函数无返回值且无副作用(除了可选回调),Progress 为空时直接跳过。 func reportProgress(request Request, state transaction.State, message string) { if request.Progress != nil { request.Progress(state, message) } } +// rollbackBeforeSwitch 在切换网关前因启动或健康检查失败而启动补偿流程。 +// ctx 用于取消;transactionID 定位事务;request 提供单元名与槽位配置; +// installedPath 本次已安装的 JAR 路径;cause 是触发补偿的原始错误。 +// 它先把事务置为 rolling back,再补偿并置为 rolled back,最终总是返回 cause 以保留原始错误上下文。 func (e *Executor) rollbackBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string, cause error) error { if _, err := e.store.Transition(ctx, transactionID, transaction.StateRollingBack, "native backend preparation failed; compensation started"); err != nil { return errors.Join(cause, err) @@ -257,6 +311,9 @@ func (e *Executor) rollbackBeforeSwitch(ctx context.Context, transactionID strin return cause } +// compensateBeforeSwitch 执行切换前的补偿:停止后端单元并把槽位软链接恢复到先前的目标。 +// ctx 用于取消;transactionID 定位事务;request 提供单元名与槽位配置;installedPath 是本次安装的 JAR 路径。 +// 返回值是停止或恢复步骤的错误;两个步骤都通过协调器执行以保持幂等。 func (e *Executor) compensateBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string) error { stopOperation := &unitStopOperation{units: e.units, name: request.UnitName} if _, err := e.coordinator.ExecuteStep(ctx, transactionID, stopIntent(request), stopOperation); err != nil { @@ -273,6 +330,9 @@ func (e *Executor) compensateBeforeSwitch(ctx context.Context, transactionID str return nil } +// failUnlessRecoverable 判断错误是否可恢复:可恢复时原样返回,否则把事务置为 failed 状态。 +// ctx 用于取消;transactionID 定位事务;cause 是待判定的错误。 +// 返回值为 cause 与(可选的)状态转换错误的合并结果。 func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID string, cause error) error { if recoverable(cause) { return cause @@ -281,11 +341,15 @@ func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID stri return errors.Join(cause, transitionErr) } +// recoverable 判断错误是否允许重试,即是否为不确定步骤错误或步骤冲突错误。 +// cause 待判定的错误。若可通过重试恢复则返回 true,否则返回 false。 func recoverable(cause error) bool { var uncertain *transaction.UncertainStepError return errors.As(cause, &uncertain) || errors.Is(cause, transaction.ErrStepConflict) } +// validateRequest 校验 Request 中所有字段的合法性,不产生任何副作用。 +// request 待校验的请求。任一字段不满足约束即返回错误;全部通过则返回 nil。 func validateRequest(request Request) error { if !filepath.IsAbs(request.ArtifactPath) { return errors.New("native backend JAR path must be absolute") @@ -318,6 +382,9 @@ func validateRequest(request Request) error { return nil } +// inspectInitialSlot 校验槽位软链接的初始状态与期望的先前目标一致。 +// path 槽位软链接路径;previousTarget 是期望指向的先前目标,首次部署时可为空。 +// 返回错误的条件包括:软链接缺失但与期望不符、路径不是软链接、目标不符,或先前目标不是普通文件。 func inspectInitialSlot(path, previousTarget string) error { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { @@ -351,6 +418,8 @@ func inspectInitialSlot(path, previousTarget string) error { return nil } +// installIntent 构造“安装不可变原生后端 JAR”的事务步骤意图。 +// request 提供源路径、发布路径与身份。返回值是序列化后的事务步骤意图。 func installIntent(request Request) transaction.StepIntent { return intent(stepInstallJar, "install immutable native backend JAR", struct { SourcePath string `json:"sourcePath"` @@ -359,6 +428,8 @@ func installIntent(request Request) transaction.StepIntent { }{request.ArtifactPath, request.ReleasePath, request.ArtifactIdentity}) } +// bindIntent 构造“绑定非活跃槽位软链接”的事务步骤意图。 +// request 提供槽位路径与先前目标;installedPath 是本次安装后的 JAR 路径。返回值是事务步骤意图。 func bindIntent(request Request, installedPath string) transaction.StepIntent { return intent(stepBindSlot, "bind inactive native backend slot", struct { SlotJarPath string `json:"slotJarPath"` @@ -367,12 +438,16 @@ func bindIntent(request Request, installedPath string) transaction.StepIntent { }{request.SlotJarPath, request.PreviousSlotTarget, installedPath}) } +// startIntent 构造“启动非活跃原生后端 systemd 单元”的事务步骤意图。 +// request 提供单元名。返回值是事务步骤意图。 func startIntent(request Request) transaction.StepIntent { return intent(stepStartUnit, "start inactive native backend systemd unit", struct { UnitName string `json:"unitName"` }{request.UnitName}) } +// healthIntent 构造“等待原生后端 Actuator 健康检查”的事务步骤意图。 +// request 提供单元名与端点,超时使用包级常量 healthTimeout。返回值是事务步骤意图。 func healthIntent(request Request) transaction.StepIntent { return intent(stepCheckHealth, "wait for native backend Actuator health", struct { UnitName string `json:"unitName"` @@ -381,12 +456,16 @@ func healthIntent(request Request) transaction.StepIntent { }{request.UnitName, request.HealthEndpoint, healthTimeout}) } +// stopIntent 构造“停止失败的非活跃原生后端 systemd 单元”的事务步骤意图。 +// request 提供单元名。返回值是事务步骤意图。 func stopIntent(request Request) transaction.StepIntent { return intent(stepStopUnit, "stop failed inactive native backend systemd unit", struct { UnitName string `json:"unitName"` }{request.UnitName}) } +// restoreIntent 构造“恢复非活跃槽位软链接”的事务步骤意图。 +// request 提供槽位路径与先前目标;installedPath 是本次安装后需被替换的 JAR 路径。返回值是事务步骤意图。 func restoreIntent(request Request, installedPath string) transaction.StepIntent { return intent(stepRestoreSlot, "restore inactive native backend slot", struct { SlotJarPath string `json:"slotJarPath"` @@ -395,6 +474,9 @@ func restoreIntent(request Request, installedPath string) transaction.StepIntent }{request.SlotJarPath, installedPath, request.PreviousSlotTarget}) } +// intent 用键、名称与值构造事务步骤意图,其中值会被序列化为 JSON。 +// key 步骤的持久化键;name 是人类可读的步骤名;value 是待序列化的载荷。 +// 序列化失败时直接 panic(内部载荷应始终可序列化)。返回值是事务步骤意图。 func intent(key, name string, value any) transaction.StepIntent { payload, err := json.Marshal(value) if err != nil { diff --git a/internal/nativebackendexecutor/executor_test.go b/internal/nativebackendexecutor/executor_test.go index c0801fa..5fc35d7 100644 --- a/internal/nativebackendexecutor/executor_test.go +++ b/internal/nativebackendexecutor/executor_test.go @@ -20,6 +20,8 @@ import ( "yms-daemon/internal/transaction" ) +// TestExecutorInstallsJarStartsExactUnitAndReachesSwitching 验证成功路径:安装 JAR、启动精确单元、 +// 槽位软链接指向新版本、事务推进到 switching,且重复运行不会再次启动单元。 func TestExecutorInstallsJarStartsExactUnitAndReachesSwitching(t *testing.T) { ctx := context.Background() executor, store, releaseStore, units, request, previousTarget := testNativeExecutor(t) @@ -65,6 +67,8 @@ func TestExecutorInstallsJarStartsExactUnitAndReachesSwitching(t *testing.T) { } } +// TestExecutorRollsBackSlotAndStopsUnitWhenHealthFails 验证健康检查失败时:事务回滚、槽位软链接恢复、 +// 单元被停止且状态回到非活跃。 func TestExecutorRollsBackSlotAndStopsUnitWhenHealthFails(t *testing.T) { ctx := context.Background() executor, store, _, units, request, previousTarget := testNativeExecutor(t) @@ -96,6 +100,7 @@ func TestExecutorRollsBackSlotAndStopsUnitWhenHealthFails(t *testing.T) { } } +// TestExecutorResumesPersistedRollback 验证从持久化的 rolling back 状态恢复补偿:恢复槽位并停止单元。 func TestExecutorResumesPersistedRollback(t *testing.T) { ctx := context.Background() executor, store, _, units, request, previousTarget := testNativeExecutor(t) @@ -134,6 +139,7 @@ func TestExecutorResumesPersistedRollback(t *testing.T) { } } +// TestExecutorRollbackRemovesFirstDeploymentSlotLink 验证首次部署(无先前目标)失败时,回滚会移除槽位软链接。 func TestExecutorRollbackRemovesFirstDeploymentSlotLink(t *testing.T) { ctx := context.Background() executor, store, _, _, request, _ := testNativeExecutor(t) @@ -159,6 +165,8 @@ func TestExecutorRollbackRemovesFirstDeploymentSlotLink(t *testing.T) { } } +// TestExecutorRecoversRecordedSlotIntentWithoutChangingRequest 验证崩溃恢复:已记录的槽位意图不依赖请求变更, +// 执行器能直接复用并完成后续步骤。 func TestExecutorRecoversRecordedSlotIntentWithoutChangingRequest(t *testing.T) { ctx := context.Background() executor, store, releaseStore, units, request, _ := testNativeExecutor(t) @@ -197,6 +205,7 @@ func TestExecutorRecoversRecordedSlotIntentWithoutChangingRequest(t *testing.T) } } +// TestExecutorRejectsChangedRecoveryIntentAndPreservesStartingState 验证请求变更导致步骤冲突时被拒绝,且事务停留在 starting 状态。 func TestExecutorRejectsChangedRecoveryIntentAndPreservesStartingState(t *testing.T) { ctx := context.Background() executor, store, _, _, request, _ := testNativeExecutor(t) @@ -221,6 +230,7 @@ func TestExecutorRejectsChangedRecoveryIntentAndPreservesStartingState(t *testin } } +// TestExecutorRejectsActiveUnitBeforeChangingFiles 验证单元已活跃时校验失败:事务进入 failed,且不改动发布存储与槽位软链接。 func TestExecutorRejectsActiveUnitBeforeChangingFiles(t *testing.T) { ctx := context.Background() executor, store, releaseStore, units, request, previousTarget := testNativeExecutor(t) @@ -246,6 +256,7 @@ func TestExecutorRejectsActiveUnitBeforeChangingFiles(t *testing.T) { } } +// TestUnitStopOperationAcceptsSystemdFailedAsStopped 验证 unitStopOperation 把 systemd 的 failed 状态视为已停止。 func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { units := &fakeUnitManager{unit: systemd.Unit{ Name: "yms-backend@8080.service", @@ -263,6 +274,8 @@ func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { } } +// testNativeExecutor 构造一套完整且可复用的原生后端测试夹具。 +// t 用于失败报告与清理注册。返回执行器、事务存储、发布存储、伪造的单元管理器、请求与先前槽位目标。 func testNativeExecutor(t *testing.T) (*Executor, *transaction.Store, *filestore.Store, *fakeUnitManager, Request, string) { t.Helper() store, err := transaction.OpenStore(context.Background(), filepath.Join(t.TempDir(), "transactions.db")) @@ -320,6 +333,8 @@ func testNativeExecutor(t *testing.T) (*Executor, *transaction.Store, *filestore return executor, store, releaseStore, units, request, previousTarget } +// createNativeTransaction 在测试事务存储中创建一条原生后端事务记录。 +// t 用于失败报告;store 是事务存储;suffix 用于构造唯一的事务 ID。返回创建的事务记录。 func createNativeTransaction(t *testing.T, store *transaction.Store, suffix string) transaction.Transaction { t.Helper() record, _, err := store.CreateTransaction(context.Background(), transaction.CreateRequest{ @@ -334,6 +349,8 @@ func createNativeTransaction(t *testing.T, store *transaction.Store, suffix stri return record } +// transitionNativeToPrepared 把事务依次推进到 validating 与 prepared 状态,供测试预置前置步骤。 +// t 用于失败报告;store 是事务存储;transactionID 定位事务。 func transitionNativeToPrepared(t *testing.T, store *transaction.Store, transactionID string) { t.Helper() ctx := context.Background() @@ -345,11 +362,14 @@ func transitionNativeToPrepared(t *testing.T, store *transaction.Store, transact } } +// testIdentity 根据内容计算文件身份(大小与 SHA-256),用于测试夹具与校验。 +// content 文件内容。返回对应的 filestore.Identity。 func testIdentity(content []byte) filestore.Identity { digest := sha256.Sum256(content) return filestore.Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} } +// fakeUnitManager systemd.Manager 的测试替身,记录单元状态与启动/停止调用次数。 type fakeUnitManager struct { mu sync.Mutex unit systemd.Unit @@ -360,6 +380,8 @@ type fakeUnitManager struct { startedName string } +// Inspect 返回单元状态:名称不匹配时返回 ErrUnitNotFound。 +// 参数 ctx 与 name 用于取消与定位,name 决定返回哪个单元。返回单元状态与错误。 func (m *fakeUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { m.mu.Lock() defer m.mu.Unlock() @@ -369,6 +391,8 @@ func (m *fakeUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, return m.unit, nil } +// Start 模拟启动单元:记录调用与名称,成功置为活跃,失败置为 failed 并返回 startErr。 +// 参数 ctx 与 name 用于取消与定位,name 决定记录的名称。返回值是启动错误(若配置了 startErr)。 func (m *fakeUnitManager) Start(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() @@ -384,6 +408,8 @@ func (m *fakeUnitManager) Start(_ context.Context, name string) error { return nil } +// Stop 模拟停止单元:记录调用,成功置为非活跃,失败返回 stopErr。 +// 参数 ctx 与 name 用于取消与定位。返回值是停止错误(若配置了 stopErr)。 func (m *fakeUnitManager) Stop(context.Context, string) error { m.mu.Lock() defer m.mu.Unlock() @@ -396,6 +422,7 @@ func (m *fakeUnitManager) Stop(context.Context, string) error { return nil } +// fakeActuatorChecker actuatorChecker 的测试替身,返回可配置的健康报告与错误。 type fakeActuatorChecker struct { waitReport healthcheck.ActuatorReport waitErr error @@ -404,6 +431,8 @@ type fakeActuatorChecker struct { checkErr error } +// Wait 模拟等待健康检查:先执行运行探针,未运行返回 ErrWorkloadStopped,否则返回 waitReport 与 waitErr。 +// ctx 用于取消;running 是运行探针。返回值是健康报告与错误。 func (c *fakeActuatorChecker) Wait(ctx context.Context, _ string, _ time.Duration, running healthcheck.RunningProbe) (healthcheck.ActuatorReport, error) { isRunning, err := running(ctx) if err != nil { @@ -415,6 +444,8 @@ func (c *fakeActuatorChecker) Wait(ctx context.Context, _ string, _ time.Duratio return c.waitReport, c.waitErr } +// Check 模拟单次健康检查:先执行运行探针,未运行返回 ErrWorkloadStopped,否则返回 checkReport、checkReady 与 checkErr。 +// ctx 用于取消;running 是运行探针。返回值是健康报告、就绪标志与错误。 func (c *fakeActuatorChecker) Check(ctx context.Context, _ string, running healthcheck.RunningProbe) (healthcheck.ActuatorReport, bool, error) { isRunning, err := running(ctx) if err != nil { @@ -426,5 +457,6 @@ func (c *fakeActuatorChecker) Check(ctx context.Context, _ string, running healt return c.checkReport, c.checkReady, c.checkErr } +// 以下编译期断言确保测试替身实现了相应接口。 var _ systemd.Manager = (*fakeUnitManager)(nil) var _ actuatorChecker = (*fakeActuatorChecker)(nil) diff --git a/internal/nativebackendexecutor/operations.go b/internal/nativebackendexecutor/operations.go index 337fcd0..29dbe0a 100644 --- a/internal/nativebackendexecutor/operations.go +++ b/internal/nativebackendexecutor/operations.go @@ -16,6 +16,8 @@ import ( "yms-daemon/internal/transaction" ) +// installJarOperation “安装后端 JAR 到发布存储”的事务操作,实现 transaction.Operation。 +// 它把源文件按给定身份提交到发布存储,并通过 Inspect 判断是否已生效。 type installJarOperation struct { store *filestore.Store sourcePath string @@ -23,6 +25,8 @@ type installJarOperation struct { identity filestore.Identity } +// Apply 执行安装:打开源 JAR 并以指定身份提交到发布存储。 +// 参数 ctx 未使用,仅用于满足接口签名。返回值是打开或提交失败时的错误。 func (o *installJarOperation) Apply(context.Context) error { source, err := os.Open(o.sourcePath) if err != nil { @@ -33,6 +37,9 @@ func (o *installJarOperation) Apply(context.Context) error { return err } +// Inspect 检查安装是否已生效,返回事务检查结果。 +// 参数 ctx 未使用,仅用于满足接口签名。目标冲突时返回未知状态,未找到时返回未应用, +// 找到时返回已应用并携带结果 JSON;其余情况返回错误。 func (o *installJarOperation) Inspect(context.Context) (transaction.Inspection, error) { file, found, err := o.store.Inspect(o.releasePath, o.identity) if errors.Is(err, filestore.ErrDestinationConflict) { @@ -47,12 +54,16 @@ func (o *installJarOperation) Inspect(context.Context) (transaction.Inspection, return transaction.Inspection{Status: transaction.InspectionApplied, Result: resultJSON(file)}, nil } +// slotLinkOperation “替换槽位软链接”的事务操作,实现 transaction.Operation。 +// 它把槽位软链接原子地替换为期望目标,并在 desiredTarget 为空时移除软链接。 type slotLinkOperation struct { path string desiredTarget string previousTarget string } +// Apply 原子地替换槽位软链接指向 desiredTarget,空目标则移除软链接。 +// ctx 用于 Inspect 调用。返回值是检查、目录校验或文件操作失败时的错误。 func (o *slotLinkOperation) Apply(ctx context.Context) error { inspection, err := o.Inspect(ctx) if err != nil { @@ -118,6 +129,9 @@ func (o *slotLinkOperation) Apply(ctx context.Context) error { return syncDirectory(parent) } +// Inspect 判断槽位软链接当前状态与期望是否一致,返回事务检查结果。 +// 参数 ctx 未使用,仅用于满足接口签名。软链接指向 desiredTarget 时为已应用, +// 指向 previousTarget 时为未应用,其余情况为未知;错误时返回错误。 func (o *slotLinkOperation) Inspect(context.Context) (transaction.Inspection, error) { info, err := os.Lstat(o.path) if errors.Is(err, os.ErrNotExist) { @@ -155,15 +169,21 @@ func (o *slotLinkOperation) Inspect(context.Context) (transaction.Inspection, er return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil } +// unitStartOperation “启动 systemd 单元”的事务操作,实现 transaction.Operation。 +// 它启动指定单元,并通过 Inspect 依据单元活跃状态判断是否已生效。 type unitStartOperation struct { units systemd.Manager name string } +// Apply 启动指定单元。 +// ctx 用于取消。返回值是启动失败时的错误。 func (o *unitStartOperation) Apply(ctx context.Context) error { return o.units.Start(ctx, o.name) } +// Inspect 检查单元是否已启动:活跃为已应用,非活跃或失败为未应用,其余为未知。 +// ctx 用于取消。返回值是事务检查结果;检查单元失败时返回错误。 func (o *unitStartOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { unit, err := o.units.Inspect(ctx, o.name) if err != nil { @@ -180,15 +200,21 @@ func (o *unitStartOperation) Inspect(ctx context.Context) (transaction.Inspectio } } +// unitStopOperation “停止 systemd 单元”的事务操作,实现 transaction.Operation。 +// 它停止指定单元,并通过 Inspect 依据单元活跃状态判断是否已停止。 type unitStopOperation struct { units systemd.Manager name string } +// Apply 停止指定单元。 +// ctx 用于取消。返回值是停止失败时的错误。 func (o *unitStopOperation) Apply(ctx context.Context) error { return o.units.Stop(ctx, o.name) } +// Inspect 检查单元是否已停止:非活跃或失败为已应用,活跃为未应用,其余为未知。 +// ctx 用于取消。返回值是事务检查结果;检查单元失败时返回错误。 func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { unit, err := o.units.Inspect(ctx, o.name) if err != nil { @@ -205,6 +231,8 @@ func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection } } +// healthOperation “等待后端 Actuator 健康检查”的事务操作,实现 transaction.Operation。 +// 它等待健康检查就绪,并通过互斥锁缓存已确认的报告以避免重复探测。 type healthOperation struct { units systemd.Manager checker actuatorChecker @@ -217,6 +245,8 @@ type healthOperation struct { confirmed bool } +// Apply 阻塞等待健康检查就绪,成功后缓存已确认的报告。 +// ctx 用于取消与超时。返回值是等待失败时的错误。 func (o *healthOperation) Apply(ctx context.Context) error { report, err := o.checker.Wait(ctx, o.endpoint, o.timeout, o.running) if err != nil { @@ -229,6 +259,8 @@ func (o *healthOperation) Apply(ctx context.Context) error { return nil } +// Inspect 检查健康状态:若已有确认报告则直接返回,否则执行一次即时健康检查。 +// ctx 用于取消。返回值是事务检查结果;检查器错误由 healthInspection 归一化后返回。 func (o *healthOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { o.mu.Lock() if o.confirmed { @@ -241,6 +273,8 @@ func (o *healthOperation) Inspect(ctx context.Context) (transaction.Inspection, return healthInspection(report, ready, err) } +// running 判断后端单元当前是否处于活跃状态,作为健康检查的探针。 +// ctx 用于取消。单元不存在时返回 false 且无错误;其他检查失败时返回错误。 func (o *healthOperation) running(ctx context.Context) (bool, error) { unit, err := o.units.Inspect(ctx, o.unitName) if errors.Is(err, systemd.ErrUnitNotFound) { @@ -252,6 +286,8 @@ func (o *healthOperation) running(ctx context.Context) (bool, error) { return unit.ActiveState == activeState, nil } +// healthInspection 把健康检查结果归一化为事务检查状态。 +// report 健康报告;ready 是就绪标志;err 是检查错误。工作负载停止、出错或未就绪均为未应用,否则为已应用。 func healthInspection(report healthcheck.ActuatorReport, ready bool, err error) (transaction.Inspection, error) { result := resultJSON(report) if errors.Is(err, healthcheck.ErrWorkloadStopped) { @@ -263,6 +299,8 @@ func healthInspection(report healthcheck.ActuatorReport, ready bool, err error) return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } +// linkResult 把槽位路径与目标序列化为 JSON 结果。 +// path 槽位软链接路径;target 是软链接指向的目标。返回值是序列化后的 JSON。 func linkResult(path, target string) json.RawMessage { return resultJSON(struct { Path string `json:"path"` @@ -270,6 +308,8 @@ func linkResult(path, target string) json.RawMessage { }{path, target}) } +// unitResult 把 systemd 单元状态序列化为 JSON 结果。 +// unit 待序列化的单元。返回值是序列化后的 JSON。 func unitResult(unit systemd.Unit) json.RawMessage { return resultJSON(struct { Name string `json:"name"` @@ -279,6 +319,8 @@ func unitResult(unit systemd.Unit) json.RawMessage { }{unit.Name, unit.LoadState, unit.ActiveState, unit.SubState}) } +// resultJSON 把任意值序列化为 JSON 原始消息。 +// value 待序列化的值。序列化失败时直接 panic(内部结果应始终可序列化)。返回值是序列化后的 JSON。 func resultJSON(value any) json.RawMessage { payload, err := json.Marshal(value) if err != nil { @@ -287,6 +329,8 @@ func resultJSON(value any) json.RawMessage { return payload } +// syncDirectory 打开目录并同步其元数据到磁盘,确保重命名或删除持久化。 +// path 待刷新的目录路径。返回值是打开、同步或关闭失败时的错误。 func syncDirectory(path string) error { directory, err := os.Open(path) if err != nil { @@ -300,6 +344,7 @@ func syncDirectory(path string) error { return nil } +// 以下编译期断言确保各操作类型都实现了 transaction.Operation 接口。 var _ transaction.Operation = (*installJarOperation)(nil) var _ transaction.Operation = (*slotLinkOperation)(nil) var _ transaction.Operation = (*unitStartOperation)(nil) diff --git a/internal/processlock/lock.go b/internal/processlock/lock.go index 7f73f6c..35ea8e8 100644 --- a/internal/processlock/lock.go +++ b/internal/processlock/lock.go @@ -1,3 +1,7 @@ +// Package processlock 提供服务端进程级排他锁,确保同一时刻只有一个 serve 进程持有内核锁。 +// +// 锁基于 Linux advisory flock 实现,具体见 lock_linux.go; +// 在非 Linux 平台返回 ErrUnsupported(见 lock_unsupported.go)。 package processlock import "errors" diff --git a/internal/processlock/lock_linux.go b/internal/processlock/lock_linux.go index 18579bc..dc81dc1 100644 --- a/internal/processlock/lock_linux.go +++ b/internal/processlock/lock_linux.go @@ -15,7 +15,9 @@ import ( // Lock 持有与文件描述符绑定的 Linux advisory flock。 // 锁文件可以永久存在;只有内核锁状态表示当前所有权。 type Lock struct { - mu sync.Mutex + // mu 保证释放操作与状态访问的并发安全。 + mu sync.Mutex + // file 与内核锁绑定的锁文件描述符。 file *os.File } @@ -50,6 +52,7 @@ func Acquire(path string) (*Lock, error) { return &Lock{file: file}, nil } +// writeOwnerPID 将当前进程 PID 写入锁文件并落盘,供诊断排查。 func writeOwnerPID(file *os.File) error { if err := file.Truncate(0); err != nil { return fmt.Errorf("truncate process lock metadata: %w", err) diff --git a/internal/processlock/lock_linux_test.go b/internal/processlock/lock_linux_test.go index 8bd5309..22f23f6 100644 --- a/internal/processlock/lock_linux_test.go +++ b/internal/processlock/lock_linux_test.go @@ -9,6 +9,8 @@ import ( "testing" ) +// TestLockUsesKernelOwnershipAndLeavesFileInPlace 验证锁由内核状态决定所有权、 +// 锁文件在释放后保留、写入诊断 PID,并可在释放后重新获取。 func TestLockUsesKernelOwnershipAndLeavesFileInPlace(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "serve.lock") diff --git a/internal/runtimepaths/paths.go b/internal/runtimepaths/paths.go index 65bb167..989081b 100644 --- a/internal/runtimepaths/paths.go +++ b/internal/runtimepaths/paths.go @@ -1,16 +1,25 @@ -// Package runtimepaths contains the server paths frozen for the daemon service. +// Package runtimepaths 集中定义守护进程服务运行时固定使用的宿主路径。 +// 这些路径在部署时被固化,供守护进程服务端与命令行客户端两侧共享,避免路径分散在各处导致不一致。 package runtimepaths +// 守护进程运行时自身使用的宿主路径,均为绝对路径。 const ( - Socket = "/run/yms-daemon/yms-daemon.sock" - Lock = "/run/yms-daemon/yms-daemon.lock" + // Socket 守护进程监听的本地 Unix Socket 路径。 + Socket = "/run/yms-daemon/yms-daemon.sock" + // Lock 守护进程用于保证单实例运行的进程锁文件路径。 + Lock = "/run/yms-daemon/yms-daemon.lock" + // Database 守护进程持久化事务状态的 SQLite 数据库文件路径。 Database = "/var/lib/yms-daemon/yms-daemon.db" + // WorkRoot 守护进程执行更新时的临时工作目录根。 WorkRoot = "/var/lib/yms-daemon/work" - Log = "/var/log/yms-daemon/yms-daemon.log" + // Log 守护进程写入运行日志的文件路径。 + Log = "/var/log/yms-daemon/yms-daemon.log" ) +// 守护进程操作宿主机 Nginx 时依赖的配置和可执行文件。 const ( - HostNginxConfig = "/etc/nginx/nginx.conf" + // HostNginxConfig 宿主机 Nginx 的配置文件路径。 + HostNginxConfig = "/etc/nginx/nginx.conf" + // HostNginxExecutable 宿主机 Nginx 可执行文件的路径。 HostNginxExecutable = "/usr/sbin/nginx" - HostNginxService = "nginx.service" ) diff --git a/internal/systemd/manager.go b/internal/systemd/manager.go index 7f3013d..5b3496f 100644 --- a/internal/systemd/manager.go +++ b/internal/systemd/manager.go @@ -1,4 +1,7 @@ -// Package systemd defines the exact systemd boundary used by native executors. +// Package systemd 定义原生执行器所依赖的精确 systemd 边界。 +// +// 该包声明 systemd 操作所需的最小抽象,具体实现见 systemctl.go, +// 它直接调用 systemctl 可执行文件,绝不经过 shell。 package systemd import ( @@ -6,19 +9,27 @@ import ( "errors" ) +// ErrUnitNotFound 表示未找到指定的 systemd 单元。 var ErrUnitNotFound = errors.New("systemd unit not found") -// Unit is the systemd state required for idempotent start and stop inspection. +// Unit 执行幂等启停检查所需的 systemd 状态。 type Unit struct { - Name string - LoadState string + // Name 表示单元名称。 + Name string + // LoadState 表示单元的加载状态。 + LoadState string + // ActiveState 表示单元的活动状态。 ActiveState string - SubState string + // SubState 表示单元的子状态。 + SubState string } -// Manager performs direct systemd operations without invoking a shell. +// Manager 在不调用 shell 的前提下直接执行 systemd 操作。 type Manager interface { + // Inspect 检查指定单元并返回其状态。 Inspect(context.Context, string) (Unit, error) + // Start 启动指定单元。 Start(context.Context, string) error + // Stop 停止指定单元。 Stop(context.Context, string) error } diff --git a/internal/systemd/systemctl.go b/internal/systemd/systemctl.go index aef9e2f..fb5881c 100644 --- a/internal/systemd/systemctl.go +++ b/internal/systemd/systemctl.go @@ -12,19 +12,25 @@ import ( ) const ( - loadStateProperty = "LoadState" + // loadStateProperty systemctl show 输出的加载状态属性名。 + loadStateProperty = "LoadState" + // activeStateProperty systemctl show 输出的活动状态属性名。 activeStateProperty = "ActiveState" - subStateProperty = "SubState" - loadedState = "loaded" - notFoundState = "not-found" + // subStateProperty systemctl show 输出的子状态属性名。 + subStateProperty = "SubState" + // loadedState 表示单元已加载。 + loadedState = "loaded" + // notFoundState 表示单元未找到。 + notFoundState = "not-found" ) -// Systemctl invokes one exact systemctl executable directly, never through a shell. +// Systemctl 直接调用一个精确的 systemctl 可执行文件,绝不经过 shell。 type Systemctl struct { + // executable systemctl 的绝对路径。 executable string } -// NewSystemctl requires the absolute executable path supplied by local daemon configuration. +// NewSystemctl 要求传入本地守护进程配置提供的可执行文件绝对路径。 func NewSystemctl(executable string) (*Systemctl, error) { if !filepath.IsAbs(executable) { return nil, errors.New("systemctl executable path must be absolute") @@ -32,6 +38,8 @@ func NewSystemctl(executable string) (*Systemctl, error) { return &Systemctl{executable: executable}, nil } +// Inspect 查询指定单元的关键状态属性并返回其状态。 +// 未找到单元时错误包装 ErrUnitNotFound;加载状态异常时返回错误。 func (s *Systemctl) Inspect(ctx context.Context, unitName string) (Unit, error) { if err := validateUnitName(unitName); err != nil { return Unit{}, err @@ -62,14 +70,17 @@ func (s *Systemctl) Inspect(ctx context.Context, unitName string) (Unit, error) return unit, nil } +// Start 启动指定单元。 func (s *Systemctl) Start(ctx context.Context, unitName string) error { return s.changeState(ctx, "start", unitName) } +// Stop 停止指定单元。 func (s *Systemctl) Stop(ctx context.Context, unitName string) error { return s.changeState(ctx, "stop", unitName) } +// changeState 通过 systemctl 执行给定动作(start 或 stop)。 func (s *Systemctl) changeState(ctx context.Context, action, unitName string) error { if err := validateUnitName(unitName); err != nil { return err @@ -81,6 +92,8 @@ func (s *Systemctl) changeState(ctx context.Context, action, unitName string) er return nil } +// parseUnitProperties 解析 systemctl show 的输出, +// 严格要求恰好包含三个预期属性且不重复、不多余。 func parseUnitProperties(unitName string, output []byte) (Unit, error) { values := make(map[string]string, 3) scanner := bufio.NewScanner(bytes.NewReader(output)) @@ -116,6 +129,7 @@ func parseUnitProperties(unitName string, output []byte) (Unit, error) { }, nil } +// validateUnitName 校验单元名称必须非空且不含首尾空白。 func validateUnitName(unitName string) error { if unitName == "" || strings.TrimSpace(unitName) != unitName { return errors.New("exact systemd unit name is required") @@ -123,6 +137,7 @@ func validateUnitName(unitName string) error { return nil } +// commandError 包装命令执行错误,并附带去除首尾空白的命令输出作为诊断细节。 func commandError(action string, output []byte, err error) error { detail := strings.TrimSpace(string(output)) if detail == "" { diff --git a/internal/systemd/systemctl_test.go b/internal/systemd/systemctl_test.go index 60743e0..7266bf3 100644 --- a/internal/systemd/systemctl_test.go +++ b/internal/systemd/systemctl_test.go @@ -4,6 +4,7 @@ import ( "testing" ) +// TestParseUnitPropertiesRequiresExactProperties 验证属性解析严格接受恰好三个预期属性,并拒绝缺失、多余或重复属性。 func TestParseUnitPropertiesRequiresExactProperties(t *testing.T) { t.Parallel() unit, err := parseUnitProperties("yms-green.service", []byte("LoadState=loaded\nActiveState=inactive\nSubState=dead\n")) @@ -26,6 +27,7 @@ func TestParseUnitPropertiesRequiresExactProperties(t *testing.T) { } } +// TestNewSystemctlRequiresAbsoluteExecutable 验证相对路径会被拒绝,绝对路径会被接受。 func TestNewSystemctlRequiresAbsoluteExecutable(t *testing.T) { t.Parallel() if _, err := NewSystemctl("systemctl"); err == nil { @@ -37,6 +39,7 @@ func TestNewSystemctlRequiresAbsoluteExecutable(t *testing.T) { } } +// TestValidateUnitNamePreservesOpaqueValue 验证合法单元名被原样保留,空串或带首尾空白被拒绝。 func TestValidateUnitNamePreservesOpaqueValue(t *testing.T) { t.Parallel() if err := validateUnitName("backend-green.service"); err != nil { diff --git a/internal/transaction/backend_container_deployment.go b/internal/transaction/backend_container_deployment.go index 8a4580a..1803cf7 100644 --- a/internal/transaction/backend_container_deployment.go +++ b/internal/transaction/backend_container_deployment.go @@ -9,21 +9,31 @@ import ( "time" ) +// backendService backend 容器部署事务使用的服务名常量。 +// +// 该常量同时用于部署表相关事务的归属判定与历史查询的条件过滤。 const backendService = "backend" // BackendContainerDeployment is the last container slot committed for backend. // Runtime inspection remains authoritative for the current external state; this // record distinguishes a fresh installation from loss or drift after deployment. +// +// BackendContainerDeployment backend 最近一次提交使用的容器槽位持久化记录。 +// 它只保留单行(singleton_id=1),用于区分全新安装与部署后丢失/漂移:运行时 inspect +// 始终是当前外部状态的权威来源,而本记录提供“机器是否曾成功部署”的持久化证据。 type BackendContainerDeployment struct { - ActivePort int - ContainerName string - ImageDigest string - ContainerID string - TransactionID string - UpdatedAt time.Time + ActivePort int // 当前对外服务的端口,只能是 8080 或 8081。 + ContainerName string // 生效容器的名称。 + ImageDigest string // 生效容器镜像的摘要。 + ContainerID string // 生效容器的 ID。 + TransactionID string // 提交本次部署的事务 ID。 + UpdatedAt time.Time // 记录最近一次更新(提交)时间(UTC)。 } // BackendContainerDeployment returns the single committed backend container state. +// +// BackendContainerDeployment 返回当前唯一已提交的 backend 容器部署记录。参数 ctx +// 用于取消查询。若尚无部署记录则返回 ErrNotFound。 func (s *Store) BackendContainerDeployment(ctx context.Context) (BackendContainerDeployment, error) { return scanBackendContainerDeployment(s.db.QueryRowContext(ctx, ` SELECT active_port, container_name, image_digest, container_id, @@ -36,6 +46,13 @@ func (s *Store) BackendContainerDeployment(ctx context.Context) (BackendContaine // contains a committed container backend transaction. This is the legacy // deployment evidence used when a database predates the deployment table. // Failed and rolled-back first-install attempts do not mark a machine deployed. +// +// HasCommittedBackendContainerTransactionHistory 报告当前存储中是否存在已提交的 +// backend 容器事务历史。当数据库版本早于部署表(缺少 backend_container_deployment +// 单例记录)时,它作为历史部署证据使用:只要存在 service 为 backend 且幂等键匹配 +// backend:container:* 且状态为 COMMITTED 的事务,即视为该机器已部署。失败或回滚的 +// 首次安装尝试不会把机器标记为已部署。参数 ctx 用于取消查询,返回是否已部署的布尔值 +// 及可能的错误。 func (s *Store) HasCommittedBackendContainerTransactionHistory(ctx context.Context) (bool, error) { var found int if err := s.db.QueryRowContext(ctx, ` @@ -54,6 +71,13 @@ func (s *Store) HasCommittedBackendContainerTransactionHistory(ctx context.Conte // CommitBackendContainerDeployment atomically records the active container and // commits its transaction. A crash cannot leave COMMITTED without the matching // deployment row, or publish a deployment row for an unfinished transaction. +// +// CommitBackendContainerDeployment 原子地记录生效容器并把其事务提交为 COMMITTED。 +// 参数 ctx 用于取消操作;transactionID 为待提交的事务 ID,必须与 backend 服务匹配 +// 且当前状态为 DRAINING;deployment 为要写入的容器部署信息;message 为随状态事件 +// 记录的信息。返回值为更新后的最新事务快照。由于部署行写入与事务状态提交在同一个 +// Serializable 事务内完成,崩溃不可能留下“已 COMMITTED 却没有对应部署行”或“未完成 +// 事务却已发布部署行”的中间状态。 func (s *Store) CommitBackendContainerDeployment( ctx context.Context, transactionID string, @@ -141,6 +165,11 @@ func (s *Store) CommitBackendContainerDeployment( return record, nil } +// validateBackendContainerDeployment 校验 backend 容器部署信息的合法性。 +// +// ActivePort 必须是 8080 或 8081;ContainerName、ImageDigest、ContainerID 均不能 +// 为空且不能含有首尾空白(即必须是精确值)。全部通过返回 nil,否则返回描述具体 +// 问题的错误。 func validateBackendContainerDeployment(deployment BackendContainerDeployment) error { if deployment.ActivePort != 8080 && deployment.ActivePort != 8081 { return fmt.Errorf("backend container deployment port must be 8080 or 8081: %d", deployment.ActivePort) @@ -160,6 +189,11 @@ func validateBackendContainerDeployment(deployment BackendContainerDeployment) e return nil } +// scanBackendContainerDeployment 从单行结果反序列化一条 backend 容器部署记录。 +// +// 参数 row 提供扫描能力,返回反序列化后的 BackendContainerDeployment。将持久化的 +// updated_at 文本解析为 time.Time。未命中返回 ErrNotFound,其他解析失败返回包装后 +// 的错误。 func scanBackendContainerDeployment(row rowScanner) (BackendContainerDeployment, error) { var deployment BackendContainerDeployment var updatedAt string diff --git a/internal/transaction/coordinator.go b/internal/transaction/coordinator.go index 524a1a0..738b970 100644 --- a/internal/transaction/coordinator.go +++ b/internal/transaction/coordinator.go @@ -9,50 +9,72 @@ import ( ) // InspectionStatus 表示外部系统中某一步的实际结果。 +// +// 它是 Operation.Inspect 的核对结论,Coordinator 据此判断副作用是否真实发生, +// 从而决定步骤的最终状态。UNKNOWN 表示暂时无法确认,必须保留意图等待恢复。 type InspectionStatus string const ( - InspectionApplied InspectionStatus = "APPLIED" - InspectionNotApplied InspectionStatus = "NOT_APPLIED" - InspectionUnknown InspectionStatus = "UNKNOWN" + InspectionApplied InspectionStatus = "APPLIED" // 外部副作用已确认发生。 + InspectionNotApplied InspectionStatus = "NOT_APPLIED" // 外部副作用已确认未发生。 + InspectionUnknown InspectionStatus = "UNKNOWN" // 无法确认副作用是否发生。 ) -// Inspection 是执行器通过 inspect、摘要、健康检查等方式得到的实际状态。 +// Inspection 执行器通过 inspect、摘要、健康检查等方式得到的实际状态。 +// +// 它把对外的状态核对结果封装为统一结构:Status 给出确定/不确定的结论,Result +// 保存核对得到的原始证据,用于在步骤成功时持久化。 type Inspection struct { - Status InspectionStatus - Result json.RawMessage + Status InspectionStatus // 核对结论。 + Result json.RawMessage // 核对得到的原始结果载荷,可为空。 } -// Operation 是一个可核对实际结果的外部副作用。 -// Apply 返回成功只代表调用完成;最终成功必须由 Inspect 确认。 +// Operation 一个可核对实际结果的外部副作用。 +// +// 实现者负责实际执行外部操作(Apply)并提供事后核对(Inspect)。Apply 返回 +// 成功只代表调用完成,副作用是否真正生效必须由 Inspect 确认,因此调用方在 +// Apply 之后无论成功与否都要再次 Inspect,以精确判定外部状态。 type Operation interface { Apply(context.Context) error Inspect(context.Context) (Inspection, error) } // UncertainStepError 表示当前无法确认外部副作用是否已经发生。 -// 这种错误必须保留 INTENT_RECORDED,等待恢复流程再次核对。 +// +// 这种错误必须保留 INTENT_RECORDED,等待恢复流程再次核对。它携带事务与步骤 +// 定位信息及根本原因(Cause),并通过 Unwrap 暴露 Cause 供调用方溯源。 type UncertainStepError struct { - TransactionID string - StepKey string - Cause error + TransactionID string // 所属事务 ID。 + StepKey string // 关联步骤键。 + Cause error // 导致无法确认的根本原因。 } +// Error 返回带事务、步骤与原因的无法确认错误描述。 func (e *UncertainStepError) Error() string { return fmt.Sprintf("external step result is uncertain: transaction=%s step=%s: %v", e.TransactionID, e.StepKey, e.Cause) } +// Unwrap 返回根本原因 Cause,支持 errors.Is/errors.As 溯源。 func (e *UncertainStepError) Unwrap() error { return e.Cause } // Coordinator 串行化单机更新,并实现“先记录意图、再执行、最后 inspect”的步骤协议。 +// +// 它通过容量为 1 的 permit 通道保证同一时刻进程内最多只有一个完整更新流程在 +// 执行区运行,从而简化外部副作用与数据库状态的一致性推理。所有对外部副作用的 +// 操作都必须经过 ExecuteStep,以确保意图先落库、结果可核对、崩溃可恢复。 type Coordinator struct { - store *Store - logger *slog.Logger - permit chan struct{} + store *Store // 持久化存储,记录事务、步骤与事件。 + logger *slog.Logger // 结构化日志记录器。 + permit chan struct{} // 容量为 1 的信号量,串行化进入执行区。 } +// NewCoordinator 创建并初始化事务协调器。 +// +// 参数 store 为必填的事务存储,logger 可选,传入 nil 时回退到 slog.Default。 +// 初始化时向 permit 通道放入一个令牌,表示执行区当前空闲。返回错误仅发生在 +// store 为 nil 时。 func NewCoordinator(store *Store, logger *slog.Logger) (*Coordinator, error) { if store == nil { return nil, errors.New("transaction store is required") @@ -66,6 +88,11 @@ func NewCoordinator(store *Store, logger *slog.Logger) (*Coordinator, error) { } // RunExclusive 在一个进程内只允许一个完整更新流程进入执行区。 +// +// 参数 run 是独占执行的更新函数,返回其执行结果;若 run 为 nil 或 ctx 在获得 +// 执行令牌前已取消,则直接返回相应错误。该方法通过 acquire 令牌、defer 释放 +// 令牌实现互斥,释放与获取均不阻塞超过令牌容量,因此不存在死锁。返回值为 +// run 的返回值,或在等待令牌时 ctx 取消导致的错误。 func (c *Coordinator) RunExclusive(ctx context.Context, run func(context.Context) error) error { if run == nil { return errors.New("exclusive update function is required") @@ -80,7 +107,14 @@ func (c *Coordinator) RunExclusive(ctx context.Context, run func(context.Context } // ExecuteStep 执行或恢复一个外部步骤。 -// 相同 step key 再次调用时先核对现场,禁止直接重复 Apply。 +// +// 参数 transactionID 是所属事务,intent 描述要执行的外部副作用,operation 提供 +// 实际执行与核对能力。返回值为该步骤的最终持久化快照以及可能的错误。协议要点: +// 相同 step key 再次调用时先核对现场,禁止直接重复 Apply;若意图已成功则直接 +// 返回;若已失败则依据 inspect 结论决定补记成功或重新打开;若为未执行过的新 +// 意图,则先记录意图、Apply 后再 Inspect,依据核对结果落库成功或失败。当核对 +// 结果为 UNKNOWN 或 Inspect 本身出错时,返回 UncertainStepError 并保留 +// INTENT_RECORDED 等待后续恢复。 func (c *Coordinator) ExecuteStep(ctx context.Context, transactionID string, intent StepIntent, operation Operation) (Step, error) { if operation == nil { return Step{}, errors.New("external operation is required") @@ -176,10 +210,18 @@ func (c *Coordinator) ExecuteStep(ctx context.Context, transactionID string, int } } +// completeApplied 将已确认生效的步骤原子落库为 SUCCEEDED。 +// +// 参数 transactionID 与 stepKey 定位目标步骤,inspection.Result 作为成功结果 +// 写入。返回更新后的步骤快照及可能的存储错误。 func (c *Coordinator) completeApplied(ctx context.Context, transactionID, stepKey string, inspection Inspection) (Step, error) { return c.store.CompleteStep(ctx, transactionID, stepKey, StepSucceeded, inspection.Result, "") } +// uncertain 记录无法确认外部副作用结果的告警日志并构造 UncertainStepError。 +// +// 参数 transactionID 与 stepKey 用于定位,cause 为根本原因。该方法不改变存储 +// 状态,仅记录日志并返回错误,交由上层决定是否保留意图等待恢复。 func (c *Coordinator) uncertain(ctx context.Context, transactionID, stepKey string, cause error) error { c.logger.WarnContext(ctx, "external step result is uncertain", "transaction_id", transactionID, diff --git a/internal/transaction/coordinator_test.go b/internal/transaction/coordinator_test.go index 41ad37b..e2b5abe 100644 --- a/internal/transaction/coordinator_test.go +++ b/internal/transaction/coordinator_test.go @@ -10,6 +10,8 @@ import ( "testing" ) +// TestCoordinatorRecoversIntentWithoutRepeatingAppliedOperation 验证崩溃恢复时, +// 若已记录意图的外部副作用经 Inspect 确认为已生效,则直接补记成功而不再重复 Apply。 func TestCoordinatorRecoversIntentWithoutRepeatingAppliedOperation(t *testing.T) { t.Parallel() ctx := context.Background() @@ -30,6 +32,8 @@ func TestCoordinatorRecoversIntentWithoutRepeatingAppliedOperation(t *testing.T) } } +// TestCoordinatorRecoversNotAppliedIntentThenExecutesOnce 验证已记录意图但 Inspect +// 确认副作用未发生时,恢复流程会重新 Apply 且只执行一次,最终补记成功。 func TestCoordinatorRecoversNotAppliedIntentThenExecutesOnce(t *testing.T) { t.Parallel() ctx := context.Background() @@ -50,6 +54,8 @@ func TestCoordinatorRecoversNotAppliedIntentThenExecutesOnce(t *testing.T) { } } +// TestCoordinatorLeavesIntentPendingWhenInspectionIsUnknown 验证 Inspect 无法确认 +// 副作用状态时,返回 UncertainStepError 且步骤保留 INTENT_RECORDED 等待后续恢复。 func TestCoordinatorLeavesIntentPendingWhenInspectionIsUnknown(t *testing.T) { t.Parallel() ctx := context.Background() @@ -72,6 +78,8 @@ func TestCoordinatorLeavesIntentPendingWhenInspectionIsUnknown(t *testing.T) { } } +// TestCoordinatorRetriesFailedStepOnlyAfterManualResumeInspection 验证失败的步骤不会 +// 自动重试,只有人工恢复后再次调用 ExecuteStep,经 Inspect 确认未生效才重新 Apply。 func TestCoordinatorRetriesFailedStepOnlyAfterManualResumeInspection(t *testing.T) { t.Parallel() ctx := context.Background() @@ -99,6 +107,8 @@ func TestCoordinatorRetriesFailedStepOnlyAfterManualResumeInspection(t *testing. } } +// TestCoordinatorCompletesFailedStepAlreadyAppliedBeforeManualResume 验证失败步骤在 +// 人工恢复时经 Inspect 发现副作用其实已生效,则直接补记成功而不重复 Apply。 func TestCoordinatorCompletesFailedStepAlreadyAppliedBeforeManualResume(t *testing.T) { t.Parallel() ctx := context.Background() @@ -122,6 +132,8 @@ func TestCoordinatorCompletesFailedStepAlreadyAppliedBeforeManualResume(t *testi } } +// TestCoordinatorExclusiveExecutionHonorsContext 验证 RunExclusive 的互斥与上下文取消: +// 已有流程占用执行区时,后续取消的上下文会立即返回 context.Canceled,且不进入执行区。 func TestCoordinatorExclusiveExecutionHonorsContext(t *testing.T) { t.Parallel() store := openTestStore(t) @@ -152,6 +164,10 @@ func TestCoordinatorExclusiveExecutionHonorsContext(t *testing.T) { } } +// fakeOperation 测试用的 Operation 实现,可编程地模拟 Apply 与 Inspect 行为。 +// +// 字段 applied 表示副作用是否已生效,result 为核对结果,applyErr 与 inspectErr 分别 +// 模拟执行与核对失败;applyCalls 与 inspectCalls 用于断言调用次数。 type fakeOperation struct { applied bool result json.RawMessage @@ -161,6 +177,7 @@ type fakeOperation struct { inspectCalls atomic.Int32 } +// Apply 模拟执行外部副作用:递增 applyCalls 计数,成功时把 applied 置为 true。 func (o *fakeOperation) Apply(context.Context) error { o.applyCalls.Add(1) if o.applyErr == nil { @@ -169,6 +186,8 @@ func (o *fakeOperation) Apply(context.Context) error { return o.applyErr } +// Inspect 模拟核对外部副作用:递增 inspectCalls 计数,根据 applied 返回 +// APPLIED 或 NOT_APPLIED;若设置了 inspectErr 则返回该错误。 func (o *fakeOperation) Inspect(context.Context) (Inspection, error) { o.inspectCalls.Add(1) if o.inspectErr != nil { @@ -180,6 +199,10 @@ func (o *fakeOperation) Inspect(context.Context) (Inspection, error) { return Inspection{Status: InspectionNotApplied}, nil } +// createTestTransaction 创建一条用于测试的事务记录。 +// +// 参数 store 为目标存储,suffix 用于生成唯一的事务 ID 与幂等键。创建失败时以 +// t.Fatalf 终止测试并返回错误说明。 func createTestTransaction(t *testing.T, store *Store, suffix string) Transaction { t.Helper() record, _, err := store.CreateTransaction(context.Background(), CreateRequest{ @@ -194,6 +217,9 @@ func createTestTransaction(t *testing.T, store *Store, suffix string) Transactio return record } +// newTestCoordinator 创建用于测试的 Coordinator,日志输出丢弃到 io.Discard。 +// +// 参数 store 为底层存储。创建失败时以 t.Fatalf 终止测试。 func newTestCoordinator(t *testing.T, store *Store) *Coordinator { t.Helper() coordinator, err := NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil))) diff --git a/internal/transaction/errors.go b/internal/transaction/errors.go index 42b3431..958caf0 100644 --- a/internal/transaction/errors.go +++ b/internal/transaction/errors.go @@ -6,21 +6,27 @@ import ( ) var ( - ErrNotFound = errors.New("transaction record not found") - ErrActiveExists = errors.New("an unfinished transaction already exists") - ErrStepConflict = errors.New("step key already refers to different intent") - ErrStepNotPending = errors.New("step is not waiting for an execution result") + ErrNotFound = errors.New("transaction record not found") // 请求的事务记录不存在。 + ErrActiveExists = errors.New("an unfinished transaction already exists") // 已存在一条未结束的事务,阻塞新事务创建。 + ErrStepConflict = errors.New("step key already refers to different intent") // 同一 step key 已被不同意图占用。 + ErrStepNotPending = errors.New("step is not waiting for an execution result") // 步骤不处于等待执行结果的待定状态。 ) // ActiveTransactionError 告知调用方当前阻塞新请求的事务。 +// +// 当尝试创建新事务却发现数据库中仍存在未结束事务时返回,TransactionID 指向 +// 那条正在占用单活动事务槽位的已有事务。其 Unwrap 返回 ErrActiveExists, +// 因此调用方既可用 errors.Is 判断大类,也可用 errors.As 取出具体事务 ID。 type ActiveTransactionError struct { - TransactionID string + TransactionID string // 当前阻塞新请求的未结束事务 ID。 } +// Error 返回带事务 ID 的阻塞错误描述。 func (e *ActiveTransactionError) Error() string { return fmt.Sprintf("%s: %s", ErrActiveExists, e.TransactionID) } +// Unwrap 返回底层哨兵错误 ErrActiveExists,支持 errors.Is 判定。 func (e *ActiveTransactionError) Unwrap() error { return ErrActiveExists } diff --git a/internal/transaction/model.go b/internal/transaction/model.go index 1a8c14a..c0b1946 100644 --- a/internal/transaction/model.go +++ b/internal/transaction/model.go @@ -1,3 +1,13 @@ +// Package transaction 实现服务端更新请求的事务状态机与持久化层。 +// +// 该包负责把一次单机更新请求建模为一个持久化的事务记录,并通过严格的 +// 状态机约束其生命周期:从 CREATED 一路推进到 COMMITTED,或进入 +// ROLLING_BACK/ROLLED_BACK/FAILED 等终态。事务的执行遵循“先记录意图、 +// 再执行外部副作用、最后 inspect 核对”的协议,以保证进程崩溃后能够安全恢复。 +// +// 包内数据统一存储在单个本地 SQLite 数据库中(见 store.go),Coordinator +// 负责在进程内串行化执行并驱动外部步骤,其余类型则定义了贯穿全局的数据模型、 +// 状态、事件与错误约定。 package transaction import ( @@ -5,72 +15,94 @@ import ( "time" ) -// Transaction 是一次更新请求的服务端持久化快照。 +// Transaction 一次更新请求的服务端持久化快照。 +// +// 它同时承担幂等控制与状态推进两个职责:IdempotencyKey 唯一标识一次客户端 +// 重试,Version 用于乐观锁防止并发覆盖,State 记录当前状态机位置。除终态外, +// 数据库中同时最多只允许存在一条未结束的事务(由单活动事务索引保证)。 type Transaction struct { - ID string - IdempotencyKey string - Source string - Service string - Request json.RawMessage - State State - Version int64 - CreatedAt time.Time - UpdatedAt time.Time + ID string // 事务唯一标识,为空时由存储层自动生成。 + IdempotencyKey string // 幂等键,重复请求据此返回同一事务。 + Source string // 请求来源,如 ymsctl 或守护进程。 + Service string // 目标服务名,如 backend。 + Request json.RawMessage // 原始请求载荷,仅作快照保存。 + State State // 当前状态机状态。 + Version int64 // 乐观锁版本号,每次状态变化自增。 + CreatedAt time.Time // 事务首次创建时间(UTC)。 + UpdatedAt time.Time // 最近一次变更时间(UTC)。 } -// ListFilter limits the history query used by the ymsctl list command. +// ListFilter ymsctl list 命令查询历史记录时使用的过滤条件。 +// +// 所有字段均按精确值匹配,服务端不会对过滤条件做任何推断或模糊处理。 type ListFilter struct { - Limit int - Service string - State State + Limit int // 最多返回的记录条数,必须在 1 到 1000 之间。 + Service string // 按服务名精确过滤,为空表示不过滤。 + State State // 按状态精确过滤,为空表示不过滤。 } // CreateRequest 包含创建事务所需的不可变请求信息。 +// +// 该结构体是 CreateTransaction 的入参,其中的 Request 必须是合法的 JSON, +// 否则创建会被拒绝;空请求会被规范化为空对象 {}。 type CreateRequest struct { - ID string - IdempotencyKey string - Source string - Service string - Request json.RawMessage + ID string // 事务唯一标识,允许留空由存储层生成。 + IdempotencyKey string // 幂等键,必填且不能为空白。 + Source string // 请求来源,必填且不能为空白。 + Service string // 目标服务名,必填且不能为空白。 + Request json.RawMessage // 原始请求载荷,必须是合法 JSON。 } -// StepStatus 是外部步骤的持久化执行状态。 +// StepStatus 外部步骤的持久化执行状态。 +// +// 它描述了单个外部副作用在“记录意图 -> 执行 -> 核对”协议中所处的阶段, +// 只有 SUCCEEDED 与 FAILED 是最终状态,INTENT_RECORDED 表示仍需执行或核对。 type StepStatus string const ( - StepIntentRecorded StepStatus = "INTENT_RECORDED" - StepSucceeded StepStatus = "SUCCEEDED" - StepFailed StepStatus = "FAILED" + StepIntentRecorded StepStatus = "INTENT_RECORDED" // 已记录意图,尚未确定最终结果。 + StepSucceeded StepStatus = "SUCCEEDED" // 外部副作用已确认成功发生。 + StepFailed StepStatus = "FAILED" // 外部副作用已确认未成功发生。 ) // Step 记录一次外部副作用的意图和最终核对结果。 +// +// 一条步骤在事务生命周期内由 step_key 唯一标识,意图(Intent)在副作用执行前 +// 必须已经落库,结果(Result)则在 inspect 核对后写入,用于崩溃恢复时判断 +// 副作用是否真实发生,从而避免重复执行或丢失执行。 type Step struct { - TransactionID string - Key string - Name string - Status StepStatus - Intent json.RawMessage - Result json.RawMessage - Error string - CreatedAt time.Time - UpdatedAt time.Time + TransactionID string // 所属事务的 ID。 + Key string // 步骤在事务内的唯一键。 + Name string // 步骤的语义名称,用于日志与事件展示。 + Status StepStatus // 步骤当前状态。 + Intent json.RawMessage // 执行前持久化的意图载荷。 + Result json.RawMessage // 核对后持久化的结果载荷,可为空。 + Error string // 步骤失败时的错误信息,成功时为空字符串。 + CreatedAt time.Time // 步骤意图首次记录时间(UTC)。 + UpdatedAt time.Time // 步骤最近一次变更时间(UTC)。 } -// StepIntent 是执行外部操作前必须先持久化的内容。 +// StepIntent 执行外部操作前必须先持久化的内容。 +// +// 它描述了将要执行的外部副作用,只有先把它成功写入数据库,Coordinator 才会 +// 真正调用 Operation.Apply,从而保证任何时刻都能回答“这一步是否已执行过”。 type StepIntent struct { - Key string - Name string - Intent json.RawMessage + Key string // 步骤在事务内的唯一键,用于去重与恢复定位。 + Name string // 步骤的语义名称。 + Intent json.RawMessage // 执行该步骤所需的参数载荷。 } -// Event 是供查询和 WSS 断联恢复使用的顺序事件。 +// Event 供查询和 WSS 断联恢复使用的顺序事件。 +// +// 每个事务状态变化或步骤意图/结果变化都会追加一条有序事件,Sequence 在事务 +// 内单调递增。客户端据此实现断线后的增量拉取(EventsAfter),重放遗漏的事件。 type Event struct { - Sequence int64 - TransactionID string - StepKey string - Kind string - FromState State - ToState State - Message string - CreatedAt time.Time + Sequence int64 // 全局递增的事件顺序号。 + TransactionID string // 事件所属事务的 ID。 + StepKey string // 关联的步骤键,事务级事件为空字符串。 + Kind string // 事件类型,如 TRANSACTION_CREATED、STEP_SUCCEEDED。 + FromState State // 变化前的状态,非状态类事件为空。 + ToState State // 变化后的状态,非状态类事件为空。 + Message string // 附加的人类可读信息。 + CreatedAt time.Time // 事件产生时间(UTC)。 } diff --git a/internal/transaction/state.go b/internal/transaction/state.go index ba576d3..7b6d570 100644 --- a/internal/transaction/state.go +++ b/internal/transaction/state.go @@ -2,23 +2,31 @@ package transaction import "fmt" -// State 是服务端更新事务的持久化状态。 +// State 服务端更新事务的持久化状态。 +// +// 它刻画了一次单机更新从创建到提交的完整生命周期,以及回滚与失败两种异常 +// 路径。状态转换遵循严格的前向顺序(见 forwardTransitions),非法转换会被 +// TransitionError 拒绝,从而保证任何时刻事务都处于可解释、可恢复的确定位置。 type State string const ( - StateCreated State = "CREATED" - StateValidating State = "VALIDATING" - StatePrepared State = "PREPARED" - StateStarting State = "STARTING" - StateSwitching State = "SWITCHING" - StateVerifying State = "VERIFYING" - StateDraining State = "DRAINING" - StateCommitted State = "COMMITTED" - StateRollingBack State = "ROLLING_BACK" - StateRolledBack State = "ROLLED_BACK" - StateFailed State = "FAILED" + StateCreated State = "CREATED" // 事务已创建,等待校验。 + StateValidating State = "VALIDATING" // 正在校验请求与前置条件。 + StatePrepared State = "PREPARED" // 校验通过,准备启动。 + StateStarting State = "STARTING" // 正在启动新实例。 + StateSwitching State = "SWITCHING" // 正在切换流量。 + StateVerifying State = "VERIFYING" // 正在验证切换结果。 + StateDraining State = "DRAINING" // 正在排空旧实例。 + StateCommitted State = "COMMITTED" // 更新已提交,终态。 + StateRollingBack State = "ROLLING_BACK" // 正在回滚。 + StateRolledBack State = "ROLLED_BACK" // 回滚完成,终态。 + StateFailed State = "FAILED" // 不可恢复失败,终态。 ) +// forwardTransitions 定义状态机正常推进路径上每一步的直接后继状态。 +// +// 该映射只覆盖无异常的正常推进;回滚与失败属于特殊转换,由 CanTransitionTo +// 单独处理,不在此映射内。 var forwardTransitions = map[State]State{ StateCreated: StateValidating, StateValidating: StatePrepared, @@ -30,6 +38,9 @@ var forwardTransitions = map[State]State{ } // Valid 报告状态是否属于当前状态机协议。 +// +// 返回 true 表示 s 是状态机认可的合法状态值,可用于转换校验与持久化判断; +// 返回 false 表示 s 不是本协议定义的状态。 func (s State) Valid() bool { switch s { case StateCreated, @@ -50,11 +61,19 @@ func (s State) Valid() bool { } // Terminal 报告事务是否已经不可再推进。 +// +// COMMITTED、ROLLED_BACK、FAILED 三个终态之后不再接受任何状态转换,事务的 +// 生命周期到此结束,该判断是 CanTransitionTo 拒绝终态继续推进的依据。 func (s State) Terminal() bool { return s == StateCommitted || s == StateRolledBack || s == StateFailed } // CanTransitionTo 校验正常推进、回滚和不可恢复失败三类转换。 +// +// 该方法是状态机的核心规则:当前状态或目标状态非法、当前状态为终态时一律 +// 拒绝;从 ROLLING_BACK 只允许到达 ROLLED_BACK 或 FAILED;其他非终态允许 +// 进入 ROLLING_BACK 或 FAILED;其余情况必须严格遵循 forwardTransitions 定义 +// 的前向路径。返回 true 表示转换被允许,false 表示拒绝。 func (s State) CanTransitionTo(next State) bool { if !s.Valid() || !next.Valid() || s.Terminal() { return false @@ -69,11 +88,14 @@ func (s State) CanTransitionTo(next State) bool { } // TransitionError 表示状态机拒绝了一次转换。 +// +// 它记录了被拒绝转换的起点与终点状态,便于调用方识别非法推进并给出明确错误。 type TransitionError struct { - From State - To State + From State // 转换前的事务状态。 + To State // 请求转换到的目标状态。 } +// Error 返回描述被拒绝转换的文本,格式为“起点 -> 终点”。 func (e *TransitionError) Error() string { return fmt.Sprintf("transaction state transition is not allowed: %s -> %s", e.From, e.To) } diff --git a/internal/transaction/state_test.go b/internal/transaction/state_test.go index dcc6a07..0455b44 100644 --- a/internal/transaction/state_test.go +++ b/internal/transaction/state_test.go @@ -2,6 +2,11 @@ package transaction import "testing" +// TestStateTransitions 验证状态机的正常推进、跳过前向状态、回滚与终态等转换规则。 +// +// 覆盖场景:CREATED 到 COMMITTED 的逐级前向推进均被允许;跳过中间状态(CREATED +// 直接到 PREPARED)被拒绝;SWITCHING 允许进入回滚、ROLLING_BACK 允许到 ROLLED_BACK; +// 终态 COMMITTED 不再接受任何后续转换。 func TestStateTransitions(t *testing.T) { t.Parallel() forward := []State{ diff --git a/internal/transaction/store.go b/internal/transaction/store.go index 766b153..a8edf25 100644 --- a/internal/transaction/store.go +++ b/internal/transaction/store.go @@ -19,6 +19,12 @@ import ( const schemaVersion = 2 +// schemaV1 SQLite 数据库的首版 schema,定义事务、步骤与事件三张核心表。 +// +// transactions 表保存事务快照并通过 CHECK 约束限定合法状态;唯一部分索引 +// one_unfinished_transaction 保证任一时刻最多只有一条未结束事务;transaction_steps +// 表保存步骤意图与结果;transaction_events 表保存可增量拉取的有序事件。脚本末尾 +// 将 user_version 置为 1,供 migrate 判断已应用的版本。 const schemaV1 = ` CREATE TABLE transactions ( id TEXT PRIMARY KEY, @@ -70,6 +76,11 @@ CREATE INDEX transaction_events_by_transaction PRAGMA user_version = 1; ` +// schemaV2 第二版迁移脚本,新增 backend_container_deployment 单例表。 +// +// 该表只允许存在一行(singleton_id 固定为 1),记录 backend 容器最近一次提交 +// 使用的端口、容器与镜像信息,并把 active_port 限定在 8080 或 8081。脚本末尾 +// 将 user_version 置为 2。 const schemaV2 = ` CREATE TABLE backend_container_deployment ( singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), @@ -84,13 +95,23 @@ CREATE TABLE backend_container_deployment ( PRAGMA user_version = 2; ` -// Store 是服务端 SQLite 事务记录。一个进程只应创建一个 Store。 +// Store 服务端 SQLite 事务记录。一个进程只应创建一个 Store。 +// +// 它封装了底层 *sql.DB,并把单连接访问(MaxOpenConns/MaxIdleConns 均为 1)作为 +// 事务串行化的一部分,同时保证连接级 PRAGMA 始终生效。now 字段用于注入时间, +// 便于测试构造确定性时间戳;生产环境为 time.Now。 type Store struct { - db *sql.DB - now func() time.Time + db *sql.DB // 底层 SQLite 数据库连接池,限定为单连接。 + now func() time.Time // 当前时间来源,测试可注入固定时钟。 } // OpenStore 打开本地 SQLite,并强制校验持久化参数和 schema 版本。 +// +// 参数 path 是 SQLite 数据库文件路径,可为相对路径;ctx 用于取消连接建立与 +// 校验过程。函数会先解析绝对路径并创建父目录,再以 _txlock=immediate 的连接 +// 参数打开数据库,随后依次 ping、配置 SQLite(journal_mode=DELETE、synchronous= +// EXTRA、foreign_keys=ON)、执行迁移、收紧文件权限为 0600。任一步骤失败都会 +// 关闭连接并返回错误;成功返回可供使用的 Store。 func OpenStore(ctx context.Context, path string) (*Store, error) { if path == "" { return nil, errors.New("sqlite path is required") @@ -135,6 +156,11 @@ func OpenStore(ctx context.Context, path string) (*Store, error) { return &Store{db: db, now: time.Now}, nil } +// configureSQLite 校验并强制设置 SQLite 的持久化与约束参数。 +// +// 依次设置并回读验证:journal_mode 必须为 delete、synchronous 必须为 EXTRA(取值 +// 3)、foreign_keys 必须为 ON。任何一项设置失败或回读值不符都会返回错误,以保证 +// 后续所有事务都在预期的持久性与引用完整性约束下运行。 func configureSQLite(ctx context.Context, db *sql.DB) error { var journalMode string if err := db.QueryRowContext(ctx, "PRAGMA journal_mode = DELETE").Scan(&journalMode); err != nil { @@ -166,6 +192,12 @@ func configureSQLite(ctx context.Context, db *sql.DB) error { return nil } +// migrate 将 SQLite schema 从当前版本逐步升级到 schemaVersion。 +// +// 先读取 PRAGMA user_version 判断当前版本:若高于支持的版本则报错;若已等于目标 +// 版本则直接返回;否则在一个 Serializable 事务内按版本号递增顺序执行对应迁移脚本。 +// 每个脚本内部自行设置新的 user_version,最后统一提交。任一脚本缺失或执行失败 +// 都会回滚并返回错误。 func migrate(ctx context.Context, db *sql.DB) error { var version int if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { @@ -205,11 +237,20 @@ func migrate(ctx context.Context, db *sql.DB) error { } // Close 关闭服务端 SQLite。 +// +// 释放底层数据库连接,返回底层 Close 的错误。关闭后 Store 不应再被使用。 func (s *Store) Close() error { return s.db.Close() } // CreateTransaction 原子处理幂等重试和单活动事务约束。created=false 表示返回已有幂等事务。 +// +// 参数 request 为创建请求,其中 ID 留空时由存储层生成随机 ID;ctx 用于取消操作。 +// 返回值含义如下:record 为最终的事务快照;created 为 true 表示新建了事务,false +// 表示命中了幂等键返回已存在事务;err 非空表示操作失败。处理逻辑:先校验请求; +// 再在 Serializable 事务内按幂等键查找已有事务,若存在且非终态则直接幂等返回,若 +// 已处于 FAILED/ROLLED_BACK 则归档其幂等键并继续;随后校验无未结束事务(否则返回 +// ActiveTransactionError),最后插入新事务与创建事件并提交。 func (s *Store) CreateTransaction(ctx context.Context, request CreateRequest) (record Transaction, created bool, err error) { if err := validateCreateRequest(&request); err != nil { return Transaction{}, false, err @@ -306,6 +347,10 @@ func (s *Store) CreateTransaction(ctx context.Context, request CreateRequest) (r }, true, nil } +// validateCreateRequest 校验创建事务请求的必填字段与 JSON 合法性。 +// +// IdempotencyKey、Source、Service 均不能为空白;Request 为空时规范化为空对象 {}, +// 非空时必须是合法 JSON。校验通过返回 nil,否则返回描述具体问题的错误。 func validateCreateRequest(request *CreateRequest) error { if strings.TrimSpace(request.IdempotencyKey) == "" { return errors.New("idempotency key is required") @@ -326,17 +371,23 @@ func validateCreateRequest(request *CreateRequest) error { } // Transaction 返回指定事务的最新持久化快照。 +// +// 参数 id 为目标事务 ID,ctx 用于取消查询。若不存在对应事务则返回 ErrNotFound。 func (s *Store) Transaction(ctx context.Context, id string) (Transaction, error) { return getTransactionByID(ctx, s.db, id) } // ActiveTransaction 返回当前唯一未结束事务。 +// +// 参数 ctx 用于取消查询。存在未结束事务时返回该事务快照,否则返回 ErrNotFound。 func (s *Store) ActiveTransaction(ctx context.Context) (Transaction, error) { return getActiveTransaction(ctx, s.db) } -// ListRecent returns committed, rolled-back, failed, and in-progress transactions -// in reverse creation order. Filters are exact values and never inferred. +// ListRecent 按创建时间倒序返回事务历史,覆盖已提交、已回滚、已失败以及进行中的 +// 事务。参数 ctx 用于取消查询;filter 提供分页与过滤条件,其中 Limit 必须在 1 到 +// 1000 之间,Service 与 State 均为精确匹配且永不做推断。返回匹配的事务切片;参数 +// 非法或查询失败时返回错误。 func (s *Store) ListRecent(ctx context.Context, filter ListFilter) ([]Transaction, error) { if filter.Limit <= 0 || filter.Limit > 1000 { return nil, errors.New("transaction history limit must be between 1 and 1000") @@ -386,6 +437,13 @@ func (s *Store) ListRecent(ctx context.Context, filter ListFilter) ([]Transactio } // Transition 校验并原子提交状态变化及其恢复事件。 +// +// 参数 id 为目标事务,next 为期望推进到的状态,message 为随事件记录的人类可读 +// 信息;ctx 用于取消操作。返回值为更新后的最新事务快照。处理逻辑:若 next 非法 +// 直接报错;在 Serializable 事务内读取记录,若已处于 next 则幂等返回;否则调用 +// CanTransitionTo 校验,非法转换返回 TransitionError;随后以版本号为条件原子更新 +// 状态并写入 TRANSACTION_STATE_CHANGED 事件,最后提交。并发变更导致受影响行数 +// 不为 1 时返回“transaction changed concurrently”错误。 func (s *Store) Transition(ctx context.Context, id string, next State, message string) (Transaction, error) { if !next.Valid() { return Transaction{}, fmt.Errorf("unknown transaction state: %q", next) @@ -435,6 +493,13 @@ func (s *Store) Transition(ctx context.Context, id string, next State, message s } // RecordStepIntent 先于外部副作用持久化步骤意图。created=false 表示相同意图已经存在。 +// +// 参数 transactionID 为所属事务,intent 描述要执行的步骤,ctx 用于取消操作。返回 +// 值 record 为步骤快照;created 为 true 表示新建意图,false 表示同 key 意图已存在 +// 而幂等返回。处理逻辑:校验 intent 的 Key、Name 与 Intent JSON 合法性;在 +// Serializable 事务内确认事务存在且非终态;若同 key 步骤已存在,则校验名称与意图 +// 完全一致(否则返回 ErrStepConflict),一致则幂等返回;否则插入 INTENT_RECORDED +// 步骤并写入 STEP_INTENT_RECORDED 事件后提交。 func (s *Store) RecordStepIntent(ctx context.Context, transactionID string, intent StepIntent) (record Step, created bool, err error) { if strings.TrimSpace(intent.Key) == "" { return Step{}, false, errors.New("step key is required") @@ -507,6 +572,14 @@ func (s *Store) RecordStepIntent(ctx context.Context, transactionID string, inte } // CompleteStep 原子记录外部状态核对后的最终结果。 +// +// 参数 transactionID 与 stepKey 定位步骤,status 只能是 StepSucceeded 或 +// StepFailed,result 为核对结果载荷(可为空,非空必须合法 JSON),errorMessage +// 为失败描述(成功时传空字符串);ctx 用于取消操作。返回更新后的步骤快照。处理 +// 逻辑:在 Serializable 事务内读取步骤,若已处于目标状态且结果一致则幂等返回, +// 若结果不一致则返回 ErrStepConflict;若步骤不处于 INTENT_RECORDED 则返回 +// ErrStepNotPending;否则以 status 为条件原子更新结果并写入 STEP_SUCCEEDED 或 +// STEP_FAILED 事件后提交。 func (s *Store) CompleteStep(ctx context.Context, transactionID, stepKey string, status StepStatus, result json.RawMessage, errorMessage string) (Step, error) { if status != StepSucceeded && status != StepFailed { return Step{}, fmt.Errorf("invalid final step status: %q", status) @@ -577,8 +650,14 @@ func (s *Store) CompleteStep(ctx context.Context, transactionID, stepKey string, } // ReopenFailedStep makes one manually resumed external step pending again. -// The coordinator calls this only after Inspect has established an exact -// APPLIED or NOT_APPLIED state; UNKNOWN never reopens a failed step. +// +// ReopenFailedStep 把一个已失败的外部步骤重新置为待执行(INTENT_RECORDED),供人工 +// 恢复后重新 Apply。参数 transactionID 与 stepKey 定位步骤,message 为随 +// STEP_REOPENED 事件记录的原因;ctx 用于取消操作。Coordinator 只有在 Inspect 明确 +// 得到 APPLIED 或 NOT_APPLIED 结论后才会调用本方法;UNKNOWN 永远不会重新打开失败 +// 步骤。处理逻辑:在 Serializable 事务内读取步骤,若已是待定状态则幂等返回;若 +// 不是 FAILED 则返回 ErrStepNotPending;否则以 FAILED 为条件原子清空结果与错误 +// 信息、重置状态为 INTENT_RECORDED,并写入 STEP_REOPENED 事件后提交。 func (s *Store) ReopenFailedStep(ctx context.Context, transactionID, stepKey, message string) (Step, error) { tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { @@ -630,6 +709,10 @@ func (s *Store) ReopenFailedStep(ctx context.Context, transactionID, stepKey, me } // PendingSteps 返回重启后必须先核对实际外部状态的步骤。 +// +// 参数 transactionID 为目标事务,ctx 用于取消查询。返回该事务中所有仍处于 +// INTENT_RECORDED 状态的步骤,按创建时间与 step key 排序。进程重启后调用方据此 +// 逐一对这些步骤重新 Inspect,以确定其外部副作用是否真实发生。 func (s *Store) PendingSteps(ctx context.Context, transactionID string) ([]Step, error) { rows, err := s.db.QueryContext(ctx, ` SELECT transaction_id, step_key, name, status, intent_json, @@ -656,6 +739,10 @@ func (s *Store) PendingSteps(ctx context.Context, transactionID string) ([]Step, } // EventsAfter 返回指定顺序位置之后的事务事件。 +// +// 参数 transactionID 为目标事务,afterSequence 为起始顺序号(返回顺序号严格大于 +// 该值的事件),limit 为返回条数上限且必须在 1 到 1000 之间;ctx 用于取消查询。 +// 返回按 sequence 升序排列的事件切片,供客户端断线后增量拉取遗漏事件。 func (s *Store) EventsAfter(ctx context.Context, transactionID string, afterSequence int64, limit int) ([]Event, error) { if afterSequence < 0 { return nil, errors.New("event sequence must not be negative") @@ -704,14 +791,26 @@ func (s *Store) EventsAfter(ctx context.Context, transactionID string, afterSequ return events, nil } +// queryRower 抽象了可执行单行查询并返回 *sql.Row 的查询能力。 +// +// 该接口由 *sql.DB、*sql.Tx 与 *sql.Conn 等共同满足,使查询辅助函数(如 +// getTransactionByID)既能在普通连接上执行,也能在事务内执行,避免重复实现。 type queryRower interface { QueryRowContext(context.Context, string, ...any) *sql.Row } +// rowScanner 抽象了可从一行读取列值的扫描能力。 +// +// 该接口由 *sql.Row 与 *sql.Rows 共同满足,使 scanTransaction 与 scanStep 等 +// 反序列化辅助函数既能处理单行也能处理多行结果集中的当前行。 type rowScanner interface { Scan(...any) error } +// getTransactionByID 按主键查询单条事务记录并反序列化为 Transaction。 +// +// 参数 query 提供查询能力(可为 *sql.DB 或 *sql.Tx),id 为事务主键,ctx 用于 +// 取消查询。未命中时返回 ErrNotFound。 func getTransactionByID(ctx context.Context, query queryRower, id string) (Transaction, error) { return scanTransaction(query.QueryRowContext(ctx, ` SELECT id, idempotency_key, source, service, request_json, @@ -720,6 +819,10 @@ func getTransactionByID(ctx context.Context, query queryRower, id string) (Trans WHERE id = ?`, id)) } +// getTransactionByIdempotencyKey 按幂等键查询单条事务记录。 +// +// 参数 query 提供查询能力,key 为幂等键,ctx 用于取消查询。未命中时返回 +// ErrNotFound。 func getTransactionByIdempotencyKey(ctx context.Context, query queryRower, key string) (Transaction, error) { return scanTransaction(query.QueryRowContext(ctx, ` SELECT id, idempotency_key, source, service, request_json, @@ -728,6 +831,10 @@ func getTransactionByIdempotencyKey(ctx context.Context, query queryRower, key s WHERE idempotency_key = ?`, key)) } +// getActiveTransaction 查询当前唯一未结束事务。 +// +// 参数 query 提供查询能力,ctx 用于取消查询。返回状态不是 COMMITTED、ROLLED_BACK、 +// FAILED 的任一条事务;若无此类事务则返回 ErrNotFound。 func getActiveTransaction(ctx context.Context, query queryRower) (Transaction, error) { return scanTransaction(query.QueryRowContext(ctx, ` SELECT id, idempotency_key, source, service, request_json, @@ -737,6 +844,11 @@ func getActiveTransaction(ctx context.Context, query queryRower) (Transaction, e LIMIT 1`, StateCommitted, StateRolledBack, StateFailed)) } +// scanTransaction 从单行结果反序列化一条事务记录。 +// +// 参数 row 提供扫描能力,返回反序列化后的 Transaction。将持久化的字符串形式 +// request_json 还原为 json.RawMessage、state 还原为 State、时间文本解析为 +// time.Time。未命中返回 ErrNotFound,其他解析失败返回包装后的错误。 func scanTransaction(row rowScanner) (Transaction, error) { var record Transaction var requestJSON, state, createdAt, updatedAt string @@ -770,6 +882,10 @@ func scanTransaction(row rowScanner) (Transaction, error) { return record, nil } +// getStep 按事务与步骤键查询单条步骤记录。 +// +// 参数 query 提供查询能力,transactionID 与 stepKey 联合定位步骤,ctx 用于取消 +// 查询。未命中时返回 ErrNotFound。 func getStep(ctx context.Context, query queryRower, transactionID, stepKey string) (Step, error) { return scanStep(query.QueryRowContext(ctx, ` SELECT transaction_id, step_key, name, status, intent_json, @@ -778,6 +894,11 @@ func getStep(ctx context.Context, query queryRower, transactionID, stepKey strin WHERE transaction_id = ? AND step_key = ?`, transactionID, stepKey)) } +// scanStep 从单行结果反序列化一条步骤记录。 +// +// 参数 row 提供扫描能力,返回反序列化后的 Step。result_json 允许为 NULL,仅在 +// 有效时还原为 json.RawMessage;status 还原为 StepStatus,时间文本解析为 +// time.Time。未命中返回 ErrNotFound,其他解析失败返回包装后的错误。 func scanStep(row rowScanner) (Step, error) { var record Step var status, intentJSON, createdAt, updatedAt string @@ -815,6 +936,11 @@ func scanStep(row rowScanner) (Step, error) { return record, nil } +// insertEvent 在事务内插入一条事务事件。 +// +// 参数 ctx 用于取消操作,tx 为目标数据库事务;transactionID、stepKey、kind 分别 +// 描述事件归属、关联步骤键与事件类型;fromState、toState 记录状态变化(无变化时 +// 传空);message 为附加信息;createdAt 为事件时间。插入失败返回包装后的错误。 func insertEvent( ctx context.Context, tx *sql.Tx, @@ -844,10 +970,17 @@ func insertEvent( return nil } +// formatTime 将时间格式化为 UTC 的 RFC3339Nano 文本,用于持久化。 +// +// 参数 value 为待格式化时间,返回其 UTC 表示。所有持久化时间统一经此函数归一, +// 保证读取端可用 parseTime 精确还原。 func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) } +// parseTime 解析持久化的 RFC3339Nano 时间文本。 +// +// 参数 value 为待解析文本,返回对应 time.Time。解析失败返回包装后的错误。 func parseTime(value string) (time.Time, error) { parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil { @@ -856,6 +989,10 @@ func parseTime(value string) (time.Time, error) { return parsed, nil } +// cloneJSON 深拷贝一段 JSON 载荷,避免对外暴露底层可变字节切片。 +// +// 参数 value 为待拷贝的 json.RawMessage,空值返回 nil。用于把从数据库读取的 +// Request/Intent/Result 等载荷安全地返回给调用方,防止调用方修改影响后续读取。 func cloneJSON(value json.RawMessage) json.RawMessage { if len(value) == 0 { return nil diff --git a/internal/transaction/store_test.go b/internal/transaction/store_test.go index b1c2092..0dfdde7 100644 --- a/internal/transaction/store_test.go +++ b/internal/transaction/store_test.go @@ -13,6 +13,9 @@ import ( "github.com/ncruces/go-sqlite3/driver" ) +// TestStoreMigratesVersionOneAndCommitsBackendContainerDeployment 验证从 v1 schema +// 迁移到最新版本,以及 backend 容器部署提交的完整链路:迁移前无部署记录、非容器类 +// backend 事务不计入容器历史、未完成容器事务不计入、提交后单例记录与历史证据正确。 func TestStoreMigratesVersionOneAndCommitsBackendContainerDeployment(t *testing.T) { t.Parallel() ctx := context.Background() @@ -105,6 +108,8 @@ func TestStoreMigratesVersionOneAndCommitsBackendContainerDeployment(t *testing. } } +// TestListRecentScansSQLiteRequestTextAsRawJSON 验证 ListRecent 能正确把 SQLite 中 +// 存储的 request_json 文本还原为原始 JSON 字节,不丢失也不转义。 func TestListRecentScansSQLiteRequestTextAsRawJSON(t *testing.T) { t.Parallel() ctx := context.Background() @@ -125,6 +130,8 @@ func TestListRecentScansSQLiteRequestTextAsRawJSON(t *testing.T) { } } +// TestBackendContainerDeploymentCommitRequiresDrainingTransaction 验证 backend 容器 +// 部署提交要求事务必须处于 DRAINING 状态,否则返回 TransitionError 且不写入部署行。 func TestBackendContainerDeploymentCommitRequiresDrainingTransaction(t *testing.T) { t.Parallel() ctx := context.Background() @@ -153,6 +160,10 @@ func TestBackendContainerDeploymentCommitRequiresDrainingTransaction(t *testing. } } +// TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction 验证 CreateTransaction +// 的幂等性与单活动事务约束:同幂等键重试返回同一事务;活动事务未结束前新事务被 +// ActiveTransactionError 拒绝;失败事务的幂等键被归档后可用新 ID 重试,且原事务的 +// 幂等键被改写为带 :terminal: 前缀的归档形式。 func TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction(t *testing.T) { t.Parallel() ctx := context.Background() @@ -225,6 +236,8 @@ func TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction(t *testing.T) } } +// TestStoreTransitionStepAndEventPersistence 验证状态、步骤与事件在关闭并重开数据库 +// 后仍能正确恢复:事务状态与版本持久化、待定步骤可查询、步骤完成落库、事件有序递增。 func TestStoreTransitionStepAndEventPersistence(t *testing.T) { t.Parallel() ctx := context.Background() @@ -299,6 +312,8 @@ func TestStoreTransitionStepAndEventPersistence(t *testing.T) { } } +// TestStoreRejectsInvalidTransitionAndConflictingStepIntent 验证非法状态转换返回 +// TransitionError,以及同一步骤键以不同意图重复记录时返回 ErrStepConflict。 func TestStoreRejectsInvalidTransitionAndConflictingStepIntent(t *testing.T) { t.Parallel() ctx := context.Background() @@ -331,6 +346,8 @@ func TestStoreRejectsInvalidTransitionAndConflictingStepIntent(t *testing.T) { } } +// TestStoreSerializesConcurrentCreates 验证并发创建事务时单活动事务约束生效:多个 +// 并发创建请求中恰好一个成功,其余均以 ErrActiveExists 拒绝。 func TestStoreSerializesConcurrentCreates(t *testing.T) { t.Parallel() ctx := context.Background() @@ -368,6 +385,9 @@ func TestStoreSerializesConcurrentCreates(t *testing.T) { } } +// openTestStore 在临时目录打开一个测试用 Store,并通过 t.Cleanup 确保测试结束时关闭。 +// +// 参数 t 用于报告错误与注册清理函数。打开失败时以 t.Fatalf 终止测试。 func openTestStore(t *testing.T) *Store { t.Helper() store, err := OpenStore(context.Background(), filepath.Join(t.TempDir(), "transaction.db")) diff --git a/internal/updatepackage/backend_native.go b/internal/updatepackage/backend_native.go index 0ea91d7..a565f5a 100644 --- a/internal/updatepackage/backend_native.go +++ b/internal/updatepackage/backend_native.go @@ -1,4 +1,8 @@ -// Package updatepackage reads the exact repack ZIP format currently emitted by deploy. +// Package updatepackage 负责解析 deploy 当前产出的重打包 ZIP 更新包格式, +// 并从中提取、校验原生后端工件。包内同时支持两种来源:通过 +// artifact-selection.json 清单声明的后端原生工件(backend_native.go),以及 +// 由 Jenkins 或运维人员直接提供的 JAR 文件(direct_native.go)。包边界仅限 +// 更新包的解析、结构校验与工件落地,不涉及具体部署流程。 package updatepackage import ( @@ -19,27 +23,46 @@ import ( ) const ( - manifestName = "artifact-selection.json" - backendArtifactKind = "BACKEND" - nativeArtifactType = "native" + // manifestName 更新包根目录下描述工件选择结果的清单文件名。 + manifestName = "artifact-selection.json" + // backendArtifactKind 表示清单中后端工件的类别,取值须为 "BACKEND"。 + backendArtifactKind = "BACKEND" + // nativeArtifactType 表示原生工件类型,type 与 selectedType 均须为 "native"。 + nativeArtifactType = "native" + // maximumManifestBytes 清单文件允许的最大未压缩字节数,用于防止恶意超大清单。 maximumManifestBytes = 1 << 20 ) -// BackendNativePackage is an opened repack ZIP containing one exact native backend artifact. +// BackendNativePackage 一个已打开的、恰好包含一个原生后端工件的重打包 ZIP 更新包。 +// 它封装了底层 ZIP 句柄与被选中工件对应的条目,并通过导出字段暴露解析出的元数据。 +// 不变量:该包在被选中后始终只对应一个明确的原生后端工件;Close 之后不得再使用。 type BackendNativePackage struct { - archive *zip.ReadCloser + // archive 底层打开的 ZIP 句柄,Close 时释放。 + archive *zip.ReadCloser + // artifactEntry 被选中原生后端工件在 ZIP 中对应的条目。 artifactEntry *zip.File - PackagePath string + // PackagePath 更新包文件的绝对路径。 + PackagePath string + // PackageSHA256 整个更新包文件的 SHA-256 摘要(十六进制)。 PackageSHA256 string - CustomerCode string - VersionID string - ArtifactID int64 - FileName string - Identity filestore.Identity + // CustomerCode 从清单中解析出的客户编码。 + CustomerCode string + // VersionID 从清单中解析出的版本标识。 + VersionID string + // ArtifactID 被选中工件在清单中声明的 ID。 + ArtifactID int64 + // FileName 被选中工件在 ZIP 根目录下的文件名。 + FileName string + // Identity 被选中工件的不可变身份(未压缩大小与 SHA-256)。 + Identity filestore.Identity } -// OpenBackendNative validates the ZIP structure and selects one explicitly declared native backend artifact. +// OpenBackendNative 校验更新包文件路径、计算包摘要并解析 ZIP 结构, +// 从 artifact-selection.json 清单中选出恰好一个显式声明的原生后端工件, +// 返回已打开的 BackendNativePackage。 +// packagePath 必须是干净绝对路径且为直接普通文件;校验失败时返回错误。 +// 成功返回后,调用方须通过 Close 释放底层 ZIP 句柄。 func OpenBackendNative(packagePath string) (*BackendNativePackage, error) { if err := validateAbsoluteRegularFile(packagePath, "update package"); err != nil { return nil, err @@ -104,7 +127,8 @@ func OpenBackendNative(packagePath string) (*BackendNativePackage, error) { }, nil } -// Close releases the opened ZIP file. +// Close 释放底层 ZIP 文件句柄并清空内部条目引用。对 nil 接收者或已关闭的包, +// 重复调用幂等返回 nil。关闭后包不再可用。 func (p *BackendNativePackage) Close() error { if p == nil || p.archive == nil { return nil @@ -115,7 +139,11 @@ func (p *BackendNativePackage) Close() error { return archive.Close() } -// ExtractArtifact writes the selected JAR to destination and verifies size and SHA-256 before publishing it. +// ExtractArtifact 将被选中的原生后端 JAR 写入 destination,并在发布前校验 +// 未压缩大小与 SHA-256 摘要,两者均匹配后才原子重命名到位。 +// destination 必须是干净绝对路径;其父目录会被创建且须为直接目录(非符号链接)。 +// 过程先写入临时文件、同步后重命名,再同步父目录;任何一步失败都会清理临时文件, +// 保证 destination 不会残留半成品。 func (p *BackendNativePackage) ExtractArtifact(destination string) error { if p == nil || p.archive == nil || p.artifactEntry == nil { return errors.New("native backend update package is not open") @@ -181,30 +209,55 @@ func (p *BackendNativePackage) ExtractArtifact(destination string) error { return syncDirectory(parent) } +// artifactSelectionManifest artifact-selection.json 清单的结构化表示。 +// 字段标签严格对应 deploy 产出的 JSON 键名,不允许未知字段。 type artifactSelectionManifest struct { - CustomerCode string `json:"customerCode"` - CustomerDisplayName *string `json:"customerDisplayName"` - VersionID string `json:"versionId"` - Items []string `json:"items"` - BackendArtifacts []manifestArtifact `json:"backendArtifacts"` - FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"` - NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"` - Remark *string `json:"remark"` + // CustomerCode 客户编码,解析后不得为空。 + CustomerCode string `json:"customerCode"` + // CustomerDisplayName 客户展示名,允许为空。 + CustomerDisplayName *string `json:"customerDisplayName"` + // VersionID 版本标识,解析后不得为空。 + VersionID string `json:"versionId"` + // Items 随包附带的脚本列表。 + Items []string `json:"items"` + // BackendArtifacts 后端工件列表,其中须恰好一个被选为原生后端。 + BackendArtifacts []manifestArtifact `json:"backendArtifacts"` + // FrontendArtifacts 前端工件列表,本包解析但不使用。 + FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"` + // NodeSSRArtifacts Node SSR 工件列表,本包解析但不使用。 + NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"` + // Remark 备注信息,允许为空。 + Remark *string `json:"remark"` } +// manifestArtifact 清单中单个工件的结构化表示。 type manifestArtifact struct { - ID int64 `json:"id"` - VersionCode *string `json:"versionCode"` - ArtifactKind string `json:"artifactKind"` - Type string `json:"type"` - SelectedType string `json:"selectedType"` - Platform *string `json:"platform"` - FileName string `json:"fileName"` - FilePath *string `json:"filePath"` - SHA256 string `json:"sha256"` - ImageRef *string `json:"imageRef"` + // ID 工件唯一标识,被选中的原生后端须大于 0。 + ID int64 `json:"id"` + // VersionCode 工件版本编码,允许为空。 + VersionCode *string `json:"versionCode"` + // ArtifactKind 工件类别,后端工件须为 "BACKEND"。 + ArtifactKind string `json:"artifactKind"` + // Type 工件类型,原生工件须为 "native"。 + Type string `json:"type"` + // SelectedType 被选中的类型,原生工件须为 "native"。 + SelectedType string `json:"selectedType"` + // Platform 目标平台,允许为空。 + Platform *string `json:"platform"` + // FileName 工件在 ZIP 根目录下的文件名。 + FileName string `json:"fileName"` + // FilePath 工件在归档中的路径,允许为空。 + FilePath *string `json:"filePath"` + // SHA256 工件的十六进制 SHA-256 摘要。 + SHA256 string `json:"sha256"` + // ImageRef 镜像引用,允许为空。 + ImageRef *string `json:"imageRef"` } +// decodeManifest 读取并解析 ZIP 中名为 artifact-selection.json 的清单条目。 +// entry 必须是普通文件且不超过 maximumManifestBytes;解析前先做精确键名校验, +// 再禁用未知字段进行解码,最后校验 customerCode 与 versionId 均非空。 +// 任何一步失败都会返回描述性的错误。 func decodeManifest(entry *zip.File) (artifactSelectionManifest, error) { if entry.FileInfo().IsDir() { return artifactSelectionManifest{}, errors.New("artifact-selection.json is not a file") @@ -244,6 +297,8 @@ func decodeManifest(entry *zip.File) (artifactSelectionManifest, error) { return manifest, nil } +// rootManifestKeys 清单根对象允许出现的所有 JSON 键名集合, +// 用于精确校验,拒绝任何未知键(包括大小写不匹配的键)。 var rootManifestKeys = map[string]struct{}{ "customerCode": {}, "customerDisplayName": {}, @@ -255,6 +310,7 @@ var rootManifestKeys = map[string]struct{}{ "remark": {}, } +// artifactManifestKeys 单个工件对象允许出现的所有 JSON 键名集合。 var artifactManifestKeys = map[string]struct{}{ "id": {}, "versionCode": {}, @@ -268,6 +324,9 @@ var artifactManifestKeys = map[string]struct{}{ "imageRef": {}, } +// validateExactManifestJSON 使用 json.Decoder 对清单文档做递归精确校验: +// 根对象与各工件对象的键名必须落在预定义白名单内,且不允许重复键与未知键。 +// document 必须是单一 JSON 值,其后不得有额外内容。 func validateExactManifestJSON(document []byte) error { decoder := json.NewDecoder(bytes.NewReader(document)) decoder.UseNumber() @@ -277,6 +336,10 @@ func validateExactManifestJSON(document []byte) error { return ensureJSONEnd(decoder) } +// validateObject 校验下一个 JSON 值为对象:确认分隔符为 '{',逐键检查重复与 +// 未知字段;root 为 true 时,对三个工件列表字段递归调用 validateArtifactArray, +// 其余字段则通过 consumeJSONValue 仅做语法与重复键校验。objectPath 用于构造 +// 错误信息中的字段路径。 func validateObject(decoder *json.Decoder, objectPath string, allowed map[string]struct{}, root bool) error { token, err := decoder.Token() if err != nil { @@ -320,6 +383,9 @@ func validateObject(decoder *json.Decoder, objectPath string, allowed map[string return err } +// validateArtifactArray 校验下一个 JSON 值为工件数组:确认分隔符为 '[', +// 并逐个元素调用 validateObject 按 artifactManifestKeys 白名单精确校验。 +// arrayPath 用于构造错误信息中的数组路径,形如 "backendArtifacts[0]"。 func validateArtifactArray(decoder *json.Decoder, arrayPath string) error { token, err := decoder.Token() if err != nil { @@ -339,6 +405,9 @@ func validateArtifactArray(decoder *json.Decoder, arrayPath string) error { return err } +// consumeJSONValue 校验一个非工件字段的 JSON 值:若是标量直接通过; +// 若是数组或对象则递归检查其内部对象键不重复,以保证后续 json.Decoder 严格解码兼容。 +// valuePath 用于构造错误信息中的字段路径。 func consumeJSONValue(decoder *json.Decoder, valuePath string) error { token, err := decoder.Token() if err != nil { @@ -380,6 +449,8 @@ func consumeJSONValue(decoder *json.Decoder, valuePath string) error { } } +// displayJSONPath 将 JSON 字段路径转为错误信息中可读的展示形式; +// 空路径表示清单文档本身,返回固定的文档名。 func displayJSONPath(value string) string { if value == "" { return "artifact-selection.json" @@ -387,6 +458,8 @@ func displayJSONPath(value string) string { return value } +// ensureJSONEnd 确认 decoder 流中除已解析的 JSON 值外再无内容; +// 若还有额外值则报多 JSON 值错误,其余解码错误原样返回。 func ensureJSONEnd(decoder *json.Decoder) error { var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { @@ -398,6 +471,9 @@ func ensureJSONEnd(decoder *json.Decoder) error { return nil } +// selectNativeBackend 从后端工件列表中选出唯一的原生后端工件: +// 要求 ArtifactKind、Type、SelectedType 分别为 "BACKEND"、"native"、"native", +// 且这样的工件恰好只有一个,否则返回错误。选中后校验 id、fileName、sha256 均非空。 func selectNativeBackend(artifacts []manifestArtifact) (manifestArtifact, error) { var selected manifestArtifact count := 0 @@ -416,6 +492,9 @@ func selectNativeBackend(artifacts []manifestArtifact) (manifestArtifact, error) return selected, nil } +// validateEntries 遍历 ZIP 条目并校验安全性:条目名非空、不使用反斜杠分隔、 +// 非绝对路径、路径已清洗、非 "."、不以 "../" 开头、非符号链接且不重复, +// 校验通过后返回以条目名为键的映射,便于后续按根文件名定位工件。 func validateEntries(files []*zip.File) (map[string]*zip.File, error) { entries := make(map[string]*zip.File, len(files)) for _, file := range files { @@ -434,6 +513,8 @@ func validateEntries(files []*zip.File) (map[string]*zip.File, error) { return entries, nil } +// validateRootFileName 校验工件文件名恰好是 ZIP 根目录下的单一文件名: +// 非空、不含路径分隔符、已清洗、非 "." 或 ".."。name 即清单中声明的 fileName。 func validateRootFileName(name string) error { if name == "" || strings.ContainsAny(name, "/\\") || path.Clean(name) != name || name == "." || name == ".." { return fmt.Errorf("expected one exact ZIP root file name, got %q", name) @@ -441,6 +522,9 @@ func validateRootFileName(name string) error { return nil } +// validateAbsoluteRegularFile 校验 filePath 为干净绝对路径且经 Lstat 确认是 +// 直接普通文件(非符号链接、非目录等)。description 用于描述被校验文件用途, +// 以构造可读的错误信息。校验通过返回 nil。 func validateAbsoluteRegularFile(filePath string, description string) error { if !filepath.IsAbs(filePath) || filepath.Clean(filePath) != filePath { return fmt.Errorf("%s path must be a clean absolute path", description) @@ -455,6 +539,8 @@ func validateAbsoluteRegularFile(filePath string, description string) error { return nil } +// hashFile 计算指定文件的 SHA-256 摘要并返回其十六进制字符串。 +// 读取与关闭过程中任一错误都会以包裹形式返回。 func hashFile(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { @@ -469,6 +555,8 @@ func hashFile(filePath string) (string, error) { return hex.EncodeToString(digest.Sum(nil)), nil } +// syncDirectory 打开 directory 并调用 Sync 将目录项刷入磁盘, +// 用于在原子重命名后确保新文件在目录中的落盘。打开、同步或关闭失败均返回错误。 func syncDirectory(directory string) error { file, err := os.Open(directory) if err != nil { diff --git a/internal/updatepackage/backend_native_test.go b/internal/updatepackage/backend_native_test.go index feae6fd..dd3d9e9 100644 --- a/internal/updatepackage/backend_native_test.go +++ b/internal/updatepackage/backend_native_test.go @@ -12,6 +12,8 @@ import ( "testing" ) +// TestOpenAndExtractNativeBackend 验证正常流程:打开合法的原生后端更新包后, +// 元数据与身份正确,且 ExtractArtifact 提取出的内容与原始 JAR 完全一致。 func TestOpenAndExtractNativeBackend(t *testing.T) { jar := []byte("native backend JAR") packagePath := writeBackendPackage(t, jar, nil) @@ -38,6 +40,8 @@ func TestOpenAndExtractNativeBackend(t *testing.T) { } } +// TestOpenRejectsUnknownManifestField 验证清单中出现大小写错误的未知字段时, +// OpenBackendNative 会返回包含该字段名的错误。 func TestOpenRejectsUnknownManifestField(t *testing.T) { jar := []byte("native backend JAR") packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) { @@ -49,6 +53,8 @@ func TestOpenRejectsUnknownManifestField(t *testing.T) { } } +// TestOpenRejectsMissingDeclaredArtifact 验证清单声明的 fileName 在 ZIP 中缺失时, +// OpenBackendNative 会返回包含该文件名的错误。 func TestOpenRejectsMissingDeclaredArtifact(t *testing.T) { jar := []byte("native backend JAR") packagePath := writePackageEntries(t, map[string][]byte{ @@ -63,6 +69,8 @@ func TestOpenRejectsMissingDeclaredArtifact(t *testing.T) { } } +// TestExtractRejectsArtifactDigestMismatch 验证清单中的 sha256 与实际工件不符时, +// ExtractArtifact 返回摘要不匹配错误,且不会在目标路径留下任何已发布文件。 func TestExtractRejectsArtifactDigestMismatch(t *testing.T) { jar := []byte("native backend JAR") packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) { @@ -83,6 +91,8 @@ func TestExtractRejectsArtifactDigestMismatch(t *testing.T) { } } +// TestOpenRejectsUnsafeAndDuplicateEntries 验证 OpenBackendNative 会拒绝包含 +// 不安全路径条目(如 "../" 前缀)的更新包,以及包含重复 ZIP 条目的更新包。 func TestOpenRejectsUnsafeAndDuplicateEntries(t *testing.T) { jar := []byte("native backend JAR") manifest := manifestJSON(t, jar, nil) @@ -126,6 +136,8 @@ func TestOpenRejectsUnsafeAndDuplicateEntries(t *testing.T) { }) } +// writeBackendPackage 构造一个包含清单与 backend.jar 的合法更新包, +// 返回其临时文件路径。modify 可选,用于在写入前改写清单内容。 func writeBackendPackage(t *testing.T, jar []byte, modify func(map[string]any)) string { t.Helper() return writePackageEntries(t, map[string][]byte{ @@ -134,6 +146,8 @@ func writeBackendPackage(t *testing.T, jar []byte, modify func(map[string]any)) }) } +// manifestJSON 构造一份以 jar 摘要为 sha256 的完整清单 JSON,返回序列化字节。 +// modify 可选,用于在序列化前改写清单内容。 func manifestJSON(t *testing.T, jar []byte, modify func(map[string]any)) []byte { t.Helper() digest := sha256.Sum256(jar) @@ -168,6 +182,8 @@ func manifestJSON(t *testing.T, jar []byte, modify func(map[string]any)) []byte return content } +// writePackageEntries 将给定名字到内容的映射写入一个临时 ZIP 文件, +// 返回该文件路径。映射遍历顺序不影响结果。 func writePackageEntries(t *testing.T, entries map[string][]byte) string { t.Helper() packagePath := filepath.Join(t.TempDir(), "package.zip") diff --git a/internal/updatepackage/direct_native.go b/internal/updatepackage/direct_native.go index 85c1ceb..0ed9715 100644 --- a/internal/updatepackage/direct_native.go +++ b/internal/updatepackage/direct_native.go @@ -14,17 +14,22 @@ import ( "yms-daemon/internal/filestore" ) -// DirectNativeJAR is one JAR supplied directly by Jenkins or an operator. -// FileName is treated as opaque text; no version is parsed from it. +// DirectNativeJAR 表示由 Jenkins 或运维人员直接提供的一个原生后端 JAR 文件。 +// FileName 被当作不透明文本处理,不会从中解析任何版本信息。 type DirectNativeJAR struct { - Path string - SHA256 string + // Path JAR 文件的绝对路径。 + Path string + // SHA256 JAR 文件的十六进制 SHA-256 摘要。 + SHA256 string + // FileName JAR 文件的基础文件名(不含目录)。 FileName string + // Identity JAR 文件的不可变身份(大小与 SHA-256),用于事务校验。 Identity filestore.Identity } -// OpenDirectNativeJAR validates the direct file, verifies every ZIP entry and -// records the immutable identity used by the transaction. +// OpenDirectNativeJAR 校验直接提供的 JAR 文件:确认其为干净绝对路径下的直接 +// 普通文件、文件名以 ".jar" 结尾,并打开验证 ZIP 内所有条目可完整读取, +// 最后记录事务所需的不可变身份。任一校验失败返回错误。 func OpenDirectNativeJAR(jarPath string) (DirectNativeJAR, error) { if err := validateAbsoluteRegularFile(jarPath, "native backend JAR"); err != nil { return DirectNativeJAR{}, err @@ -48,8 +53,10 @@ func OpenDirectNativeJAR(jarPath string) (DirectNativeJAR, error) { }, nil } -// CopyArtifact copies the exact JAR into transaction storage and rejects a -// source file that changes after OpenDirectNativeJAR returns. +// CopyArtifact 将 JAR 原样复制到事务存储的 destination,并拒绝在 +// OpenDirectNativeJAR 返回之后内容发生变化的源文件。destination 必须是干净 +// 绝对路径;复制过程先写临时文件,边复制边计算摘要,比对大小与 SHA-256 一致后 +// 对临时副本再次校验 ZIP,最后原子重命名并同步父目录。任一步失败均清理临时文件。 func (j DirectNativeJAR) CopyArtifact(destination string) error { if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination { return errors.New("native backend JAR destination must be a clean absolute path") @@ -111,6 +118,8 @@ func (j DirectNativeJAR) CopyArtifact(destination string) error { return syncDirectory(parent) } +// verifyJARArchive 打开 jarPath 对应的 ZIP 并逐条目完整读取(丢弃内容), +// 确认归档结构有效且每个非目录条目可解压。空归档或任一读取失败均返回错误。 func verifyJARArchive(jarPath string) error { archive, err := zip.OpenReader(jarPath) if err != nil { @@ -137,6 +146,8 @@ func verifyJARArchive(jarPath string) error { return nil } +// identifyFile 计算指定文件的大小与 SHA-256 摘要,返回不可变身份 Identity。 +// 读取或关闭失败时返回错误。 func identifyFile(filePath string) (filestore.Identity, error) { file, err := os.Open(filePath) if err != nil { diff --git a/internal/updatepackage/direct_native_test.go b/internal/updatepackage/direct_native_test.go index f788ead..18902e0 100644 --- a/internal/updatepackage/direct_native_test.go +++ b/internal/updatepackage/direct_native_test.go @@ -9,6 +9,8 @@ import ( "testing" ) +// TestOpenAndCopyDirectNativeJAR 验证正常流程:打开直接提供的 JAR 后元数据正确, +// 且 CopyArtifact 复制出的内容与源文件完全一致。 func TestOpenAndCopyDirectNativeJAR(t *testing.T) { jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("backend classes")) jar, err := OpenDirectNativeJAR(jarPath) @@ -33,6 +35,8 @@ func TestOpenAndCopyDirectNativeJAR(t *testing.T) { } } +// TestOpenDirectNativeJARRejectsInvalidArchive 验证内容并非合法 ZIP 的 JAR 文件 +// 会被 OpenDirectNativeJAR 拒绝。 func TestOpenDirectNativeJARRejectsInvalidArchive(t *testing.T) { path := filepath.Join(t.TempDir(), "glory-soft-yms.jar") if err := os.WriteFile(path, []byte("not a JAR"), 0o600); err != nil { @@ -43,6 +47,8 @@ func TestOpenDirectNativeJARRejectsInvalidArchive(t *testing.T) { } } +// TestCopyDirectNativeJARRejectsChangedSource 验证源 JAR 在 OpenDirectNativeJAR +// 之后被替换时,CopyArtifact 会返回包含 "changed" 的错误。 func TestCopyDirectNativeJARRejectsChangedSource(t *testing.T) { jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("first")) jar, err := OpenDirectNativeJAR(jarPath) @@ -63,6 +69,8 @@ func TestCopyDirectNativeJARRejectsChangedSource(t *testing.T) { } } +// writeDirectNativeJAR 在临时目录创建名为 fileName 的 JAR,内含一个 +// BOOT-INF/classes/application.properties 条目,内容为 content,返回文件路径。 func writeDirectNativeJAR(t *testing.T, fileName string, content []byte) string { t.Helper() jarPath := filepath.Join(t.TempDir(), fileName) diff --git a/main.go b/main.go index 25e1580..b4f41b2 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,5 @@ +// 本包是 yms-daemon 的入口,根据可执行文件名决定运行角色: +// 当二进制名为 ymsd 时作为守护进程服务端长期运行,否则作为 ymsctl 命令行客户端处理用户命令。 package main import ( @@ -29,8 +31,12 @@ import ( "yms-daemon/internal/transaction" ) +// serviceBackend 当前命令行客户端与守护进程唯一支持的目标服务名。 const serviceBackend = "backend" +// main 进程入口,负责根据可执行文件名分流到守护进程服务端或命令行客户端。 +// 它先建立可被 os.Interrupt 与 SIGTERM 中断的上下文,再判断二进制名是否为 ymsd: +// 是则校验不接受任何参数并运行 runServe,否则把剩余参数交给 run 处理并以返回码退出。 func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() @@ -48,6 +54,10 @@ func main() { os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr)) } +// run 解析并执行命令行客户端的一个子命令,返回进程退出码。 +// arguments 去除程序名后的命令行参数,stdout 与 stderr 分别接收正常输出与错误输出。 +// 它支持 update、list、restart、help 子命令;无参数或未知命令时向 stderr 输出用法并返回 2, +// 命令执行失败返回 1,成功返回 0。 func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Writer) int { if len(arguments) == 0 { writeUsage(stderr) @@ -125,20 +135,34 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr } } +// updateArguments 保存 update 子命令解析后的参数。 type updateArguments struct { - service string - inputType string - file string - imageReference string - quite bool - startLog bool -} - -type restartArguments struct { + // service 目标服务名,当前仅接受 backend。 service string - quite bool + // inputType 更新输入类型,由 -f 或 --native-jar 推导得到。 + inputType string + // file 本地制品的绝对路径,容器镜像输入时为空。 + file string + // imageReference 容器镜像引用,仅在 --container-image 输入时非空。 + imageReference string + // quite 为真时抑制进度与成功结果的输出。 + quite bool + // startLog 为真时在容器更新完成后输出启动日志。 + startLog bool } +// restartArguments 保存 restart 子命令解析后的参数。 +type restartArguments struct { + // service 目标服务名,当前仅接受 backend。 + service string + // quite 为真时抑制进度与成功结果的输出。 + quite bool +} + +// parseUpdateArgs 解析 update 子命令的 flag 参数并做业务校验。 +// 它要求 --service 必须为 backend,-f、--native-jar、--container-image 三者必须且只能提供一个; +// 对文件类输入会解析为绝对路径并校验其为非符号链接的普通文件,容器镜像输入不允许附带 --no-start-log。 +// 校验失败时向 output 写入原因并返回 error。 func parseUpdateArgs(arguments []string, output io.Writer) (updateArguments, error) { flags := flag.NewFlagSet("update", flag.ContinueOnError) flags.SetOutput(output) @@ -192,6 +216,8 @@ func parseUpdateArgs(arguments []string, output io.Writer) (updateArguments, err return updateArguments{service: *service, inputType: inputType, file: absoluteFile, quite: *quite}, nil } +// parseRestartArgs 解析 restart 子命令的 flag 参数并做业务校验。 +// 它要求 --service 必须为 backend,且不接受任何位置参数;校验失败时向 output 写入原因并返回 error。 func parseRestartArgs(arguments []string, output io.Writer) (restartArguments, error) { flags := flag.NewFlagSet("restart", flag.ContinueOnError) flags.SetOutput(output) @@ -209,6 +235,9 @@ func parseRestartArgs(arguments []string, output io.Writer) (restartArguments, e return restartArguments{service: *service, quite: *quite}, nil } +// runServe 以守护进程服务端角色运行:初始化日志、获取进程锁、加载部署配置、 +// 打开事务存储与协调器、构建 Nginx 控制器与后端更新器,最后通过 daemonserver 开始服务。 +// 它使用具名返回 result 以便在各资源清理阶段合并所有 Close 错误,最终返回服务端的运行错误。 func runServe(ctx context.Context) (result error) { logger, logCloser, err := logging.New(runtimepaths.Log) if err != nil { @@ -238,8 +267,6 @@ func runServe(ctx context.Context) (result error) { gateway, err := hostnginx.NewController( runtimepaths.HostNginxConfig, runtimepaths.HostNginxExecutable, - config.Backend.SystemctlPath, - runtimepaths.HostNginxService, ) if err != nil { return err @@ -277,6 +304,7 @@ func runServe(ctx context.Context) (result error) { return server.Serve(ctx) } +// writeUsage 向 output 写出命令行客户端各子命令的用法说明。 func writeUsage(output io.Writer) { fmt.Fprintln(output, "usage:") fmt.Fprintln(output, " ymsctl update --service backend -f [--quite]") @@ -286,6 +314,8 @@ func writeUsage(output io.Writer) { fmt.Fprintln(output, " ymsctl list [--limit ] [--service backend] [--state ] [--json]") } +// writeUpdateProgress 把一条进度事件格式化写入 output:状态字段左对齐占 14 列, +// 状态为空时显示为 INFO,随后输出事件消息。 func writeUpdateProgress(output io.Writer, event daemonapi.Response) { state := event.State if state == "" { @@ -294,6 +324,9 @@ func writeUpdateProgress(output io.Writer, event daemonapi.Response) { fmt.Fprintf(output, "%-14s %s\n", state, event.Message) } +// runList 执行 list 子命令:解析过滤参数,打开事务存储读取最近事务, +// 按是否指定 --json 决定以稳定 JSON 或制表符分隔的表格形式输出到 stdout。 +// 它支持 --limit、--service、--state、--json 四个选项,返回查询或输出阶段的错误。 func runList(ctx context.Context, arguments []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("list", flag.ContinueOnError) flags.SetOutput(stderr) diff --git a/main_test.go b/main_test.go index ca8211d..d36cb4b 100644 --- a/main_test.go +++ b/main_test.go @@ -11,6 +11,8 @@ import ( "yms-daemon/internal/daemonapi" ) +// TestParseUpdateArgsAcceptsBackendAndResolvesFile 验证 update 子命令能接受 backend 服务与 -f 文件参数, +// 并正确解析出 repack-zip 输入类型与文件路径。 func TestParseUpdateArgsAcceptsBackendAndResolvesFile(t *testing.T) { file := filepath.Join(t.TempDir(), "package.zip") if err := os.WriteFile(file, []byte("zip"), 0o600); err != nil { @@ -25,6 +27,8 @@ func TestParseUpdateArgsAcceptsBackendAndResolvesFile(t *testing.T) { } } +// TestParseUpdateArgsAcceptsDirectNativeJAR 验证 update 子命令能接受 --native-jar 参数, +// 并正确解析出 native-jar 输入类型与文件路径。 func TestParseUpdateArgsAcceptsDirectNativeJAR(t *testing.T) { file := filepath.Join(t.TempDir(), "glory-soft-yms.jar") if err := os.WriteFile(file, []byte("jar"), 0o600); err != nil { @@ -39,6 +43,8 @@ func TestParseUpdateArgsAcceptsDirectNativeJAR(t *testing.T) { } } +// TestParseUpdateArgsAcceptsContainerImage 验证 update 子命令能接受 --container-image 参数, +// 并正确解析出 container-image 输入类型与镜像引用。 func TestParseUpdateArgsAcceptsContainerImage(t *testing.T) { imageReference := "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1" request, err := parseUpdateArgs([]string{"--service", "backend", "--container-image", imageReference}, &bytes.Buffer{}) @@ -50,6 +56,8 @@ func TestParseUpdateArgsAcceptsContainerImage(t *testing.T) { } } +// TestParseUpdateArgsControlsContainerStartupLogs 验证 --no-start-log 能关闭容器启动日志, +// 且默认情况下启动日志处于开启状态。 func TestParseUpdateArgsControlsContainerStartupLogs(t *testing.T) { imageReference := "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1" request, err := parseUpdateArgs([]string{"--service", "backend", "--container-image", imageReference, "--no-start-log"}, &bytes.Buffer{}) @@ -68,6 +76,8 @@ func TestParseUpdateArgsControlsContainerStartupLogs(t *testing.T) { } } +// TestParseUpdateArgsRejectsNativeJARAndContainerImageTogether 验证同时提供 --native-jar 与 +// --container-image 时会被拒绝,并返回互斥错误提示。 func TestParseUpdateArgsRejectsNativeJARAndContainerImageTogether(t *testing.T) { jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") arguments := []string{ @@ -81,6 +91,7 @@ func TestParseUpdateArgsRejectsNativeJARAndContainerImageTogether(t *testing.T) } } +// TestParseUpdateArgsAcceptsQuite 验证 --quite 选项能被解析并保留到参数结果中。 func TestParseUpdateArgsAcceptsQuite(t *testing.T) { file := filepath.Join(t.TempDir(), "glory-soft-yms.jar") if err := os.WriteFile(file, []byte("jar"), 0o600); err != nil { @@ -95,6 +106,7 @@ func TestParseUpdateArgsAcceptsQuite(t *testing.T) { } } +// TestWriteUpdateProgress 验证进度事件的格式化输出:状态字段左对齐占 14 列后紧跟消息。 func TestWriteUpdateProgress(t *testing.T) { var output bytes.Buffer writeUpdateProgress(&output, daemonapi.Response{State: "STARTING", Message: "Starting backend"}) @@ -103,6 +115,7 @@ func TestWriteUpdateProgress(t *testing.T) { } } +// TestParseRestartArgs 验证 restart 子命令能接受 backend 服务与 --quite 选项。 func TestParseRestartArgs(t *testing.T) { request, err := parseRestartArgs([]string{"--service", "backend", "--quite"}, &bytes.Buffer{}) if err != nil { @@ -113,6 +126,8 @@ func TestParseRestartArgs(t *testing.T) { } } +// TestParseRestartArgsRejectsUnsupportedInput 验证 restart 子命令对缺失服务、非 backend 服务 +// 以及位置参数等非法输入均会拒绝。 func TestParseRestartArgsRejectsUnsupportedInput(t *testing.T) { for name, arguments := range map[string][]string{ "missing service": nil, @@ -127,6 +142,8 @@ func TestParseRestartArgsRejectsUnsupportedInput(t *testing.T) { } } +// TestParseUpdateArgsRejectsIncompleteOrUnsupportedInput 验证 update 子命令对缺失服务、缺失文件、 +// 非 backend 服务、位置参数以及多个互斥输入等非法组合均会拒绝。 func TestParseUpdateArgsRejectsIncompleteOrUnsupportedInput(t *testing.T) { for name, arguments := range map[string][]string{ "missing service": {"-f", "/tmp/package.zip"}, @@ -144,6 +161,7 @@ func TestParseUpdateArgsRejectsIncompleteOrUnsupportedInput(t *testing.T) { } } +// TestRunPrintsUsageWithoutCommand 验证不带任何子命令调用 run 时返回退出码 2 并向 stderr 输出用法。 func TestRunPrintsUsageWithoutCommand(t *testing.T) { var stderr bytes.Buffer if exitCode := run(context.Background(), nil, &bytes.Buffer{}, &stderr); exitCode != 2 {