升级管理

This commit is contained in:
waner
2026-04-07 09:45:27 +08:00
parent 738c2d4ae6
commit 93539ccd97
43 changed files with 1647 additions and 483 deletions
+25 -12
View File
@@ -156,27 +156,28 @@ Open:
- `POST /api/v1/upgrades/previews` - `POST /api/v1/upgrades/previews`
- `POST /api/v1/upgrades` - `POST /api/v1/upgrades`
- `POST /api/v1/upgrades/{taskId}/execute` - `POST /api/v1/upgrades/{taskId}/execute`
- `POST /api/v1/upgrades/{taskId}/rollback`
- `GET /api/v1/upgrades` - `GET /api/v1/upgrades`
- `GET /api/v1/upgrades/{taskId}` - `GET /api/v1/upgrades/{taskId}`
- `GET /api/v1/upgrades/{taskId}/log` - `GET /api/v1/upgrades/{taskId}/log`
当前支持范围: 当前支持范围:
- 仅支持离线升级 - 仅支持离线升级
- 当前支持升级对象: - 当前支持任务类型:
- `TMS` - `TMS`
- `STANDARD_APP` - `RECEIVER`
- `DIRECT_APP` - `FIRMWARE`
- 当前不支持: - 当前不支持:
- 在线升级 - 在线升级
- `CVE` / 操作系统补丁升级 - `CVE` / 操作系统补丁升级
- 密码卡固件升级
当前流程: 当前流程:
- 先上传离线升级包,复用现有文件上传能力,得到 `fileId` - 先上传离线升级包,复用现有文件上传能力,得到 `fileId`
- 后端基于 `fileId` 解析升级包并做预检 - 后端基于 `fileId` 解析升级包并做预检
- 预检结果返回升级对象、当前版本、目标版本和版本变化提示 - 预检结果返回 `taskType`、`productType`、当前版本、目标版本和版本变化提示
- 用户确认后创建升级任务 - 用户确认后创建升级任务
- 调用执行接口后,任务异步进入后台执行 - 调用执行接口后,任务异步进入后台执行
- 升级失败后,如升级包提供 `rollback` 入口,可手工调用回滚接口
- 前端通过列表、详情和日志接口轮询任务状态 - 前端通过列表、详情和日志接口轮询任务状态
当前升级包要求: 当前升级包要求:
@@ -184,29 +185,41 @@ Open:
- 至少包含: - 至少包含:
- `manifest.json` - `manifest.json`
- `signature.sig` - `signature.sig`
- `payload.zip` - `payload/` 下按需包含:
- `app/`
- `web/dist/`
- `config/`
- `sql/`
- `firmware/`
- `scripts/`
- `signature.sig` 需要能被设备密码卡使用“固件完整性密钥”完成验签 - `signature.sig` 需要能被设备密码卡使用“固件完整性密钥”完成验签
- 服务端需要配置 `tms.upgrade.signature-public-key-base64`(Base64 编码的公钥 blob) - 服务端需要配置 `tms.upgrade.signature-public-key-pem-path`(公钥 PEM 文件路径)
- `manifest.json` 当前要求包含: - `manifest.json` 当前要求包含:
- `packageId` - `packageId`
- `targetComponent` - `taskType`
- `targetVersion` - `productType`
- `version`
- `minCompatibleVersion` - `minCompatibleVersion`
- `description` - `description`
- `orchestrator` - `entrypoints.execute`
- `fileChecksums.payloadZip` - 可选 `entrypoints.precheck`
- 可选 `entrypoints.verify`
- 可选 `entrypoints.rollback`
当前执行规则: 当前执行规则:
- 同一时刻只允许一个升级任务处于 `RUNNING` - 同一时刻只允许一个升级任务处于 `RUNNING`
- 不允许目标版本低于当前版本 - 不允许目标版本低于当前版本
- 不满足最小兼容版本时拒绝升级 - 不满足最小兼容版本时拒绝升级
- `execute.sh` 必填,`precheck.sh`、`verify.sh`、`rollback.sh` 可选
- 执行顺序为:`precheck -> execute -> verify`
- 回滚不自动触发,需要调用回滚接口
- 升级成功后会更新 `tms_device_software_version` - 升级成功后会更新 `tms_device_software_version`
- 升级执行日志统一写入 `tms.upgrade.log-dir` - 升级执行日志统一写入 `tms.upgrade.log-dir`
当前配置项: 当前配置项:
- `tms.upgrade.staging-root-dir` - `tms.upgrade.staging-root-dir`
- `tms.upgrade.log-dir` - `tms.upgrade.log-dir`
- `tms.upgrade.signature-public-key-base64` - `tms.upgrade.signature-public-key-pem-path`
升级包规范见: 升级包规范见:
- [2026-03-19-upgrade-package-spec.md](/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-03-19-upgrade-package-spec.md) - [2026-03-19-upgrade-package-spec.md](/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-03-19-upgrade-package-spec.md)
+1 -1
View File
@@ -9,7 +9,7 @@ tms:
upgrade: upgrade:
staging-root-dir: /home/tmp/tms-upgrade-staging staging-root-dir: /home/tmp/tms-upgrade-staging
log-dir: /home/tms/tmp/tms-upgrade-logs log-dir: /home/tms/tmp/tms-upgrade-logs
signature-public-key-base64: change-me-base64-public-key-blob signature-public-key-pem-path: /home/tms/config/upgrade-public-key.pem
security: security:
internal-token: change-me-internal-token internal-token: change-me-internal-token
internal-auth: internal-auth:
+354
View File
@@ -32,6 +32,317 @@ TMS 负责:
- 直参版介质解压到厂商约定目录 - 直参版介质解压到厂商约定目录
- 数据库凭据和 RabbitMQ 管理员凭据 - 数据库凭据和 RabbitMQ 管理员凭据
## 2.1 核心原则:先部署预置,再执行初始化
当前 CISD 初始化功能不是完整替代厂商部署手册,而是建立在“基础环境和介质已就绪”的前提上执行客户化步骤。
因此现场应严格分成两个阶段:
### 第一阶段:部署/出厂预置阶段
由运维或出厂人员提前完成,目标是把机器准备成“可接受初始化”的基线环境。
### 第二阶段:CISD 初始化阶段
由 TMS 根据前端输入的客户化参数执行,目标是完成机构号、节点地址、MQ/TLQ、数据库导入、应用配置、WEB 发布和启动验收。
如果第一阶段未完成,第二阶段不应直接试跑。
## 2.2 部署阶段必须提前完成的步骤
以下内容必须在部署阶段完成,不应再依赖 `/api/v1/init/**` 初始化接口去补做。
### 2.2.1 操作系统与基础目录
需要提前完成:
- 系统用户、用户组创建
- 标准收发器运行目录创建
- 目录权限和属主设置
- `/home/cemp4i`、`/home/tms`、`/home/tlq` 等目录存在且可访问
这类动作属于一次性环境准备,不属于客户化初始化。
### 2.2.2 基础软件安装
需要提前完成:
- JDK 安装
- nginx 安装
- RabbitMQ 安装
- TLQ 基础软件安装
- TiDB/MySQL 客户端可用
- `rabbitmqctl`、`rabbitmqadmin`、`mysql`、`unzip` 等命令可用
初始化阶段默认依赖这些命令和软件已经存在。
### 2.2.3 标准收发器介质上传与解压
需要提前完成:
- 企业版 / 间参版标准收发器介质解压到 `/home/cemp4i`
- 直参版介质解压到厂商约定目录
- `cpconfig.cfg`
- `cpackage/toolsh/*.sh`
- `cpackage/rabq/*.sh`
- `cpackage/software/nginx.conf`
- `cpackage/front/front.zip`
- SQL 模板和 JAR 介质文件存在
当前初始化流程假定这些文件已经存在,不负责解压标准介质。
### 2.2.4 平台级凭据预置
需要提前完成:
- 数据库导库账号/密码
- RabbitMQ 管理员账号/密码
- 其他平台级敏感配置
这些凭据不属于前端输入的客户化参数,建议通过:
- 环境变量
- 独立受保护配置文件
- 现场预置脚本
进行管理。
### 2.2.5 防火墙、服务注册和开机自启
需要提前完成:
- 防火墙端口放通
- RabbitMQ/nginx 服务注册
- 必要的开机自启设置
这类动作属于环境级配置,不应放在初始化阶段重复执行。
### 2.2.6 标准收发器部署文档中应提前做的脚本
结合标准收发器部署文档和当前 TMS 初始化边界,以下脚本应视为部署阶段脚本,不纳入初始化主流程:
- `setpd.sh`
- `opencport.sh`
- `install_newpackage.sh`
- `setrabqstart.sh`
- `setxsd.sh`
- `setngservice.sh`
- `pongx.sh`
这些脚本覆盖的通常是:
- 用户与目录准备
- 端口开放
- RabbitMQ 编译/安装/服务化
- nginx 安装/服务化
- 信创依赖和基础环境处理
### 2.2.7 厂商脚本与部署阶段的对应关系
下表用于帮助现场快速判断:哪些脚本应在部署阶段提前执行,哪些不应再放到初始化接口中执行。
| 脚本 | 建议阶段 | 说明 |
|---|---|---|
| `setpd.sh` | 部署阶段 | 用户、目录、权限等基础准备 |
| `opencport.sh` | 部署阶段 | 端口开放,属于系统环境配置 |
| `install_newpackage.sh` | 部署阶段 | RabbitMQ/OTP 安装与编译 |
| `setrabqstart.sh` | 部署阶段 | RabbitMQ 服务启动/服务化准备 |
| `setxsd.sh` | 部署阶段 | 信创/环境依赖准备 |
| `setngservice.sh` | 部署阶段 | nginx 服务注册/服务化 |
| `pongx.sh` | 部署阶段 | 基础环境处理脚本 |
| `setup_nodeps.sh` | 部署阶段 | 基础无依赖环境准备 |
| `setsuseunixodbc.sh` | 部署阶段 | ODBC/基础运行依赖准备 |
## 2.3 标准版初始化阶段执行的步骤
以下内容属于客户化初始化,保留在 `/api/v1/init/**` 流程中执行。
### 2.3.1 配置渲染
包括:
- `cpconfig.cfg` 渲染
- 机构号、节点地址、组织名称等客户化字段写入
### 2.3.2 license 和输入文件处理
包括:
- 收发器 license 上传
- TLQ license / 配置包上传
- CFMQ 配置上传
- 通过 `fileId` 暂存并按目标路径落位
### 2.3.3 MQ/TLQ 客户化初始化
包括:
- `SET_RABBITMQ`
- `SET_TLQ`
- `TLQ_CONF_APPLY`
- RabbitMQ 通道账号和队列初始化
说明:
- 标准版 RabbitMQ 用户/队列初始化是客户化动作
- 但 RabbitMQ 软件本体安装、服务注册必须提前完成
### 2.3.4 数据库初始化
包括:
- `DB_RENDER`
- `DB_APPLY`
说明:
- 当前 TMS 负责渲染 SQL 和触发导库命令
- 但数据库客户端可用、导库凭据可用,必须由部署阶段保证
### 2.3.5 应用、nginx 和 WEB 客户化
包括:
- `DEPLOY_RECEIVER`
- `APP_MQ_CREDENTIALS_APPLY`
- `NGINX_CONFIG_APPLY`
- `WEB_CONFIG_APPLY`
说明:
- 这些步骤处理的是客户化配置和发布
- 不是安装 nginx 或解压标准介质
### 2.3.6 启动和校验
包括:
- `START_CMSP_CMTP`
- `START_NGINX`
- `POST_CHECK`
- `VERIFY`
说明:
- 当前初始化阶段负责启动和验收
- 但对应启动脚本、停止脚本、运行用户和目标路径需要提前准备好
### 2.3.7 厂商脚本与初始化阶段的对应关系
下表用于说明:哪些厂商脚本仍在初始化阶段使用,哪些脚本已经被当前 TMS 内置逻辑替代。
| 脚本/能力 | 当前阶段 | 当前处理方式 |
|---|---|---|
| `setfraq.sh` | 初始化阶段 | 仍由 TMS 调用,用于切换标准版 `RABBITMQ` 模式 |
| `setftq.sh` | 初始化阶段 | 仍由 TMS 调用,用于切换 `TLQ` 或 `RABBITMQ_TLQ` 模式 |
| `settlq.sh` | 初始化阶段 | 仍由 TMS 调用,用于落位和替换 TLQ 配置 |
| `setuprabq.sh` | 初始化阶段 | 由 TMS staging 后执行,不直接污染源脚本 |
| `addrabq_R_01.sh` / `addrabq_R_02.sh` | 初始化阶段 | 由 TMS staging 后执行,按节点选择 |
| `setsql.sh` | 初始化阶段 | 已不再作为主导入口;当前改为 `DB_RENDER + DB_APPLY` |
| `setsptp.sh` | 初始化阶段 | 仍由 TMS 调用,用于标准收发器应用部署 |
| `setweb.sh` | 初始化阶段 | 当前由 TMS 内置 `WEB_CONFIG_APPLY` 替代 |
| `setngx.sh` | 初始化阶段 | 当前由 TMS 内置 `NGINX_CONFIG_APPLY` 替代 |
需要注意:
- `setweb.sh` 和 `setngx.sh` 所代表的“WEB 发布”和“nginx 配置发布”仍属于初始化阶段动作
- 只是当前代码不再直接执行这两个脚本,而是由 TMS 内置逻辑完成
## 2.4 分版本预置清单
本节在通用边界之外,再按版本给出一份现场可直接对照的清单,帮助运维快速判断:
- 当前设备属于哪个版本
- 初始化前必须先准备好哪些内容
- 初始化阶段还会继续执行哪些客户化动作
### 2.4.1 企业版预置清单
适用范围:
- `ENTERPRISE + RABBITMQ`
- `ENTERPRISE + TLQ`
部署阶段必须完成:
- 标准版介质已解压到 `/home/cemp4i`
- `/home/cemp4i/cpconfig.cfg`、`/home/cemp4i/cpackage/toolsh/*.sh`、`/home/cemp4i/cpackage/sql_script/*`、`/home/cemp4i/cpackage/front/front.zip` 已存在
- JDK、nginx、RabbitMQ、TLQ、数据库客户端已安装
- `/home/tms`、`/home/cemp4i`、`/home/tlq` 目录及权限已准备
- 数据库导库凭据、RabbitMQ 管理员凭据已预置
- `standard-db-apply-command`、`standard-app-start-command`、`standard-nginx-start-command`、`standard-post-check-command` 对应脚本已部署
按支持方式补充要求:
| 支持方式 | 部署前额外要求 | 初始化阶段仍会执行 |
|---|---|---|
| `RABBITMQ` | RabbitMQ 服务可用,`rabbitmqctl`、`rabbitmqadmin` 可执行 | `SET_RABBITMQ`、RabbitMQ 用户和队列初始化、应用 MQ 账号写回 |
| `TLQ` | TLQ 基础软件和目录可用,`/home/tlq` 可写 | `SET_TLQ`、`TLQ_CONF_APPLY`、TLQ 配置包落位 |
企业版初始化阶段不会补做:
- RabbitMQ/TLQ 软件安装
- nginx 安装和服务注册
- 数据库账号创建
- 标准介质解压
### 2.4.2 间参版预置清单
适用范围:
- `INDIRECT + RABBITMQ`
- `INDIRECT + RABBITMQ_TLQ`
部署阶段必须完成:
- 标准版介质已解压到 `/home/cemp4i`
- 间参版对应的 `setsptp.sh`、`setfraq.sh`、`setftq.sh`、`settlq.sh`、RabbitMQ 脚本、SQL 模板已齐备
- JDK、nginx、RabbitMQ、TLQ、数据库客户端已安装
- `/home/tms`、`/home/cemp4i`、`/home/tlq` 目录及权限已准备
- 数据库导库凭据、RabbitMQ 管理员凭据已预置
- TMS 所依赖的标准初始化辅助脚本已部署到 `/home/tms/bin`
按支持方式补充要求:
| 支持方式 | 部署前额外要求 | 初始化阶段仍会执行 |
|---|---|---|
| `RABBITMQ` | RabbitMQ 服务可用 | `SET_RABBITMQ`、RabbitMQ 用户和队列初始化、应用 MQ 账号写回 |
| `RABBITMQ_TLQ` | RabbitMQ 和 TLQ 都要提前可用 | `SET_TLQ`、`TLQ_CONF_APPLY`,同时仍会执行 RabbitMQ 用户和队列初始化 |
需要特别说明:
- 间参版不存在“纯 TLQ”初始化模式
- `RABBITMQ_TLQ` 不是替代 RabbitMQ,而是 RabbitMQ 与 TLQ 共存
- 因此如果现场只准备了 TLQ、没有准备 RabbitMQ,间参版 `RABBITMQ_TLQ` 初始化仍会失败
### 2.4.3 轻量直参版预置清单
适用范围:
- `DIRECT + RABBITMQ_TLQ`
- `DIRECT + RABBITMQ_CFMQ`
部署阶段必须完成:
- 直参版介质已按厂商要求解压到约定目录
- 直参版 `cpconfig.cfg` 已在厂商约定位置存在
- 直参版数据库脚本目录、TLQ/CFMQ 相关目录、签名服务依赖目录已存在
- JDK、RabbitMQ、TLQ 或 CFMQ、数据库客户端已安装
- 数据库导库凭据、RabbitMQ 管理员凭据已预置
- 直参版初始化依赖的目标路径具备写权限
按支持方式补充要求:
| 支持方式 | 部署前额外要求 | 初始化阶段仍会执行 |
|---|---|---|
| `RABBITMQ_TLQ` | RabbitMQ 与 TLQ 运行环境都已就绪 | `MQ_USER_APPLY`、`DB_INIT`、`TLQ_INIT_CFG` |
| `RABBITMQ_CFMQ` | RabbitMQ 与 CFMQ 运行环境都已就绪 | `MQ_USER_APPLY`、`DB_INIT`、`CFMQ_APPLY` |
直参版与标准版的差异:
- 不走标准版 `/home/cemp4i/cpackage/toolsh/*.sh` 主链
- 不走标准版 `DB_RENDER + DB_APPLY`
- 不走标准版 `WEB_CONFIG_APPLY`、`NGINX_CONFIG_APPLY`
- 初始化更依赖直参版厂商目录结构和配置文件落位
### 2.4.4 现场使用建议
现场可以按以下顺序判断:
1. 先看设备预制版本是 `ENTERPRISE`、`INDIRECT` 还是 `DIRECT`
2. 再看该版本下的支持方式是 `RABBITMQ`、`TLQ`、`RABBITMQ_TLQ` 还是 `RABBITMQ_CFMQ`
3. 先完成本节对应版本的预置项
4. 再进入 `/api/v1/init/template -> /preview -> /tasks -> /execute` 初始化流程
如果当前版本对应的预置项没有完成,不要直接尝试执行初始化任务。
## 2.5 不应再由初始化阶段执行的内容
为了避免把“环境搭建”与“客户化初始化”混在一起,以下动作不应再由初始化阶段重复承担:
- 创建系统用户和用户组
- 批量创建基础目录
- 安装或编译 RabbitMQ
- 安装或编译 nginx
- 开放系统端口
- 解压标准收发器完整介质
- 创建数据库导库账号
- 创建 RabbitMQ 管理员账号
- 注册 systemd / rc.local 服务
这些动作应在出厂或部署阶段完成。
## 3. 环境要求 ## 3. 环境要求
### 3.1 操作系统 ### 3.1 操作系统
@@ -391,9 +702,52 @@ PRODUCT_TYPE=ENTERPRISE /Users/waner/Work/CISD/文档/tms-framework/scripts/cisd
- 标准版基础文件是否存在 - 标准版基础文件是否存在
- RabbitMQ 工具是否可用 - RabbitMQ 工具是否可用
- 直参版场景下的脚本目录是否存在 - 直参版场景下的脚本目录是否存在
- 标准介质与初始化依赖是否已经完成预置
建议在首次联调前执行一次。 建议在首次联调前执行一次。
## 11.1 现场建议的执行顺序
建议按以下顺序实施:
### 步骤 1:完成部署阶段预置
至少确认:
- 基础软件已安装
- 标准介质已解压
- 运行目录和用户已准备
- 平台级凭据已准备
- `/home/tms` 已部署完毕
### 步骤 2:执行本机预检查
执行:
```bash
PRODUCT_TYPE=ENTERPRISE /Users/waner/Work/CISD/文档/tms-framework/scripts/cisd_init_precheck.sh
```
或按实际产品类型设置:
- `ENTERPRISE`
- `INDIRECT`
- `DIRECT`
### 步骤 3:启动 TMS
执行:
```bash
/home/tms/scripts/tms.sh start
```
### 步骤 4:通过前端或接口执行初始化
在确认预置无误后,再执行:
- 文件上传
- 初始化预检
- 创建初始化任务
- 执行初始化任务
## 12. 初始化运行期目录与日志 ## 12. 初始化运行期目录与日志
### 12.1 初始化步骤日志 ### 12.1 初始化步骤日志
@@ -1,49 +1,51 @@
# 标准收发器离线升级包最小样例 # RECEIVER 离线升级包最小样例
本目录提供 `STANDARD_APP` 离线升级包的最小示例骨架,用于对齐打包方、后端和联调流程。 本目录保留原有 `standard-app` 路径,便于继续联调,但样例内容已经切换到当前第一版 `RECEIVER` 升级包契约。
目录结构: 目录结构:
```text ```text
standard-app/ standard-app/
├── manifest.json ├── manifest.json
├── build-package.sh
└── payload/ └── payload/
└── scripts/ └── scripts/
└── orchestrator.sh ├── execute.sh
├── precheck.sh
├── verify.sh
└── rollback.sh
``` ```
说明: 说明:
- 本目录中的 `manifest.json` 使用了占位符 `__PAYLOAD_SHA256__` - `execute.sh` 必填
- 真正打包前,需要先对 `payload.zip` 计算 `SHA-256`,再替换该字段 - `precheck.sh`、`verify.sh`、`rollback.sh` 可选
- `signature.sig` 不放在仓库示例里,必须由打包方使用“固件完整性密钥”私钥在打包阶段生成 - 本样例里四个脚本都给出,方便联调
- `signature.sig` 不放在仓库示例里,必须由打包方在打包阶段生成
- `build-package.sh` 会把 `manifest.json`、`signature.sig` 和 `payload/` 打成最终 `upgrade-package.zip`
推荐打包步骤: 推荐打包步骤:
1. 进入当前示例目录 1. 进入当前示例目录
2. 将 `payload/` 压缩为 `payload.zip` 2. 根据实际设备修改 `manifest.json` 中的 `productType`、`version`、`minCompatibleVersion`
3. 计算 `payload.zip` 的 `SHA-256` 3. 对 `manifest.json` 原始字节生成真实的 `signature.sig`
4. 用实际摘要替换 `manifest.json` 中的 `__PAYLOAD_SHA256__` 4. 执行 `./build-package.sh`
5. 对 `manifest.json` 原始字节生成 `signature.sig`
6. 将 `manifest.json`、`signature.sig`、`payload.zip` 压缩成最终 `upgrade-package.zip`
参考命令: 参考命令:
```bash ```bash
cd docs/examples/upgrade-package/standard-app cd docs/examples/upgrade-package/standard-app
zip -rq payload.zip payload # 这里替换为正式签名工具,生成 manifest.json 对应的 signature.sig
PAYLOAD_SHA256="$(shasum -a 256 payload.zip | awk '{print $1}')" # sign-manifest manifest.json > signature.sig
sed "s/__PAYLOAD_SHA256__/${PAYLOAD_SHA256}/g" manifest.json > manifest.generated.json
# 这里改为公司正式签名工具,生成 manifest.generated.json 对应的 signature.sig chmod +x build-package.sh
# sign-manifest manifest.generated.json > signature.sig ./build-package.sh
zip -j upgrade-package.zip manifest.generated.json signature.sig payload.zip
``` ```
联调时建议: 联调时建议:
- 先用这个目录骨架做一个 `STANDARD_APP` 预检样包 - 先用这个目录骨架做一个 `RECEIVER` 预检样包
- `orchestrator.sh` 可以先只输出日志并返回 `0` - 脚本可以先只输出日志并返回 `0`
- 等预检、建任务、执行链路跑通后,再替换成真实升级脚本 - 等预检、建任务、执行、回滚链路跑通后,再替换成真实升级脚本
- 如果当前环境启用了真实密码卡验签,占位 `signature.sig` 无法通过预检
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_PATH="${1:-${SCRIPT_DIR}/upgrade-package.zip}"
SIGNATURE_PATH="${SCRIPT_DIR}/signature.sig"
if [[ ! -f "${SIGNATURE_PATH}" ]]; then
echo "missing signature file: ${SIGNATURE_PATH}" >&2
echo "generate a real signature.sig for manifest.json before building the package" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "${tmpdir}"' EXIT
cp "${SCRIPT_DIR}/manifest.json" "${tmpdir}/manifest.json"
cp "${SIGNATURE_PATH}" "${tmpdir}/signature.sig"
cp -R "${SCRIPT_DIR}/payload" "${tmpdir}/payload"
rm -f "${OUTPUT_PATH}"
(
cd "${tmpdir}"
zip -rq "${OUTPUT_PATH}" manifest.json signature.sig payload
)
echo "package built: ${OUTPUT_PATH}"
@@ -1,13 +1,14 @@
{ {
"packageFormatVersion": 1, "packageId": "PKG-20260403-RECEIVER-001",
"packageId": "PKG-20260319-STANDARD-001", "taskType": "RECEIVER",
"targetComponent": "STANDARD_APP", "productType": "DIRECT",
"targetVersion": "V1.3.2", "version": "V1.4.0",
"minCompatibleVersion": "V1.3.0", "minCompatibleVersion": "V1.3.0",
"description": "标准收发器企业版离线升级包最小样例", "description": "receiver upgrade package",
"createdAt": "2026-03-19T16:30:00+08:00", "entrypoints": {
"orchestrator": "scripts/orchestrator.sh", "execute": "payload/scripts/execute.sh",
"fileChecksums": { "precheck": "payload/scripts/precheck.sh",
"payloadZip": "__PAYLOAD_SHA256__" "verify": "payload/scripts/verify.sh",
"rollback": "payload/scripts/rollback.sh"
} }
} }
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
TASK_ID="${1:-UNKNOWN}"
echo "[upgrade] task=${TASK_ID} phase=execute message=execute placeholder"
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
TASK_ID="${1:-UNKNOWN}"
echo "[upgrade] task=${TASK_ID} phase=backup message=start backup placeholder"
echo "[upgrade] task=${TASK_ID} phase=stop-service message=stop service placeholder"
echo "[upgrade] task=${TASK_ID} phase=deploy message=deploy placeholder"
echo "[upgrade] task=${TASK_ID} phase=health-check message=health check placeholder"
echo "[upgrade] task=${TASK_ID} phase=done message=upgrade completed"
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
TASK_ID="${1:-UNKNOWN}"
echo "[upgrade] task=${TASK_ID} phase=precheck message=precheck placeholder"
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
TASK_ID="${1:-UNKNOWN}"
echo "[upgrade] task=${TASK_ID} phase=rollback message=rollback placeholder"
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
TASK_ID="${1:-UNKNOWN}"
echo "[upgrade] task=${TASK_ID} phase=verify message=verify placeholder"
@@ -363,12 +363,12 @@ public interface PcieCryptoService {
//----------------------------------------SDF通用算法运算类接口------------------------------------------ //----------------------------------------SDF通用算法运算类接口------------------------------------------
/** /**
* 使用通用内部 ECC 密钥执行签名。 * 使用通用内部 ECC 密钥执行签名。(用户密钥使用)
*/ */
BackupDataResult eccInternalSign(EccInternalSignRequest request); BackupDataResult eccInternalSign(EccInternalSignRequest request);
/** /**
* 使用外部 ECC 私钥执行签名。 * 使用外部 ECC 私钥执行签名。(用户密钥使用)
*/ */
BackupDataResult eccExternalSign(EccExternalSignRequest request); BackupDataResult eccExternalSign(EccExternalSignRequest request);
@@ -7,7 +7,7 @@ public class UpgradeProperties {
private String stagingRootDir = "/home/tmp/tms-upgrade-staging"; private String stagingRootDir = "/home/tmp/tms-upgrade-staging";
private String logDir = "/home/tms/tmp/tms-upgrade-logs"; private String logDir = "/home/tms/tmp/tms-upgrade-logs";
private String signaturePublicKeyBase64 = ""; private String signaturePublicKeyPemPath = "";
public String getStagingRootDir() { public String getStagingRootDir() {
return stagingRootDir; return stagingRootDir;
@@ -25,11 +25,11 @@ public class UpgradeProperties {
this.logDir = logDir; this.logDir = logDir;
} }
public String getSignaturePublicKeyBase64() { public String getSignaturePublicKeyPemPath() {
return signaturePublicKeyBase64; return signaturePublicKeyPemPath;
} }
public void setSignaturePublicKeyBase64(String signaturePublicKeyBase64) { public void setSignaturePublicKeyPemPath(String signaturePublicKeyPemPath) {
this.signaturePublicKeyBase64 = signaturePublicKeyBase64; this.signaturePublicKeyPemPath = signaturePublicKeyPemPath;
} }
} }
@@ -67,15 +67,24 @@ public class UpgradeController {
return ApiResponse.success(upgradeService.executeTask(taskId)); return ApiResponse.success(upgradeService.executeTask(taskId));
} }
@PostMapping("/upgrades/{taskId}/rollback")
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
public ApiResponse<UpgradeTaskResponse> rollback(
@Parameter(description = "升级任务号")
@PathVariable String taskId
) {
return ApiResponse.success(upgradeService.rollbackTask(taskId));
}
@GetMapping("/upgrades") @GetMapping("/upgrades")
@Operation(summary = "查询升级记录列表", description = "分页查询升级任务列表,支持按升级对象和状态筛选。") @Operation(summary = "查询升级记录列表", description = "分页查询升级任务列表,支持按升级对象和状态筛选。")
public ApiResponse<UpgradeTaskPageResponse> list( public ApiResponse<UpgradeTaskPageResponse> list(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int pageSize, @RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String targetComponent, @RequestParam(required = false) String taskType,
@RequestParam(required = false) String status @RequestParam(required = false) String status
) { ) {
return ApiResponse.success(upgradeService.listTasks(page, pageSize, targetComponent, status)); return ApiResponse.success(upgradeService.listTasks(page, pageSize, taskType, status));
} }
@GetMapping("/upgrades/{taskId}") @GetMapping("/upgrades/{taskId}")
@@ -9,8 +9,10 @@ public class UpgradeCreateTaskResponse {
private String taskId; private String taskId;
@Schema(description = "升级任务状态", example = "PENDING_CONFIRM") @Schema(description = "升级任务状态", example = "PENDING_CONFIRM")
private String status; private String status;
@Schema(description = "升级对象", example = "STANDARD_APP") @Schema(description = "升级任务类型", example = "RECEIVER")
private String targetComponent; private String taskType;
@Schema(description = "设备产品类型", example = "DIRECT")
private String productType;
@Schema(description = "当前版本", example = "V1.3.1") @Schema(description = "当前版本", example = "V1.3.1")
private String currentVersion; private String currentVersion;
@Schema(description = "目标版本", example = "V1.3.2") @Schema(description = "目标版本", example = "V1.3.2")
@@ -32,12 +34,20 @@ public class UpgradeCreateTaskResponse {
this.status = status; this.status = status;
} }
public String getTargetComponent() { public String getTaskType() {
return targetComponent; return taskType;
} }
public void setTargetComponent(String targetComponent) { public void setTaskType(String taskType) {
this.targetComponent = targetComponent; this.taskType = taskType;
}
public String getProductType() {
return productType;
}
public void setProductType(String productType) {
this.productType = productType;
} }
public String getCurrentVersion() { public String getCurrentVersion() {
@@ -7,18 +7,18 @@ public class UpgradeManifest {
@Schema(description = "升级包唯一标识", example = "PKG_20260319_001") @Schema(description = "升级包唯一标识", example = "PKG_20260319_001")
private String packageId; private String packageId;
@Schema(description = "升级对象", example = "STANDARD_APP") @Schema(description = "升级任务类型", example = "RECEIVER")
private String targetComponent; private String taskType;
@Schema(description = "目标版本", example = "V1.3.2") @Schema(description = "设备产品类型", example = "DIRECT")
private String targetVersion; private String productType;
@Schema(description = "升级版本", example = "V1.3.2")
private String version;
@Schema(description = "兼容的最低旧版本", example = "V1.3.0") @Schema(description = "兼容的最低旧版本", example = "V1.3.0")
private String minCompatibleVersion; private String minCompatibleVersion;
@Schema(description = "升级包描述", example = "修复标准收发器已知问题") @Schema(description = "升级包描述", example = "修复标准收发器已知问题")
private String description; private String description;
@Schema(description = "升级执行脚本路径", example = "scripts/orchestrator.sh") @Schema(description = "升级脚本入口")
private String orchestrator; private Entrypoints entrypoints = new Entrypoints();
@Schema(description = "升级包校验信息")
private FileChecksums fileChecksums = new FileChecksums();
public String getPackageId() { public String getPackageId() {
return packageId; return packageId;
@@ -28,20 +28,28 @@ public class UpgradeManifest {
this.packageId = packageId; this.packageId = packageId;
} }
public String getTargetComponent() { public String getTaskType() {
return targetComponent; return taskType;
} }
public void setTargetComponent(String targetComponent) { public void setTaskType(String taskType) {
this.targetComponent = targetComponent; this.taskType = taskType;
} }
public String getTargetVersion() { public String getProductType() {
return targetVersion; return productType;
} }
public void setTargetVersion(String targetVersion) { public void setProductType(String productType) {
this.targetVersion = targetVersion; this.productType = productType;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
} }
public String getMinCompatibleVersion() { public String getMinCompatibleVersion() {
@@ -60,31 +68,50 @@ public class UpgradeManifest {
this.description = description; this.description = description;
} }
public String getOrchestrator() { public Entrypoints getEntrypoints() {
return orchestrator; return entrypoints;
} }
public void setOrchestrator(String orchestrator) { public void setEntrypoints(Entrypoints entrypoints) {
this.orchestrator = orchestrator; this.entrypoints = entrypoints;
} }
public FileChecksums getFileChecksums() { public static class Entrypoints {
return fileChecksums; private String execute;
} private String precheck;
private String verify;
private String rollback;
public void setFileChecksums(FileChecksums fileChecksums) { public String getExecute() {
this.fileChecksums = fileChecksums; return execute;
}
public static class FileChecksums {
private String payloadZip;
public String getPayloadZip() {
return payloadZip;
} }
public void setPayloadZip(String payloadZip) { public void setExecute(String execute) {
this.payloadZip = payloadZip; this.execute = execute;
}
public String getPrecheck() {
return precheck;
}
public void setPrecheck(String precheck) {
this.precheck = precheck;
}
public String getVerify() {
return verify;
}
public void setVerify(String verify) {
this.verify = verify;
}
public String getRollback() {
return rollback;
}
public void setRollback(String rollback) {
this.rollback = rollback;
} }
} }
} }
@@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "离线升级包预检响应") @Schema(description = "离线升级包预检响应")
public class UpgradePreviewResponse { public class UpgradePreviewResponse {
@Schema(description = "升级对象", example = "STANDARD_APP") @Schema(description = "升级任务类型", example = "RECEIVER")
private String targetComponent; private String taskType;
@Schema(description = "设备产品类型", example = "DIRECT")
private String productType;
@Schema(description = "当前版本", example = "V1.3.1") @Schema(description = "当前版本", example = "V1.3.1")
private String currentVersion; private String currentVersion;
@Schema(description = "目标版本", example = "V1.3.2") @Schema(description = "目标版本", example = "V1.3.2")
@@ -24,12 +26,20 @@ public class UpgradePreviewResponse {
@Schema(description = "升级包说明", example = "修复标准收发器已知问题") @Schema(description = "升级包说明", example = "修复标准收发器已知问题")
private String description; private String description;
public String getTargetComponent() { public String getTaskType() {
return targetComponent; return taskType;
} }
public void setTargetComponent(String targetComponent) { public void setTaskType(String taskType) {
this.targetComponent = targetComponent; this.taskType = taskType;
}
public String getProductType() {
return productType;
}
public void setProductType(String productType) {
this.productType = productType;
} }
public String getCurrentVersion() { public String getCurrentVersion() {
@@ -9,8 +9,10 @@ public class UpgradeTaskResponse {
private String taskId; private String taskId;
@Schema(description = "升级方式", example = "OFFLINE") @Schema(description = "升级方式", example = "OFFLINE")
private String upgradeMode; private String upgradeMode;
@Schema(description = "升级对象", example = "STANDARD_APP") @Schema(description = "升级任务类型", example = "RECEIVER")
private String targetComponent; private String taskType;
@Schema(description = "设备产品类型", example = "DIRECT")
private String productType;
@Schema(description = "升级包文件标识", example = "FILE-20260319-000001") @Schema(description = "升级包文件标识", example = "FILE-20260319-000001")
private String packageFileId; private String packageFileId;
@Schema(description = "升级包名称", example = "upgrade-package.zip") @Schema(description = "升级包名称", example = "upgrade-package.zip")
@@ -48,12 +50,20 @@ public class UpgradeTaskResponse {
this.upgradeMode = upgradeMode; this.upgradeMode = upgradeMode;
} }
public String getTargetComponent() { public String getTaskType() {
return targetComponent; return taskType;
} }
public void setTargetComponent(String targetComponent) { public void setTaskType(String taskType) {
this.targetComponent = targetComponent; this.taskType = taskType;
}
public String getProductType() {
return productType;
}
public void setProductType(String productType) {
this.productType = productType;
} }
public String getPackageFileId() { public String getPackageFileId() {
@@ -9,7 +9,8 @@ public class UpgradeTaskEntity extends BaseEntity {
private String taskId; private String taskId;
private String upgradeMode; private String upgradeMode;
private String targetComponent; private String taskType;
private String productType;
private String packageFileId; private String packageFileId;
private String packageName; private String packageName;
private String packageHash; private String packageHash;
@@ -41,12 +42,20 @@ public class UpgradeTaskEntity extends BaseEntity {
this.upgradeMode = upgradeMode; this.upgradeMode = upgradeMode;
} }
public String getTargetComponent() { public String getTaskType() {
return targetComponent; return taskType;
} }
public void setTargetComponent(String targetComponent) { public void setTaskType(String taskType) {
this.targetComponent = targetComponent; this.taskType = taskType;
}
public String getProductType() {
return productType;
}
public void setProductType(String productType) {
this.productType = productType;
} }
public String getPackageFileId() { public String getPackageFileId() {
@@ -2,6 +2,6 @@ package com.cisd.tms.modules.upgrade.enums;
public enum UpgradeTargetComponent { public enum UpgradeTargetComponent {
TMS, TMS,
STANDARD_APP, RECEIVER,
DIRECT_APP FIRMWARE
} }
@@ -4,5 +4,8 @@ public enum UpgradeTaskStatus {
PENDING_CONFIRM, PENDING_CONFIRM,
RUNNING, RUNNING,
SUCCESS, SUCCESS,
FAILED FAILED,
ROLLING_BACK,
ROLLBACK_SUCCESS,
ROLLBACK_FAILED
} }
@@ -16,6 +16,7 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.lang.ProcessBuilder.Redirect;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@@ -55,23 +56,40 @@ public class UpgradeTaskRunner {
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + task.getPackageFileId())); .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + task.getPackageFileId()));
Path stagedDir = upgradePackageStagingService.stage(task.getTaskId(), Path.of(record.getStoragePath())); Path stagedDir = upgradePackageStagingService.stage(task.getTaskId(), Path.of(record.getStoragePath()));
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class); UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
Path payloadDir = upgradePackageStagingService.stage(task.getTaskId() + "-payload", stagedDir.resolve("payload.zip")); Path root = stagedDir.normalize().toAbsolutePath();
Path orchestrator = payloadDir.resolve(manifest.getOrchestrator()).normalize().toAbsolutePath();
if (!Files.exists(orchestrator)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade orchestrator not found");
}
Process process = new ProcessBuilder("bash", orchestrator.toString(), task.getTaskId()) int precheckCode = runOptionalScript("precheck", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getPrecheck(), task, logPath);
.directory(payloadDir.toFile()) if (precheckCode != 0) {
.redirectErrorStream(true)
.redirectOutput(logPath.toFile())
.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
upgradeTaskRepository.updateStatus( upgradeTaskRepository.updateStatus(
task.getTaskId(), task.getTaskId(),
"FAILED", "FAILED",
"orchestrator exited with " + exitCode, "precheck exited with " + precheckCode,
logPath.toString(),
startedAt,
LocalDateTime.now()
);
return;
}
int executeCode = runRequiredScript("execute", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getExecute(), task, logPath);
if (executeCode != 0) {
upgradeTaskRepository.updateStatus(
task.getTaskId(),
"FAILED",
"execute exited with " + executeCode,
logPath.toString(),
startedAt,
LocalDateTime.now()
);
return;
}
int verifyCode = runOptionalScript("verify", root, manifest.getEntrypoints() == null ? "" : manifest.getEntrypoints().getVerify(), task, logPath);
if (verifyCode != 0) {
upgradeTaskRepository.updateStatus(
task.getTaskId(),
"FAILED",
"verify exited with " + verifyCode,
logPath.toString(), logPath.toString(),
startedAt, startedAt,
LocalDateTime.now() LocalDateTime.now()
@@ -80,8 +98,8 @@ public class UpgradeTaskRunner {
} }
DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity(); DeviceSoftwareVersionEntity versionEntity = new DeviceSoftwareVersionEntity();
versionEntity.setComponentCode(task.getTargetComponent()); versionEntity.setComponentCode(task.getTaskType());
versionEntity.setComponentName(componentName(task.getTargetComponent())); versionEntity.setComponentName(componentName(task.getTaskType()));
versionEntity.setCurrentVersion(task.getTargetVersion()); versionEntity.setCurrentVersion(task.getTargetVersion());
versionEntity.setSourceType("UPGRADE"); versionEntity.setSourceType("UPGRADE");
versionEntity.setDetectedAt(LocalDateTime.now()); versionEntity.setDetectedAt(LocalDateTime.now());
@@ -113,15 +131,88 @@ public class UpgradeTaskRunner {
} }
} }
public void rollback(UpgradeTaskEntity task) {
LocalDateTime startedAt = LocalDateTime.now();
Path logPath = logPath(task.getTaskId() + "-rollback");
try {
Files.createDirectories(logPath.getParent());
Files.writeString(logPath, "");
FileRecordEntity record = fileRecordRepository.findByFileId(task.getPackageFileId())
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + task.getPackageFileId()));
Path stagedDir = upgradePackageStagingService.stage(task.getTaskId() + "-rollback", Path.of(record.getStoragePath()));
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
String rollbackPath = manifest.getEntrypoints() == null ? "" : trim(manifest.getEntrypoints().getRollback());
if (rollbackPath.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");
}
int exitCode = runRequiredScript("rollback", stagedDir.normalize().toAbsolutePath(), rollbackPath, task, logPath);
if (exitCode != 0) {
upgradeTaskRepository.updateStatus(
task.getTaskId(),
"ROLLBACK_FAILED",
"rollback exited with " + exitCode,
logPath.toString(),
startedAt,
LocalDateTime.now()
);
return;
}
upgradeTaskRepository.updateStatus(
task.getTaskId(),
"ROLLBACK_SUCCESS",
"",
logPath.toString(),
startedAt,
LocalDateTime.now()
);
} catch (Exception ex) {
try {
Files.createDirectories(logPath.getParent());
Files.writeString(logPath, ex.getMessage() == null ? "upgrade rollback failed" : ex.getMessage());
} catch (IOException ignored) {
}
upgradeTaskRepository.updateStatus(
task.getTaskId(),
"ROLLBACK_FAILED",
ex.getMessage(),
logPath.toString(),
startedAt,
LocalDateTime.now()
);
}
}
private Path logPath(String taskId) { private Path logPath(String taskId) {
return Path.of(trim(upgradeProperties.getLogDir())).normalize().toAbsolutePath().resolve(taskId + ".log"); return Path.of(trim(upgradeProperties.getLogDir())).normalize().toAbsolutePath().resolve(taskId + ".log");
} }
private int runOptionalScript(String phase, Path stagedDir, String scriptPath, UpgradeTaskEntity task, Path logPath) throws IOException, InterruptedException {
if (trim(scriptPath).isEmpty()) {
return 0;
}
return runRequiredScript(phase, stagedDir, scriptPath, task, logPath);
}
private int runRequiredScript(String phase, Path stagedDir, String scriptPath, UpgradeTaskEntity task, Path logPath) throws IOException, InterruptedException {
Path resolved = stagedDir.resolve(trim(scriptPath)).normalize().toAbsolutePath();
if (!resolved.startsWith(stagedDir) || !Files.exists(resolved)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade " + phase + " script not found");
}
Process process = new ProcessBuilder("bash", resolved.toString(), task.getTaskId())
.directory(stagedDir.toFile())
.redirectErrorStream(true)
.redirectOutput(Redirect.appendTo(logPath.toFile()))
.start();
return process.waitFor();
}
private String componentName(String componentCode) { private String componentName(String componentCode) {
return switch (trim(componentCode)) { return switch (trim(componentCode)) {
case "TMS" -> "设备管理软件"; case "TMS" -> "设备管理软件";
case "STANDARD_APP" -> "标准收发器"; case "RECEIVER" -> "标准收发器";
case "DIRECT_APP" -> "直参轻量化终端"; case "FIRMWARE" -> "固件";
default -> componentCode; default -> componentCode;
}; };
} }
@@ -15,14 +15,14 @@ public interface UpgradeTaskMapper extends BaseMapperX<UpgradeTaskEntity> {
UpgradeTaskEntity selectRunningTask(); UpgradeTaskEntity selectRunningTask();
List<UpgradeTaskEntity> selectPage( List<UpgradeTaskEntity> selectPage(
@Param("targetComponent") String targetComponent, @Param("taskType") String taskType,
@Param("status") String status, @Param("status") String status,
@Param("offset") int offset, @Param("offset") int offset,
@Param("limit") int limit @Param("limit") int limit
); );
long countPage( long countPage(
@Param("targetComponent") String targetComponent, @Param("taskType") String taskType,
@Param("status") String status @Param("status") String status
); );
@@ -13,9 +13,9 @@ public interface UpgradeTaskRepository {
Optional<UpgradeTaskEntity> findRunningTask(); Optional<UpgradeTaskEntity> findRunningTask();
List<UpgradeTaskEntity> findPage(String targetComponent, String status, int offset, int limit); List<UpgradeTaskEntity> findPage(String taskType, String status, int offset, int limit);
long countPage(String targetComponent, String status); long countPage(String taskType, String status);
void updateStatus( void updateStatus(
String taskId, String taskId,
@@ -33,13 +33,13 @@ public class UpgradeTaskRepositoryImpl implements UpgradeTaskRepository {
} }
@Override @Override
public List<UpgradeTaskEntity> findPage(String targetComponent, String status, int offset, int limit) { public List<UpgradeTaskEntity> findPage(String taskType, String status, int offset, int limit) {
return upgradeTaskMapper.selectPage(targetComponent, status, offset, limit); return upgradeTaskMapper.selectPage(taskType, status, offset, limit);
} }
@Override @Override
public long countPage(String targetComponent, String status) { public long countPage(String taskType, String status) {
return upgradeTaskMapper.countPage(targetComponent, status); return upgradeTaskMapper.countPage(taskType, status);
} }
@Override @Override
@@ -1,5 +1,6 @@
package com.cisd.tms.modules.upgrade.service; package com.cisd.tms.modules.upgrade.service;
import com.cisd.tms.common.config.properties.CisdPresetProperties;
import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException; import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.device.entity.DeviceSoftwareVersionEntity; import com.cisd.tms.modules.device.entity.DeviceSoftwareVersionEntity;
@@ -13,12 +14,8 @@ import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService;
import com.cisd.tms.modules.upgrade.support.UpgradeVersionComparator; import com.cisd.tms.modules.upgrade.support.UpgradeVersionComparator;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -30,6 +27,7 @@ public class UpgradePackageService {
private final UpgradePackageStagingService upgradePackageStagingService; private final UpgradePackageStagingService upgradePackageStagingService;
private final UpgradePackageSignatureVerifier signatureVerifier; private final UpgradePackageSignatureVerifier signatureVerifier;
private final UpgradeVersionComparator versionComparator; private final UpgradeVersionComparator versionComparator;
private final CisdPresetProperties cisdPresetProperties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public UpgradePackageService( public UpgradePackageService(
@@ -38,6 +36,7 @@ public class UpgradePackageService {
UpgradePackageStagingService upgradePackageStagingService, UpgradePackageStagingService upgradePackageStagingService,
UpgradePackageSignatureVerifier signatureVerifier, UpgradePackageSignatureVerifier signatureVerifier,
UpgradeVersionComparator versionComparator, UpgradeVersionComparator versionComparator,
CisdPresetProperties cisdPresetProperties,
ObjectMapper objectMapper ObjectMapper objectMapper
) { ) {
this.fileRecordRepository = fileRecordRepository; this.fileRecordRepository = fileRecordRepository;
@@ -45,32 +44,20 @@ public class UpgradePackageService {
this.upgradePackageStagingService = upgradePackageStagingService; this.upgradePackageStagingService = upgradePackageStagingService;
this.signatureVerifier = signatureVerifier; this.signatureVerifier = signatureVerifier;
this.versionComparator = versionComparator; this.versionComparator = versionComparator;
this.cisdPresetProperties = cisdPresetProperties;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public UpgradePreviewResponse preview(String fileId) { public UpgradePreviewResponse preview(String fileId) {
FileRecordEntity record = fileRecordRepository.findByFileId(normalize(fileId)) StagedUpgradePackage stagedPackage = stagePackage(fileId);
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + normalize(fileId))); UpgradeManifest manifest = stagedPackage.manifest();
Path packagePath = Path.of(record.getStoragePath()).normalize().toAbsolutePath(); validateManifest(manifest);
if (!Files.exists(packagePath)) { validateProductType(manifest.getProductType());
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found on disk"); signatureVerifier.verify(manifest, stagedPackage.manifestPath(), stagedPackage.signaturePath());
} validateExecuteScript(stagedPackage.stagedDir(), manifest);
Path stagedDir = upgradePackageStagingService.stage(record.getFileId(), packagePath); String currentVersion = currentVersion(manifest.getTaskType());
Path manifestPath = stagedDir.resolve("manifest.json"); String targetVersion = normalize(manifest.getVersion());
Path payloadZipPath = stagedDir.resolve("payload.zip");
Path signaturePath = stagedDir.resolve("signature.sig");
if (!Files.exists(manifestPath) || !Files.exists(payloadZipPath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package structure invalid");
}
UpgradeManifest manifest = readManifest(manifestPath);
verifyPayloadChecksum(manifest, payloadZipPath);
signatureVerifier.verify(manifest, manifestPath, payloadZipPath, signaturePath);
String currentVersion = currentVersion(manifest.getTargetComponent());
String targetVersion = normalize(manifest.getTargetVersion());
if (!currentVersion.isEmpty() && versionComparator.compare(targetVersion, currentVersion) < 0) { if (!currentVersion.isEmpty() && versionComparator.compare(targetVersion, currentVersion) < 0) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "target version is lower than current version"); throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "target version is lower than current version");
@@ -81,10 +68,11 @@ public class UpgradePackageService {
} }
UpgradePreviewResponse response = new UpgradePreviewResponse(); UpgradePreviewResponse response = new UpgradePreviewResponse();
response.setTargetComponent(normalize(manifest.getTargetComponent())); response.setTaskType(normalize(manifest.getTaskType()));
response.setProductType(normalize(manifest.getProductType()));
response.setCurrentVersion(currentVersion); response.setCurrentVersion(currentVersion);
response.setTargetVersion(targetVersion); response.setTargetVersion(targetVersion);
response.setVersionChangeSummary(buildVersionSummary(manifest.getTargetComponent(), currentVersion, targetVersion)); response.setVersionChangeSummary(buildVersionSummary(currentVersion, targetVersion));
response.setVerifyPassed(true); response.setVerifyPassed(true);
response.setVerifyMessage("校验通过"); response.setVerifyMessage("校验通过");
response.setAllowed(true); response.setAllowed(true);
@@ -93,6 +81,10 @@ public class UpgradePackageService {
return response; return response;
} }
public UpgradeManifest loadManifest(String fileId) {
return stagePackage(fileId).manifest();
}
private UpgradeManifest readManifest(Path manifestPath) { private UpgradeManifest readManifest(Path manifestPath) {
try { try {
return objectMapper.readValue(Files.readString(manifestPath), UpgradeManifest.class); return objectMapper.readValue(Files.readString(manifestPath), UpgradeManifest.class);
@@ -101,30 +93,37 @@ public class UpgradePackageService {
} }
} }
private void verifyPayloadChecksum(UpgradeManifest manifest, Path payloadZipPath) { private StagedUpgradePackage stagePackage(String fileId) {
String expected = normalize(manifest.getFileChecksums() == null ? "" : manifest.getFileChecksums().getPayloadZip()); FileRecordEntity record = fileRecordRepository.findByFileId(normalize(fileId))
if (expected.isEmpty()) { .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + normalize(fileId)));
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "payload checksum missing"); Path packagePath = Path.of(record.getStoragePath()).normalize().toAbsolutePath();
if (!Files.exists(packagePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found on disk");
} }
String actual = sha256Hex(payloadZipPath);
if (!expected.equalsIgnoreCase(actual)) { Path stagedDir = upgradePackageStagingService.stage(record.getFileId(), packagePath);
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "payload checksum mismatch"); Path manifestPath = stagedDir.resolve("manifest.json");
Path signaturePath = stagedDir.resolve("signature.sig");
if (!Files.exists(manifestPath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package structure invalid");
} }
return new StagedUpgradePackage(stagedDir, manifestPath, signaturePath, readManifest(manifestPath));
} }
private String sha256Hex(Path file) { private void validateManifest(UpgradeManifest manifest) {
try (InputStream inputStream = Files.newInputStream(file)) { if (manifest == null) {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "manifest.json is invalid");
byte[] buffer = new byte[8192]; }
int read; if (normalize(manifest.getPackageId()).isEmpty()
while ((read = inputStream.read(buffer)) >= 0) { || normalize(manifest.getTaskType()).isEmpty()
if (read > 0) { || normalize(manifest.getProductType()).isEmpty()
digest.update(buffer, 0, read); || normalize(manifest.getVersion()).isEmpty()
} || normalize(manifest.getMinCompatibleVersion()).isEmpty()) {
} throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "manifest.json is invalid");
return HexFormat.of().formatHex(digest.digest()); }
} catch (IOException | NoSuchAlgorithmException ex) { UpgradeManifest.Entrypoints entrypoints = manifest.getEntrypoints();
throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), "failed to calculate payload checksum"); if (entrypoints == null || normalize(entrypoints.getExecute()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade execute script not found");
} }
} }
@@ -133,19 +132,40 @@ public class UpgradePackageService {
return cached.map(DeviceSoftwareVersionEntity::getCurrentVersion).map(this::normalize).orElse(""); return cached.map(DeviceSoftwareVersionEntity::getCurrentVersion).map(this::normalize).orElse("");
} }
private String buildVersionSummary(String componentCode, String currentVersion, String targetVersion) { private void validateProductType(String packageProductType) {
String label = switch (normalize(componentCode)) { String currentProductType = normalizeUpper(cisdPresetProperties.getProductType());
case "TMS" -> "设备管理软件"; String packageType = normalizeUpper(packageProductType);
case "STANDARD_APP" -> "标准收发器"; if (!currentProductType.isEmpty() && !packageType.equals(currentProductType)) {
case "DIRECT_APP" -> "直参轻量化终端"; throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package product type does not match current device");
default -> "目标组件"; }
}; }
private void validateExecuteScript(Path stagedDir, UpgradeManifest manifest) {
Path executePath = stagedDir.resolve(normalize(manifest.getEntrypoints().getExecute())).normalize().toAbsolutePath();
if (!executePath.startsWith(stagedDir.normalize().toAbsolutePath()) || !Files.exists(executePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade execute script not found");
}
}
private String buildVersionSummary(String currentVersion, String targetVersion) {
String from = currentVersion.isEmpty() ? "未记录版本" : currentVersion; String from = currentVersion.isEmpty() ? "未记录版本" : currentVersion;
String to = targetVersion.isEmpty() ? "未知目标版本" : targetVersion; String to = targetVersion.isEmpty() ? "未知目标版本" : targetVersion;
return label + "将从 " + from + " 升级到 " + to; return "当前设备将从 " + from + " 升级到 " + to;
} }
private String normalize(String value) { private String normalize(String value) {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
private String normalizeUpper(String value) {
return normalize(value).toUpperCase();
}
private record StagedUpgradePackage(
Path stagedDir,
Path manifestPath,
Path signaturePath,
UpgradeManifest manifest
) {
}
} }
@@ -7,6 +7,7 @@ import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest; import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeLogResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeLogResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewRequest; import com.cisd.tms.modules.upgrade.dto.UpgradePreviewRequest;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse; import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
@@ -102,7 +103,8 @@ public class UpgradeService {
UpgradeTaskEntity entity = new UpgradeTaskEntity(); UpgradeTaskEntity entity = new UpgradeTaskEntity();
entity.setTaskId(buildTaskId()); entity.setTaskId(buildTaskId());
entity.setUpgradeMode("OFFLINE"); entity.setUpgradeMode("OFFLINE");
entity.setTargetComponent(preview.getTargetComponent()); entity.setTaskType(preview.getTaskType());
entity.setProductType(preview.getProductType());
entity.setPackageFileId(request.getFileId().trim()); entity.setPackageFileId(request.getFileId().trim());
entity.setPackageName(fileRecord.getOriginalFilename()); entity.setPackageName(fileRecord.getOriginalFilename());
entity.setPackageHash(""); entity.setPackageHash("");
@@ -120,7 +122,8 @@ public class UpgradeService {
UpgradeCreateTaskResponse response = new UpgradeCreateTaskResponse(); UpgradeCreateTaskResponse response = new UpgradeCreateTaskResponse();
response.setTaskId(entity.getTaskId()); response.setTaskId(entity.getTaskId());
response.setStatus(entity.getStatus()); response.setStatus(entity.getStatus());
response.setTargetComponent(entity.getTargetComponent()); response.setTaskType(entity.getTaskType());
response.setProductType(entity.getProductType());
response.setCurrentVersion(entity.getCurrentVersion()); response.setCurrentVersion(entity.getCurrentVersion());
response.setTargetVersion(entity.getTargetVersion()); response.setTargetVersion(entity.getTargetVersion());
return response; return response;
@@ -150,12 +153,37 @@ public class UpgradeService {
return toResponse(loadTask(taskId)); return toResponse(loadTask(taskId));
} }
public UpgradeTaskPageResponse listTasks(int page, int pageSize, String targetComponent, String status) { public UpgradeTaskResponse rollbackTask(String taskId) {
UpgradeTaskEntity task = loadTask(taskId);
UpgradeManifest manifest = upgradePackageService.loadManifest(task.getPackageFileId());
if (manifest.getEntrypoints() == null || trim(manifest.getEntrypoints().getRollback()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");
}
UpgradeTaskEntity runningTask = upgradeTaskRepository.findRunningTask().orElse(null);
if (runningTask != null && !runningTask.getTaskId().equals(taskId)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "another upgrade task is running");
}
if (activeExecutions.containsKey(taskId)) {
return toResponse(loadTask(taskId));
}
upgradeTaskRepository.updateStatus(taskId, "ROLLING_BACK", "", task.getDetailLogPath(), LocalDateTime.now(), null);
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
upgradeTaskRunner.rollback(loadTask(taskId));
} finally {
activeExecutions.remove(taskId);
}
}, backgroundExecutor);
activeExecutions.put(taskId, future);
return toResponse(loadTask(taskId));
}
public UpgradeTaskPageResponse listTasks(int page, int pageSize, String taskType, String status) {
int normalizedPage = Math.max(1, page); int normalizedPage = Math.max(1, page);
int normalizedPageSize = Math.max(1, pageSize); int normalizedPageSize = Math.max(1, pageSize);
int offset = (normalizedPage - 1) * normalizedPageSize; int offset = (normalizedPage - 1) * normalizedPageSize;
List<UpgradeTaskEntity> entities = upgradeTaskRepository.findPage(trim(targetComponent), trim(status), offset, normalizedPageSize); List<UpgradeTaskEntity> entities = upgradeTaskRepository.findPage(trim(taskType), trim(status), offset, normalizedPageSize);
long total = upgradeTaskRepository.countPage(trim(targetComponent), trim(status)); long total = upgradeTaskRepository.countPage(trim(taskType), trim(status));
UpgradeTaskPageResponse response = new UpgradeTaskPageResponse(); UpgradeTaskPageResponse response = new UpgradeTaskPageResponse();
response.setPage(normalizedPage); response.setPage(normalizedPage);
@@ -203,7 +231,8 @@ public class UpgradeService {
UpgradeTaskResponse response = new UpgradeTaskResponse(); UpgradeTaskResponse response = new UpgradeTaskResponse();
response.setTaskId(entity.getTaskId()); response.setTaskId(entity.getTaskId());
response.setUpgradeMode(entity.getUpgradeMode()); response.setUpgradeMode(entity.getUpgradeMode());
response.setTargetComponent(entity.getTargetComponent()); response.setTaskType(entity.getTaskType());
response.setProductType(entity.getProductType());
response.setPackageFileId(entity.getPackageFileId()); response.setPackageFileId(entity.getPackageFileId());
response.setPackageName(entity.getPackageName()); response.setPackageName(entity.getPackageName());
response.setCurrentVersion(entity.getCurrentVersion()); response.setCurrentVersion(entity.getCurrentVersion());
@@ -9,7 +9,7 @@ import java.nio.file.Path;
public class NoopUpgradePackageSignatureVerifier implements UpgradePackageSignatureVerifier { public class NoopUpgradePackageSignatureVerifier implements UpgradePackageSignatureVerifier {
@Override @Override
public void verify(UpgradeManifest manifest, Path manifestPath, Path payloadZipPath, Path signaturePath) { public void verify(UpgradeManifest manifest, Path manifestPath, Path signaturePath) {
if (!Files.exists(signaturePath)) { if (!Files.exists(signaturePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found"); throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found");
} }
@@ -1,100 +0,0 @@
package com.cisd.tms.modules.upgrade.support;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.integration.crypto.pcie.Gm0018AlgorithmIds;
import com.cisd.tms.integration.crypto.pcie.model.EccExternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import org.springframework.stereotype.Component;
@Component
public class PcieUpgradePackageSignatureVerifier implements UpgradePackageSignatureVerifier {
private final UpgradeProperties upgradeProperties;
private final PcieCryptoService pcieCryptoService;
public PcieUpgradePackageSignatureVerifier(UpgradeProperties upgradeProperties, PcieCryptoService pcieCryptoService) {
this.upgradeProperties = upgradeProperties;
this.pcieCryptoService = pcieCryptoService;
}
@Override
public void verify(UpgradeManifest manifest, Path manifestPath, Path payloadZipPath, Path signaturePath) {
if (!Files.exists(signaturePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found");
}
byte[] publicKeyBlob = decodeConfiguredPublicKey();
byte[] signature = loadSignature(signaturePath);
byte[] manifestBytes = readBytes(manifestPath, "failed to read manifest for signature verification");
EccExternalVerifyRequest request = new EccExternalVerifyRequest();
request.setAlgId(Gm0018AlgorithmIds.SM2_SIGN);
request.setPublicKeyBlob(publicKeyBlob);
request.setData(manifestBytes);
request.setSignature(signature);
try {
pcieCryptoService.eccExternalVerify(request);
} catch (BizException ex) {
throw ex;
} catch (RuntimeException ex) {
throw new BizException(ErrorCode.CRYPTO_CARD_ERROR.getCode(), "upgrade package signature verification failed");
}
}
private byte[] decodeConfiguredPublicKey() {
String configured = normalize(upgradeProperties.getSignaturePublicKeyBase64());
if (configured.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key not configured");
}
try {
byte[] decoded = Base64.getDecoder().decode(configured);
if (decoded.length == 0) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key is invalid");
}
return decoded;
} catch (IllegalArgumentException ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key is invalid");
}
}
private byte[] loadSignature(Path signaturePath) {
byte[] raw = readBytes(signaturePath, "failed to read upgrade signature file");
if (raw.length == 0) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file is empty");
}
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (!text.isEmpty()) {
try {
byte[] decoded = Base64.getDecoder().decode(text);
if (decoded.length > 0) {
return decoded;
}
} catch (IllegalArgumentException ignored) {
// Keep backward compatibility with raw binary signature files.
}
}
return raw;
}
private byte[] readBytes(Path path, String failureMessage) {
try {
return Files.readAllBytes(path);
} catch (IOException ex) {
throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), failureMessage);
}
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,122 @@
package com.cisd.tms.modules.upgrade.support;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.PublicKey;
import java.security.Security;
import java.security.Signature;
import java.util.Base64;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.stereotype.Component;
@Component
public class SoftUpgradePackageSignatureVerifier implements UpgradePackageSignatureVerifier {
private static final String PROVIDER = "BC";
private static final String SIGNATURE_ALGORITHM = "SM3withSM2";
static {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
private final UpgradeProperties upgradeProperties;
public SoftUpgradePackageSignatureVerifier(UpgradeProperties upgradeProperties) {
this.upgradeProperties = upgradeProperties;
}
@Override
public void verify(UpgradeManifest manifest, Path manifestPath, Path signaturePath) {
if (!Files.exists(signaturePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file not found");
}
PublicKey publicKey = loadPublicKey();
byte[] manifestBytes = readBytes(manifestPath, "failed to read manifest for signature verification");
byte[] signatureBytes = loadSignature(signaturePath);
try {
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM, PROVIDER);
signature.initVerify(publicKey);
signature.update(manifestBytes);
if (!signature.verify(signatureBytes)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature verification failed");
}
} catch (BizException ex) {
throw ex;
} catch (Exception ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature verification failed");
}
}
private PublicKey loadPublicKey() {
Path pemPath = Path.of(normalize(upgradeProperties.getSignaturePublicKeyPemPath())).normalize().toAbsolutePath();
if (normalize(upgradeProperties.getSignaturePublicKeyPemPath()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem path not configured");
}
if (!Files.exists(pemPath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem file not found");
}
try {
String pemContent = Files.readString(pemPath, StandardCharsets.UTF_8);
try (Reader reader = new StringReader(pemContent); PEMParser parser = new PEMParser(reader)) {
Object parsed = parser.readObject();
if (!(parsed instanceof SubjectPublicKeyInfo publicKeyInfo)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem is invalid");
}
return new JcaPEMKeyConverter().setProvider(PROVIDER).getPublicKey(publicKeyInfo);
}
} catch (BizException ex) {
throw ex;
} catch (IOException ex) {
throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), "failed to read upgrade signature public key pem");
} catch (Exception ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade signature public key pem is invalid");
}
}
private byte[] loadSignature(Path signaturePath) {
byte[] raw = readBytes(signaturePath, "failed to read upgrade signature file");
if (raw.length == 0) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package signature file is empty");
}
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (!text.isEmpty()) {
try {
byte[] decoded = Base64.getDecoder().decode(text);
if (decoded.length > 0) {
return decoded;
}
} catch (IllegalArgumentException ignored) {
// Keep compatibility with raw binary signature files.
}
}
return raw;
}
private byte[] readBytes(Path path, String failureMessage) {
try {
return Files.readAllBytes(path);
} catch (IOException ex) {
throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), failureMessage);
}
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
}
@@ -5,5 +5,9 @@ import java.nio.file.Path;
public interface UpgradePackageSignatureVerifier { public interface UpgradePackageSignatureVerifier {
void verify(UpgradeManifest manifest, Path manifestPath, Path payloadZipPath, Path signaturePath); void verify(UpgradeManifest manifest, Path manifestPath, Path signaturePath);
default void verify(UpgradeManifest manifest, Path manifestPath, Path payloadZipPath, Path signaturePath) {
verify(manifest, manifestPath, signaturePath);
}
} }
+2 -2
View File
@@ -172,8 +172,8 @@ tms:
staging-root-dir: ${TMS_UPGRADE_STAGING_ROOT_DIR:/home/tmp/tms-upgrade-staging} staging-root-dir: ${TMS_UPGRADE_STAGING_ROOT_DIR:/home/tmp/tms-upgrade-staging}
# 升级任务统一日志目录。 # 升级任务统一日志目录。
log-dir: ${TMS_UPGRADE_LOG_DIR:/home/tms/tmp/tms-upgrade-logs} log-dir: ${TMS_UPGRADE_LOG_DIR:/home/tms/tmp/tms-upgrade-logs}
# 升级包验签使用的“固件完整性密钥”公钥 blob(Base64 编码);为空时升级预检会拒绝通过。 # 升级包验签使用的公钥 PEM 文件路径;为空时升级预检会拒绝通过。
signature-public-key-base64: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_BASE64:} signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:}
mk: mk:
init-identify: init-identify:
# SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。 # SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。
@@ -0,0 +1,6 @@
ALTER TABLE t_upgrade_task
ADD COLUMN product_type VARCHAR(16) NULL AFTER target_component;
UPDATE t_upgrade_task
SET product_type = 'ENTERPRISE'
WHERE product_type IS NULL OR product_type = '';
@@ -0,0 +1,6 @@
ALTER TABLE t_upgrade_task
CHANGE COLUMN target_component task_type VARCHAR(32) NOT NULL;
ALTER TABLE t_upgrade_task
DROP INDEX idx_t_upgrade_task_component,
ADD INDEX idx_t_upgrade_task_task_type (task_type);
@@ -8,7 +8,8 @@
<id property="id" column="id"/> <id property="id" column="id"/>
<result property="taskId" column="task_id"/> <result property="taskId" column="task_id"/>
<result property="upgradeMode" column="upgrade_mode"/> <result property="upgradeMode" column="upgrade_mode"/>
<result property="targetComponent" column="target_component"/> <result property="taskType" column="task_type"/>
<result property="productType" column="product_type"/>
<result property="packageFileId" column="package_file_id"/> <result property="packageFileId" column="package_file_id"/>
<result property="packageName" column="package_name"/> <result property="packageName" column="package_name"/>
<result property="packageHash" column="package_hash"/> <result property="packageHash" column="package_hash"/>
@@ -28,7 +29,7 @@
</resultMap> </resultMap>
<sql id="UpgradeTaskColumns"> <sql id="UpgradeTaskColumns">
id, task_id, upgrade_mode, target_component, package_file_id, package_name, package_hash, id, task_id, upgrade_mode, task_type, product_type, package_file_id, package_name, package_hash,
current_version, target_version, status, verify_passed, verify_message, manifest_json, current_version, target_version, status, verify_passed, verify_message, manifest_json,
detail_log_path, error_message, operator, started_at, finished_at, create_time, update_time detail_log_path, error_message, operator, started_at, finished_at, create_time, update_time
</sql> </sql>
@@ -52,8 +53,8 @@
SELECT <include refid="UpgradeTaskColumns"/> SELECT <include refid="UpgradeTaskColumns"/>
FROM t_upgrade_task FROM t_upgrade_task
<where> <where>
<if test="targetComponent != null and targetComponent != ''"> <if test="taskType != null and taskType != ''">
AND target_component = #{targetComponent} AND task_type = #{taskType}
</if> </if>
<if test="status != null and status != ''"> <if test="status != null and status != ''">
AND status = #{status} AND status = #{status}
@@ -67,8 +68,8 @@
SELECT COUNT(1) SELECT COUNT(1)
FROM t_upgrade_task FROM t_upgrade_task
<where> <where>
<if test="targetComponent != null and targetComponent != ''"> <if test="taskType != null and taskType != ''">
AND target_component = #{targetComponent} AND task_type = #{taskType}
</if> </if>
<if test="status != null and status != ''"> <if test="status != null and status != ''">
AND status = #{status} AND status = #{status}
@@ -15,21 +15,23 @@ class UpgradeDtoContractTest {
@Test @Test
void shouldSerializeUpgradePreviewFields() throws Exception { void shouldSerializeUpgradePreviewFields() throws Exception {
UpgradePreviewResponse response = new UpgradePreviewResponse(); UpgradePreviewResponse response = new UpgradePreviewResponse();
response.setTargetComponent("STANDARD_APP"); response.setTaskType("RECEIVER");
response.setProductType("DIRECT");
response.setCurrentVersion("V1.3.1"); response.setCurrentVersion("V1.3.1");
response.setTargetVersion("V1.3.2"); response.setTargetVersion("V1.4.0");
response.setVersionChangeSummary("标准收发器企业版将从 V1.3.1 升级到 V1.3.2"); response.setVersionChangeSummary("当前设备将从 V1.3.1 升级到 V1.4.0");
response.setVerifyPassed(true); response.setVerifyPassed(true);
response.setVerifyMessage("校验通过"); response.setVerifyMessage("校验通过");
response.setAllowed(true); response.setAllowed(true);
response.setBlockReason(""); response.setBlockReason("");
response.setDescription("修复标准收发器已知问题"); response.setDescription("receiver upgrade package");
String json = objectMapper.writeValueAsString(response); String json = objectMapper.writeValueAsString(response);
Assertions.assertTrue(json.contains("\"targetComponent\":\"STANDARD_APP\"")); Assertions.assertTrue(json.contains("\"taskType\":\"RECEIVER\""));
Assertions.assertTrue(json.contains("\"productType\":\"DIRECT\""));
Assertions.assertTrue(json.contains("\"currentVersion\":\"V1.3.1\"")); Assertions.assertTrue(json.contains("\"currentVersion\":\"V1.3.1\""));
Assertions.assertTrue(json.contains("\"targetVersion\":\"V1.3.2\"")); Assertions.assertTrue(json.contains("\"targetVersion\":\"V1.4.0\""));
Assertions.assertTrue(json.contains("\"versionChangeSummary\"")); Assertions.assertTrue(json.contains("\"versionChangeSummary\""));
Assertions.assertTrue(json.contains("\"verifyPassed\":true")); Assertions.assertTrue(json.contains("\"verifyPassed\":true"));
Assertions.assertTrue(json.contains("\"allowed\":true")); Assertions.assertTrue(json.contains("\"allowed\":true"));
@@ -46,8 +48,8 @@ class UpgradeDtoContractTest {
@Test @Test
void shouldExposeExpectedUpgradeEnums() { void shouldExposeExpectedUpgradeEnums() {
Assertions.assertEquals("TMS", UpgradeTargetComponent.TMS.name()); Assertions.assertEquals("TMS", UpgradeTargetComponent.TMS.name());
Assertions.assertEquals("STANDARD_APP", UpgradeTargetComponent.STANDARD_APP.name()); Assertions.assertEquals("RECEIVER", UpgradeTargetComponent.RECEIVER.name());
Assertions.assertEquals("DIRECT_APP", UpgradeTargetComponent.DIRECT_APP.name()); Assertions.assertEquals("FIRMWARE", UpgradeTargetComponent.FIRMWARE.name());
Assertions.assertEquals("PENDING_CONFIRM", UpgradeTaskStatus.PENDING_CONFIRM.name()); Assertions.assertEquals("PENDING_CONFIRM", UpgradeTaskStatus.PENDING_CONFIRM.name());
Assertions.assertEquals("RUNNING", UpgradeTaskStatus.RUNNING.name()); Assertions.assertEquals("RUNNING", UpgradeTaskStatus.RUNNING.name());
Assertions.assertEquals("SUCCESS", UpgradeTaskStatus.SUCCESS.name()); Assertions.assertEquals("SUCCESS", UpgradeTaskStatus.SUCCESS.name());
@@ -46,7 +46,8 @@ class UpgradeControllerTest {
FileService fileService = Mockito.mock(FileService.class); FileService fileService = Mockito.mock(FileService.class);
UpgradePreviewResponse preview = new UpgradePreviewResponse(); UpgradePreviewResponse preview = new UpgradePreviewResponse();
preview.setTargetComponent("STANDARD_APP"); preview.setTaskType("RECEIVER");
preview.setProductType("DIRECT");
preview.setCurrentVersion("V1.3.1"); preview.setCurrentVersion("V1.3.1");
preview.setTargetVersion("V1.3.2"); preview.setTargetVersion("V1.3.2");
preview.setAllowed(true); preview.setAllowed(true);
@@ -56,7 +57,8 @@ class UpgradeControllerTest {
UpgradeCreateTaskResponse create = new UpgradeCreateTaskResponse(); UpgradeCreateTaskResponse create = new UpgradeCreateTaskResponse();
create.setTaskId("UPG-001"); create.setTaskId("UPG-001");
create.setStatus("PENDING_CONFIRM"); create.setStatus("PENDING_CONFIRM");
create.setTargetComponent("STANDARD_APP"); create.setTaskType("RECEIVER");
create.setProductType("DIRECT");
create.setCurrentVersion("V1.3.1"); create.setCurrentVersion("V1.3.1");
create.setTargetVersion("V1.3.2"); create.setTargetVersion("V1.3.2");
Mockito.when(upgradeService.createTask(Mockito.any())).thenReturn(create); Mockito.when(upgradeService.createTask(Mockito.any())).thenReturn(create);
@@ -64,7 +66,10 @@ class UpgradeControllerTest {
UpgradeTaskResponse task = new UpgradeTaskResponse(); UpgradeTaskResponse task = new UpgradeTaskResponse();
task.setTaskId("UPG-001"); task.setTaskId("UPG-001");
task.setStatus("RUNNING"); task.setStatus("RUNNING");
task.setTaskType("RECEIVER");
task.setProductType("DIRECT");
Mockito.when(upgradeService.executeTask("UPG-001")).thenReturn(task); Mockito.when(upgradeService.executeTask("UPG-001")).thenReturn(task);
Mockito.when(upgradeService.rollbackTask("UPG-001")).thenReturn(task);
Mockito.when(upgradeService.getTask("UPG-001")).thenReturn(task); Mockito.when(upgradeService.getTask("UPG-001")).thenReturn(task);
UpgradeTaskPageResponse page = new UpgradeTaskPageResponse(); UpgradeTaskPageResponse page = new UpgradeTaskPageResponse();
@@ -80,7 +85,8 @@ class UpgradeControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"fileId\":\"FILE-001\"}")) .content("{\"fileId\":\"FILE-001\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"targetComponent\":\"STANDARD_APP\""))); .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskType\":\"RECEIVER\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"productType\":\"DIRECT\"")));
mockMvc.perform(post("/api/v1/upgrades") mockMvc.perform(post("/api/v1/upgrades")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -92,10 +98,14 @@ class UpgradeControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"RUNNING\""))); .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"RUNNING\"")));
mockMvc.perform(post("/api/v1/upgrades/UPG-001/rollback"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"RUNNING\"")));
mockMvc.perform(get("/api/v1/upgrades") mockMvc.perform(get("/api/v1/upgrades")
.param("page", "1") .param("page", "1")
.param("pageSize", "10") .param("pageSize", "10")
.param("targetComponent", "") .param("taskType", "")
.param("status", "")) .param("status", ""))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"total\":1"))); .andExpect(content().string(org.hamcrest.Matchers.containsString("\"total\":1")));
@@ -0,0 +1,259 @@
package com.cisd.tms.modules.upgrade.executor;
import com.cisd.tms.modules.device.entity.DeviceSoftwareVersionEntity;
import com.cisd.tms.modules.device.repository.DeviceSoftwareVersionRepository;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import com.cisd.tms.modules.upgrade.entity.UpgradeTaskEntity;
import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository;
import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class UpgradeTaskRunnerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void shouldRunPrecheckExecuteAndVerifyScriptsInOrder() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
FileRecordEntity fileRecord = buildPackage(tempDir, true, "#!/bin/sh\necho execute\nexit 0\n", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository();
InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
UpgradeTaskRunner runner = new UpgradeTaskRunner(
taskRepository,
fileRepository,
versionRepository,
stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")),
properties(tempDir.resolve("staging"), tempDir.resolve("logs")),
objectMapper
);
runner.run(task("UPG-001", fileRecord.getFileId(), "TMS", "V1.0.1"));
Assertions.assertEquals("SUCCESS", taskRepository.status);
Assertions.assertEquals("V1.0.1", versionRepository.store.get("TMS").getCurrentVersion());
String log = Files.readString(Path.of(taskRepository.logPath));
Assertions.assertTrue(log.contains("precheck"));
Assertions.assertTrue(log.contains("execute"));
Assertions.assertTrue(log.contains("verify"));
Assertions.assertTrue(log.indexOf("precheck") < log.indexOf("execute"));
Assertions.assertTrue(log.indexOf("execute") < log.indexOf("verify"));
}
@Test
void shouldMarkTaskFailedWhenExecuteScriptFails() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 2\n", false);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository();
UpgradeTaskRunner runner = new UpgradeTaskRunner(
taskRepository,
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")),
properties(tempDir.resolve("staging"), tempDir.resolve("logs")),
objectMapper
);
runner.run(task("UPG-002", fileRecord.getFileId(), "RECEIVER", "V1.3.2"));
Assertions.assertEquals("FAILED", taskRepository.status);
Assertions.assertTrue(taskRepository.errorMessage.contains("execute"));
}
@Test
void shouldRunRollbackScriptWhenRequested() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-runner-test");
FileRecordEntity fileRecord = buildPackage(tempDir, false, "#!/bin/sh\necho execute\nexit 0\n", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
InMemoryUpgradeTaskRepository taskRepository = new InMemoryUpgradeTaskRepository();
UpgradeTaskRunner runner = new UpgradeTaskRunner(
taskRepository,
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging"), tempDir.resolve("logs")),
properties(tempDir.resolve("staging"), tempDir.resolve("logs")),
objectMapper
);
runner.rollback(task("UPG-003", fileRecord.getFileId(), "FIRMWARE", "V3.0.0"));
Assertions.assertEquals("ROLLBACK_SUCCESS", taskRepository.status);
String log = Files.readString(Path.of(taskRepository.logPath));
Assertions.assertTrue(log.contains("rollback"));
}
private UpgradeProperties properties(Path stagingRoot, Path logDir) {
UpgradeProperties properties = new UpgradeProperties();
properties.setStagingRootDir(stagingRoot.toString());
properties.setLogDir(logDir.toString());
return properties;
}
private UpgradePackageStagingService stagingService(Path stagingRoot, Path logDir) {
try {
Files.createDirectories(logDir);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
return new UpgradePackageStagingService(properties(stagingRoot, logDir));
}
private FileRecordEntity buildPackage(Path tempDir, boolean includeOptionalScripts, String executeScript, boolean includeRollback)
throws Exception {
Path packagePath = tempDir.resolve("upgrade-package.zip");
UpgradeManifest manifest = new UpgradeManifest();
manifest.setPackageId("PKG-001");
manifest.setTaskType("TMS");
manifest.setProductType("ENTERPRISE");
manifest.setVersion("V1.0.1");
manifest.setMinCompatibleVersion("V1.0.0");
UpgradeManifest.Entrypoints entrypoints = new UpgradeManifest.Entrypoints();
entrypoints.setExecute("payload/scripts/execute.sh");
if (includeOptionalScripts) {
entrypoints.setPrecheck("payload/scripts/precheck.sh");
entrypoints.setVerify("payload/scripts/verify.sh");
}
if (includeRollback) {
entrypoints.setRollback("payload/scripts/rollback.sh");
}
manifest.setEntrypoints(entrypoints);
try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) {
outputStream.putNextEntry(new ZipEntry("manifest.json"));
outputStream.write(objectMapper.writeValueAsBytes(manifest));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("signature.sig"));
outputStream.write("signature".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
if (includeOptionalScripts) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/precheck.sh"));
outputStream.write("#!/bin/sh\necho precheck\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/verify.sh"));
outputStream.write("#!/bin/sh\necho verify\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write(executeScript.getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
if (includeRollback) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/rollback.sh"));
outputStream.write("#!/bin/sh\necho rollback\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
}
FileRecordEntity entity = new FileRecordEntity();
entity.setFileId("FILE-001");
entity.setOriginalFilename("upgrade-package.zip");
entity.setStoragePath(packagePath.toString());
entity.setStatus("ACTIVE");
return entity;
}
private UpgradeTaskEntity task(String taskId, String fileId, String taskType, String targetVersion) {
UpgradeTaskEntity entity = new UpgradeTaskEntity();
entity.setTaskId(taskId);
entity.setPackageFileId(fileId);
entity.setTaskType(taskType);
entity.setTargetVersion(targetVersion);
return entity;
}
private static class InMemoryUpgradeTaskRepository implements UpgradeTaskRepository {
private String status;
private String errorMessage;
private String logPath;
@Override
public void save(UpgradeTaskEntity entity) {
}
@Override
public Optional<UpgradeTaskEntity> findByTaskId(String taskId) {
return Optional.empty();
}
@Override
public Optional<UpgradeTaskEntity> findRunningTask() {
return Optional.empty();
}
@Override
public java.util.List<UpgradeTaskEntity> findPage(String taskType, String status, int offset, int limit) {
return java.util.List.of();
}
@Override
public long countPage(String taskType, String status) {
return 0;
}
@Override
public void updateStatus(String taskId, String status, String errorMessage, String detailLogPath, LocalDateTime startedAt, LocalDateTime finishedAt) {
this.status = status;
this.errorMessage = errorMessage;
this.logPath = detailLogPath;
}
}
private static class InMemoryFileRecordRepository implements FileRecordRepository {
private final Map<String, FileRecordEntity> store = new HashMap<>();
@Override
public Optional<FileRecordEntity> findByFileId(String fileId) {
return Optional.ofNullable(store.get(fileId));
}
@Override
public void save(FileRecordEntity entity) {
store.put(entity.getFileId(), entity);
}
}
private static class InMemoryDeviceSoftwareVersionRepository implements DeviceSoftwareVersionRepository {
private final Map<String, DeviceSoftwareVersionEntity> store = new HashMap<>();
@Override
public Optional<DeviceSoftwareVersionEntity> findByComponentCode(String componentCode) {
return Optional.ofNullable(store.get(componentCode));
}
@Override
public void saveOrUpdate(DeviceSoftwareVersionEntity entity) {
store.put(entity.getComponentCode(), entity);
}
}
}
@@ -25,7 +25,8 @@ class UpgradeTaskRepositoryTest {
UpgradeTaskEntity found = repository.findByTaskId("UPG-001").orElseThrow(); UpgradeTaskEntity found = repository.findByTaskId("UPG-001").orElseThrow();
Assertions.assertEquals("UPG-001", found.getTaskId()); Assertions.assertEquals("UPG-001", found.getTaskId());
Assertions.assertEquals("STANDARD_APP", found.getTargetComponent()); Assertions.assertEquals("RECEIVER", found.getTaskType());
Assertions.assertEquals("DIRECT", found.getProductType());
} }
@Test @Test
@@ -88,7 +89,8 @@ class UpgradeTaskRepositoryTest {
UpgradeTaskEntity entity = new UpgradeTaskEntity(); UpgradeTaskEntity entity = new UpgradeTaskEntity();
entity.setTaskId(taskId); entity.setTaskId(taskId);
entity.setUpgradeMode("OFFLINE"); entity.setUpgradeMode("OFFLINE");
entity.setTargetComponent("STANDARD_APP"); entity.setTaskType("RECEIVER");
entity.setProductType("DIRECT");
entity.setPackageFileId("FILE-001"); entity.setPackageFileId("FILE-001");
entity.setPackageName("upgrade-package.zip"); entity.setPackageName("upgrade-package.zip");
entity.setPackageHash("abc123"); entity.setPackageHash("abc123");
@@ -97,7 +99,7 @@ class UpgradeTaskRepositoryTest {
entity.setStatus(status); entity.setStatus(status);
entity.setVerifyPassed(Boolean.TRUE); entity.setVerifyPassed(Boolean.TRUE);
entity.setVerifyMessage("ok"); entity.setVerifyMessage("ok");
entity.setManifestJson("{\"targetComponent\":\"STANDARD_APP\"}"); entity.setManifestJson("{\"taskType\":\"RECEIVER\",\"productType\":\"DIRECT\"}");
entity.setOperator("admin"); entity.setOperator("admin");
return entity; return entity;
} }
@@ -2,6 +2,7 @@ package com.cisd.tms.modules.upgrade.service;
import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException; import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.common.config.properties.CisdPresetProperties;
import com.cisd.tms.modules.device.entity.DeviceSoftwareVersionEntity; import com.cisd.tms.modules.device.entity.DeviceSoftwareVersionEntity;
import com.cisd.tms.modules.device.repository.DeviceSoftwareVersionRepository; import com.cisd.tms.modules.device.repository.DeviceSoftwareVersionRepository;
import com.cisd.tms.modules.file.entity.FileRecordEntity; import com.cisd.tms.modules.file.entity.FileRecordEntity;
@@ -9,37 +10,51 @@ import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties; import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest; import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse; import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.support.SoftUpgradePackageSignatureVerifier;
import com.cisd.tms.modules.upgrade.support.UpgradePackageSignatureVerifier; import com.cisd.tms.modules.upgrade.support.UpgradePackageSignatureVerifier;
import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService; import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService;
import com.cisd.tms.modules.upgrade.support.UpgradeVersionComparator; import com.cisd.tms.modules.upgrade.support.UpgradeVersionComparator;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.StringWriter;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest; import java.security.KeyPair;
import java.security.NoSuchAlgorithmException; import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
import java.util.HashMap; import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class UpgradePackageServiceTest { class UpgradePackageServiceTest {
private static final String PROVIDER = "BC";
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
static {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@Test @Test
void shouldPreviewUpgradePackageWithVersionChange() throws Exception { void shouldPreviewUpgradePackageWithVersionChange() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test"); Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "STANDARD_APP", "V1.3.2", "V1.3.0", "修复标准收发器问题"); FileRecordEntity fileRecord = buildPackage(tempDir, "RECEIVER", "DIRECT", "V1.4.0", "V1.3.0", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord); fileRepository.save(fileRecord);
InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository(); InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
versionRepository.save(component("STANDARD_APP", "标准收发器企业版", "V1.3.1")); versionRepository.save(component("RECEIVER", "标准收发器", "V1.3.1"));
UpgradePackageService service = new UpgradePackageService( UpgradePackageService service = new UpgradePackageService(
fileRepository, fileRepository,
@@ -47,47 +62,48 @@ class UpgradePackageServiceTest {
stagingService(tempDir.resolve("staging")), stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(), new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(), new UpgradeVersionComparator(),
preset("DIRECT"),
objectMapper objectMapper
); );
UpgradePreviewResponse response = service.preview(fileRecord.getFileId()); UpgradePreviewResponse response = service.preview(fileRecord.getFileId());
Assertions.assertEquals("STANDARD_APP", response.getTargetComponent()); Assertions.assertEquals("RECEIVER", response.getTaskType());
Assertions.assertEquals("DIRECT", response.getProductType());
Assertions.assertEquals("V1.3.1", response.getCurrentVersion()); Assertions.assertEquals("V1.3.1", response.getCurrentVersion());
Assertions.assertEquals("V1.3.2", response.getTargetVersion()); Assertions.assertEquals("V1.4.0", response.getTargetVersion());
Assertions.assertTrue(response.isVerifyPassed()); Assertions.assertTrue(response.isVerifyPassed());
Assertions.assertTrue(response.isAllowed()); Assertions.assertTrue(response.isAllowed());
Assertions.assertTrue(response.getVersionChangeSummary().contains("V1.3.1")); Assertions.assertTrue(response.getVersionChangeSummary().contains("V1.3.1"));
Assertions.assertTrue(response.getVersionChangeSummary().contains("V1.3.2")); Assertions.assertTrue(response.getVersionChangeSummary().contains("V1.4.0"));
} }
@Test @Test
void shouldRejectPayloadChecksumMismatch() throws Exception { void shouldRejectWhenProductTypeDoesNotMatchCurrentDevice() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test"); Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "TMS", "V1.0.1", "V1.0.0", "修复 TMS 问题"); FileRecordEntity fileRecord = buildPackage(tempDir, "RECEIVER", "DIRECT", "V1.4.0", "V1.3.0", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord); fileRepository.save(fileRecord);
tamperManifestChecksum(Path.of(fileRecord.getStoragePath()));
UpgradePackageService service = new UpgradePackageService( UpgradePackageService service = new UpgradePackageService(
fileRepository, fileRepository,
new InMemoryDeviceSoftwareVersionRepository(), new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging")), stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(), new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(), new UpgradeVersionComparator(),
preset("ENTERPRISE"),
objectMapper objectMapper
); );
BizException exception = Assertions.assertThrows(BizException.class, () -> service.preview(fileRecord.getFileId())); BizException exception = Assertions.assertThrows(BizException.class, () -> service.preview(fileRecord.getFileId()));
Assertions.assertEquals(ErrorCode.BIZ_ERROR.getCode(), exception.getCode()); Assertions.assertEquals(ErrorCode.BIZ_ERROR.getCode(), exception.getCode());
Assertions.assertEquals("payload checksum mismatch", exception.getMessage()); Assertions.assertEquals("upgrade package product type does not match current device", exception.getMessage());
} }
@Test @Test
void shouldRejectDowngrade() throws Exception { void shouldRejectDowngrade() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test"); Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "TMS", "V1.0.0", "V0.9.0", "尝试降级"); FileRecordEntity fileRecord = buildPackage(tempDir, "TMS", "ENTERPRISE", "V1.0.0", "V0.9.0", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord); fileRepository.save(fileRecord);
InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository(); InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
@@ -99,6 +115,7 @@ class UpgradePackageServiceTest {
stagingService(tempDir.resolve("staging")), stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(), new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(), new UpgradeVersionComparator(),
preset("ENTERPRISE"),
objectMapper objectMapper
); );
@@ -109,11 +126,11 @@ class UpgradePackageServiceTest {
@Test @Test
void shouldRejectWhenCurrentVersionIsLowerThanMinCompatibleVersion() throws Exception { void shouldRejectWhenCurrentVersionIsLowerThanMinCompatibleVersion() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test"); Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "DIRECT_APP", "V2.0.0", "V1.5.0", "需要更高起始版本"); FileRecordEntity fileRecord = buildPackage(tempDir, "RECEIVER", "DIRECT", "V2.0.0", "V1.5.0", true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository(); InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord); fileRepository.save(fileRecord);
InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository(); InMemoryDeviceSoftwareVersionRepository versionRepository = new InMemoryDeviceSoftwareVersionRepository();
versionRepository.save(component("DIRECT_APP", "直参轻量化终端", "V1.4.9")); versionRepository.save(component("RECEIVER", "标准收发器", "V1.4.9"));
UpgradePackageService service = new UpgradePackageService( UpgradePackageService service = new UpgradePackageService(
fileRepository, fileRepository,
@@ -121,6 +138,7 @@ class UpgradePackageServiceTest {
stagingService(tempDir.resolve("staging")), stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(), new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(), new UpgradeVersionComparator(),
preset("DIRECT"),
objectMapper objectMapper
); );
@@ -128,31 +146,96 @@ class UpgradePackageServiceTest {
Assertions.assertEquals("current version does not satisfy minimum compatible version", exception.getMessage()); Assertions.assertEquals("current version does not satisfy minimum compatible version", exception.getMessage());
} }
@Test
void shouldRejectWhenExecuteScriptIsMissing() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "FIRMWARE", "DIRECT", "V3.0.0", "V2.0.0", false);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
UpgradePackageService service = new UpgradePackageService(
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(),
preset("DIRECT"),
objectMapper
);
BizException exception = Assertions.assertThrows(BizException.class, () -> service.preview(fileRecord.getFileId()));
Assertions.assertEquals("upgrade execute script not found", exception.getMessage());
}
@Test
void shouldPreviewUpgradePackageWithSoftSignatureVerification() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test");
KeyPair keyPair = generateSm2KeyPair();
Path publicKeyPath = tempDir.resolve("upgrade-public-key.pem");
Files.writeString(publicKeyPath, toPem("PUBLIC KEY", keyPair.getPublic().getEncoded()), StandardCharsets.UTF_8);
FileRecordEntity fileRecord = buildSignedPackage(
tempDir,
"TMS",
"ENTERPRISE",
"V1.1.0",
"V1.0.0",
keyPair
);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
UpgradeProperties properties = new UpgradeProperties();
properties.setSignaturePublicKeyPemPath(publicKeyPath.toString());
UpgradePackageService service = new UpgradePackageService(
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging")),
new SoftUpgradePackageSignatureVerifier(properties),
new UpgradeVersionComparator(),
preset("ENTERPRISE"),
objectMapper
);
UpgradePreviewResponse response = service.preview(fileRecord.getFileId());
Assertions.assertTrue(response.isVerifyPassed());
Assertions.assertEquals("TMS", response.getTaskType());
}
private UpgradePackageStagingService stagingService(Path stagingRoot) { private UpgradePackageStagingService stagingService(Path stagingRoot) {
UpgradeProperties properties = new UpgradeProperties(); UpgradeProperties properties = new UpgradeProperties();
properties.setStagingRootDir(stagingRoot.toString()); properties.setStagingRootDir(stagingRoot.toString());
return new UpgradePackageStagingService(properties); return new UpgradePackageStagingService(properties);
} }
private FileRecordEntity buildPackage(Path tempDir, String targetComponent, String targetVersion, String minCompatibleVersion, String description) private CisdPresetProperties preset(String productType) {
throws IOException, NoSuchAlgorithmException { CisdPresetProperties properties = new CisdPresetProperties();
properties.setProductType(productType);
return properties;
}
private FileRecordEntity buildPackage(Path tempDir, String taskType, String productType, String version, String minCompatibleVersion, boolean includeExecute)
throws IOException {
Path packagePath = tempDir.resolve("upgrade-package.zip"); Path packagePath = tempDir.resolve("upgrade-package.zip");
Path payloadPath = tempDir.resolve("payload.zip"); byte[] signatureBytes = "signature".getBytes(StandardCharsets.UTF_8);
Files.write(payloadPath, "payload-content".getBytes(StandardCharsets.UTF_8));
UpgradeManifest manifest = new UpgradeManifest(); UpgradeManifest manifest = new UpgradeManifest();
manifest.setPackageId("PKG-001"); manifest.setPackageId("PKG-001");
manifest.setTargetComponent(targetComponent); manifest.setTaskType(taskType);
manifest.setTargetVersion(targetVersion); manifest.setProductType(productType);
manifest.setVersion(version);
manifest.setMinCompatibleVersion(minCompatibleVersion); manifest.setMinCompatibleVersion(minCompatibleVersion);
manifest.setDescription(description); manifest.setDescription(taskType.toLowerCase() + " upgrade package");
manifest.setOrchestrator("scripts/orchestrator.sh"); UpgradeManifest.Entrypoints entrypoints = new UpgradeManifest.Entrypoints();
UpgradeManifest.FileChecksums checksums = new UpgradeManifest.FileChecksums(); entrypoints.setExecute("payload/scripts/execute.sh");
checksums.setPayloadZip(sha256Hex(payloadPath)); entrypoints.setPrecheck("payload/scripts/precheck.sh");
manifest.setFileChecksums(checksums); entrypoints.setVerify("payload/scripts/verify.sh");
entrypoints.setRollback("payload/scripts/rollback.sh");
manifest.setEntrypoints(entrypoints);
byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest); byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest);
byte[] signatureBytes = "signature".getBytes(StandardCharsets.UTF_8);
try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) { try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) {
outputStream.putNextEntry(new ZipEntry("manifest.json")); outputStream.putNextEntry(new ZipEntry("manifest.json"));
@@ -163,8 +246,27 @@ class UpgradePackageServiceTest {
outputStream.write(signatureBytes); outputStream.write(signatureBytes);
outputStream.closeEntry(); outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload.zip")); outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.write(Files.readAllBytes(payloadPath)); outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
if (includeExecute) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
outputStream.putNextEntry(new ZipEntry("payload/scripts/precheck.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/verify.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/rollback.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry(); outputStream.closeEntry();
} }
@@ -176,15 +278,29 @@ class UpgradePackageServiceTest {
return entity; return entity;
} }
private void tamperManifestChecksum(Path packagePath) throws Exception { private FileRecordEntity buildSignedPackage(
Path tempDir = Files.createTempDirectory("upgrade-manifest-tamper"); Path tempDir,
UpgradePackageStagingService stagingService = stagingService(tempDir.resolve("staging")); String taskType,
Path staged = stagingService.stage("FILE-TAMPER", packagePath); String productType,
UpgradeManifest manifest = objectMapper.readValue(Files.readString(staged.resolve("manifest.json")), UpgradeManifest.class); String version,
manifest.getFileChecksums().setPayloadZip("deadbeef"); String minCompatibleVersion,
KeyPair keyPair
) throws Exception {
Path packagePath = tempDir.resolve("signed-upgrade-package.zip");
UpgradeManifest manifest = new UpgradeManifest();
manifest.setPackageId("PKG-002");
manifest.setTaskType(taskType);
manifest.setProductType(productType);
manifest.setVersion(version);
manifest.setMinCompatibleVersion(minCompatibleVersion);
manifest.setDescription(taskType.toLowerCase() + " signed upgrade package");
UpgradeManifest.Entrypoints entrypoints = new UpgradeManifest.Entrypoints();
entrypoints.setExecute("payload/scripts/execute.sh");
manifest.setEntrypoints(entrypoints);
byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest); byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest);
byte[] payloadBytes = Files.readAllBytes(staged.resolve("payload.zip")); byte[] signatureBytes = sign(manifestBytes, keyPair);
byte[] signatureBytes = Files.readAllBytes(staged.resolve("signature.sig"));
try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) { try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) {
outputStream.putNextEntry(new ZipEntry("manifest.json")); outputStream.putNextEntry(new ZipEntry("manifest.json"));
@@ -195,16 +311,21 @@ class UpgradePackageServiceTest {
outputStream.write(signatureBytes); outputStream.write(signatureBytes);
outputStream.closeEntry(); outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload.zip")); outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.write(payloadBytes); outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry(); outputStream.closeEntry();
} }
}
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException { FileRecordEntity entity = new FileRecordEntity();
MessageDigest digest = MessageDigest.getInstance("SHA-256"); entity.setFileId("FILE-UPGRADE-002");
digest.update(Files.readAllBytes(file)); entity.setOriginalFilename("signed-upgrade-package.zip");
return HexFormat.of().formatHex(digest.digest()); entity.setStoragePath(packagePath.toString());
entity.setStatus("ACTIVE");
return entity;
} }
private static DeviceSoftwareVersionEntity component(String code, String name, String version) { private static DeviceSoftwareVersionEntity component(String code, String name, String version) {
@@ -215,9 +336,30 @@ class UpgradePackageServiceTest {
return entity; return entity;
} }
private KeyPair generateSm2KeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", PROVIDER);
generator.initialize(new ECGenParameterSpec("sm2p256v1"));
return generator.generateKeyPair();
}
private byte[] sign(byte[] data, KeyPair keyPair) throws Exception {
Signature signature = Signature.getInstance("SM3withSM2", PROVIDER);
signature.initSign(keyPair.getPrivate());
signature.update(data);
return signature.sign();
}
private String toPem(String type, byte[] content) throws Exception {
StringWriter writer = new StringWriter();
try (PemWriter pemWriter = new PemWriter(writer)) {
pemWriter.writeObject(new PemObject(type, content));
}
return writer.toString();
}
private static class PassThroughSignatureVerifier implements UpgradePackageSignatureVerifier { private static class PassThroughSignatureVerifier implements UpgradePackageSignatureVerifier {
@Override @Override
public void verify(UpgradeManifest manifest, Path manifestPath, Path payloadZipPath, Path signaturePath) { public void verify(UpgradeManifest manifest, Path manifestPath, Path signaturePath) {
} }
} }
@@ -6,6 +6,7 @@ import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties; import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest; import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse; import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse; import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
@@ -40,7 +41,8 @@ class UpgradeServiceTest {
Mockito.when(fileRecordRepository.findByFileId("FILE-001")).thenReturn(Optional.of(fileRecord)); Mockito.when(fileRecordRepository.findByFileId("FILE-001")).thenReturn(Optional.of(fileRecord));
UpgradePreviewResponse preview = new UpgradePreviewResponse(); UpgradePreviewResponse preview = new UpgradePreviewResponse();
preview.setTargetComponent("STANDARD_APP"); preview.setTaskType("RECEIVER");
preview.setProductType("DIRECT");
preview.setCurrentVersion("V1.3.1"); preview.setCurrentVersion("V1.3.1");
preview.setTargetVersion("V1.3.2"); preview.setTargetVersion("V1.3.2");
preview.setAllowed(true); preview.setAllowed(true);
@@ -64,7 +66,8 @@ class UpgradeServiceTest {
UpgradeCreateTaskResponse response = service.createTask(request); UpgradeCreateTaskResponse response = service.createTask(request);
Assertions.assertEquals("PENDING_CONFIRM", response.getStatus()); Assertions.assertEquals("PENDING_CONFIRM", response.getStatus());
Assertions.assertEquals("STANDARD_APP", response.getTargetComponent()); Assertions.assertEquals("RECEIVER", response.getTaskType());
Assertions.assertEquals("DIRECT", response.getProductType());
Assertions.assertEquals("V1.3.1", response.getCurrentVersion()); Assertions.assertEquals("V1.3.1", response.getCurrentVersion());
Assertions.assertEquals("V1.3.2", response.getTargetVersion()); Assertions.assertEquals("V1.3.2", response.getTargetVersion());
Assertions.assertTrue(response.getTaskId().startsWith("UPG-")); Assertions.assertTrue(response.getTaskId().startsWith("UPG-"));
@@ -134,6 +137,38 @@ class UpgradeServiceTest {
Assertions.assertEquals("SUCCESS", response.getStatus()); Assertions.assertEquals("SUCCESS", response.getStatus());
} }
@Test
void shouldRejectRollbackWhenPackageDoesNotDeclareRollbackScript() {
InMemoryUpgradeTaskRepository repository = new InMemoryUpgradeTaskRepository();
repository.save(entity("UPG-001", "FAILED"));
UpgradePackageService packageService = Mockito.mock(UpgradePackageService.class);
UpgradeManifest manifest = new UpgradeManifest();
manifest.setPackageId("PKG-001");
manifest.setTaskType("RECEIVER");
manifest.setProductType("DIRECT");
manifest.setVersion("V1.3.2");
manifest.setMinCompatibleVersion("V1.3.0");
UpgradeManifest.Entrypoints entrypoints = new UpgradeManifest.Entrypoints();
entrypoints.setExecute("payload/scripts/execute.sh");
manifest.setEntrypoints(entrypoints);
Mockito.when(packageService.loadManifest("FILE-001")).thenReturn(manifest);
UpgradeService service = new UpgradeService(
repository,
Mockito.mock(FileRecordRepository.class),
packageService,
Mockito.mock(UpgradeTaskRunner.class),
properties(),
Runnable::run,
new ConcurrentHashMap<>()
);
BizException exception = Assertions.assertThrows(BizException.class, () -> service.rollbackTask("UPG-001"));
Assertions.assertEquals("upgrade rollback script not found", exception.getMessage());
}
private static UpgradeProperties properties() { private static UpgradeProperties properties() {
UpgradeProperties properties = new UpgradeProperties(); UpgradeProperties properties = new UpgradeProperties();
properties.setLogDir("/home/tms/tmp/tms-upgrade-logs"); properties.setLogDir("/home/tms/tmp/tms-upgrade-logs");
@@ -144,7 +179,8 @@ class UpgradeServiceTest {
UpgradeTaskEntity entity = new UpgradeTaskEntity(); UpgradeTaskEntity entity = new UpgradeTaskEntity();
entity.setTaskId(taskId); entity.setTaskId(taskId);
entity.setUpgradeMode("OFFLINE"); entity.setUpgradeMode("OFFLINE");
entity.setTargetComponent("STANDARD_APP"); entity.setTaskType("RECEIVER");
entity.setProductType("DIRECT");
entity.setPackageFileId("FILE-001"); entity.setPackageFileId("FILE-001");
entity.setPackageName("upgrade-package.zip"); entity.setPackageName("upgrade-package.zip");
entity.setCurrentVersion("V1.3.1"); entity.setCurrentVersion("V1.3.1");
@@ -178,9 +214,9 @@ class UpgradeServiceTest {
} }
@Override @Override
public List<UpgradeTaskEntity> findPage(String targetComponent, String status, int offset, int limit) { public List<UpgradeTaskEntity> findPage(String taskType, String status, int offset, int limit) {
return store.values().stream() return store.values().stream()
.filter(entity -> targetComponent == null || targetComponent.isBlank() || targetComponent.equals(entity.getTargetComponent())) .filter(entity -> taskType == null || taskType.isBlank() || taskType.equals(entity.getTaskType()))
.filter(entity -> status == null || status.isBlank() || status.equals(entity.getStatus())) .filter(entity -> status == null || status.isBlank() || status.equals(entity.getStatus()))
.sorted(Comparator.comparing(UpgradeTaskEntity::getTaskId).reversed()) .sorted(Comparator.comparing(UpgradeTaskEntity::getTaskId).reversed())
.skip(offset) .skip(offset)
@@ -189,8 +225,8 @@ class UpgradeServiceTest {
} }
@Override @Override
public long countPage(String targetComponent, String status) { public long countPage(String taskType, String status) {
return findPage(targetComponent, status, 0, Integer.MAX_VALUE).size(); return findPage(taskType, status, 0, Integer.MAX_VALUE).size();
} }
@Override @Override
@@ -1,96 +0,0 @@
package com.cisd.tms.modules.upgrade.support;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.integration.crypto.pcie.Gm0018AlgorithmIds;
import com.cisd.tms.integration.crypto.pcie.model.EccExternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class PcieUpgradePackageSignatureVerifierTest {
@Test
void shouldVerifyUpgradeManifestWithConfiguredIntegrityKey() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path payloadZipPath = tempDir.resolve("payload.zip");
Path signaturePath = tempDir.resolve("signature.sig");
Files.writeString(manifestPath, "{\"packageId\":\"PKG-001\"}", StandardCharsets.UTF_8);
Files.write(payloadZipPath, new byte[] {0x01, 0x02});
byte[] signatureBytes = new byte[128];
Files.writeString(signaturePath, Base64.getEncoder().encodeToString(signatureBytes), StandardCharsets.UTF_8);
UpgradeProperties properties = new UpgradeProperties();
byte[] publicKeyBlob = new byte[132];
properties.setSignaturePublicKeyBase64(Base64.getEncoder().encodeToString(publicKeyBlob));
PcieCryptoService cryptoService = Mockito.mock(PcieCryptoService.class);
PcieUpgradePackageSignatureVerifier verifier = new PcieUpgradePackageSignatureVerifier(properties, cryptoService);
verifier.verify(new UpgradeManifest(), manifestPath, payloadZipPath, signaturePath);
ArgumentCaptor<EccExternalVerifyRequest> requestCaptor = ArgumentCaptor.forClass(EccExternalVerifyRequest.class);
Mockito.verify(cryptoService).eccExternalVerify(requestCaptor.capture());
EccExternalVerifyRequest request = requestCaptor.getValue();
Assertions.assertEquals(Gm0018AlgorithmIds.SM2_SIGN, request.getAlgId());
Assertions.assertArrayEquals(publicKeyBlob, request.getPublicKeyBlob());
Assertions.assertArrayEquals(signatureBytes, request.getSignature());
Assertions.assertArrayEquals(Files.readAllBytes(manifestPath), request.getData());
}
@Test
void shouldRejectWhenIntegrityKeyIsNotConfigured() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path payloadZipPath = tempDir.resolve("payload.zip");
Path signaturePath = tempDir.resolve("signature.sig");
Files.writeString(manifestPath, "{\"packageId\":\"PKG-001\"}", StandardCharsets.UTF_8);
Files.write(payloadZipPath, new byte[] {0x01, 0x02});
Files.write(signaturePath, new byte[128]);
PcieUpgradePackageSignatureVerifier verifier =
new PcieUpgradePackageSignatureVerifier(new UpgradeProperties(), Mockito.mock(PcieCryptoService.class));
BizException exception = Assertions.assertThrows(
BizException.class,
() -> verifier.verify(new UpgradeManifest(), manifestPath, payloadZipPath, signaturePath)
);
Assertions.assertEquals(ErrorCode.BIZ_ERROR.getCode(), exception.getCode());
Assertions.assertEquals("upgrade signature public key not configured", exception.getMessage());
}
@Test
void shouldWrapCryptoCardVerifyFailure() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path payloadZipPath = tempDir.resolve("payload.zip");
Path signaturePath = tempDir.resolve("signature.sig");
Files.writeString(manifestPath, "{\"packageId\":\"PKG-001\"}", StandardCharsets.UTF_8);
Files.write(payloadZipPath, new byte[] {0x01, 0x02});
Files.write(signaturePath, new byte[128]);
UpgradeProperties properties = new UpgradeProperties();
properties.setSignaturePublicKeyBase64(Base64.getEncoder().encodeToString(new byte[132]));
PcieCryptoService cryptoService = Mockito.mock(PcieCryptoService.class);
Mockito.doThrow(new IllegalStateException("verify failed")).when(cryptoService).eccExternalVerify(Mockito.any());
PcieUpgradePackageSignatureVerifier verifier = new PcieUpgradePackageSignatureVerifier(properties, cryptoService);
BizException exception = Assertions.assertThrows(
BizException.class,
() -> verifier.verify(new UpgradeManifest(), manifestPath, payloadZipPath, signaturePath)
);
Assertions.assertEquals(ErrorCode.CRYPTO_CARD_ERROR.getCode(), exception.getCode());
Assertions.assertEquals("upgrade package signature verification failed", exception.getMessage());
}
}
@@ -0,0 +1,137 @@
package com.cisd.tms.modules.upgrade.support;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.upgrade.config.UpgradeProperties;
import com.cisd.tms.modules.upgrade.dto.UpgradeManifest;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class SoftUpgradePackageSignatureVerifierTest {
private static final String PROVIDER = "BC";
static {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@Test
void shouldVerifyUpgradeManifestWithPemPublicKey() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path signaturePath = tempDir.resolve("signature.sig");
Path publicKeyPath = tempDir.resolve("public_key.pem");
byte[] manifestBytes = "{\"packageId\":\"PKG-001\"}".getBytes(StandardCharsets.UTF_8);
Files.write(manifestPath, manifestBytes);
KeyPair keyPair = generateSm2KeyPair();
Files.writeString(publicKeyPath, toPem("PUBLIC KEY", keyPair.getPublic().getEncoded()), StandardCharsets.UTF_8);
Files.write(signaturePath, sign(manifestBytes, keyPair));
UpgradeProperties properties = new UpgradeProperties();
properties.setSignaturePublicKeyPemPath(publicKeyPath.toString());
SoftUpgradePackageSignatureVerifier verifier = new SoftUpgradePackageSignatureVerifier(properties);
verifier.verify(new UpgradeManifest(), manifestPath, signaturePath);
}
@Test
void shouldVerifyBase64SignatureContent() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path signaturePath = tempDir.resolve("signature.sig");
Path publicKeyPath = tempDir.resolve("public_key.pem");
byte[] manifestBytes = "{\"packageId\":\"PKG-001\"}".getBytes(StandardCharsets.UTF_8);
Files.write(manifestPath, manifestBytes);
KeyPair keyPair = generateSm2KeyPair();
Files.writeString(publicKeyPath, toPem("PUBLIC KEY", keyPair.getPublic().getEncoded()), StandardCharsets.UTF_8);
Files.writeString(signaturePath, Base64.getEncoder().encodeToString(sign(manifestBytes, keyPair)), StandardCharsets.UTF_8);
UpgradeProperties properties = new UpgradeProperties();
properties.setSignaturePublicKeyPemPath(publicKeyPath.toString());
SoftUpgradePackageSignatureVerifier verifier = new SoftUpgradePackageSignatureVerifier(properties);
verifier.verify(new UpgradeManifest(), manifestPath, signaturePath);
}
@Test
void shouldRejectWhenPemPathIsNotConfigured() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path signaturePath = tempDir.resolve("signature.sig");
Files.writeString(manifestPath, "{\"packageId\":\"PKG-001\"}", StandardCharsets.UTF_8);
Files.write(signaturePath, new byte[] {0x01});
SoftUpgradePackageSignatureVerifier verifier = new SoftUpgradePackageSignatureVerifier(new UpgradeProperties());
BizException exception = Assertions.assertThrows(
BizException.class,
() -> verifier.verify(new UpgradeManifest(), manifestPath, signaturePath)
);
Assertions.assertEquals(ErrorCode.BIZ_ERROR.getCode(), exception.getCode());
Assertions.assertEquals("upgrade signature public key pem path not configured", exception.getMessage());
}
@Test
void shouldRejectWhenSignatureIsInvalid() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-signature-verifier");
Path manifestPath = tempDir.resolve("manifest.json");
Path signaturePath = tempDir.resolve("signature.sig");
Path publicKeyPath = tempDir.resolve("public_key.pem");
Files.writeString(manifestPath, "{\"packageId\":\"PKG-001\"}", StandardCharsets.UTF_8);
Files.write(signaturePath, "invalid-signature".getBytes(StandardCharsets.UTF_8));
Files.writeString(publicKeyPath, toPem("PUBLIC KEY", generateSm2KeyPair().getPublic().getEncoded()), StandardCharsets.UTF_8);
UpgradeProperties properties = new UpgradeProperties();
properties.setSignaturePublicKeyPemPath(publicKeyPath.toString());
SoftUpgradePackageSignatureVerifier verifier = new SoftUpgradePackageSignatureVerifier(properties);
BizException exception = Assertions.assertThrows(
BizException.class,
() -> verifier.verify(new UpgradeManifest(), manifestPath, signaturePath)
);
Assertions.assertEquals(ErrorCode.BIZ_ERROR.getCode(), exception.getCode());
Assertions.assertEquals("upgrade package signature verification failed", exception.getMessage());
}
private KeyPair generateSm2KeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", PROVIDER);
generator.initialize(new ECGenParameterSpec("sm2p256v1"));
return generator.generateKeyPair();
}
private byte[] sign(byte[] data, KeyPair keyPair) throws Exception {
Signature signature = Signature.getInstance("SM3withSM2", PROVIDER);
signature.initSign(keyPair.getPrivate());
signature.update(data);
return signature.sign();
}
private String toPem(String type, byte[] content) throws Exception {
StringWriter writer = new StringWriter();
try (PemWriter pemWriter = new PemWriter(writer)) {
pemWriter.writeObject(new PemObject(type, content));
}
return writer.toString();
}
}