Compare commits

...
Author SHA1 Message Date
Qiufeng 8eb1cbad83 fix: stabilize systemd deployment and release updates
Release / release (push) Canceled after 0s
2026-08-18 16:25:41 +08:00
Qiufeng cea7048f6c fix: make online updates observable and recoverable
Release / release (push) Canceled after 0s
2026-08-18 12:56:14 +08:00
Qiufeng 10d2e2f0d1 fix: leave setup wizard after completion
Release / release (push) Canceled after 0s
2026-08-18 11:38:28 +08:00
Qiufeng bb5e63aaf0 fix: force-stop service account during purge 2026-08-18 11:24:57 +08:00
29 changed files with 1550 additions and 158 deletions
+56 -32
View File
@@ -8,10 +8,13 @@
## 本地开发
后端默认连接本机 MySQL `127.0.0.1:3307`,启动 Java 服务:
本地开发数据库由环境变量显式指定,启动 Java 服务:
```bash
cd backend
SPRING_PROFILES_ACTIVE=local \
DB_URL='jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \
DB_USERNAME=kaidi DB_PASSWORD=kaidi_local_2026 \
./mvnw spring-boot:run
```
@@ -51,12 +54,28 @@ npm run build
两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x,
PostgreSQL 18 兼容工作继续冻结。
### 生产部署契约(先看这里)
- **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是
`https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。
- 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。
- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才备份数据库、切换 `current` 并重启服务。更新器下载的是 Release 制品,不是 Git 工作树。
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
### 32 位 Linux 支持边界
当前支持的是 **i386/i486/i586/i686 + glibc + systemd + 可运行的 32 位 Java 17**。安装器会校验用户空间位数、Java 架构、glibc 和 `/lib/ld-linux.so.2`(兼容 `/lib32`、`/lib/i386-linux-gnu` 路径);musl 或缺少 32 位加载器的系统会在安装前明确失败,不会安装后才出现无法启动。
32 位服务器应使用首次访问 `/setup` 的向导输入外部 MySQL 8.4 连接信息,这条路径不需要在服务器安装 MySQL 客户端。在线更新前仍需准备与数据库兼容的 `mysqldump`;如果 32 位系统无法提供该客户端,应先关闭在线更新或从独立备份机执行数据库备份,不要在更新过程中临时安装数据库。
### systemd 一键安装(推荐)
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.29/install.sh | sudo env KAIDI_APP_PORT=18080 bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.31/install.sh | sudo env KAIDI_APP_PORT=18080 bash
```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -69,9 +88,9 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/purge.sh \
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.31/purge.sh \
-o /tmp/kaidi-purge.sh \
&& printf '%s %s\n' c49c5460260d2c67d88aced99463e3d98a67f8ef0309f5c8d5d8a8f5e4addf36 /tmp/kaidi-purge.sh \
&& printf '%s %s\n' 44dbf10a9540aa9235bb8aff2dec603f1febbd45072cd8c4f6c40b25a6127801 /tmp/kaidi-purge.sh \
| sha256sum -c - \
&& sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \
&& rm -f /tmp/kaidi-purge.sh
@@ -83,20 +102,20 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.25 直链与宝塔手动部署
### Preview.31 直链与宝塔手动部署
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.25/kaidi-finance-1.0.0-preview.25.tar.gz`
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.31/kaidi-finance-1.0.0-preview.31.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.25/SHA256SUMS`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.31/SHA256SUMS`
服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机
已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;启用在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser`
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser`
以及与外部 MySQL 兼容的 `mysqldump`。程序不会替你安装这些依赖。
#### 1. 下载、校验、解压
@@ -105,7 +124,7 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash
VERSION=1.0.0-preview.25
VERSION=1.0.0-preview.31
APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -121,8 +140,8 @@ sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
#### 2. 执行本地初始化
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置、在线更新监听器和一次性
安装码;不会下载 Java、安装 MySQL 或修改反向代理。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置和一次性安装码;不会下载
Java、安装 MySQL 或修改反向代理。宝塔手动模式不会开放在线更新按钮。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
```bash
sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
@@ -184,24 +203,24 @@ HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为
```bash
curl -fsS http://127.0.0.1:18080/actuator/health
curl -fsS http://127.0.0.1:18080/ | head
systemctl is-enabled kaidi-update.path
```
#### 5. 后台在线更新
#### 5. 手动部署模式的更新边界
初始化脚本已启用 `kaidi-update.path`。后台“系统治理 → 系统更新”按明确阶段执行:点击“获取版本”只读取版本信息,
发现新版本后点击“立即更新”才开始下载和验签;页面显示“下载完成”后,再点击“立即更新并重启”。
root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java,
健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。
宝塔手动部署不启用 Preview 的在线更新契约。需要升级时,先停止宝塔项目,下载并校验新的 Release 压缩包,
解压到新的 `releases/<VERSION>`,再由宝塔项目切换路径并启动;确认健康检查通过后再删除旧版本。
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
更新前请在服务器预装与外部 MySQL 兼容的 `mysqldump`,更新器只生成权限为 `0600` 的备份,不会自动安装 MySQL。迁移失败时恢复旧应用版本并保留数据库备份和事务证据,遵循至少一个版本向后兼容的迁移规则。
手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`;不要手工删除 `current`,在线更新负责原子切换和回滚。
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
### Linux 32 位
压缩包本身不绑定 CPU 架构,关键是宝塔项目选用可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
MySQL 8.4;提前准备外部 MySQL,完成上述手动解压、初始化和向导即可。
压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的
`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。
### 停用并移除程序
@@ -235,14 +254,15 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash
git tag v1.0.0-preview.25
git push origin v1.0.0-preview.25
git tag v1.0.0-preview.31
git push origin v1.0.0-preview.31
```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
`/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。
宝塔模式下,root 更新器只负责下载、验签、切换 `current` 和停止旧 Java,**不创建或重启
`kaidi-finance.service`**;宝塔项目的“项目意外重启”负责按新链接拉起 Java。
**Preview 的稳定在线更新契约只支持 systemd 一键安装模式**:更新器由
`kaidi-update.service` 执行,直接停止、切换并重启 `kaidi-finance.service`,不依赖宝塔面板的意外重启。
宝塔手动部署仅用于手动启动和反向代理;其面板进程管理与 systemd 更新器不是同一套生命周期,暂不作为在线更新的稳定路径。
更新流程固定为:
@@ -251,7 +271,8 @@ git push origin v1.0.0-preview.25
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
5. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。
5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
保留数据库备份和事务证据供人工处理。
@@ -264,26 +285,29 @@ sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json
```
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到
`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动重复安装。修复日志所示的
磁盘、权限或旧版本健康问题后,才明确执行一次:
进程被中断时,`kaidi-update.path` 会根据 `processing/request.json` 或 `transactions/active` 自动恢复一次。
如果 `status.json` 显示 `RECOVERY_REQUIRED`,失败请求会归档到 `/var/lib/kaidi-update/failed`,事务证据会移到
`transactions/recovery-required*` 并退出自动触发路径,新下载和安装请求也会被拒绝。修复日志所示的磁盘、权限或
旧版本健康问题后,将唯一一份待恢复事务移回 `active`,再明确执行一次:
```bash
sudo mv /var/lib/kaidi-update/transactions/recovery-required /var/lib/kaidi-update/transactions/active
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
sudo systemctl start kaidi-update.service
sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json
```
只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后,
才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。
如果目录带时间后缀,先通过 `ls -1d /var/lib/kaidi-update/transactions/recovery-required*` 确认唯一目录,再替换上面
命令中的源路径。只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理
完成后,才算本次恢复结束。后台会保留真实失败状态,不会把待恢复请求误显示成普通排队。
## 手工生成 Release
```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.25
./scripts/package-release.sh 1.0.0-preview.31
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release
```
@@ -0,0 +1,11 @@
package com.kaidi.finance.update.api;
import java.time.Instant;
public record SystemUpdateEventView(
Instant occurredAt,
String level,
String stage,
String message
) {
}
@@ -14,6 +14,12 @@ public record SystemUpdateView(
Instant publishedAt,
Instant checkedAt,
Instant statusUpdatedAt,
Long downloadedBytes,
Long totalBytes,
Long bytesPerSecond,
Integer downloadPercent,
Integer restartExpectedSeconds,
List<SystemUpdateEventView> events,
List<String> allowedActions
) {
}
@@ -11,6 +11,7 @@ import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateEventView;
import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException;
import java.io.InputStream;
@@ -44,6 +45,8 @@ public class SystemUpdateApplicationService {
private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
private static final int MAX_EVENT_COUNT = 120;
private static final int MAX_REDIRECTS = 3;
private static final String PRERELEASE_IDENTIFIER =
"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)";
@@ -57,8 +60,8 @@ public class SystemUpdateApplicationService {
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
"INSTALLING", "RUNNING");
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "PRECHECKING", "VERIFYING", "DOWNLOADING",
"BACKING_UP", "INSTALLING", "RUNNING");
private final SystemUpdateProperties properties;
private final AuthorizationService authorizationService;
@@ -353,6 +356,10 @@ public class SystemUpdateApplicationService {
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) {
UpdateStatus currentStatus = readStatus();
if ("RECOVERY_REQUIRED".equals(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"更新服务需要人工恢复,暂不接受新的更新请求");
}
if (BUSY_STATES.contains(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"已有系统更新任务正在执行");
@@ -421,7 +428,10 @@ public class SystemUpdateApplicationService {
String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT);
if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN";
return new UpdateStatus(state, root.path("message").asText(""),
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)));
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300));
} catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
}
@@ -433,17 +443,60 @@ public class SystemUpdateApplicationService {
String candidateVersion = manifest == null ? status.targetVersion() : manifest.version();
boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0;
boolean busy = BUSY_STATES.contains(status.state());
boolean recoveryRequired = "RECOVERY_REQUIRED".equals(status.state());
boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize());
boolean ready = "READY".equals(status.state()) && candidateVersion != null
&& candidateVersion.equals(status.targetVersion());
List<String> actions = new ArrayList<>();
if (enabled && !busy && !pending) actions.add("CHECK");
if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) {
if (enabled && !busy && !pending && !recoveryRequired) actions.add("CHECK");
if (enabled && available && !busy && !pending && !recoveryRequired
&& authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD");
}
return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(),
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), List.copyOf(actions));
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
}
private List<SystemUpdateEventView> readUpdateEvents() {
Path statusFile = properties.statusFile().toAbsolutePath().normalize();
Path parent = statusFile.getParent();
if (parent == null) return List.of();
Path eventFile = parent.resolve("events.jsonl").normalize();
if (!eventFile.startsWith(parent) || !Files.isRegularFile(eventFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(eventFile)) {
return List.of();
}
try {
if (Files.size(eventFile) > MAX_EVENT_LOG_BYTES) return List.of();
List<String> lines = Files.readAllLines(eventFile, StandardCharsets.UTF_8);
int first = Math.max(0, lines.size() - MAX_EVENT_COUNT);
List<SystemUpdateEventView> events = new ArrayList<>();
for (int index = first; index < lines.size(); index++) {
String line = lines.get(index);
if (line.isBlank() || line.length() > 4096) continue;
try {
JsonNode event = objectMapper.readTree(line);
Instant occurredAt = parseInstant(event.path("occurredAt").asText(null));
String level = event.path("level").asText("INFO").toUpperCase(Locale.ROOT);
String stage = event.path("stage").asText("UNKNOWN").toUpperCase(Locale.ROOT);
String message = event.path("message").asText("");
if (occurredAt == null || !level.matches("^(INFO|WARN|ERROR)$")
|| !stage.matches("^[A-Z_]{2,32}$") || message.isBlank()) {
continue;
}
events.add(new SystemUpdateEventView(occurredAt, level, stage,
message.length() > 1000 ? message.substring(0, 1000) : message));
} catch (IOException ignored) {
// Ignore a partially written event line.
}
}
return List.copyOf(events);
} catch (IOException exception) {
return List.of();
}
}
private boolean hasPendingRequest(Path requestFile) {
@@ -564,6 +617,20 @@ public class SystemUpdateApplicationService {
}
}
private static Long nonNegativeLong(JsonNode root, String field) {
JsonNode value = root.path(field);
if (!value.canConvertToLong()) return null;
long parsed = value.asLong();
return parsed < 0 ? null : parsed;
}
private static Integer boundedInteger(JsonNode root, String field, int minimum, int maximum) {
JsonNode value = root.path(field);
if (!value.canConvertToInt()) return null;
int parsed = value.asInt();
return parsed < minimum || parsed > maximum ? null : parsed;
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
@@ -580,7 +647,12 @@ public class SystemUpdateApplicationService {
String releaseNotes) {
}
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null);
}
}
private record QueueTransition(UpdateStatus previous, UpdateStatus queued) {
@@ -1,3 +1,9 @@
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
server:
servlet:
session:
+1 -4
View File
@@ -17,11 +17,8 @@ spring:
resources:
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
profiles:
default: local
default: production
datasource:
url: ${DB_URL:jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC}
username: ${DB_USERNAME:kaidi}
password: ${DB_PASSWORD:kaidi_local_2026}
hikari:
maximum-pool-size: 20
minimum-idle: 2
@@ -211,6 +211,81 @@ class SystemUpdateApplicationServiceTest {
}
}
@Test
void restoresDownloadMetricsAndRuntimeEventsFromUpdaterState() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
Path inbox = Files.createDirectory(tempDir.resolve("progress-inbox"));
Path statusFile = tempDir.resolve("progress-status.json");
Files.writeString(statusFile, """
{"state":"DOWNLOADING","message":"Downloading signed release","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:00:00Z","downloadedBytes":5242880,"totalBytes":10485760,
"bytesPerSecond":1048576,"downloadPercent":50,"restartExpectedSeconds":10}
""");
Files.writeString(tempDir.resolve("events.jsonl"), """
{"occurredAt":"2026-08-18T02:59:59Z","level":"INFO","stage":"VERIFYING","message":"签名校验开始"}
{"occurredAt":"2026-08-18T03:00:00Z","level":"INFO","stage":"DOWNLOADING","message":"更新包下载中"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
var status = service.status();
assertEquals("DOWNLOADING", status.state());
assertEquals(5_242_880L, status.downloadedBytes());
assertEquals(10_485_760L, status.totalBytes());
assertEquals(1_048_576L, status.bytesPerSecond());
assertEquals(50, status.downloadPercent());
assertEquals(10, status.restartExpectedSeconds());
assertEquals(2, status.events().size());
assertEquals("DOWNLOADING", status.events().get(1).stage());
assertFalse(status.allowedActions().contains("DOWNLOAD"));
}
@Test
void recoveryRequiredStateBlocksEveryUpdateAction() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json";
byte[] body = """
{"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"}]}
""".formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json",
exchange -> {
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
Path inbox = Files.createDirectory(tempDir.resolve("recovery-inbox"));
Path statusFile = tempDir.resolve("recovery-status.json");
Files.writeString(statusFile, """
{"state":"RECOVERY_REQUIRED","message":"自动回滚未完成","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:10:00Z"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
try {
var status = service.status();
assertEquals("RECOVERY_REQUIRED", status.state());
assertTrue(status.allowedActions().isEmpty());
assertThrows(BusinessException.class,
() -> service.download(new DownloadUpdateRequest("1.0.0-preview.2")));
assertFalse(Files.exists(inbox.resolve("request.json")));
} finally {
server.stop(0);
}
}
@Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+5 -2
View File
@@ -95,7 +95,7 @@ main() {
[ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \
|| die "systemd is required for the privileged background updater"
for command in find openssl sha256sum; do
for command in find openssl setsid sha256sum; do
command -v "$command" >/dev/null 2>&1 || die "$command is required"
done
@@ -155,6 +155,9 @@ main() {
field_key=$(openssl rand -base64 32 | tr -d '\n')
setup_code="KD-$(random_secret)"
setup_hash=$(printf '%s' "$setup_code" | sha256sum | awk '{print $1}')
# The Baota process manager does not own a restartable application unit.
# Keep its updater files for diagnostics, but do not expose online update
# actions until the panel lifecycle is integrated with the transaction state machine.
write_env_file "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \
SERVER_ADDRESS 127.0.0.1 \
@@ -174,7 +177,7 @@ main() {
FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \
FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \
FINANCE_SETUP_RESTART_AFTER_COMPLETE true \
FINANCE_UPDATE_ENABLED true \
FINANCE_UPDATE_ENABLED false \
UPDATE_RELEASE_BASE_URL '' \
UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \
UPDATE_RELEASE_TOKEN '' \
+16 -4
View File
@@ -51,7 +51,7 @@ is_managed_env_name() {
FINANCE_SETUP_TOKEN_SHA256|FINANCE_SETUP_ENV_FILE|FINANCE_SETUP_MARKER_FILE|\
FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\
UPDATE_RELEASE_BASE_URL|UPDATE_RELEASE_API_URL|UPDATE_RELEASE_TOKEN|\
UPDATE_REQUEST_FILE|UPDATE_STATUS_FILE|UPDATE_CURRENT_VERSION_FILE|APP_VERSION|KAIDI_PID_FILE)
UPDATE_REQUEST_FILE|UPDATE_STATUS_FILE|UPDATE_CURRENT_VERSION_FILE|APP_VERSION|KAIDI_PID_FILE|KAIDI_JAVA_BIN)
return 0
;;
*) return 1 ;;
@@ -85,14 +85,23 @@ load_env_file() {
# The setup-generated runtime file has precedence over the base file. Non-empty
# variables supplied by Baota have precedence over both; empty panel fields do not
# mask the protected configuration written by the setup wizard.
load_env_file "$RUNTIME_ENV_FILE"
load_env_file "$CONFIG_FILE"
# mask the protected configuration written by the setup wizard. The systemd unit
# loads both files itself as root, then sets KAIDI_ENV_PRELOADED so the service
# user never needs traversal permission for /etc/kaidi (which is intentionally
# root-only).
if [ "${KAIDI_ENV_PRELOADED:-false}" != true ]; then
load_env_file "$RUNTIME_ENV_FILE"
load_env_file "$CONFIG_FILE"
fi
JAVA_BIN=${KAIDI_JAVA_BIN:-}
if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then
JAVA_BIN="$JAVA_HOME/bin/java"
fi
if [ -z "$JAVA_BIN" ] && [ -x /opt/kaidi/runtime/java/bin/java ]; then
# Compatibility for installations created before external Java paths were persisted.
JAVA_BIN=/opt/kaidi/runtime/java/bin/java
fi
if [ -z "$JAVA_BIN" ]; then
JAVA_BIN=$(command -v java 2>/dev/null || true)
fi
@@ -102,6 +111,9 @@ if [[ "$JAVA_BIN" == */* ]]; then
else
command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available"
fi
JAVA_VERSION=$("$JAVA_BIN" -version 2>&1 | sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' | head -n 1)
[[ "$JAVA_VERSION" =~ ^[0-9]+$ ]] && [ "$JAVA_VERSION" -ge 17 ] \
|| die "Java 17 or newer is required"
export SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-production}
export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1}
+6 -6
View File
@@ -1,26 +1,26 @@
SPRING_PROFILES_ACTIVE=production
SERVER_PORT=18080
SERVER_ADDRESS=127.0.0.1
SESSION_COOKIE_SECURE=false
SESSION_COOKIE_SECURE=true
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=kaidi
DB_URL=jdbc:mysql://DB_HOST:DB_PORT/DB_NAME?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=DB_USERNAME
DB_PASSWORD=CHANGE_ME
FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY
FILE_STORAGE_ROOT=/var/lib/kaidi/files
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
FILE_SCANNER_ENABLED=false
FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.9
APP_VERSION=1.0.0-preview.31
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API.
UPDATE_RELEASE_BASE_URL=
UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
UPDATE_RELEASE_TOKEN=READ_ONLY_GITEA_TOKEN
UPDATE_RELEASE_TOKEN=
UPDATE_REQUEST_FILE=/var/lib/kaidi-update/inbox/request.json
UPDATE_STATUS_FILE=/var/lib/kaidi-update/status.json
+1 -1
View File
@@ -5,7 +5,7 @@ umask 077
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
INSTALLER="$ROOT/deploy/install.sh"
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-6256b05025aa314d4c02e7c0f5d2ca530f28ec5d10f833504b34c09ada6e02a5}
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-6a454aff209e62d7ac75b7f97670d700ec05be710854a02136f41f55e82f9fa9}
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
+40 -10
View File
@@ -31,6 +31,7 @@ INSTALL_TRANSACTION_ARMED=false
INSTALL_COMMITTED=false
RELEASE_DIR=
BACKUP_DIR=
JAVA_BIN=
JAVA_STAGED_DIR=
JAVA_PREVIOUS_DIR=
JAVA_ACTIVATED=false
@@ -81,6 +82,15 @@ preflight_host() {
esac
[ "$host_bits" = "$expected_bits" ] \
|| die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit"
if [ "$HOST_ARCH" = x86 ]; then
getconf GNU_LIBC_VERSION >/dev/null 2>&1 \
|| die "32-bit Linux deployment requires glibc; the downloaded i686 Java runtime is not compatible with musl"
if ! for loader in /lib/ld-linux.so.2 /lib32/ld-linux.so.2 /lib/i386-linux-gnu/ld-linux.so.2; do
[ -x "$loader" ] && break
done; then
die "32-bit Linux deployment requires the glibc loader /lib/ld-linux.so.2"
fi
fi
[[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \
|| die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256"
case "$REINSTALL" in
@@ -209,6 +219,17 @@ install_packages() {
fi
}
preflight_runtime_commands() {
local command_name
for command_name in \
awk bash chmod chown cp curl cut date find getconf getent grep gzip head id install jq journalctl \
ln mktemp mv nologin openssl readlink rm runuser sed sha256sum sleep sort systemctl \
systemd-analyze tar tr useradd usermod groupadd wc flock setsid; do
command -v "$command_name" >/dev/null 2>&1 \
|| die "Required command is missing after dependency preparation: $command_name"
done
}
azul_arch() {
case "${HOST_ARCH:-$(normalized_host_arch)}" in
x86_64) printf x86 ;;
@@ -282,12 +303,13 @@ system_java_home() {
prepare_java() {
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line
JAVA_BIN=
JAVA_STAGED_DIR=
if system_home=$(system_java_home); then
JAVA_STAGED_DIR="$WORK_DIR/java.next"
mkdir -p "$JAVA_STAGED_DIR"
log "Copying existing Java 17 runtime from $system_home into the managed application directory"
COPYFILE_DISABLE=1 cp -R "$system_home/." "$JAVA_STAGED_DIR/"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
JAVA_BIN="$system_home/bin/java"
java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
|| die "The existing Java runtime cannot start on this host"
log "Using existing Java 17+ runtime at $system_home"
log "Local Java verified: $java_line"
return 0
fi
@@ -312,11 +334,14 @@ prepare_java() {
tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR"
java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \
|| die "Downloaded Java runtime architecture does not match $HOST_ARCH"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) \
|| die "The downloaded Java runtime cannot start; verify glibc and the host architecture"
JAVA_BIN="$APP_ROOT/runtime/java/bin/java"
log "Downloaded Java verified: $java_line"
}
activate_java() {
[ -n "$JAVA_STAGED_DIR" ] || return 0
mkdir -p "$APP_ROOT/runtime"
JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous"
if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then
@@ -383,10 +408,12 @@ verify_service_access() {
runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \
|| die "$SERVICE_USER cannot traverse or read the active release"
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|| die "$SERVICE_USER cannot execute the managed Java runtime"
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 \
|| die "The managed Java runtime cannot execute as $SERVICE_USER"
[ -n "$JAVA_BIN" ] && [ -x "$JAVA_BIN" ] \
|| die "The selected Java runtime is unavailable at $JAVA_BIN"
runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
|| die "$SERVICE_USER cannot execute the selected Java runtime"
runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1 \
|| die "The selected Java runtime cannot execute as $SERVICE_USER"
runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \
|| die "$SERVICE_USER cannot write the file-storage directory"
runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \
@@ -828,6 +855,7 @@ validate_inputs
configure_app_port
preflight_host
install_packages
preflight_runtime_commands
BASE=${RELEASE_BASE_URL%/}
WORK_DIR=$(mktemp -d)
@@ -926,6 +954,7 @@ if [ "$SETUP_WIZARD" = true ]; then
fi
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \
KAIDI_JAVA_BIN "$JAVA_BIN" \
SERVER_PORT "$APP_PORT" \
SERVER_ADDRESS "$SERVER_ADDRESS" \
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
@@ -963,6 +992,7 @@ write_env_file "$CONFIG_ROOT/update.env" \
KAIDI_APP_ROOT "$APP_ROOT" \
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
KAIDI_SERVICE_NAME kaidi-finance.service \
KAIDI_JAVA_BIN "$JAVA_BIN" \
KAIDI_HEALTH_URL "$HEALTH_URL" \
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
+77 -8
View File
@@ -67,8 +67,61 @@ related_pids() {
done
}
managed_service_account() {
local entry home shell
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
[ -n "$entry" ] || return 1
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) return 0 ;;
*) return 1 ;;
esac
}
service_user_pids() {
local uid=$1 proc pid owner_uid
for proc in /proc/[0-9]*; do
[ -d "$proc" ] || continue
pid=${proc##*/}
owner_uid=$(stat -c '%u' "$proc" 2>/dev/null || true)
[ "$owner_uid" = "$uid" ] && printf '%s\n' "$pid"
done
}
terminate_uid_processes() {
local uid=$1 deadline
local -a pids=()
mapfile -t pids < <(service_user_pids "$uid")
if [ "${#pids[@]}" -gt 0 ]; then
log "Stopping processes owned by the dedicated $SERVICE_USER account: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
fi
deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -gt 0 ] || return 0
sleep 1
done
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -eq 0 ]
}
all_pids_owned_by_service_user() {
local service_uid pid owner_uid
managed_service_account || return 1
service_uid=$(id -u "$SERVICE_USER")
for pid in "$@"; do
[ -d "/proc/$pid" ] || continue
owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true)
[ "$owner_uid" = "$service_uid" ] || return 1
done
}
stop_managed_processes() {
local deadline
local allow_service_restart=${1:-false} deadline
local -a pids=()
mapfile -t pids < <(related_pids)
if [ "${#pids[@]}" -gt 0 ]; then
@@ -84,8 +137,12 @@ stop_managed_processes() {
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -eq 0 ] \
|| die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again"
[ "${#pids[@]}" -eq 0 ] && return 0
if [ "$allow_service_restart" = true ] && all_pids_owned_by_service_user "${pids[@]}"; then
log "A process manager restarted the dedicated $SERVICE_USER account; forced account cleanup will stop it"
return 0
fi
die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again"
}
create_recovery_backup() {
@@ -120,6 +177,10 @@ create_recovery_backup() {
remove_systemd_units() {
local unit
rm -rf \
/etc/systemd/system/kaidi-finance.service.d \
/etc/systemd/system/kaidi-update.service.d \
/etc/systemd/system/kaidi-update.path.d
rm -f \
/etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \
@@ -149,15 +210,23 @@ remove_download_archives() {
}
remove_service_identity() {
local entry home shell group_entry gid members primary_users
local entry home shell service_uid group_entry gid members primary_users
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
if [ -n "$entry" ]; then
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false)
userdel "$SERVICE_USER" \
|| die "Failed to remove the dedicated $SERVICE_USER service account"
service_uid=$(id -u "$SERVICE_USER")
terminate_uid_processes "$service_uid" || true
if ! userdel --force "$SERVICE_USER"; then
terminate_uid_processes "$service_uid" || true
userdel --force "$SERVICE_USER" \
|| die "Failed to remove the dedicated $SERVICE_USER service account"
fi
terminate_uid_processes "$service_uid" \
|| die "Processes owned by the removed $SERVICE_USER account are still running"
rm -rf "/run/user/$service_uid"
;;
*)
log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed"
@@ -182,13 +251,13 @@ main() {
validate_inputs
log "External MySQL data and reverse-proxy configuration will not be modified"
stop_systemd_units
stop_managed_processes
stop_managed_processes true
create_recovery_backup
remove_systemd_units
remove_managed_paths
remove_download_archives
remove_service_identity
stop_managed_processes
stop_managed_processes false
log "Local Kaidi installation state has been removed"
if [ -n "$BACKUP_ARCHIVE" ]; then
log "Recovery backup: $BACKUP_ARCHIVE"
+2 -1
View File
@@ -11,8 +11,9 @@ User=kaidi
Group=kaidi
EnvironmentFile=/etc/kaidi/kaidi.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env
Environment=KAIDI_ENV_PRELOADED=true
WorkingDirectory=/opt/kaidi/current
ExecStart=/opt/kaidi/runtime/java/bin/java -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar /opt/kaidi/current/app.jar
ExecStart=/opt/kaidi/current/ops/baota-start.sh
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
+2
View File
@@ -4,6 +4,8 @@ Description=Watch for Kaidi Finance update requests
[Path]
PathChanged=/var/lib/kaidi-update/inbox
PathExists=/var/lib/kaidi-update/inbox/request.json
PathExists=/var/lib/kaidi-update/processing/request.json
PathExists=/var/lib/kaidi-update/transactions/active
Unit=kaidi-update.service
[Install]
+246 -14
View File
@@ -12,12 +12,14 @@ FAILED_REQUEST_ROOT=${KAIDI_UPDATE_FAILED_ROOT:-$STATE_ROOT/failed}
TRANSACTION_ROOT=${KAIDI_UPDATE_TRANSACTION_ROOT:-$STATE_ROOT/transactions}
ACTIVE_TRANSACTION=$TRANSACTION_ROOT/active
STATUS_FILE=${UPDATE_STATUS_FILE:-$STATE_ROOT/status.json}
EVENT_LOG_FILE=${UPDATE_EVENT_LOG_FILE:-$STATE_ROOT/events.jsonl}
PUBLIC_KEY=${UPDATE_PUBLIC_KEY:-/etc/kaidi/release-public.pem}
RELEASE_BASE_URL=${UPDATE_RELEASE_BASE_URL:-}
RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
JAVA_BIN=${KAIDI_JAVA_BIN:-$APP_ROOT/runtime/java/bin/java}
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
PROCESS_MANAGER=${KAIDI_PROCESS_MANAGER:-systemd}
PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
@@ -39,6 +41,10 @@ CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION=
TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID=
DOWNLOAD_PGID=
LAST_DOWNLOAD_SPEED=0
SURFACE_FAILURE=
case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
*[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;;
@@ -95,7 +101,8 @@ load_runtime_database_env() {
runtime_size=$(wc -c < "$RUNTIME_ENV_FILE" | tr -d '[:space:]')
[ "$runtime_size" -le 65536 ] \
|| bootstrap_die "Setup runtime environment is too large"
runtime_owner=$(stat -c '%u' "$RUNTIME_ENV_FILE" 2>/dev/null || true)
runtime_owner=$(stat -c '%u' "$RUNTIME_ENV_FILE" 2>/dev/null \
|| stat -f '%u' "$RUNTIME_ENV_FILE" 2>/dev/null || true)
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
[ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \
|| bootstrap_die "Setup runtime environment has an unexpected owner"
@@ -116,6 +123,7 @@ load_runtime_database_env() {
KAIDI_DB_NAME) KAIDI_DB_NAME=$runtime_value; export KAIDI_DB_NAME ;;
KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;;
KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;;
DB_URL) KAIDI_DB_URL=$runtime_value; export KAIDI_DB_URL ;;
esac
done < "$RUNTIME_ENV_FILE"
@@ -126,20 +134,84 @@ load_runtime_database_env() {
|| bootstrap_die "Setup runtime database port is invalid"
}
validate_runtime_database_config() {
[ -e "$RUNTIME_ENV_FILE" ] || return 0
[ -n "${KAIDI_DB_URL:-}" ] \
|| fail "Database configuration is invalid: DB_URL is missing; application was not restarted"
[ -n "${KAIDI_DB_HOST:-}" ] && [ -n "${KAIDI_DB_NAME:-}" ] \
|| fail "Database configuration is invalid: database host and name are missing; application was not restarted"
expected_url_prefix="jdbc:mysql://${KAIDI_DB_HOST}:${KAIDI_DB_PORT}/${KAIDI_DB_NAME}"
case "$KAIDI_DB_URL" in
"$expected_url_prefix"|"$expected_url_prefix"\?*) ;;
*) fail "Database configuration is invalid: DB_URL does not match the configured MySQL host, port, and database; application was not restarted" ;;
esac
}
status() {
state=$1
message=$2
version=${3:-}
downloaded_bytes=${4:-}
total_bytes=${5:-}
bytes_per_second=${6:-}
download_percent=${7:-}
restart_expected_seconds=${8:-}
previous_state=
previous_message=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
fi
tmp="$STATUS_FILE.tmp.$$"
jq -n \
--arg state "$state" \
--arg message "$message" \
--arg version "$version" \
--arg downloadedBytes "$downloaded_bytes" \
--arg totalBytes "$total_bytes" \
--arg bytesPerSecond "$bytes_per_second" \
--arg downloadPercent "$download_percent" \
--arg restartExpectedSeconds "$restart_expected_seconds" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end)' > "$tmp"
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
+ (if ($downloadPercent | test("^[0-9]+$")) then {downloadPercent:($downloadPercent | tonumber)} else {} end)
+ (if ($restartExpectedSeconds | test("^[0-9]+$"))
then {restartExpectedSeconds:($restartExpectedSeconds | tonumber)} else {} end)' > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$STATUS_FILE"
if [ "$state" != "$previous_state" ] || [ "$message" != "$previous_message" ]; then
case "$state" in
FAILED|RECOVERY_REQUIRED) event ERROR "$state" "$message" || true ;;
*) event INFO "$state" "$message" || true ;;
esac
fi
}
event() {
level=$1
stage=$2
message=$3
event_tmp="$EVENT_LOG_FILE.tmp.$$"
jq -cn --arg occurredAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg level "$level" --arg stage "$stage" --arg message "$message" \
'{occurredAt:$occurredAt,level:$level,stage:$stage,message:$message}' >> "$EVENT_LOG_FILE"
chmod 0644 "$EVENT_LOG_FILE"
event_size=$(wc -c < "$EVENT_LOG_FILE" | tr -d '[:space:]')
if [ "$event_size" -gt 262144 ]; then
tail -n 160 "$EVENT_LOG_FILE" > "$event_tmp"
chmod 0644 "$event_tmp"
mv -f "$event_tmp" "$EVENT_LOG_FILE"
fi
}
reset_event_log() {
rm -f "$EVENT_LOG_FILE"
touch "$EVENT_LOG_FILE"
chmod 0644 "$EVENT_LOG_FILE"
}
archive_processing_request() {
@@ -166,6 +238,7 @@ prepare_update_layout() {
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|| bootstrap_die "Service user $SERVICE_USER is not a member of group $SERVICE_GROUP"
command -v runuser >/dev/null 2>&1 || bootstrap_die "runuser is required"
command -v setsid >/dev/null 2>&1 || bootstrap_die "setsid is required"
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \
|| bootstrap_die "Managed application or update directories could not be prepared"
@@ -204,8 +277,9 @@ verify_release_access() {
if [ "$PROCESS_MANAGER" = baota ]; then
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh"
else
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
&& runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" \
&& runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
&& runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1
fi
}
@@ -213,6 +287,14 @@ verify_release_access() {
cleanup() {
rc=$?
trap - EXIT HUP INT TERM
if [ -n "$DOWNLOAD_PID" ]; then
if [ -n "$DOWNLOAD_PGID" ]; then
kill -TERM -- "-$DOWNLOAD_PGID" 2>/dev/null || true
else
kill -TERM "$DOWNLOAD_PID" 2>/dev/null || true
fi
wait "$DOWNLOAD_PID" 2>/dev/null || true
fi
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
[ -z "$CACHE_TEMP" ] || rm -rf "$CACHE_TEMP"
if [ "$rc" -ne 0 ] && [ "$TERMINAL_STATUS_WRITTEN" != true ]; then
@@ -253,6 +335,83 @@ download() {
esac
}
download_with_progress() {
progress_url=$1
progress_output=$2
progress_total=${3:-0}
progress_started=$(date +%s)
progress_previous_time=$progress_started
progress_previous_bytes=0
rm -f "$progress_output"
touch "$progress_output"
chmod 0600 "$progress_output"
case "$progress_url" in
https://*)
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
setsid curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$RELEASE_AUTH_HEADER_FILE" "$progress_url" -o "$progress_output" &
else
setsid curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
--tlsv1.2 "$progress_url" -o "$progress_output" &
fi
;;
*)
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || return 2
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
setsid curl --fail --silent --show-error --proto '=http,https' \
--header "@$RELEASE_AUTH_HEADER_FILE" "$progress_url" -o "$progress_output" &
else
setsid curl --fail --silent --show-error --location --proto '=http,https' \
--proto-redir '=http,https' "$progress_url" -o "$progress_output" &
fi
;;
esac
DOWNLOAD_PID=$!
DOWNLOAD_PGID=$DOWNLOAD_PID
while kill -0 "$DOWNLOAD_PID" 2>/dev/null; do
progress_now=$(date +%s)
progress_bytes=$(wc -c < "$progress_output" 2>/dev/null | tr -d '[:space:]' || printf 0)
case "$progress_bytes:$progress_total" in *[!0-9:]*) progress_bytes=0; progress_total=0 ;; esac
progress_delta_seconds=$((progress_now - progress_previous_time))
[ "$progress_delta_seconds" -ge 1 ] || progress_delta_seconds=1
progress_delta_bytes=$((progress_bytes - progress_previous_bytes))
[ "$progress_delta_bytes" -ge 0 ] || progress_delta_bytes=0
progress_speed=$((progress_delta_bytes / progress_delta_seconds))
if [ "$progress_total" -gt 0 ]; then
progress_percent=$((progress_bytes * 100 / progress_total))
[ "$progress_percent" -le 99 ] || progress_percent=99
else
progress_percent=0
fi
status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION" \
"$progress_bytes" "$progress_total" "$progress_speed" "$progress_percent"
progress_previous_time=$progress_now
progress_previous_bytes=$progress_bytes
sleep 1
done
if wait "$DOWNLOAD_PID"; then
DOWNLOAD_PID=
DOWNLOAD_PGID=
else
DOWNLOAD_PID=
DOWNLOAD_PGID=
return 1
fi
progress_bytes=$(wc -c < "$progress_output" | tr -d '[:space:]')
[ "$progress_total" -gt 0 ] || progress_total=$progress_bytes
progress_finished=$(date +%s)
progress_elapsed=$((progress_finished - progress_started))
[ "$progress_elapsed" -ge 1 ] || progress_elapsed=1
progress_speed=$((progress_bytes / progress_elapsed))
LAST_DOWNLOAD_SPEED=$progress_speed
status DOWNLOADING "Release $TARGET_VERSION download completed" "$TARGET_VERSION" \
"$progress_bytes" "$progress_total" "$progress_speed" 100
}
release_asset_url() {
asset_name=$1
if [ -n "$RELEASE_API_URL" ]; then
@@ -284,30 +443,56 @@ EOF
wait_for_health() {
health_url=$1
attempts=0
health_file=$(mktemp "$STATE_ROOT/work/health-response.XXXXXX")
while [ "$attempts" -lt "$HEALTH_ATTEMPTS" ]; do
if curl -fsS "$health_url" | jq -e '.status == "UP"' >/dev/null 2>&1; then
health_http=$(curl --silent --show-error --connect-timeout 2 --max-time 4 \
--output "$health_file" --write-out '%{http_code}' "$health_url" 2>/dev/null || true)
if [ "$health_http" = 200 ] && jq -e '.status == "UP"' "$health_file" >/dev/null 2>&1; then
rm -f "$health_file"
return 0
fi
if [ "$health_http" = 200 ]; then
SURFACE_FAILURE=HEALTH_DOWN
elif [ -n "$health_http" ] && [ "$health_http" != 000 ]; then
SURFACE_FAILURE="HEALTH_HTTP_$health_http"
else
SURFACE_FAILURE=HEALTH_UNREACHABLE
fi
attempts=$((attempts + 1))
sleep "$HEALTH_INTERVAL_SECONDS"
done
rm -f "$health_file"
return 1
}
verify_app_surface() {
expected_release=${1:-}
SURFACE_FAILURE=
wait_for_health "$HEALTH_URL" || return 1
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX")
if curl -fsS "$APP_INDEX_URL" -o "$index_file" \
index_http=$(curl --silent --show-error --connect-timeout 2 --max-time 5 \
--output "$index_file" --write-out '%{http_code}' "$APP_INDEX_URL" 2>/dev/null || true)
if [ "$index_http" = 200 ] \
&& grep -Eiq '<!doctype|<html' "$index_file"; then
rm -f "$index_file"
if [ "$PROCESS_MANAGER" = baota ] && [ -n "$expected_release" ]; then
running_release=$(baota_process_release) || return 1
[ "$running_release" = "$expected_release" ] || return 1
running_release=$(baota_process_release) || {
SURFACE_FAILURE=PROCESS_IDENTITY_INVALID
return 1
}
if [ "$running_release" != "$expected_release" ]; then
SURFACE_FAILURE=RELEASE_MISMATCH
return 1
fi
fi
return 0
fi
rm -f "$index_file"
if [ "$index_http" != 200 ]; then
SURFACE_FAILURE="INDEX_HTTP_${index_http:-000}"
else
SURFACE_FAILURE=INDEX_CONTENT_INVALID
fi
return 1
}
@@ -554,6 +739,15 @@ remove_failed_release() {
esac
}
quarantine_active_transaction() {
[ -d "$ACTIVE_TRANSACTION" ] || return 0
recovery_transaction="$TRANSACTION_ROOT/recovery-required"
if [ -e "$recovery_transaction" ] || [ -L "$recovery_transaction" ]; then
recovery_transaction="$TRANSACTION_ROOT/recovery-required-$(date -u +%Y%m%dT%H%M%SZ)-$$"
fi
mv "$ACTIVE_TRANSACTION" "$recovery_transaction"
}
rollback_active_transaction() {
reason=$1
previous_target=$(transaction_value previous-target)
@@ -583,10 +777,19 @@ rollback_active_transaction() {
fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained"
fi
TERMINAL_STATUS_WRITTEN=true
status FAILED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
recovery_guard_failed=false
if ! archive_processing_request; then
printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2
recovery_guard_failed=true
fi
if ! quarantine_active_transaction; then
printf '%s\n' "Update transaction evidence could not be quarantined after an incomplete rollback" >&2
recovery_guard_failed=true
fi
if [ "$recovery_guard_failed" = true ]; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi
status RECOVERY_REQUIRED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2
exit 1
}
@@ -651,6 +854,16 @@ else
fi
chmod 0600 "$PROCESSING_FILE"
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ] \
&& jq -e '.state == "RECOVERY_REQUIRED"' "$STATUS_FILE" >/dev/null 2>&1; then
TERMINAL_STATUS_WRITTEN=true
if ! archive_processing_request; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi
printf '%s\n' "Update service is locked for manual recovery; new requests are rejected" >&2
exit 1
fi
if ! REQUESTED_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \
"$PROCESSING_FILE"); then
fail "Update request version is invalid"
@@ -661,7 +874,11 @@ REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|| fail "Update request action is invalid"
prepare_update_layout
reset_event_log
load_runtime_database_env
if [ "$REQUEST_ACTION" = INSTALL ]; then
validate_runtime_database_config
fi
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|| fail "UPDATE_RELEASE_TOKEN is invalid"
@@ -714,6 +931,8 @@ ARTIFACT=$(jq -er '.artifact | strings | select(test("^[A-Za-z0-9][A-Za-z0-9._+-
"$WORK_DIR/release-manifest.json") || fail "Release artifact name is invalid"
EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
"$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid"
EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \
"$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid"
CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
@@ -726,12 +945,16 @@ fi
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION"
ARTIFACT_URL=$(release_asset_url "$ARTIFACT") || fail "Release artifact asset is missing"
download "$ARTIFACT_URL" "$WORK_DIR/release.tar.gz" || fail "Release artifact download failed"
download_with_progress "$ARTIFACT_URL" "$WORK_DIR/release.tar.gz" "$EXPECTED_SIZE" \
|| fail "Release artifact download failed"
else
status VERIFYING "Revalidating cached release $TARGET_VERSION" "$TARGET_VERSION"
fi
ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}')
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || fail "Release artifact SHA-256 verification failed"
ACTUAL_SIZE=$(wc -c < "$WORK_DIR/release.tar.gz" | tr -d '[:space:]')
[ "$EXPECTED_SIZE" -eq 0 ] || [ "$ACTUAL_SIZE" -eq "$EXPECTED_SIZE" ] \
|| fail "Release artifact size verification failed"
tar -tzf "$WORK_DIR/release.tar.gz" > "$WORK_DIR/archive.list" || fail "Release archive could not be listed"
if grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list"; then
@@ -768,7 +991,8 @@ if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
fi
mv "$CACHE_TEMP" "$CACHE_DIR" || fail "Verified release cache could not be activated"
CACHE_TEMP=
status READY "Release $TARGET_VERSION is downloaded and verified; confirm installation" "$TARGET_VERSION"
status READY "Release $TARGET_VERSION is downloaded and verified; confirm installation" "$TARGET_VERSION" \
"$ACTUAL_SIZE" "$ACTUAL_SIZE" "$LAST_DOWNLOAD_SPEED" 100
TERMINAL_STATUS_WRITTEN=true
complete_request
exit 0
@@ -787,10 +1011,17 @@ if command -v restorecon >/dev/null 2>&1; then
fi
if ! verify_release_access "$RELEASE_DIR"; then
rm -rf "$RELEASE_DIR"
fail "Service user cannot access the release or managed Java runtime"
fail "Service user cannot access the release or selected Java runtime"
fi
PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true)
status PRECHECKING "Validating current release before switching to $TARGET_VERSION" "$TARGET_VERSION"
if [ -z "$PREVIOUS_TARGET" ] || [ ! -d "$PREVIOUS_TARGET" ]; then
fail "Current release path is invalid; application was not restarted"
fi
if ! verify_app_surface "$PREVIOUS_TARGET"; then
fail "Current release preflight failed (${SURFACE_FAILURE:-UNKNOWN}); application was not restarted"
fi
mkdir "$ACTIVE_TRANSACTION"
write_transaction_value target-version "$TARGET_VERSION"
write_transaction_value previous-target "$PREVIOUS_TARGET"
@@ -817,7 +1048,7 @@ if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
fi
write_transaction_value phase APP_SWITCHED
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION"
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" "" "" "" "" 10
write_transaction_value phase HEALTH_CHECKING
if ! restart_application; then
rollback_active_transaction "Release application restart failed"
@@ -837,5 +1068,6 @@ fi
service_state=$(application_state 2>/dev/null || true)
[ -n "$service_state" ] || service_state=unknown
surface_failure=${SURFACE_FAILURE:-UNKNOWN}
rollback_active_transaction \
"Release health or application-surface verification failed (service state: $service_state)"
"Release application verification failed ($surface_failure; service state: $service_state)"
+66
View File
@@ -137,3 +137,69 @@ test('confirms completion after the setup response is interrupted by a service r
await expect(page.getByText('网络请求失败')).toHaveCount(0);
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
});
test('leaves the completed wizard when post-completion status polling is unavailable', async ({ page }) => {
let completionStarted = false;
await page.route('**/api/v1/setup/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
if (completionStarted) {
await route.abort('connectionreset');
return;
}
await route.fulfill({
json: {
data: {
required: true,
locked: false,
ready: false,
supportedDatabaseTypes: ['MYSQL'],
message: '请完成安装',
},
},
});
return;
}
const body = request.postDataJSON() as Record<string, unknown>;
if (pathname.endsWith('/test-connection')) {
await route.fulfill({
json: {
data: {
successful: true,
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: true,
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
},
},
});
return;
}
expect(body.adminUsername).toBe('admin');
completionStarted = true;
await route.fulfill({
json: {
data: {
completed: true,
username: 'admin',
message: '安装完成,系统正在切换到正式模式',
restartAfterSeconds: 1,
},
},
});
});
await page.goto('/setup');
await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click();
await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page).toHaveURL(/\/login$/, { timeout: 5_000 });
});
+103 -4
View File
@@ -28,6 +28,7 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
'QUEUED',
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
@@ -59,6 +60,14 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
statusUpdatedAt: null,
downloadedBytes: state === 'READY' ? 10_485_760 : state === 'DOWNLOADING' ? 5_242_880 : 0,
totalBytes: ['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(state) ? 10_485_760 : null,
bytesPerSecond: state === 'DOWNLOADING' ? 1_048_576 : 0,
downloadPercent: state === 'READY' ? 100 : state === 'DOWNLOADING' ? 50 : 0,
restartExpectedSeconds: state === 'RUNNING' ? 10 : null,
events: checked
? [{ occurredAt: '2026-08-16T00:01:00Z', level: 'INFO', stage: state, message: `状态进入 ${state}` }]
: [],
allowedActions:
!enabled || recoveryPending || busy
? []
@@ -72,8 +81,11 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
interface FixtureOptions {
canExecute?: boolean;
checkResponseState?: string;
downloadProgressReads?: number;
failDownloadResponse?: boolean;
failInstallResponse?: boolean;
restartOfflineReads?: number;
}
async function installFixture(
@@ -87,9 +99,18 @@ async function installFixture(
completeBusyAfterFirstRead = false,
options: FixtureOptions = {},
) {
const { canExecute = true, failDownloadResponse = false, failInstallResponse = false } = options;
const {
canExecute = true,
checkResponseState,
downloadProgressReads = 0,
failDownloadResponse = false,
failInstallResponse = false,
restartOfflineReads = 0,
} = options;
let checked = recoveryPending || Boolean(initialState);
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE';
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
let progressDeadline = 0;
let restartDeadline = 0;
const history: Array<Record<string, unknown>> = [];
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
history.unshift({
@@ -131,13 +152,19 @@ async function installFixture(
});
}
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
if (state === 'RUNNING' && Date.now() < restartDeadline) {
return route.abort('connectionrefused');
}
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED';
const response = updateView(state, checked, enabled, recoveryPending);
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
return route.fulfill({ json: envelope(response) });
}
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
checked = true;
recordHistory('SYSTEM_UPDATE_CHECK');
if (checkResponseState) state = checkResponseState;
const response = updateView(state, checked, enabled, recoveryPending);
if (!canExecute) response.allowedActions = ['CHECK'];
return route.fulfill({ json: envelope(response) });
@@ -145,7 +172,8 @@ async function installFixture(
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
downloadBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
state = 'READY';
state = downloadProgressReads > 0 ? 'DOWNLOADING' : 'READY';
progressDeadline = Date.now() + downloadProgressReads * 1000;
if (failDownloadResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
}
@@ -153,7 +181,13 @@ async function installFixture(
installBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_REQUEST');
const queued = updateView('INSTALL_QUEUED', true, enabled);
state = failInstallResponse || completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
restartDeadline = Date.now() + restartOfflineReads * 1000;
state =
restartOfflineReads > 0
? 'RUNNING'
: failInstallResponse || completeAfterInstall
? 'SUCCEEDED'
: 'INSTALL_QUEUED';
if (failInstallResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(queued) });
}
@@ -189,6 +223,7 @@ test('system administrator checks, downloads, and explicitly installs a signed o
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
await expect(page.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
@@ -198,6 +233,38 @@ test('system administrator checks, downloads, and explicitly installs a signed o
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]);
});
test('download dialog shows real byte progress and transfer speed without a false permission warning', async ({
page,
}) => {
await installFixture(page, [], [], true, false, false, undefined, false, { downloadProgressReads: 8 });
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('50%', { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText('1.0 MB/s', { exact: true })).toBeVisible();
await expect(dialog.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible({ timeout: 12_000 });
});
test('restart dialog keeps counting down and reconnects while the backend is temporarily offline', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY', false, { restartOfflineReads: 8 });
await page.goto('/governance/update');
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
timeout: 5_000,
});
await expect(dialog.getByText('应用服务连接中断,正在等待进程重启', { exact: true })).toBeVisible();
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
timeout: 15_000,
});
});
test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY');
await page.goto('/governance/update');
@@ -220,6 +287,19 @@ test('view-only administrators see a download permission message', async ({ page
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
});
test('a check response that is already queued is treated as busy instead of missing permission', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
checkResponseState: 'DOWNLOAD_QUEUED',
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByRole('dialog', { name: '系统更新' })).toBeVisible();
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(page.getByText('下载请求已排队', { exact: true }).first()).toBeVisible();
});
test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
failDownloadResponse: true,
@@ -252,6 +332,25 @@ test('accepted install continues to the refresh countdown after its response is
});
});
test('a lost install response stays pending while the application restarts', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY', false, {
failInstallResponse: true,
restartOfflineReads: 4,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('立即更新并重启失败', { exact: false })).toHaveCount(0);
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
timeout: 5_000,
});
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
timeout: 10_000,
});
});
test('brand logo returns the active identity to its workbench', async ({ page }) => {
await installFixture(page, [], []);
await page.goto('/governance/update');
+14
View File
@@ -2,6 +2,13 @@ import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface SystemUpdateEvent {
occurredAt: string;
level: 'INFO' | 'WARN' | 'ERROR';
stage: string;
message: string;
}
export interface SystemUpdateView {
enabled: boolean;
currentVersion: string;
@@ -13,6 +20,12 @@ export interface SystemUpdateView {
publishedAt?: string | null;
checkedAt?: string | null;
statusUpdatedAt?: string | null;
downloadedBytes?: number | null;
totalBytes?: number | null;
bytesPerSecond?: number | null;
downloadPercent?: number | null;
restartExpectedSeconds?: number | null;
events: SystemUpdateEvent[];
allowedActions: string[];
}
@@ -21,6 +34,7 @@ function normalize(value: SystemUpdateView): SystemUpdateView {
...value,
enabled: Boolean(value?.enabled),
updateAvailable: Boolean(value?.updateAvailable),
events: Array.isArray(value?.events) ? value.events : [],
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
};
}
@@ -47,7 +47,7 @@
立即更新
</t-button>
<t-button
v-if="canInstall"
v-if="canInstall && !updateDialogVisible"
theme="primary"
:loading="installingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@@ -66,22 +66,36 @@
<t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps>
<t-alert v-if="statusError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ statusError }}</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link>
<div v-if="showDownloadProgress && !updateDialogVisible" class="download-progress" aria-live="polite">
<div class="download-progress__heading">
<span>更新包下载进度</span>
<strong>{{ updateProgress }}%</strong>
</div>
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
<div class="update-status-message">
<strong>{{ updateStateLabel }}</strong>
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
<small v-if="updateActionHint">{{ updateActionHint }}</small>
<t-progress :percentage="updateProgress" :label="false" />
<div class="download-progress__meta">
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
<span>{{ downloadSpeedLabel }}</span>
</div>
</t-alert>
</div>
<template v-if="!updateDialogVisible">
<t-alert v-if="statusError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ statusError }}</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link>
</div>
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
<div class="update-status-message">
<strong>{{ updateStateLabel }}</strong>
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
<small v-if="updateActionHint">{{ updateActionHint }}</small>
</div>
</t-alert>
</template>
<div v-if="updateStatus?.releaseNotes" class="release-notes">
<span>版本说明</span>
@@ -123,16 +137,99 @@
</t-table>
<t-empty v-else description="暂无系统更新记录" />
</section>
<t-dialog
v-model:visible="updateDialogVisible"
aria-label="系统更新"
header="系统更新"
width="min(720px, calc(100vw - 32px))"
:footer="false"
:close-btn="dialogClosable"
:close-on-overlay-click="false"
:close-on-esc-keydown="dialogClosable"
>
<div class="update-dialog" role="dialog" aria-label="系统更新" aria-live="polite" aria-modal="true">
<t-steps :current="updateStep" :layout="stepLayout" status="process">
<t-step-item title="获取版本" content="确认目标版本" />
<t-step-item title="下载校验" content="下载并验证签名" />
<t-step-item title="安装重启" content="备份、切换、重启" />
<t-step-item title="完成" content="健康检查与刷新" />
</t-steps>
<div class="dialog-status-line">
<t-tag :theme="updateStateTheme" variant="light">{{ updateStateLabel }}</t-tag>
<span>{{ updateStatus?.message || '等待更新服务返回状态' }}</span>
</div>
<t-alert v-if="statusError" theme="error" :close-btn="false">{{ statusError }}</t-alert>
<t-alert v-else-if="installRequestUncertain" theme="warning" :close-btn="false">
安装请求连接中断,正在查询服务器确认是否已受理,请勿重复提交。
</t-alert>
<div v-if="showDownloadProgress" class="download-progress download-progress--dialog">
<div class="download-progress__heading">
<span>正在下载 {{ updateStatus?.latestVersion || updateStatus?.currentVersion }}</span>
<strong>{{ updateProgress }}%</strong>
</div>
<t-progress :percentage="updateProgress" :label="false" />
<div class="download-progress__meta">
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
<span>{{ downloadSpeedLabel }}</span>
</div>
</div>
<t-alert v-if="connectionInterrupted" theme="warning" :close-btn="false">
服务正在重启,页面会持续重连,不需要手动刷新。
<span v-if="restartCountdown !== null && restartCountdown > 0">预计 {{ restartCountdown }} 秒后恢复。</span>
<span v-else>预计时间已到,仍在等待服务恢复。</span>
</t-alert>
<t-alert v-else-if="restartCountdown !== null && installInProgress" theme="info" :close-btn="false">
应用进程正在切换,预计 {{ restartCountdown }} 秒后恢复访问。
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else-if="updateStatus?.state === 'RECOVERY_REQUIRED'" theme="error" :close-btn="false">
自动回滚未恢复应用,已停止接受新的更新任务,请先完成服务器恢复。
</t-alert>
<section class="runtime-log" aria-label="更新运行日志">
<div class="runtime-log__heading">
<span>运行日志</span>
<small>{{ updateEvents.length }} 条</small>
</div>
<div class="runtime-log__body" role="log">
<div v-for="(event, index) in updateEvents" :key="`${event.occurredAt}-${index}`" class="runtime-log__row">
<time>{{ formatLogTime(event.occurredAt) }}</time>
<t-tag size="small" :theme="eventTheme(event.level)" variant="light">{{
eventStageLabel(event.stage)
}}</t-tag>
<span>{{ event.message }}</span>
</div>
<t-empty v-if="!updateEvents.length" description="等待更新服务输出日志" />
</div>
</section>
<div class="dialog-actions">
<t-button v-if="canInstall" theme="primary" :loading="installingUpdate" @click="installNow">
<template #icon><t-icon name="poweroff" /></template>
立即更新并重启
</t-button>
<t-button v-if="dialogClosable" variant="outline" @click="updateDialogVisible = false">关闭</t-button>
</div>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { isAxiosError } from 'axios';
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { AuditLog } from '@/api/audit';
import { getAuditLogs } from '@/api/audit';
import type { SystemUpdateView } from '@/api/system-update';
import type { SystemUpdateEvent, SystemUpdateView } from '@/api/system-update';
import {
checkSystemUpdate,
downloadSystemUpdate,
@@ -143,6 +240,7 @@ import {
defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false);
@@ -155,24 +253,50 @@ const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false);
const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null);
const restartCountdown = ref<number | null>(null);
const updateDialogVisible = ref(false);
const connectionInterrupted = ref(false);
const localEvents = ref<SystemUpdateEvent[]>([]);
const awaitingRefresh = ref(false);
const installRequestUncertain = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let restartTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([
'QUEUED',
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
'INSTALLING',
'RUNNING',
]);
const installStates = new Set(['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const installStates = new Set(['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || ''));
const installInProgress = computed(
() => installStates.has(updateStatus.value?.state || '') || connectionInterrupted.value,
);
const dialogClosable = computed(() => !updateBusy.value && !connectionInterrupted.value);
const showDownloadProgress = computed(() =>
['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(updateStatus.value?.state || ''),
);
const updateProgress = computed(() => {
const value = Number(updateStatus.value?.downloadPercent ?? 0);
return Math.min(100, Math.max(0, Number.isFinite(value) ? Math.round(value) : 0));
});
const downloadSpeedLabel = computed(() =>
updateStatus.value?.state === 'READY' ? '下载完成' : formatSpeed(updateStatus.value?.bytesPerSecond),
);
const updateEvents = computed(() =>
[...(updateStatus.value?.events || []), ...localEvents.value]
.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt))
.slice(-120),
);
const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed(
() =>
@@ -180,7 +304,7 @@ const currentIsLatest = computed(
Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable &&
!updateBusy.value &&
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state),
!['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(updateStatus.value.state),
);
const canCheckUpdate = computed(
() =>
@@ -220,6 +344,7 @@ const updateStateLabel = computed(() => {
QUEUED: '已排队',
DOWNLOAD_QUEUED: '下载已排队',
INSTALL_QUEUED: '等待重启',
PRECHECKING: '安装预检',
VERIFYING: '校验中',
DOWNLOADING: '下载中',
READY: '下载完成',
@@ -228,6 +353,7 @@ const updateStateLabel = computed(() => {
RUNNING: '重启中',
SUCCEEDED: '更新成功',
FAILED: '更新失败',
RECOVERY_REQUIRED: '需要人工恢复',
UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state ||
@@ -236,22 +362,23 @@ const updateStateLabel = computed(() => {
});
const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
if (['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
return '更新包已确认,系统正在预检、备份、切换并重启。';
}
if (state === 'RECOVERY_REQUIRED') return '自动回滚未完成,新的更新操作已锁定。';
if (state === 'READY') {
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
}
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。';
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
if (['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
return '更新包已确认,系统正在备份、切换并重启。';
}
if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。';
return '';
});
const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success';
if (['FAILED', 'UNKNOWN'].includes(state)) return 'danger';
if (['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(state)) return 'danger';
if (updateBusy.value) return 'warning';
return 'default';
});
@@ -272,13 +399,37 @@ async function loadStatus(silent = false) {
}
try {
const status = await getSystemUpdateStatus();
if (connectionInterrupted.value) {
appendLocalEvent('INFO', 'RUNNING', '应用服务连接已恢复,正在确认更新结果');
connectionInterrupted.value = false;
}
updateStatus.value = status;
if (installStates.has(status.state)) awaitingRefresh.value = true;
if (installStates.has(status.state)) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
installRequestUncertain.value = false;
statusError.value = '';
}
if (status.state === 'RUNNING') {
startRestartCountdown(status.restartExpectedSeconds ?? 10);
} else if (!installStates.has(status.state)) {
stopRestartCountdown();
}
if (status.state === 'RECOVERY_REQUIRED') updateDialogVisible.value = true;
handleCompletion(status);
if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling();
} catch (error) {
if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败');
const restartExpected = awaitingRefresh.value || installStates.has(updateStatus.value?.state || '');
if (restartExpected) {
if (!connectionInterrupted.value) appendLocalEvent('WARN', 'RUNNING', '应用服务连接中断,正在等待进程重启');
connectionInterrupted.value = true;
updateDialogVisible.value = true;
startRestartCountdown(updateStatus.value?.restartExpectedSeconds ?? 10);
startPolling();
} else if (!silent) {
statusError.value = friendlyError(error, '系统版本状态读取失败');
}
} finally {
if (!silent) statusLoading.value = false;
}
@@ -326,6 +477,10 @@ async function checkLatest() {
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
: '更新包已下载完成,但当前账号没有安装权限',
);
} else if (busyStates.has(checked.state)) {
updateDialogVisible.value = true;
startPolling();
MessagePlugin.info(checked.message || '更新任务正在处理中,请查看当前进度');
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
} else {
@@ -342,10 +497,13 @@ async function downloadNow() {
const version = updateStatus.value?.latestVersion;
if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
downloadingUpdate.value = true;
updateDialogVisible.value = true;
localEvents.value = [];
statusError.value = '';
startPolling();
try {
updateStatus.value = await downloadSystemUpdate(version);
appendLocalEvent('INFO', 'DOWNLOAD_QUEUED', `版本 ${version} 的下载请求已提交`);
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
void loadHistory(true);
} catch (error) {
@@ -361,16 +519,30 @@ async function installNow() {
const version = updateStatus.value?.latestVersion;
if (!version || installingUpdate.value || !canInstall.value) return;
installingUpdate.value = true;
updateDialogVisible.value = true;
statusError.value = '';
stopRefreshCountdown();
stopRestartCountdown();
awaitingRefresh.value = true;
installRequestUncertain.value = false;
setPendingUpdate(true);
startPolling();
try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
appendLocalEvent('INFO', 'INSTALL_QUEUED', `已确认安装 ${version},等待更新服务执行`);
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
void loadHistory(true);
} catch (error) {
statusError.value = friendlyError(error, '立即更新并重启失败');
if (responseMayHaveBeenLost(error)) {
statusError.value = '';
installRequestUncertain.value = true;
appendLocalEvent('WARN', 'INSTALL_QUEUED', '安装请求连接中断,正在确认服务器是否已受理');
} else {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
statusError.value = friendlyError(error, '立即更新并重启失败');
}
void loadStatus(true);
} finally {
installingUpdate.value = false;
@@ -380,7 +552,7 @@ async function installNow() {
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 3000);
pollTimer = setInterval(() => void loadStatus(true), 1000);
}
function stopPolling() {
@@ -390,19 +562,66 @@ function stopPolling() {
function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return;
if (status.state === 'READY') {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
return;
}
if (status.state === 'SUCCEEDED') {
statusError.value = '';
awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
startRefreshCountdown();
void loadHistory(true);
return;
}
if (['FAILED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
if (['FAILED', 'RECOVERY_REQUIRED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
stopRefreshCountdown();
}
}
function startRestartCountdown(seconds: number) {
if (restartTimer) return;
restartCountdown.value = Math.max(0, Math.min(300, Math.round(seconds)));
restartTimer = setInterval(() => {
if (restartCountdown.value === null || restartCountdown.value <= 0) return;
restartCountdown.value -= 1;
}, 1000);
}
function stopRestartCountdown() {
if (restartTimer) clearInterval(restartTimer);
restartTimer = undefined;
restartCountdown.value = null;
}
function setPendingUpdate(pending: boolean) {
try {
if (pending) sessionStorage.setItem(UPDATE_PENDING_KEY, 'true');
else sessionStorage.removeItem(UPDATE_PENDING_KEY);
} catch {
// The in-memory state still keeps the current page resilient when storage is unavailable.
}
}
function hasPendingUpdate() {
try {
return sessionStorage.getItem(UPDATE_PENDING_KEY) === 'true';
} catch {
return false;
}
}
function startRefreshCountdown() {
if (refreshTimer) return;
updateRefreshSeconds.value = 10;
@@ -423,6 +642,64 @@ function stopRefreshCountdown() {
updateRefreshSeconds.value = null;
}
function appendLocalEvent(level: SystemUpdateEvent['level'], stage: string, message: string) {
const latest = localEvents.value.at(-1);
if (latest?.stage === stage && latest.message === message) return;
localEvents.value = [...localEvents.value, { occurredAt: new Date().toISOString(), level, stage, message }].slice(
-30,
);
}
function formatBytes(value?: number | null) {
if (value === null || value === undefined || !Number.isFinite(value) || value < 0) return '待获取';
if (value < 1024) return `${Math.round(value)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let amount = value / 1024;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function formatSpeed(value?: number | null) {
if (value === null || value === undefined || value <= 0) return '正在计算网速';
return `${formatBytes(value)}/s`;
}
function formatLogTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function eventTheme(level: SystemUpdateEvent['level']): TagTheme {
if (level === 'ERROR') return 'danger';
if (level === 'WARN') return 'warning';
return 'primary';
}
function eventStageLabel(stage: string) {
return (
{
DOWNLOAD_QUEUED: '排队',
VERIFYING: '校验',
DOWNLOADING: '下载',
READY: '就绪',
INSTALL_QUEUED: '确认',
PRECHECKING: '预检',
BACKING_UP: '备份',
INSTALLING: '安装',
RUNNING: '重启',
SUCCEEDED: '完成',
FAILED: '失败',
RECOVERY_REQUIRED: '恢复',
}[stage] || stage
);
}
function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue;
@@ -468,6 +745,12 @@ function friendlyError(error: unknown, fallback: string) {
return (error as Error)?.message || fallback;
}
function responseMayHaveBeenLost(error: unknown) {
return (
(isAxiosError(error) && !error.response) || (error as { transportFailure?: boolean })?.transportFailure === true
);
}
function updateViewportMode() {
isNarrow.value = window.innerWidth <= 640;
}
@@ -475,11 +758,18 @@ function updateViewportMode() {
onMounted(() => {
updateViewportMode();
window.addEventListener('resize', updateViewportMode);
if (hasPendingUpdate()) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
startRestartCountdown(10);
startPolling();
}
void refreshPage();
});
onBeforeUnmount(() => {
stopPolling();
stopRefreshCountdown();
stopRestartCountdown();
window.removeEventListener('resize', updateViewportMode);
});
</script>
@@ -613,6 +903,103 @@ onBeforeUnmount(() => {
width: 100%;
}
.download-progress {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.download-progress__heading,
.download-progress__meta,
.runtime-log__heading,
.dialog-status-line,
.dialog-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.download-progress__heading strong {
font-variant-numeric: tabular-nums;
}
.download-progress__meta {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.update-dialog {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.dialog-status-line {
justify-content: flex-start;
min-width: 0;
}
.dialog-status-line > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-log {
min-width: 0;
}
.runtime-log__heading {
margin-bottom: 8px;
}
.runtime-log__heading small {
color: var(--td-text-color-secondary);
}
.runtime-log__body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 240px;
padding: 12px;
overflow: auto;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.runtime-log__row {
display: grid;
grid-template-columns: 72px 58px minmax(0, 1fr);
align-items: start;
gap: 8px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 22px;
}
.runtime-log__row time {
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
}
.runtime-log__row > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.dialog-actions {
justify-content: flex-end;
padding-top: 4px;
}
@media (width <= 900px) {
.update-heading {
align-items: stretch;
@@ -650,5 +1037,23 @@ onBeforeUnmount(() => {
flex-direction: column;
gap: 8px;
}
.download-progress__meta,
.dialog-status-line,
.dialog-actions {
align-items: flex-start;
flex-direction: column;
}
.dialog-actions,
.dialog-actions :deep(.t-button) {
width: 100%;
}
.runtime-log__row {
grid-template-columns: 64px 52px minmax(0, 1fr);
gap: 6px;
font-size: 12px;
}
}
</style>
+8 -9
View File
@@ -378,19 +378,18 @@ function confirmSetup() {
function startCountdown() {
window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) void openLogin();
countdown.value = Math.max(0, countdown.value - 1);
if (countdown.value === 0) openLogin();
}, 1000);
}
async function openLogin() {
function openLogin() {
window.clearInterval(countdownTimer);
completionMessage.value = '系统正在启动,请稍候。';
if (await waitForSetupCompletion()) {
await router.replace('/login');
return;
}
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
countdown.value = 0;
completionMessage.value = '正在进入登录页。';
// A full navigation clears the setup-mode SPA state retained across the
// service restart. The login route guard performs the final setup check.
window.location.replace('/login');
}
onMounted(async () => {
+1
View File
@@ -325,6 +325,7 @@ export class VAxios {
status: e.response?.status,
requestId,
fieldErrors: problem?.fieldErrors,
transportFailure: !e.response,
});
if (e.response?.status === 401 && window.location.pathname !== '/login') {
window.dispatchEvent(new CustomEvent('kaidi:session-expired'));
+31
View File
@@ -5967,6 +5967,18 @@ components:
format: int64
type: integer
type: object
SystemUpdateEventView:
properties:
level:
type: string
message:
type: string
occurredAt:
format: date-time
type: string
stage:
type: string
type: object
SystemUpdateView:
properties:
allowedActions:
@@ -5976,10 +5988,23 @@ components:
checkedAt:
format: date-time
type: string
bytesPerSecond:
format: int64
type: integer
currentVersion:
type: string
enabled:
type: boolean
downloadPercent:
format: int32
type: integer
downloadedBytes:
format: int64
type: integer
events:
items:
"$ref": "#/components/schemas/SystemUpdateEventView"
type: array
latestVersion:
type: string
message:
@@ -5989,11 +6014,17 @@ components:
type: string
releaseNotes:
type: string
restartExpectedSeconds:
format: int32
type: integer
state:
type: string
statusUpdatedAt:
format: date-time
type: string
totalBytes:
format: int64
type: integer
updateAvailable:
type: boolean
type: object
+25 -2
View File
@@ -75,13 +75,24 @@ JAR="$ROOT/backend/target/finance-system-$VERSION.jar"
JAR_METADATA_DIR="$WORK/jar-metadata"
mkdir -p "$JAR_METADATA_DIR"
(cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties)
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties \
BOOT-INF/classes/application.yml BOOT-INF/classes/application-local.yml \
BOOT-INF/classes/application-production.yml)
JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; }
grep -Fq 'default: production' "$JAR_METADATA_DIR/BOOT-INF/classes/application.yml" \
|| { printf 'Release JAR must default to the production profile\n' >&2; exit 1; }
if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
"$JAR_METADATA_DIR/BOOT-INF/classes/application.yml" \
"$JAR_METADATA_DIR/BOOT-INF/classes/application-local.yml" \
"$JAR_METADATA_DIR/BOOT-INF/classes/application-production.yml"; then
printf 'Release JAR contains a development database fallback\n' >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
@@ -102,9 +113,19 @@ cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh"
if find "$STAGE" -type l -print -quit | grep -q .; then
printf 'Release stage contains a symbolic link\n' >&2
exit 1
fi
if find "$STAGE" -type f -print | grep -Eq '(^|/)(\.DS_Store|__MACOSX|[^/]*\._[^/]*)$'; then
printf 'Release stage contains macOS metadata\n' >&2
exit 1
fi
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT")
ARTIFACT_SIZE_BYTES=$(wc -c < "$OUTPUT_DIR/$ARTIFACT" | tr -d '[:space:]')
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
--sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json"
@@ -167,7 +188,8 @@ EOF
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"}
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \
RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \
@@ -179,6 +201,7 @@ const manifest = {
version: process.env.VERSION,
artifact: process.env.ARTIFACT,
sha256: process.env.SHA256,
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
publishedAt: new Date().toISOString(),
minimumJava: 17,
source: {
+32
View File
@@ -51,6 +51,10 @@ EOF
cat > "$WORK/java" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${1:-}" = -version ]; then
printf 'openjdk version "17-fixture"\n' >&2
exit 0
fi
{
printf 'cwd=%s\n' "$PWD"
printf 'args=%s\n' "$*"
@@ -92,6 +96,34 @@ PID_RECORD=$(sed -n 's/^pid-record=//p' "$WORK/java.log")
SELF_PID=$(sed -n 's/^self-pid=//p' "$WORK/java.log")
[ "$PID_RECORD" = "$SELF_PID 424242" ] || fail 'PID file did not bind the PID to its proc start time'
# systemd reads the root-only EnvironmentFile before dropping to User=kaidi.
# The launcher must therefore be able to start with those files intentionally
# unreadable by the service user.
chmod 000 "$WORK/base.env" "$WORK/runtime.env"
MOCK_JAVA_LOG="$WORK/preloaded.log" \
PATH="$WORK/mock-bin:$PATH" \
KAIDI_ENV_PRELOADED=true \
KAIDI_JAVA_BIN="$WORK/java" \
SPRING_PROFILES_ACTIVE=production \
SERVER_ADDRESS=127.0.0.1 \
SERVER_PORT=21000 \
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
DB_USERNAME=fixture-user \
DB_PASSWORD='fixture-password' \
FIELD_ENCRYPTION_KEY=fixture-field-key \
FILE_STORAGE_ROOT="$WORK/files" \
FILE_STORAGE_TEMP="$WORK/tmp" \
FINANCE_SETUP_ENABLED=false \
FINANCE_UPDATE_ENABLED=true \
FILE_SCANNER_ENABLED=false \
KAIDI_PID_FILE="$WORK/state/preloaded.pid" \
KAIDI_CONFIG_FILE="$WORK/base.env" \
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
"$RELEASE/ops/baota-start.sh"
grep -Fqx 'port=21000' "$WORK/preloaded.log" \
|| fail 'launcher still depended on the root-only configuration files under systemd'
chmod 0644 "$WORK/base.env" "$WORK/runtime.env"
MOCK_JAVA_LOG="$WORK/explicit.log" \
PATH="$WORK/mock-bin:$PATH" \
KAIDI_JAVA_BIN="$WORK/java" \
+34 -13
View File
@@ -29,6 +29,7 @@ mode_of() {
sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^java_arch_matches_host()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^java_home_from_bin()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -192,7 +193,7 @@ cat > "$WORK/fake-jre/bin/java" <<'JAVA'
if [ "${1:-}" = '-XshowSettings:properties' ]; then
printf ' os.arch = x86\n' >&2
fi
printf 'openjdk version "17-fixture"\n' >&2
printf 'openjdk version "17.0.8"\n' >&2
JAVA
chmod 0755 "$WORK/fake-jre/bin/java"
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
@@ -213,16 +214,22 @@ download() {
esac
}
ARCH_FIXTURE=i686
APP_ROOT="$WORK/app"
export KAIDI_JAVA_HOME="$WORK/missing-java-home"
prepare_java >/dev/null 2>&1
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
[ "$JAVA_BIN" = "$APP_ROOT/runtime/java/bin/java" ] \
|| fail 'downloaded Java did not select the managed fallback path'
rm -rf "$JAVA_STAGED_DIR"
JAVA_STAGED_DIR=
export KAIDI_JAVA_HOME="$WORK/fake-jre"
prepare_java >/dev/null 2>&1
[ -d "$JAVA_STAGED_DIR" ] && [ ! -L "$JAVA_STAGED_DIR" ] \
|| fail 'existing Java 17 runtime was not copied into managed storage'
cmp -s "$WORK/fake-jre/bin/java" "$JAVA_STAGED_DIR/bin/java" \
|| fail 'installer staged an unexpected local Java runtime'
[ -z "$JAVA_STAGED_DIR" ] \
|| fail 'installer copied an existing server Java runtime into application storage'
EXPECTED_LOCAL_JAVA=$(readlink -f "$WORK/fake-jre/bin/java" 2>/dev/null \
|| printf '%s' "$WORK/fake-jre/bin/java")
[ "$JAVA_BIN" = "$EXPECTED_LOCAL_JAVA" ] \
|| fail 'installer did not select the existing server Java executable directly'
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
RELEASE_TOKEN=fixture-read-only-token
@@ -280,6 +287,12 @@ grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer rejects unsupported custom roots'
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer enforces MySQL 8.4.x'
grep -Fq '32-bit Linux deployment requires glibc' "$ROOT/deploy/install.sh" \
|| fail 'installer does not reject unsupported musl 32-bit hosts before downloading Java'
grep -Fq '32-bit Linux deployment requires the glibc loader' "$ROOT/deploy/install.sh" \
|| fail 'installer does not reject a 32-bit host without the glibc loader'
grep -Fq 'preflight_runtime_commands' "$ROOT/deploy/install.sh" \
|| fail 'installer does not validate required runtime commands'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer uses the reinstall-safe environment writer'
@@ -289,14 +302,14 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer obtains the Java runtime SHA-256'
grep -Fq 'Copying existing Java 17 runtime' "$ROOT/deploy/install.sh" \
grep -Fq 'Using existing Java 17+ runtime' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer reuses a local Java 17 runtime'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'cp -R "$system_home/." "$JAVA_STAGED_DIR/"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer copies a local Java runtime into managed storage'
grep -Fq 'JAVA_BIN="$system_home/bin/java"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer records the selected server Java path'
# shellcheck disable=SC2016 # Match literal installer source.
! grep -Fq 'ln -s "$system_home"' "$ROOT/deploy/install.sh" \
|| fail 'installer must not link the service to an externally managed Java directory'
! grep -Fq 'cp -R "$system_home/."' "$ROOT/deploy/install.sh" \
|| fail 'installer copied an existing server Java runtime into managed storage'
# shellcheck disable=SC2016 # Match the literal installer command.
grep -Fq -- '--connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer performs a quiet bounded health check'
@@ -317,12 +330,18 @@ grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh
|| fail 'installer can execute before the complete curl stream is parsed'
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer has a bounded restart burst'
grep -Fqx 'ExecStart=/opt/kaidi/current/ops/baota-start.sh' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service bypasses the Java-selecting launcher'
grep -Fqx 'Environment=KAIDI_ENV_PRELOADED=true' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'systemd service user would need to read the root-only configuration directory'
grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service can automatically repeat a failed switching transaction'
grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service requires the Baota application path on a systemd-only installation'
! grep -Fq '/var/lib/kaidi-update/processing' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path can automatically repeat a claimed switching transaction'
grep -Fqx 'PathExists=/var/lib/kaidi-update/processing/request.json' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path does not resume an interrupted claimed request'
grep -Fqx 'PathExists=/var/lib/kaidi-update/transactions/active' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path does not resume an interrupted switching transaction'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256'
@@ -390,9 +409,11 @@ grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|| fail 'update state parent is not group-accessible to the application user'
grep -Fq 'kaidi-finance-1.0.0-preview.25.tar.gz' "$ROOT/README.md" \
grep -Fq 'kaidi-finance-1.0.0-preview.31.tar.gz' "$ROOT/README.md" \
|| fail 'README does not document the public manual-deployment artifact'
grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \
|| fail 'README does not document the local Baota initialization command'
grep -Fq 'FINANCE_UPDATE_ENABLED false' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota manual mode still exposes the unstable online updater'
printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
+8
View File
@@ -83,5 +83,13 @@ grep -Fq 'KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION' "$ROOT/deploy/purge.sh" \
|| fail 'destructive removal no longer requires explicit confirmation'
grep -Fq 'External MySQL data and reverse-proxy configuration will not be modified' "$ROOT/deploy/purge.sh" \
|| fail 'purge boundary is no longer explicit'
# shellcheck disable=SC2016 # Match literal service-account cleanup calls.
grep -Fq 'terminate_uid_processes "$service_uid"' "$ROOT/deploy/purge.sh" \
|| fail 'dedicated service-account processes are not terminated before account removal'
# shellcheck disable=SC2016 # Match literal forced account removal.
grep -Fq 'userdel --force "$SERVICE_USER"' "$ROOT/deploy/purge.sh" \
|| fail 'dedicated service-account removal is not resilient to a restarting panel process'
grep -Fq '/etc/systemd/system/kaidi-update.service.d' "$ROOT/deploy/purge.sh" \
|| fail 'updater systemd drop-ins are not removed'
printf 'Local purge and recovery fixture passed\n'
+142 -12
View File
@@ -65,10 +65,14 @@ write_mock_commands() {
output=
url=
header_file=
write_out=
http_status=200
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
while [ "$#" -gt 0 ]; do
case "$1" in
-o) shift; output=$1 ;;
-o|--output) shift; output=$1 ;;
--write-out) shift; write_out=$1 ;;
--connect-timeout|--max-time) shift ;;
--header|-H)
shift
case "${1:-}" in @*) header_file=${1#@} ;; esac
@@ -90,6 +94,26 @@ esac
if [ -n "$output" ]; then
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
elif [ "$url" = "${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}" ]; then
health_ok=false
case "${MOCK_HEALTH:-success}" in
success) health_ok=true ;;
fail-new)
[ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ] && health_ok=true
;;
fail-after-first)
health_count=$(cat "$MOCK_APP_ROOT/health-count" 2>/dev/null || printf 0)
health_count=$((health_count + 1))
printf '%s\n' "$health_count" > "$MOCK_APP_ROOT/health-count"
[ "$health_count" -eq 1 ] && health_ok=true
;;
esac
if [ "$health_ok" = true ]; then
printf '{"status":"UP"}\n' > "$output"
else
printf '{"status":"DOWN"}\n' > "$output"
http_status=503
fi
elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
else
@@ -104,6 +128,7 @@ elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
else
exit 22
fi
[ -z "$write_out" ] || printf '%s' "$http_status"
SH
cat > "$mock_bin/systemctl" <<'SH'
@@ -116,6 +141,11 @@ SH
cat > "$mock_bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/setsid" <<'SH'
#!/bin/sh
exec "$@"
SH
cat > "$mock_bin/systemd-analyze" <<'SH'
@@ -191,10 +221,14 @@ build_release() {
local unit_prefix=${3:-new}
local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$fixture/private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \
-out "$fixture/release-public.pem" >/dev/null 2>&1
if [ ! -s "$WORK/fixture-private.pem" ]; then
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$WORK/fixture-private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$WORK/fixture-private.pem" -pubout \
-out "$WORK/fixture-public.pem" >/dev/null 2>&1
fi
cp "$WORK/fixture-private.pem" "$fixture/private.pem"
cp "$WORK/fixture-public.pem" "$fixture/release-public.pem"
printf 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION"
@@ -211,10 +245,12 @@ build_release() {
local artifact="kaidi-finance-$version.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" .
local sha
local sha size
sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" --argjson size "$size" \
'{version:$version,artifact:$artifact,sha256:$sha,artifactSizeBytes:$size,
publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
> "$fixture/release/release-manifest.json"
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
@@ -269,6 +305,7 @@ write_request() {
download_and_prepare_install() {
local fixture=$1
local version=$2
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY'
@@ -276,6 +313,16 @@ download_and_prepare_install() {
|| fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|| fail 'download phase did not persist the verified artifact cache'
[ "$(jq -r '.downloadPercent' "$fixture/state/status.json")" -eq 100 ] \
|| fail 'download phase did not persist 100 percent progress'
[ "$(jq -r '.totalBytes' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist artifact bytes'
[ "$(jq -r '.bytesPerSecond' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist a measured transfer speed'
grep -Fq '"stage":"DOWNLOADING"' "$fixture/state/events.jsonl" \
|| fail 'download phase did not persist structured runtime events'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'download phase changed the application service'
write_request "$fixture" "$version" INSTALL
}
@@ -407,7 +454,7 @@ assert_identical_systemd_operations_case() {
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
: > "$fixture/systemctl.log"
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
@@ -474,17 +521,33 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded'
fi
grep -Fq 'manual recovery is required' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request'
[ -d "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case did not preserve transaction evidence'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case left transaction evidence on the automatic recovery path'
[ -d "$fixture/state/transactions/recovery-required" ] \
|| fail 'incomplete rollback case did not quarantine transaction evidence'
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'incomplete rollback case did not lock the updater for recovery'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request'
truncate -s 0 "$fixture/systemctl.log"
write_request "$fixture" "$version" DOWNLOAD
if run_update "$fixture" success > "$fixture/retry.log" 2>&1; then
fail 'recovery-locked updater accepted a new download request'
fi
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'recovery-locked updater replaced the manual recovery status'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'recovery-locked download request changed the application service'
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'recovery-locked updater left a request on an automatic trigger path'
}
assert_download_failure_case() {
@@ -526,6 +589,58 @@ assert_database_failure_case() {
|| fail 'database failure did not persist FAILED'
}
assert_unhealthy_baseline_blocks_restart_case() {
local fixture="$WORK/unhealthy-baseline"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'unhealthy baseline unexpectedly reached installation'
fi
grep -Fq 'Current release preflight failed' "$fixture/update.log" \
|| fail 'unhealthy baseline did not preserve its preflight diagnostic'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'unhealthy baseline restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'unhealthy baseline changed the active release'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'unhealthy baseline created a switching transaction'
}
assert_invalid_database_url_blocks_restart_case() {
local fixture="$WORK/invalid-database-url"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
cat > "$fixture/runtime.env" <<'EOF'
DB_URL="jdbc:mysql://"
KAIDI_DB_HOST="127.0.0.1"
KAIDI_DB_PORT="3306"
KAIDI_DB_NAME="kaidi_finance"
KAIDI_DB_USERNAME="kaidi"
KAIDI_DB_PASSWORD="fixture"
EOF
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'invalid database URL unexpectedly reached installation'
fi
grep -Fq 'DB_URL does not match the configured MySQL host, port, and database' "$fixture/update.log" \
|| fail 'structurally invalid JDBC URL did not preserve its diagnostic'
! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'invalid database URL restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'invalid database URL changed the active release'
}
assert_symlink_request_rejected() {
local fixture="$WORK/symlink-request"
local version='1.0.0-preview.2'
@@ -570,15 +685,30 @@ assert_install_without_verified_cache_rejected() {
|| fail 'install without cache changed the active application'
}
printf '[update-fixture] success\n'
assert_success_case
printf '[update-fixture] identical-systemd-operations\n'
assert_identical_systemd_operations_case
printf '[update-fixture] path-watcher-failure\n'
assert_update_path_failure_keeps_application_case
printf '[update-fixture] rollback\n'
assert_rollback_case
printf '[update-fixture] incomplete-rollback-recovery-lock\n'
assert_incomplete_rollback_requires_manual_recovery_case
printf '[update-fixture] download-failure\n'
assert_download_failure_case
printf '[update-fixture] database-backup-failure\n'
assert_database_failure_case
printf '[update-fixture] unhealthy-baseline\n'
assert_unhealthy_baseline_blocks_restart_case
printf '[update-fixture] invalid-database-url\n'
assert_invalid_database_url_blocks_restart_case
printf '[update-fixture] symlink-request\n'
assert_symlink_request_rejected
printf '[update-fixture] missing-verified-cache\n'
assert_install_without_verified_cache_rejected
printf '[update-fixture] private-gitea\n'
assert_private_gitea_release_case
printf '[update-fixture] cross-origin-gitea\n'
assert_cross_origin_gitea_browser_url_ignored
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
+24 -1
View File
@@ -56,6 +56,10 @@ EXPECTED_ARTIFACT_SHA256=$(jq -er \
release-manifest.json)
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|| { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; }
EXPECTED_ARTIFACT_SIZE=$(jq -er '.artifactSizeBytes | numbers | floor | select(. > 0)' \
release-manifest.json)
[ "$(wc -c < "$ARTIFACT" | tr -d '[:space:]')" -eq "$EXPECTED_ARTIFACT_SIZE" ] \
|| { printf 'Release artifact size does not match the signed manifest\n' >&2; exit 1; }
[ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
jq -e --arg version "$VERSION" \
@@ -149,17 +153,36 @@ if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; the
printf 'Release archive contains private key material\n' >&2
exit 1
fi
if grep -Eq '(^|/)(\.DS_Store|__MACOSX|[^/]*\._[^/]*)$' <<< "$ARCHIVE_LIST"; then
printf 'Release archive contains macOS metadata\n' >&2
exit 1
fi
if tar -tvzf "$ARTIFACT" | grep -Eq '^l'; then
printf 'Release archive contains a symbolic link\n' >&2
exit 1
fi
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"
(cd "$WORK" && jar -xf app.jar \
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties)
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties \
BOOT-INF/classes/application.yml BOOT-INF/classes/application-local.yml \
BOOT-INF/classes/application-production.yml)
JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; }
grep -Fq 'default: production' "$WORK/BOOT-INF/classes/application.yml" \
|| { printf 'Release JAR does not default to the production profile\n' >&2; exit 1; }
if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
"$WORK/BOOT-INF/classes/application.yml" \
"$WORK/BOOT-INF/classes/application-local.yml" \
"$WORK/BOOT-INF/classes/application-production.yml"; then
printf 'Release JAR contains a development database fallback\n' >&2
exit 1
fi
compare_archive_file() {
archive_path=$1