Compare commits

..
Author SHA1 Message Date
Qiufeng 2712c5b8c1 ci: resolve release tag from checked out refs
Release / release (push) Failing after 7s
2026-08-18 19:33:06 +08:00
Qiufeng 5a48993d26 ci: use registered release runner label
Release / release (push) Failing after 10s
2026-08-18 19:30:25 +08:00
Qiufeng 0b91e1fc5f ci: isolate release runner and derive tag metadata
Release / release (push) Canceled after 0s
2026-08-18 17:54:23 +08:00
Qiufeng 95cd1daeb8 ci: use valid release token secret name
Release / release (push) Canceled after 0s
2026-08-18 17:38:40 +08:00
Qiufeng d6e97f1b4a fix: discover existing MySQL client tools
Release / release (push) Canceled after 0s
2026-08-18 17:31:51 +08:00
Qiufeng a975c75827 ci: use available Gitea runner label
Release / release (push) Canceled after 0s
2026-08-18 16:38:56 +08:00
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
31 changed files with 1660 additions and 182 deletions
+33 -15
View File
@@ -10,7 +10,10 @@ permissions:
jobs: jobs:
release: release:
runs-on: ubuntu-24.04 # The temporary release runner is isolated from stale queued jobs that use
# the old ubuntu-24.04 label. Restore ubuntu-latest after this release when
# the repository's normal Linux runner is online again.
runs-on: kaidi-release
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -18,16 +21,23 @@ jobs:
- name: Validate release tag - name: Validate release tag
id: release_meta id: release_meta
env:
GITEA_REF_NAME: ${{ github.ref_name }}
GITEA_SHA: ${{ github.sha }}
run: | run: |
GITEA_REF_NAME=${GITHUB_REF_NAME:-}
if [ -z "$GITEA_REF_NAME" ]; then GITEA_REF_NAME=${GITHUB_REF#refs/tags/}; fi
if [ -z "$GITEA_REF_NAME" ] || [ "$GITEA_REF_NAME" = "$GITHUB_REF" ]; then
GITEA_REF_NAME=$(git tag --points-at HEAD | awk '/^v[0-9A-Za-z][0-9A-Za-z._+-]*$/{print; exit}')
fi
VERSION=${GITEA_REF_NAME#v} VERSION=${GITEA_REF_NAME#v}
test "$GITEA_REF_NAME" = "v$VERSION" test "$GITEA_REF_NAME" = "v$VERSION"
./scripts/check-semver.sh "$VERSION" ./scripts/check-semver.sh "$VERSION"
GITEA_SHA=$(git rev-parse HEAD)
test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA" test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" {
printf 'version=%s\n' "$VERSION"
printf 'ref=%s\n' "$GITEA_REF_NAME"
printf 'revision=%s\n' "$GITEA_SHA"
} >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
@@ -55,7 +65,11 @@ jobs:
npm test npm test
npm run audit:dependencies npm run audit:dependencies
npm run build npm run build
npx playwright install --with-deps chromium if [ "${RUNNER_OS:-Linux}" = macOS ]; then
npx playwright install chromium
else
npx playwright install --with-deps chromium
fi
npm run test:e2e npm run test:e2e
npm run test:dist npm run test:dist
@@ -83,14 +97,18 @@ jobs:
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }} KAIDI_RELEASE_NOTES: Kaidi Finance ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_REVISION: ${{ github.sha }} KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_SOURCE_REF: ${{ github.ref_name }} KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_DIRTY: 'false' KAIDI_SOURCE_DIRTY: 'false'
run: | run: |
test -n "$RELEASE_SIGNING_KEY_B64" test -n "$RELEASE_SIGNING_KEY_B64"
trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem" if base64 --decode </dev/null >/dev/null 2>&1; then
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem"
else
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 -D > "$RUNNER_TEMP/release-key.pem"
fi
chmod 600 "$RUNNER_TEMP/release-key.pem" chmod 600 "$RUNNER_TEMP/release-key.pem"
KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \ KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \
./scripts/package-release.sh "$RELEASE_VERSION" ./scripts/package-release.sh "$RELEASE_VERSION"
@@ -98,18 +116,18 @@ jobs:
- name: Verify signed release assets - name: Verify signed release assets
env: env:
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }} KAIDI_EXPECTED_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }} KAIDI_EXPECTED_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_EXPECTED_SOURCE_DIRTY: 'false' KAIDI_EXPECTED_SOURCE_DIRTY: 'false'
run: ./scripts/verify-release.sh dist/release run: ./scripts/verify-release.sh dist/release
- name: Publish Gitea release - name: Publish Gitea release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }} GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }} GITEA_REPOSITORY: ${{ github.repository }}
GITEA_REF_NAME: ${{ github.ref_name }} GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }}
GITEA_SHA: ${{ github.sha }} GITEA_SHA: ${{ steps.release_meta.outputs.revision }}
run: ./scripts/publish-gitea-release.sh run: ./scripts/publish-gitea-release.sh
- name: Upload Playwright report after failure - name: Upload Playwright report after failure
+61 -34
View File
@@ -8,10 +8,13 @@
## 本地开发 ## 本地开发
后端默认连接本机 MySQL `127.0.0.1:3307`,启动 Java 服务: 本地开发数据库由环境变量显式指定,启动 Java 服务:
```bash ```bash
cd backend 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 ./mvnw spring-boot:run
``` ```
@@ -51,12 +54,28 @@ npm run build
两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x, 两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x,
PostgreSQL 18 兼容工作继续冻结。 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 一键安装(推荐) ### systemd 一键安装(推荐)
先在宝塔面板停止并删除当前错误的 Java 项目,再执行: 先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```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.34/install.sh | sudo env KAIDI_APP_PORT=18080 bash
``` ```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -69,9 +88,9 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理: 随后执行一键清理:
```bash ```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.34/purge.sh \
-o /tmp/kaidi-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 - \ | sha256sum -c - \
&& sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \ && sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \
&& rm -f /tmp/kaidi-purge.sh && rm -f /tmp/kaidi-purge.sh
@@ -83,21 +102,23 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.25 直链与宝塔手动部署 ### Preview.31 直链与宝塔手动部署
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 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.34/kaidi-finance-1.0.0-preview.34.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.34/SHA256SUMS`
服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机 服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机
已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要 已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;启用在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser` `bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser`
以及与外部 MySQL 兼容的 `mysqldump`。程序不会替你安装这些依赖。 以及与外部 MySQL 兼容的 `mysqldump`。安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的
`/www/server/mysql/bin/` 路径;如工具安装在其他位置,可分别设置 `KAIDI_MYSQL_CLIENT` 和
`KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。
#### 1. 下载、校验、解压 #### 1. 下载、校验、解压
@@ -105,7 +126,7 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
VERSION=1.0.0-preview.25 VERSION=1.0.0-preview.34
APP_ROOT=/www/wwwroot/kaidi APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION" RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT" sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -121,8 +142,8 @@ sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
#### 2. 执行本地初始化 #### 2. 执行本地初始化
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置、在线更新监听器和一次性 初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置和一次性安装码;不会下载
安装码;不会下载 Java、安装 MySQL 或修改反向代理。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。 Java、安装 MySQL 或修改反向代理。宝塔手动模式不会开放在线更新按钮。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
```bash ```bash
sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080 sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
@@ -184,24 +205,24 @@ HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为
```bash ```bash
curl -fsS http://127.0.0.1:18080/actuator/health curl -fsS http://127.0.0.1:18080/actuator/health
curl -fsS http://127.0.0.1:18080/ | head curl -fsS http://127.0.0.1:18080/ | head
systemctl is-enabled kaidi-update.path
``` ```
#### 5. 后台在线更新 #### 5. 手动部署模式的更新边界
初始化脚本已启用 `kaidi-update.path`。后台“系统治理 → 系统更新”按明确阶段执行:点击“获取版本”只读取版本信息, 宝塔手动部署不启用 Preview 的在线更新契约。需要升级时,先停止宝塔项目,下载并校验新的 Release 压缩包,
发现新版本后点击“立即更新”才开始下载和验签;页面显示“下载完成”后,再点击“立即更新并重启”。 解压到新的 `releases/<VERSION>`,再由宝塔项目切换路径并启动;确认健康检查通过后再删除旧版本。
root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java, 不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。 需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
更新前请在服务器预装与外部 MySQL 兼容的 `mysqldump`,更新器只生成权限为 `0600` 的备份,不会自动安装 MySQL。迁移失败时恢复旧应用版本并保留数据库备份和事务证据,遵循至少一个版本向后兼容的迁移规则。 手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`;不要手工删除 `current`,在线更新负责原子切换和回滚。 后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
### Linux 32 位 ### Linux 32 位
压缩包本身不绑定 CPU 架构,关键是宝塔项目选用可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装 压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
MySQL 8.4;提前准备外部 MySQL,完成上述手动解压、初始化和向导即可。 MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的
`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。
### 停用并移除程序 ### 停用并移除程序
@@ -228,21 +249,23 @@ base64 < release-signing-private.pem | tr -d '\n'
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256 将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建 保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建
Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求 Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。 act_runner 提供 `ubuntu-latest` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁;如果
Actions 页面显示 “No matching online runner”,先启动并注册该标签的 act_runner。
工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.25 git tag v1.0.0-preview.34
git push origin v1.0.0-preview.25 git push origin v1.0.0-preview.34
``` ```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
`/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。
宝塔模式下,root 更新器只负责下载、验签、切换 `current` 和停止旧 Java,**不创建或重启 **Preview 的稳定在线更新契约只支持 systemd 一键安装模式**:更新器由
`kaidi-finance.service`**;宝塔项目的“项目意外重启”负责按新链接拉起 Java。 `kaidi-update.service` 执行,直接停止、切换并重启 `kaidi-finance.service`,不依赖宝塔面板的意外重启。
宝塔手动部署仅用于手动启动和反向代理;其面板进程管理与 systemd 更新器不是同一套生命周期,暂不作为在线更新的稳定路径。
更新流程固定为: 更新流程固定为:
@@ -251,7 +274,8 @@ git push origin v1.0.0-preview.25
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。 文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。 3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
5. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。 5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本, 6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
保留数据库备份和事务证据供人工处理。 保留数据库备份和事务证据供人工处理。
@@ -264,26 +288,29 @@ sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json cat /var/lib/kaidi-update/status.json
``` ```
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到 进程被中断时,`kaidi-update.path` 会根据 `processing/request.json` 或 `transactions/active` 自动恢复一次。
`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动重复安装。修复日志所示的 如果 `status.json` 显示 `RECOVERY_REQUIRED`,失败请求会归档到 `/var/lib/kaidi-update/failed`,事务证据会移到
磁盘、权限或旧版本健康问题后,才明确执行一次: `transactions/recovery-required*` 并退出自动触发路径,新下载和安装请求也会被拒绝。修复日志所示的磁盘、权限或
旧版本健康问题后,将唯一一份待恢复事务移回 `active`,再明确执行一次:
```bash ```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 reset-failed kaidi-update.service kaidi-update.path
sudo systemctl start kaidi-update.service sudo systemctl start kaidi-update.service
sudo journalctl -u kaidi-update.service -n 100 --no-pager sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json cat /var/lib/kaidi-update/status.json
``` ```
只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后, 如果目录带时间后缀,先通过 `ls -1d /var/lib/kaidi-update/transactions/recovery-required*` 确认唯一目录,再替换上面
才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。 命令中的源路径。只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理
完成后,才算本次恢复结束。后台会保留真实失败状态,不会把待恢复请求误显示成普通排队。
## 手工生成 Release ## 手工生成 Release
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.25 ./scripts/package-release.sh 1.0.0-preview.34
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./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 publishedAt,
Instant checkedAt, Instant checkedAt,
Instant statusUpdatedAt, Instant statusUpdatedAt,
Long downloadedBytes,
Long totalBytes,
Long bytesPerSecond,
Integer downloadPercent,
Integer restartExpectedSeconds,
List<SystemUpdateEventView> events,
List<String> allowedActions 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.shared.security.IdentityContext;
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest; import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest; import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateEventView;
import com.kaidi.finance.update.api.SystemUpdateView; import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; 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_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 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_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 int MAX_REDIRECTS = 3;
private static final String PRERELEASE_IDENTIFIER = private static final String PRERELEASE_IDENTIFIER =
"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"; "(?: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/[^/?#]+)$"); "^(.*/)?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 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( private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP", "QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "PRECHECKING", "VERIFYING", "DOWNLOADING",
"INSTALLING", "RUNNING"); "BACKING_UP", "INSTALLING", "RUNNING");
private final SystemUpdateProperties properties; private final SystemUpdateProperties properties;
private final AuthorizationService authorizationService; private final AuthorizationService authorizationService;
@@ -353,6 +356,10 @@ public class SystemUpdateApplicationService {
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) { FileLock ignored = channel.lock()) {
UpdateStatus currentStatus = readStatus(); UpdateStatus currentStatus = readStatus();
if ("RECOVERY_REQUIRED".equals(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"更新服务需要人工恢复,暂不接受新的更新请求");
}
if (BUSY_STATES.contains(currentStatus.state())) { if (BUSY_STATES.contains(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, 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); String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT);
if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN"; if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN";
return new UpdateStatus(state, root.path("message").asText(""), 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) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
} }
@@ -433,17 +443,60 @@ public class SystemUpdateApplicationService {
String candidateVersion = manifest == null ? status.targetVersion() : manifest.version(); String candidateVersion = manifest == null ? status.targetVersion() : manifest.version();
boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0; boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0;
boolean busy = BUSY_STATES.contains(status.state()); boolean busy = BUSY_STATES.contains(status.state());
boolean recoveryRequired = "RECOVERY_REQUIRED".equals(status.state());
boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize()); boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize());
boolean ready = "READY".equals(status.state()) && candidateVersion != null boolean ready = "READY".equals(status.state()) && candidateVersion != null
&& candidateVersion.equals(status.targetVersion()); && candidateVersion.equals(status.targetVersion());
List<String> actions = new ArrayList<>(); List<String> actions = new ArrayList<>();
if (enabled && !busy && !pending) actions.add("CHECK"); if (enabled && !busy && !pending && !recoveryRequired) actions.add("CHECK");
if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) { if (enabled && available && !busy && !pending && !recoveryRequired
&& authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD"); actions.add(ready ? "INSTALL" : "DOWNLOAD");
} }
return new SystemUpdateView(enabled, current, candidateVersion, return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), 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) { 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) { private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
} }
@@ -580,7 +647,12 @@ public class SystemUpdateApplicationService {
String releaseNotes) { 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) { private record QueueTransition(UpdateStatus previous, UpdateStatus queued) {
@@ -1,3 +1,9 @@
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
server: server:
servlet: servlet:
session: session:
+1 -4
View File
@@ -17,11 +17,8 @@ spring:
resources: resources:
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/} static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
profiles: profiles:
default: local default: production
datasource: 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: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
minimum-idle: 2 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 @Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception { void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); 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" [ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \ command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \
|| die "systemd is required for the privileged background updater" || 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" command -v "$command" >/dev/null 2>&1 || die "$command is required"
done done
@@ -155,6 +155,9 @@ main() {
field_key=$(openssl rand -base64 32 | tr -d '\n') field_key=$(openssl rand -base64 32 | tr -d '\n')
setup_code="KD-$(random_secret)" setup_code="KD-$(random_secret)"
setup_hash=$(printf '%s' "$setup_code" | sha256sum | awk '{print $1}') 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" \ write_env_file "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \ SPRING_PROFILES_ACTIVE production \
SERVER_ADDRESS 127.0.0.1 \ SERVER_ADDRESS 127.0.0.1 \
@@ -174,7 +177,7 @@ main() {
FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \ FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \
FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \ FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \
FINANCE_SETUP_RESTART_AFTER_COMPLETE true \ FINANCE_SETUP_RESTART_AFTER_COMPLETE true \
FINANCE_UPDATE_ENABLED true \ FINANCE_UPDATE_ENABLED false \
UPDATE_RELEASE_BASE_URL '' \ UPDATE_RELEASE_BASE_URL '' \
UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \
UPDATE_RELEASE_TOKEN '' \ 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_TOKEN_SHA256|FINANCE_SETUP_ENV_FILE|FINANCE_SETUP_MARKER_FILE|\
FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\ FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\
UPDATE_RELEASE_BASE_URL|UPDATE_RELEASE_API_URL|UPDATE_RELEASE_TOKEN|\ 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 0
;; ;;
*) return 1 ;; *) return 1 ;;
@@ -85,14 +85,23 @@ load_env_file() {
# The setup-generated runtime file has precedence over the base file. Non-empty # 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 # variables supplied by Baota have precedence over both; empty panel fields do not
# mask the protected configuration written by the setup wizard. # mask the protected configuration written by the setup wizard. The systemd unit
load_env_file "$RUNTIME_ENV_FILE" # loads both files itself as root, then sets KAIDI_ENV_PRELOADED so the service
load_env_file "$CONFIG_FILE" # 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:-} JAVA_BIN=${KAIDI_JAVA_BIN:-}
if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then
JAVA_BIN="$JAVA_HOME/bin/java" JAVA_BIN="$JAVA_HOME/bin/java"
fi 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 if [ -z "$JAVA_BIN" ]; then
JAVA_BIN=$(command -v java 2>/dev/null || true) JAVA_BIN=$(command -v java 2>/dev/null || true)
fi fi
@@ -102,6 +111,9 @@ if [[ "$JAVA_BIN" == */* ]]; then
else else
command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available" command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available"
fi 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 SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-production}
export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1} export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1}
+6 -6
View File
@@ -1,26 +1,26 @@
SPRING_PROFILES_ACTIVE=production SPRING_PROFILES_ACTIVE=production
SERVER_PORT=18080 SERVER_PORT=18080
SERVER_ADDRESS=127.0.0.1 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_URL=jdbc:mysql://DB_HOST:DB_PORT/DB_NAME?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=kaidi DB_USERNAME=DB_USERNAME
DB_PASSWORD=CHANGE_ME DB_PASSWORD=CHANGE_ME
FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY
FILE_STORAGE_ROOT=/var/lib/kaidi/files FILE_STORAGE_ROOT=/var/lib/kaidi/files
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
FILE_SCANNER_ENABLED=false FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD= FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.9 APP_VERSION=1.0.0-preview.34
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API. # Use either a stable direct asset base URL or the public Gitea latest-release API.
UPDATE_RELEASE_BASE_URL= UPDATE_RELEASE_BASE_URL=
UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest 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_REQUEST_FILE=/var/lib/kaidi-update/inbox/request.json
UPDATE_STATUS_FILE=/var/lib/kaidi-update/status.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) ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
INSTALLER="$ROOT/deploy/install.sh" 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} 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} PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
+69 -13
View File
@@ -31,6 +31,7 @@ INSTALL_TRANSACTION_ARMED=false
INSTALL_COMMITTED=false INSTALL_COMMITTED=false
RELEASE_DIR= RELEASE_DIR=
BACKUP_DIR= BACKUP_DIR=
JAVA_BIN=
JAVA_STAGED_DIR= JAVA_STAGED_DIR=
JAVA_PREVIOUS_DIR= JAVA_PREVIOUS_DIR=
JAVA_ACTIVATED=false JAVA_ACTIVATED=false
@@ -81,6 +82,15 @@ preflight_host() {
esac esac
[ "$host_bits" = "$expected_bits" ] \ [ "$host_bits" = "$expected_bits" ] \
|| die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit" || 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}$ ]] \ [[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \
|| die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256" || die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256"
case "$REINSTALL" in case "$REINSTALL" in
@@ -209,6 +219,42 @@ install_packages() {
fi 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
}
mysql_client_bin() {
local candidate
if [ -n "${KAIDI_MYSQL_CLIENT:-}" ]; then
case "$KAIDI_MYSQL_CLIENT" in
*/*) [ -x "$KAIDI_MYSQL_CLIENT" ] && printf '%s\n' "$KAIDI_MYSQL_CLIENT" && return 0 ;;
*) candidate=$(command -v "$KAIDI_MYSQL_CLIENT" 2>/dev/null || true); [ -n "$candidate" ] && printf '%s\n' "$candidate" && return 0 ;;
esac
return 1
fi
candidate=$(command -v mysql 2>/dev/null || command -v mariadb 2>/dev/null || true)
if [ -n "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
for candidate in \
/www/server/mysql/bin/mysql /www/server/mysql/bin/mariadb \
/usr/bin/mysql /usr/bin/mariadb /usr/local/mysql/bin/mysql /opt/mysql/bin/mysql; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
azul_arch() { azul_arch() {
case "${HOST_ARCH:-$(normalized_host_arch)}" in case "${HOST_ARCH:-$(normalized_host_arch)}" in
x86_64) printf x86 ;; x86_64) printf x86 ;;
@@ -282,12 +328,13 @@ system_java_home() {
prepare_java() { prepare_java() {
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line 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 if system_home=$(system_java_home); then
JAVA_STAGED_DIR="$WORK_DIR/java.next" JAVA_BIN="$system_home/bin/java"
mkdir -p "$JAVA_STAGED_DIR" java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
log "Copying existing Java 17 runtime from $system_home into the managed application directory" || die "The existing Java runtime cannot start on this host"
COPYFILE_DISABLE=1 cp -R "$system_home/." "$JAVA_STAGED_DIR/" log "Using existing Java 17+ runtime at $system_home"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
log "Local Java verified: $java_line" log "Local Java verified: $java_line"
return 0 return 0
fi fi
@@ -312,11 +359,14 @@ prepare_java() {
tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR" tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR"
java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \ java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \
|| die "Downloaded Java runtime architecture does not match $HOST_ARCH" || 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" log "Downloaded Java verified: $java_line"
} }
activate_java() { activate_java() {
[ -n "$JAVA_STAGED_DIR" ] || return 0
mkdir -p "$APP_ROOT/runtime" mkdir -p "$APP_ROOT/runtime"
JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous" JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous"
if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then
@@ -383,10 +433,12 @@ verify_service_access() {
runuser -u "$SERVICE_USER" -- sh -c \ runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \ '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" || die "$SERVICE_USER cannot traverse or read the active release"
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ [ -n "$JAVA_BIN" ] && [ -x "$JAVA_BIN" ] \
|| die "$SERVICE_USER cannot execute the managed Java runtime" || die "The selected Java runtime is unavailable at $JAVA_BIN"
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 \ runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
|| die "The managed Java runtime cannot execute as $SERVICE_USER" || 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" \ runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \
|| die "$SERVICE_USER cannot write the file-storage directory" || die "$SERVICE_USER cannot write the file-storage directory"
runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \ runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \
@@ -590,8 +642,8 @@ preflight_database() {
jdbc:mysql://*) ;; jdbc:mysql://*) ;;
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;; *) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
esac esac
client=$(command -v mysql || command -v mariadb || true) client=$(mysql_client_bin || true)
[ -n "$client" ] || die "A MySQL command-line client is required" [ -n "$client" ] || die "A MySQL command-line client is required; set KAIDI_MYSQL_CLIENT or add mysql/mariadb to PATH"
host=$(database_host) host=$(database_host)
port=$(database_port) port=$(database_port)
name=$(database_name) name=$(database_name)
@@ -828,6 +880,7 @@ validate_inputs
configure_app_port configure_app_port
preflight_host preflight_host
install_packages install_packages
preflight_runtime_commands
BASE=${RELEASE_BASE_URL%/} BASE=${RELEASE_BASE_URL%/}
WORK_DIR=$(mktemp -d) WORK_DIR=$(mktemp -d)
@@ -926,6 +979,7 @@ if [ "$SETUP_WIZARD" = true ]; then
fi fi
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \ write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \ SPRING_PROFILES_ACTIVE production \
KAIDI_JAVA_BIN "$JAVA_BIN" \
SERVER_PORT "$APP_PORT" \ SERVER_PORT "$APP_PORT" \
SERVER_ADDRESS "$SERVER_ADDRESS" \ SERVER_ADDRESS "$SERVER_ADDRESS" \
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \ SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
@@ -963,6 +1017,7 @@ write_env_file "$CONFIG_ROOT/update.env" \
KAIDI_APP_ROOT "$APP_ROOT" \ KAIDI_APP_ROOT "$APP_ROOT" \
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \ KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
KAIDI_SERVICE_NAME kaidi-finance.service \ KAIDI_SERVICE_NAME kaidi-finance.service \
KAIDI_JAVA_BIN "$JAVA_BIN" \
KAIDI_HEALTH_URL "$HEALTH_URL" \ KAIDI_HEALTH_URL "$HEALTH_URL" \
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \ KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \ KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
@@ -970,7 +1025,8 @@ write_env_file "$CONFIG_ROOT/update.env" \
KAIDI_DB_PORT "$(database_port)" \ KAIDI_DB_PORT "$(database_port)" \
KAIDI_DB_NAME "$(database_name)" \ KAIDI_DB_NAME "$(database_name)" \
KAIDI_DB_USERNAME "$DB_USERNAME" \ KAIDI_DB_USERNAME "$DB_USERNAME" \
KAIDI_DB_PASSWORD "$DB_PASSWORD" KAIDI_DB_PASSWORD "$DB_PASSWORD" \
KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}"
chmod 0600 "$CONFIG_ROOT/update.env" chmod 0600 "$CONFIG_ROOT/update.env"
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
+77 -8
View File
@@ -67,8 +67,61 @@ related_pids() {
done 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() { stop_managed_processes() {
local deadline local allow_service_restart=${1:-false} deadline
local -a pids=() local -a pids=()
mapfile -t pids < <(related_pids) mapfile -t pids < <(related_pids)
if [ "${#pids[@]}" -gt 0 ]; then if [ "${#pids[@]}" -gt 0 ]; then
@@ -84,8 +137,12 @@ stop_managed_processes() {
kill -KILL "${pids[@]}" 2>/dev/null || true kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1 sleep 1
mapfile -t pids < <(related_pids) mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -eq 0 ] \ [ "${#pids[@]}" -eq 0 ] && return 0
|| die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again" 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() { create_recovery_backup() {
@@ -120,6 +177,10 @@ create_recovery_backup() {
remove_systemd_units() { remove_systemd_units() {
local unit 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 \ rm -f \
/etc/systemd/system/kaidi-finance.service \ /etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \ /etc/systemd/system/kaidi-update.service \
@@ -149,15 +210,23 @@ remove_download_archives() {
} }
remove_service_identity() { 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) entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
if [ -n "$entry" ]; then if [ -n "$entry" ]; then
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}') home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}') shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) "$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false)
userdel "$SERVICE_USER" \ service_uid=$(id -u "$SERVICE_USER")
|| die "Failed to remove the dedicated $SERVICE_USER service account" 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" log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed"
@@ -182,13 +251,13 @@ main() {
validate_inputs validate_inputs
log "External MySQL data and reverse-proxy configuration will not be modified" log "External MySQL data and reverse-proxy configuration will not be modified"
stop_systemd_units stop_systemd_units
stop_managed_processes stop_managed_processes true
create_recovery_backup create_recovery_backup
remove_systemd_units remove_systemd_units
remove_managed_paths remove_managed_paths
remove_download_archives remove_download_archives
remove_service_identity remove_service_identity
stop_managed_processes stop_managed_processes false
log "Local Kaidi installation state has been removed" log "Local Kaidi installation state has been removed"
if [ -n "$BACKUP_ARCHIVE" ]; then if [ -n "$BACKUP_ARCHIVE" ]; then
log "Recovery backup: $BACKUP_ARCHIVE" log "Recovery backup: $BACKUP_ARCHIVE"
+2 -1
View File
@@ -11,8 +11,9 @@ User=kaidi
Group=kaidi Group=kaidi
EnvironmentFile=/etc/kaidi/kaidi.env EnvironmentFile=/etc/kaidi/kaidi.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env EnvironmentFile=-/var/lib/kaidi/setup/application.env
Environment=KAIDI_ENV_PRELOADED=true
WorkingDirectory=/opt/kaidi/current 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 SuccessExitStatus=143
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+2
View File
@@ -4,6 +4,8 @@ Description=Watch for Kaidi Finance update requests
[Path] [Path]
PathChanged=/var/lib/kaidi-update/inbox PathChanged=/var/lib/kaidi-update/inbox
PathExists=/var/lib/kaidi-update/inbox/request.json 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 Unit=kaidi-update.service
[Install] [Install]
+274 -17
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} TRANSACTION_ROOT=${KAIDI_UPDATE_TRANSACTION_ROOT:-$STATE_ROOT/transactions}
ACTIVE_TRANSACTION=$TRANSACTION_ROOT/active ACTIVE_TRANSACTION=$TRANSACTION_ROOT/active
STATUS_FILE=${UPDATE_STATUS_FILE:-$STATE_ROOT/status.json} 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} PUBLIC_KEY=${UPDATE_PUBLIC_KEY:-/etc/kaidi/release-public.pem}
RELEASE_BASE_URL=${UPDATE_RELEASE_BASE_URL:-} RELEASE_BASE_URL=${UPDATE_RELEASE_BASE_URL:-}
RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-} RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-} RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache} CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service} 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} UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
PROCESS_MANAGER=${KAIDI_PROCESS_MANAGER:-systemd} PROCESS_MANAGER=${KAIDI_PROCESS_MANAGER:-systemd}
PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid} PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
@@ -39,6 +41,10 @@ CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE= RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION= TARGET_VERSION=
TERMINAL_STATUS_WRITTEN=false TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID=
DOWNLOAD_PGID=
LAST_DOWNLOAD_SPEED=0
SURFACE_FAILURE=
case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
*[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;; *[!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=$(wc -c < "$RUNTIME_ENV_FILE" | tr -d '[:space:]')
[ "$runtime_size" -le 65536 ] \ [ "$runtime_size" -le 65536 ] \
|| bootstrap_die "Setup runtime environment is too large" || 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) service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
[ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \ [ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \
|| bootstrap_die "Setup runtime environment has an unexpected owner" || 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_NAME) KAIDI_DB_NAME=$runtime_value; export KAIDI_DB_NAME ;;
KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;; KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;;
KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;; KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;;
DB_URL) KAIDI_DB_URL=$runtime_value; export KAIDI_DB_URL ;;
esac esac
done < "$RUNTIME_ENV_FILE" done < "$RUNTIME_ENV_FILE"
@@ -126,20 +134,84 @@ load_runtime_database_env() {
|| bootstrap_die "Setup runtime database port is invalid" || 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() { status() {
state=$1 state=$1
message=$2 message=$2
version=${3:-} 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.$$" tmp="$STATUS_FILE.tmp.$$"
jq -n \ jq -n \
--arg state "$state" \ --arg state "$state" \
--arg message "$message" \ --arg message "$message" \
--arg version "$version" \ --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)" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt} '{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" chmod 0644 "$tmp"
mv -f "$tmp" "$STATUS_FILE" 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() { archive_processing_request() {
@@ -166,6 +238,7 @@ prepare_update_layout() {
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \ id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|| bootstrap_die "Service user $SERVICE_USER is not a member of group $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 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 \ install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \ "$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \
|| bootstrap_die "Managed application or update directories could not be prepared" || bootstrap_die "Managed application or update directories could not be prepared"
@@ -204,8 +277,9 @@ verify_release_access() {
if [ "$PROCESS_MANAGER" = baota ]; then if [ "$PROCESS_MANAGER" = baota ]; then
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh"
else else
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" \
&& runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 && runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
&& runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1
fi fi
} }
@@ -213,6 +287,14 @@ verify_release_access() {
cleanup() { cleanup() {
rc=$? rc=$?
trap - EXIT HUP INT TERM 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 "$WORK_DIR" ] || rm -rf "$WORK_DIR"
[ -z "$CACHE_TEMP" ] || rm -rf "$CACHE_TEMP" [ -z "$CACHE_TEMP" ] || rm -rf "$CACHE_TEMP"
if [ "$rc" -ne 0 ] && [ "$TERMINAL_STATUS_WRITTEN" != true ]; then if [ "$rc" -ne 0 ] && [ "$TERMINAL_STATUS_WRITTEN" != true ]; then
@@ -253,6 +335,83 @@ download() {
esac 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() { release_asset_url() {
asset_name=$1 asset_name=$1
if [ -n "$RELEASE_API_URL" ]; then if [ -n "$RELEASE_API_URL" ]; then
@@ -284,30 +443,56 @@ EOF
wait_for_health() { wait_for_health() {
health_url=$1 health_url=$1
attempts=0 attempts=0
health_file=$(mktemp "$STATE_ROOT/work/health-response.XXXXXX")
while [ "$attempts" -lt "$HEALTH_ATTEMPTS" ]; do 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 return 0
fi 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)) attempts=$((attempts + 1))
sleep "$HEALTH_INTERVAL_SECONDS" sleep "$HEALTH_INTERVAL_SECONDS"
done done
rm -f "$health_file"
return 1 return 1
} }
verify_app_surface() { verify_app_surface() {
expected_release=${1:-} expected_release=${1:-}
SURFACE_FAILURE=
wait_for_health "$HEALTH_URL" || return 1 wait_for_health "$HEALTH_URL" || return 1
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX") 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 && grep -Eiq '<!doctype|<html' "$index_file"; then
rm -f "$index_file" rm -f "$index_file"
if [ "$PROCESS_MANAGER" = baota ] && [ -n "$expected_release" ]; then if [ "$PROCESS_MANAGER" = baota ] && [ -n "$expected_release" ]; then
running_release=$(baota_process_release) || return 1 running_release=$(baota_process_release) || {
[ "$running_release" = "$expected_release" ] || return 1 SURFACE_FAILURE=PROCESS_IDENTITY_INVALID
return 1
}
if [ "$running_release" != "$expected_release" ]; then
SURFACE_FAILURE=RELEASE_MISMATCH
return 1
fi
fi fi
return 0 return 0
fi fi
rm -f "$index_file" 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 return 1
} }
@@ -390,6 +575,30 @@ application_state() {
fi fi
} }
mysqldump_bin() {
if [ -n "${KAIDI_MYSQLDUMP_BIN:-}" ]; then
case "$KAIDI_MYSQLDUMP_BIN" in
*/*) [ -x "$KAIDI_MYSQLDUMP_BIN" ] && printf '%s\n' "$KAIDI_MYSQLDUMP_BIN" && return 0 ;;
*) candidate=$(command -v "$KAIDI_MYSQLDUMP_BIN" 2>/dev/null || true); [ -n "$candidate" ] && printf '%s\n' "$candidate" && return 0 ;;
esac
return 1
fi
candidate=$(command -v mysqldump 2>/dev/null || true)
if [ -n "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
for candidate in \
/www/server/mysql/bin/mysqldump /usr/bin/mysqldump \
/usr/local/mysql/bin/mysqldump /opt/mysql/bin/mysqldump; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
backup_database() { backup_database() {
DATABASE_BACKUP= DATABASE_BACKUP=
[ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return [ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return
@@ -409,11 +618,12 @@ backup_database() {
fail "Database backup failed" fail "Database backup failed"
fi fi
else else
command -v mysqldump >/dev/null 2>&1 || { dump_bin=$(mysqldump_bin || true)
[ -n "$dump_bin" ] || {
rm -f "$dump_file" "$backup_tmp" rm -f "$dump_file" "$backup_tmp"
fail "mysqldump is required before installing an update" fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory"
} }
if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} mysqldump --single-transaction --routines --triggers \ if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \
-h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \ -h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
rm -f "$dump_file" "$backup_tmp" rm -f "$dump_file" "$backup_tmp"
@@ -554,6 +764,15 @@ remove_failed_release() {
esac 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() { rollback_active_transaction() {
reason=$1 reason=$1
previous_target=$(transaction_value previous-target) previous_target=$(transaction_value previous-target)
@@ -583,10 +802,19 @@ rollback_active_transaction() {
fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained" fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained"
fi fi
TERMINAL_STATUS_WRITTEN=true 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 if ! archive_processing_request; then
printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2 printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2
recovery_guard_failed=true
fi 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 printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2
exit 1 exit 1
} }
@@ -651,6 +879,16 @@ else
fi fi
chmod 0600 "$PROCESSING_FILE" 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)' \ if ! REQUESTED_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \
"$PROCESSING_FILE"); then "$PROCESSING_FILE"); then
fail "Update request version is invalid" fail "Update request version is invalid"
@@ -661,7 +899,11 @@ REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \ | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|| fail "Update request action is invalid" || fail "Update request action is invalid"
prepare_update_layout prepare_update_layout
reset_event_log
load_runtime_database_env load_runtime_database_env
if [ "$REQUEST_ACTION" = INSTALL ]; then
validate_runtime_database_config
fi
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \ [ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \ && ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|| fail "UPDATE_RELEASE_TOKEN is invalid" || fail "UPDATE_RELEASE_TOKEN is invalid"
@@ -714,6 +956,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" "$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}$"))' \ 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" "$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) CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
@@ -726,12 +970,16 @@ fi
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION" status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION"
ARTIFACT_URL=$(release_asset_url "$ARTIFACT") || fail "Release artifact asset is missing" 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 else
status VERIFYING "Revalidating cached release $TARGET_VERSION" "$TARGET_VERSION" status VERIFYING "Revalidating cached release $TARGET_VERSION" "$TARGET_VERSION"
fi fi
ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}') ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}')
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || fail "Release artifact SHA-256 verification failed" [ "$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" 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 if grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list"; then
@@ -768,7 +1016,8 @@ if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
fi fi
mv "$CACHE_TEMP" "$CACHE_DIR" || fail "Verified release cache could not be activated" mv "$CACHE_TEMP" "$CACHE_DIR" || fail "Verified release cache could not be activated"
CACHE_TEMP= 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 TERMINAL_STATUS_WRITTEN=true
complete_request complete_request
exit 0 exit 0
@@ -787,10 +1036,17 @@ if command -v restorecon >/dev/null 2>&1; then
fi fi
if ! verify_release_access "$RELEASE_DIR"; then if ! verify_release_access "$RELEASE_DIR"; then
rm -rf "$RELEASE_DIR" 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 fi
PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true) 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" mkdir "$ACTIVE_TRANSACTION"
write_transaction_value target-version "$TARGET_VERSION" write_transaction_value target-version "$TARGET_VERSION"
write_transaction_value previous-target "$PREVIOUS_TARGET" write_transaction_value previous-target "$PREVIOUS_TARGET"
@@ -817,7 +1073,7 @@ if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
fi fi
write_transaction_value phase APP_SWITCHED 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 write_transaction_value phase HEALTH_CHECKING
if ! restart_application; then if ! restart_application; then
rollback_active_transaction "Release application restart failed" rollback_active_transaction "Release application restart failed"
@@ -837,5 +1093,6 @@ fi
service_state=$(application_state 2>/dev/null || true) service_state=$(application_state 2>/dev/null || true)
[ -n "$service_state" ] || service_state=unknown [ -n "$service_state" ] || service_state=unknown
surface_failure=${SURFACE_FAILURE:-UNKNOWN}
rollback_active_transaction \ rollback_active_transaction \
"Release health or application-surface verification failed (service state: $service_state)" "Release application verification failed ($surface_failure; service state: $service_state)"
+1 -1
View File
@@ -2936,7 +2936,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
| 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 | | 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 |
| 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` | | 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` |
| 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 | | 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 |
| Gitea 发布原子性 | tag 工作流先创建 `draft=true, prerelease=false` 的 Release,显式上传并核对恰好 9 个签名资产的名称、数量和大小,全部一致后才发布;失败或资产不齐时 `/latest` 不可见 | 还需在 Gitea 配置 `RELEASE_SIGNING_KEY_B64`、`KAIDI_RELEASE_PUBLIC_KEY_SHA256` 和可用的 `ubuntu-24.04` act_runner | | Gitea 发布原子性 | tag 工作流先创建 `draft=true, prerelease=false` 的 Release,显式上传并核对恰好 10 个签名资产的名称、数量和大小,全部一致后才发布;失败或资产不齐时 `/latest` 不可见 | 还需在 Gitea 配置 `RELEASE_SIGNING_KEY_B64`、`KAIDI_RELEASE_PUBLIC_KEY_SHA256` 和可用的 `ubuntu-latest` act_runner |
| Preview.2 制品 | `1.0.0-preview.2` 已完成构建及独立验签;应用包 SHA-256=`180168d4481044d3803eef81a88b301642a55cb5519e50401915e1cc131e10c7`,安装器 SHA-256=`6a881dcfd5476e795a6e181f8a8b234184523478f20312a669c6748889be1288`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9` | 当前清单绑定 revision `47403fd337cfd4544e45d6c851984e4a40bcd8f3`、`source.ref=local`、`source.dirty=true`;正式 tag Release 必须从干净提交重新构建,摘要将以 CI 输出为准 | | Preview.2 制品 | `1.0.0-preview.2` 已完成构建及独立验签;应用包 SHA-256=`180168d4481044d3803eef81a88b301642a55cb5519e50401915e1cc131e10c7`,安装器 SHA-256=`6a881dcfd5476e795a6e181f8a8b234184523478f20312a669c6748889be1288`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9` | 当前清单绑定 revision `47403fd337cfd4544e45d6c851984e4a40bcd8f3`、`source.ref=local`、`source.dirty=true`;正式 tag Release 必须从干净提交重新构建,摘要将以 CI 输出为准 |
| 本地 Preview.2 | 签名包内 JAR 在 `http://127.0.0.1:18095` 返回 `UP`,前端 `http://127.0.0.1:3014` 代理真实 API;系统管理员登录后,系统配置页显示当前版本 `1.0.0-preview.2` 和完整四步更新面板,页面无横向溢出,Console warning/error 为 0 | 本地未注入生产只读 Token,因此页面显示“在线更新未配置”;这证明禁用态,不替代真实 Gitea 下载和 Linux 安装冒烟 | | 本地 Preview.2 | 签名包内 JAR 在 `http://127.0.0.1:18095` 返回 `UP`,前端 `http://127.0.0.1:3014` 代理真实 API;系统管理员登录后,系统配置页显示当前版本 `1.0.0-preview.2` 和完整四步更新面板,页面无横向溢出,Console warning/error 为 0 | 本地未注入生产只读 Token,因此页面显示“在线更新未配置”;这证明禁用态,不替代真实 Gitea 下载和 Linux 安装冒烟 |
+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.getByText('网络请求失败')).toHaveCount(0);
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 }); 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', 'QUEUED',
'DOWNLOAD_QUEUED', 'DOWNLOAD_QUEUED',
'INSTALL_QUEUED', 'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING', 'VERIFYING',
'DOWNLOADING', 'DOWNLOADING',
'BACKING_UP', 'BACKING_UP',
@@ -59,6 +60,14 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
publishedAt: checked ? '2026-08-16T00:00:00Z' : null, publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
checkedAt: checked ? '2026-08-16T00:01:00Z' : null, checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
statusUpdatedAt: 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: allowedActions:
!enabled || recoveryPending || busy !enabled || recoveryPending || busy
? [] ? []
@@ -72,8 +81,11 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
interface FixtureOptions { interface FixtureOptions {
canExecute?: boolean; canExecute?: boolean;
checkResponseState?: string;
downloadProgressReads?: number;
failDownloadResponse?: boolean; failDownloadResponse?: boolean;
failInstallResponse?: boolean; failInstallResponse?: boolean;
restartOfflineReads?: number;
} }
async function installFixture( async function installFixture(
@@ -87,9 +99,18 @@ async function installFixture(
completeBusyAfterFirstRead = false, completeBusyAfterFirstRead = false,
options: FixtureOptions = {}, 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 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 history: Array<Record<string, unknown>> = [];
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => { const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
history.unshift({ history.unshift({
@@ -131,13 +152,19 @@ async function installFixture(
}); });
} }
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') { 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); const response = updateView(state, checked, enabled, recoveryPending);
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED'; if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
return route.fulfill({ json: envelope(response) }); return route.fulfill({ json: envelope(response) });
} }
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
checked = true; checked = true;
recordHistory('SYSTEM_UPDATE_CHECK'); recordHistory('SYSTEM_UPDATE_CHECK');
if (checkResponseState) state = checkResponseState;
const response = updateView(state, checked, enabled, recoveryPending); const response = updateView(state, checked, enabled, recoveryPending);
if (!canExecute) response.allowedActions = ['CHECK']; if (!canExecute) response.allowedActions = ['CHECK'];
return route.fulfill({ json: envelope(response) }); return route.fulfill({ json: envelope(response) });
@@ -145,7 +172,8 @@ async function installFixture(
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
downloadBodies.push(request.postDataJSON()); downloadBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST'); recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
state = 'READY'; state = downloadProgressReads > 0 ? 'DOWNLOADING' : 'READY';
progressDeadline = Date.now() + downloadProgressReads * 1000;
if (failDownloadResponse) return route.abort('connectionreset'); if (failDownloadResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) }); return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
} }
@@ -153,7 +181,13 @@ async function installFixture(
installBodies.push(request.postDataJSON()); installBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_REQUEST'); recordHistory('SYSTEM_UPDATE_REQUEST');
const queued = updateView('INSTALL_QUEUED', true, enabled); 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'); if (failInstallResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(queued) }); 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' }]); expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' }); const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible(); 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(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible(); 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: '管理员确认立即更新并重启' }]); 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 }) => { test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY'); await installFixture(page, [], [], true, false, false, 'READY');
await page.goto('/governance/update'); 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); 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 }) => { test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, { await installFixture(page, [], [], true, false, false, undefined, false, {
failDownloadResponse: true, 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 }) => { test('brand logo returns the active identity to its workbench', async ({ page }) => {
await installFixture(page, [], []); await installFixture(page, [], []);
await page.goto('/governance/update'); await page.goto('/governance/update');
+14
View File
@@ -2,6 +2,13 @@ import { request } from '@/utils/request';
import { commandHeaders } from './command'; import { commandHeaders } from './command';
export interface SystemUpdateEvent {
occurredAt: string;
level: 'INFO' | 'WARN' | 'ERROR';
stage: string;
message: string;
}
export interface SystemUpdateView { export interface SystemUpdateView {
enabled: boolean; enabled: boolean;
currentVersion: string; currentVersion: string;
@@ -13,6 +20,12 @@ export interface SystemUpdateView {
publishedAt?: string | null; publishedAt?: string | null;
checkedAt?: string | null; checkedAt?: string | null;
statusUpdatedAt?: string | null; statusUpdatedAt?: string | null;
downloadedBytes?: number | null;
totalBytes?: number | null;
bytesPerSecond?: number | null;
downloadPercent?: number | null;
restartExpectedSeconds?: number | null;
events: SystemUpdateEvent[];
allowedActions: string[]; allowedActions: string[];
} }
@@ -21,6 +34,7 @@ function normalize(value: SystemUpdateView): SystemUpdateView {
...value, ...value,
enabled: Boolean(value?.enabled), enabled: Boolean(value?.enabled),
updateAvailable: Boolean(value?.updateAvailable), updateAvailable: Boolean(value?.updateAvailable),
events: Array.isArray(value?.events) ? value.events : [],
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [], allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
}; };
} }
@@ -47,7 +47,7 @@
立即更新 立即更新
</t-button> </t-button>
<t-button <t-button
v-if="canInstall" v-if="canInstall && !updateDialogVisible"
theme="primary" theme="primary"
:loading="installingUpdate" :loading="installingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate" :disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@@ -66,22 +66,36 @@
<t-step-item title="完成更新" content="健康检查后刷新" /> <t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps> </t-steps>
<t-alert v-if="statusError" theme="error" :close-btn="false"> <div v-if="showDownloadProgress && !updateDialogVisible" class="download-progress" aria-live="polite">
<div class="alert-content"> <div class="download-progress__heading">
<span>{{ statusError }}</span> <span>更新包下载进度</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link> <strong>{{ updateProgress }}%</strong>
</div> </div>
</t-alert> <t-progress :percentage="updateProgress" :label="false" />
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite"> <div class="download-progress__meta">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。 <span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
</t-alert> <span>{{ downloadSpeedLabel }}</span>
<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> </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"> <div v-if="updateStatus?.releaseNotes" class="release-notes">
<span>版本说明</span> <span>版本说明</span>
@@ -123,16 +137,99 @@
</t-table> </t-table>
<t-empty v-else description="暂无系统更新记录" /> <t-empty v-else description="暂无系统更新记录" />
</section> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { isAxiosError } from 'axios';
import type { PrimaryTableCol } from 'tdesign-vue-next'; import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { AuditLog } from '@/api/audit'; import type { AuditLog } from '@/api/audit';
import { getAuditLogs } from '@/api/audit'; import { getAuditLogs } from '@/api/audit';
import type { SystemUpdateView } from '@/api/system-update'; import type { SystemUpdateEvent, SystemUpdateView } from '@/api/system-update';
import { import {
checkSystemUpdate, checkSystemUpdate,
downloadSystemUpdate, downloadSystemUpdate,
@@ -143,6 +240,7 @@ import {
defineOptions({ name: 'SystemUpdatePage' }); defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null); const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false); const isNarrow = ref(false);
@@ -155,24 +253,50 @@ const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false); const historyLoading = ref(false);
const historyError = ref(''); const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null); 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 awaitingRefresh = ref(false);
const installRequestUncertain = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined; let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined; let refreshTimer: ReturnType<typeof setInterval> | undefined;
let restartTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([ const busyStates = new Set([
'QUEUED', 'QUEUED',
'DOWNLOAD_QUEUED', 'DOWNLOAD_QUEUED',
'INSTALL_QUEUED', 'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING', 'VERIFYING',
'DOWNLOADING', 'DOWNLOADING',
'BACKING_UP', 'BACKING_UP',
'INSTALLING', 'INSTALLING',
'RUNNING', '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 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 stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed( const currentIsLatest = computed(
() => () =>
@@ -180,7 +304,7 @@ const currentIsLatest = computed(
Boolean(updateStatus.value.latestVersion) && Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable && !updateStatus.value.updateAvailable &&
!updateBusy.value && !updateBusy.value &&
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state), !['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(updateStatus.value.state),
); );
const canCheckUpdate = computed( const canCheckUpdate = computed(
() => () =>
@@ -220,6 +344,7 @@ const updateStateLabel = computed(() => {
QUEUED: '已排队', QUEUED: '已排队',
DOWNLOAD_QUEUED: '下载已排队', DOWNLOAD_QUEUED: '下载已排队',
INSTALL_QUEUED: '等待重启', INSTALL_QUEUED: '等待重启',
PRECHECKING: '安装预检',
VERIFYING: '校验中', VERIFYING: '校验中',
DOWNLOADING: '下载中', DOWNLOADING: '下载中',
READY: '下载完成', READY: '下载完成',
@@ -228,6 +353,7 @@ const updateStateLabel = computed(() => {
RUNNING: '重启中', RUNNING: '重启中',
SUCCEEDED: '更新成功', SUCCEEDED: '更新成功',
FAILED: '更新失败', FAILED: '更新失败',
RECOVERY_REQUIRED: '需要人工恢复',
UNKNOWN: '状态未知', UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] || }[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state || updateStatus.value?.state ||
@@ -236,22 +362,23 @@ const updateStateLabel = computed(() => {
}); });
const updateActionHint = computed(() => { const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE'; 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') { if (state === 'READY') {
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。'; return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
} }
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。'; if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
if (updateStatus.value?.updateAvailable) 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 '点击“获取版本”检查最新发布。'; if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。';
return ''; return '';
}); });
const updateStateTheme = computed<TagTheme>(() => { const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE'; const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success'; 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'; if (updateBusy.value) return 'warning';
return 'default'; return 'default';
}); });
@@ -272,13 +399,37 @@ async function loadStatus(silent = false) {
} }
try { try {
const status = await getSystemUpdateStatus(); const status = await getSystemUpdateStatus();
if (connectionInterrupted.value) {
appendLocalEvent('INFO', 'RUNNING', '应用服务连接已恢复,正在确认更新结果');
connectionInterrupted.value = false;
}
updateStatus.value = status; 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); handleCompletion(status);
if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling(); if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling(); else stopPolling();
} catch (error) { } 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 { } finally {
if (!silent) statusLoading.value = false; 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')) { } else if (!checked.allowedActions.includes('DOWNLOAD')) {
MessagePlugin.info('发现新版本,但当前账号没有下载权限'); MessagePlugin.info('发现新版本,但当前账号没有下载权限');
} else { } else {
@@ -342,10 +497,13 @@ async function downloadNow() {
const version = updateStatus.value?.latestVersion; const version = updateStatus.value?.latestVersion;
if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return; if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
downloadingUpdate.value = true; downloadingUpdate.value = true;
updateDialogVisible.value = true;
localEvents.value = [];
statusError.value = ''; statusError.value = '';
startPolling(); startPolling();
try { try {
updateStatus.value = await downloadSystemUpdate(version); updateStatus.value = await downloadSystemUpdate(version);
appendLocalEvent('INFO', 'DOWNLOAD_QUEUED', `版本 ${version} 的下载请求已提交`);
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”'); MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
void loadHistory(true); void loadHistory(true);
} catch (error) { } catch (error) {
@@ -361,16 +519,30 @@ async function installNow() {
const version = updateStatus.value?.latestVersion; const version = updateStatus.value?.latestVersion;
if (!version || installingUpdate.value || !canInstall.value) return; if (!version || installingUpdate.value || !canInstall.value) return;
installingUpdate.value = true; installingUpdate.value = true;
updateDialogVisible.value = true;
statusError.value = ''; statusError.value = '';
stopRefreshCountdown(); stopRefreshCountdown();
stopRestartCountdown();
awaitingRefresh.value = true; awaitingRefresh.value = true;
installRequestUncertain.value = false;
setPendingUpdate(true);
startPolling(); startPolling();
try { try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启'); updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
appendLocalEvent('INFO', 'INSTALL_QUEUED', `已确认安装 ${version},等待更新服务执行`);
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新'); MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
void loadHistory(true); void loadHistory(true);
} catch (error) { } 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); void loadStatus(true);
} finally { } finally {
installingUpdate.value = false; installingUpdate.value = false;
@@ -380,7 +552,7 @@ async function installNow() {
function startPolling() { function startPolling() {
if (pollTimer) return; if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 3000); pollTimer = setInterval(() => void loadStatus(true), 1000);
} }
function stopPolling() { function stopPolling() {
@@ -390,19 +562,66 @@ function stopPolling() {
function handleCompletion(status: SystemUpdateView) { function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return; if (!awaitingRefresh.value) return;
if (status.state === 'READY') {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
return;
}
if (status.state === 'SUCCEEDED') { if (status.state === 'SUCCEEDED') {
statusError.value = ''; statusError.value = '';
awaitingRefresh.value = false; awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
startRefreshCountdown(); startRefreshCountdown();
void loadHistory(true); void loadHistory(true);
return; return;
} }
if (['FAILED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) { if (['FAILED', 'RECOVERY_REQUIRED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false; awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
stopRefreshCountdown(); 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() { function startRefreshCountdown() {
if (refreshTimer) return; if (refreshTimer) return;
updateRefreshSeconds.value = 10; updateRefreshSeconds.value = 10;
@@ -423,6 +642,64 @@ function stopRefreshCountdown() {
updateRefreshSeconds.value = null; 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) { function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) { for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue; if (!source) continue;
@@ -468,6 +745,12 @@ function friendlyError(error: unknown, fallback: string) {
return (error as Error)?.message || fallback; return (error as Error)?.message || fallback;
} }
function responseMayHaveBeenLost(error: unknown) {
return (
(isAxiosError(error) && !error.response) || (error as { transportFailure?: boolean })?.transportFailure === true
);
}
function updateViewportMode() { function updateViewportMode() {
isNarrow.value = window.innerWidth <= 640; isNarrow.value = window.innerWidth <= 640;
} }
@@ -475,11 +758,18 @@ function updateViewportMode() {
onMounted(() => { onMounted(() => {
updateViewportMode(); updateViewportMode();
window.addEventListener('resize', updateViewportMode); window.addEventListener('resize', updateViewportMode);
if (hasPendingUpdate()) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
startRestartCountdown(10);
startPolling();
}
void refreshPage(); void refreshPage();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPolling(); stopPolling();
stopRefreshCountdown(); stopRefreshCountdown();
stopRestartCountdown();
window.removeEventListener('resize', updateViewportMode); window.removeEventListener('resize', updateViewportMode);
}); });
</script> </script>
@@ -613,6 +903,103 @@ onBeforeUnmount(() => {
width: 100%; 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) { @media (width <= 900px) {
.update-heading { .update-heading {
align-items: stretch; align-items: stretch;
@@ -650,5 +1037,23 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
gap: 8px; 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> </style>
+8 -9
View File
@@ -378,19 +378,18 @@ function confirmSetup() {
function startCountdown() { function startCountdown() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => { countdownTimer = window.setInterval(() => {
countdown.value -= 1; countdown.value = Math.max(0, countdown.value - 1);
if (countdown.value <= 0) void openLogin(); if (countdown.value === 0) openLogin();
}, 1000); }, 1000);
} }
async function openLogin() { function openLogin() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
completionMessage.value = '系统正在启动,请稍候。'; countdown.value = 0;
if (await waitForSetupCompletion()) { completionMessage.value = '正在进入登录页。';
await router.replace('/login'); // A full navigation clears the setup-mode SPA state retained across the
return; // service restart. The login route guard performs the final setup check.
} window.location.replace('/login');
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
} }
onMounted(async () => { onMounted(async () => {
+1
View File
@@ -325,6 +325,7 @@ export class VAxios {
status: e.response?.status, status: e.response?.status,
requestId, requestId,
fieldErrors: problem?.fieldErrors, fieldErrors: problem?.fieldErrors,
transportFailure: !e.response,
}); });
if (e.response?.status === 401 && window.location.pathname !== '/login') { if (e.response?.status === 401 && window.location.pathname !== '/login') {
window.dispatchEvent(new CustomEvent('kaidi:session-expired')); window.dispatchEvent(new CustomEvent('kaidi:session-expired'));
+31
View File
@@ -5967,6 +5967,18 @@ components:
format: int64 format: int64
type: integer type: integer
type: object type: object
SystemUpdateEventView:
properties:
level:
type: string
message:
type: string
occurredAt:
format: date-time
type: string
stage:
type: string
type: object
SystemUpdateView: SystemUpdateView:
properties: properties:
allowedActions: allowedActions:
@@ -5976,10 +5988,23 @@ components:
checkedAt: checkedAt:
format: date-time format: date-time
type: string type: string
bytesPerSecond:
format: int64
type: integer
currentVersion: currentVersion:
type: string type: string
enabled: enabled:
type: boolean type: boolean
downloadPercent:
format: int32
type: integer
downloadedBytes:
format: int64
type: integer
events:
items:
"$ref": "#/components/schemas/SystemUpdateEventView"
type: array
latestVersion: latestVersion:
type: string type: string
message: message:
@@ -5989,11 +6014,17 @@ components:
type: string type: string
releaseNotes: releaseNotes:
type: string type: string
restartExpectedSeconds:
format: int32
type: integer
state: state:
type: string type: string
statusUpdatedAt: statusUpdatedAt:
format: date-time format: date-time
type: string type: string
totalBytes:
format: int64
type: integer
updateAvailable: updateAvailable:
type: boolean type: boolean
type: object type: object
+25 -2
View File
@@ -75,13 +75,24 @@ JAR="$ROOT/backend/target/finance-system-$VERSION.jar"
JAR_METADATA_DIR="$WORK/jar-metadata" JAR_METADATA_DIR="$WORK/jar-metadata"
mkdir -p "$JAR_METADATA_DIR" mkdir -p "$JAR_METADATA_DIR"
(cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \ (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_POM_VERSION=$(sed -n 's/^version=//p' \
"$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties") "$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r') "$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; } || { 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" rm -rf "$OUTPUT_DIR"
mkdir -p "$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" 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" 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" ARTIFACT="kaidi-finance-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" . COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT") 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" cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \ (cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
--sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json" --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") BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"} 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_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \ BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \ INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \
@@ -179,6 +201,7 @@ const manifest = {
version: process.env.VERSION, version: process.env.VERSION,
artifact: process.env.ARTIFACT, artifact: process.env.ARTIFACT,
sha256: process.env.SHA256, sha256: process.env.SHA256,
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
publishedAt: new Date().toISOString(), publishedAt: new Date().toISOString(),
minimumJava: 17, minimumJava: 17,
source: { source: {
+32
View File
@@ -51,6 +51,10 @@ EOF
cat > "$WORK/java" <<'SH' cat > "$WORK/java" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
if [ "${1:-}" = -version ]; then
printf 'openjdk version "17-fixture"\n' >&2
exit 0
fi
{ {
printf 'cwd=%s\n' "$PWD" printf 'cwd=%s\n' "$PWD"
printf 'args=%s\n' "$*" 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") 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' [ "$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" \ MOCK_JAVA_LOG="$WORK/explicit.log" \
PATH="$WORK/mock-bin:$PATH" \ PATH="$WORK/mock-bin:$PATH" \
KAIDI_JAVA_BIN="$WORK/java" \ KAIDI_JAVA_BIN="$WORK/java" \
+48 -13
View File
@@ -29,6 +29,7 @@ mode_of() {
sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^azul_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_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 '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -36,6 +37,7 @@ mode_of() {
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^mysql_client_bin()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -46,6 +48,17 @@ source "$WORK/helpers.sh"
export SETUP_WIZARD=true export SETUP_WIZARD=true
preflight_database || fail 'setup wizard database preflight returned a failure status' preflight_database || fail 'setup wizard database preflight returned a failure status'
mkdir -p "$WORK/mysql-bin"
cat > "$WORK/mysql-bin/mysql" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$WORK/mysql-bin/mysql"
export KAIDI_MYSQL_CLIENT="$WORK/mysql-bin/mysql"
[ "$(mysql_client_bin)" = "$WORK/mysql-bin/mysql" ] \
|| fail 'installer did not honor the explicit MySQL client path'
unset KAIDI_MYSQL_CLIENT
release_permissions="$WORK/release-permissions" release_permissions="$WORK/release-permissions"
mkdir -p "$release_permissions/public" "$release_permissions/ops" mkdir -p "$release_permissions/public" "$release_permissions/ops"
printf 'jar\n' > "$release_permissions/app.jar" printf 'jar\n' > "$release_permissions/app.jar"
@@ -192,7 +205,7 @@ cat > "$WORK/fake-jre/bin/java" <<'JAVA'
if [ "${1:-}" = '-XshowSettings:properties' ]; then if [ "${1:-}" = '-XshowSettings:properties' ]; then
printf ' os.arch = x86\n' >&2 printf ' os.arch = x86\n' >&2
fi fi
printf 'openjdk version "17-fixture"\n' >&2 printf 'openjdk version "17.0.8"\n' >&2
JAVA JAVA
chmod 0755 "$WORK/fake-jre/bin/java" chmod 0755 "$WORK/fake-jre/bin/java"
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
@@ -213,16 +226,22 @@ download() {
esac esac
} }
ARCH_FIXTURE=i686 ARCH_FIXTURE=i686
APP_ROOT="$WORK/app"
export KAIDI_JAVA_HOME="$WORK/missing-java-home" export KAIDI_JAVA_HOME="$WORK/missing-java-home"
prepare_java >/dev/null 2>&1 prepare_java >/dev/null 2>&1
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged' [ -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" rm -rf "$JAVA_STAGED_DIR"
JAVA_STAGED_DIR=
export KAIDI_JAVA_HOME="$WORK/fake-jre" export KAIDI_JAVA_HOME="$WORK/fake-jre"
prepare_java >/dev/null 2>&1 prepare_java >/dev/null 2>&1
[ -d "$JAVA_STAGED_DIR" ] && [ ! -L "$JAVA_STAGED_DIR" ] \ [ -z "$JAVA_STAGED_DIR" ] \
|| fail 'existing Java 17 runtime was not copied into managed storage' || fail 'installer copied an existing server Java runtime into application storage'
cmp -s "$WORK/fake-jre/bin/java" "$JAVA_STAGED_DIR/bin/java" \ EXPECTED_LOCAL_JAVA=$(readlink -f "$WORK/fake-jre/bin/java" 2>/dev/null \
|| fail 'installer staged an unexpected local Java runtime' || 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 export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
RELEASE_TOKEN=fixture-read-only-token RELEASE_TOKEN=fixture-read-only-token
@@ -280,6 +299,14 @@ grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer rejects unsupported custom roots' || fail 'installer no longer rejects unsupported custom roots'
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \ grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer enforces MySQL 8.4.x' || 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'
grep -Fq 'KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}"' "$ROOT/deploy/install.sh" \
|| fail 'installer does not preserve a custom mysqldump path for online updates'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \ 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' || fail 'installer no longer uses the reinstall-safe environment writer'
@@ -289,14 +316,14 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \ grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer obtains the Java runtime SHA-256' || 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' || fail 'installer no longer reuses a local Java 17 runtime'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'cp -R "$system_home/." "$JAVA_STAGED_DIR/"' "$ROOT/deploy/install.sh" \ grep -Fq 'JAVA_BIN="$system_home/bin/java"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer copies a local Java runtime into managed storage' || fail 'installer no longer records the selected server Java path'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
! grep -Fq 'ln -s "$system_home"' "$ROOT/deploy/install.sh" \ ! grep -Fq 'cp -R "$system_home/."' "$ROOT/deploy/install.sh" \
|| fail 'installer must not link the service to an externally managed Java directory' || fail 'installer copied an existing server Java runtime into managed storage'
# shellcheck disable=SC2016 # Match the literal installer command. # 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" \ 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' || fail 'installer no longer performs a quiet bounded health check'
@@ -317,12 +344,18 @@ grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh
|| fail 'installer can execute before the complete curl stream is parsed' || fail 'installer can execute before the complete curl stream is parsed'
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \ grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer has a bounded restart burst' || 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" \ grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service can automatically repeat a failed switching transaction' || fail 'update service can automatically repeat a failed switching transaction'
grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \ grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service requires the Baota application path on a systemd-only installation' || 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" \ grep -Fqx 'PathExists=/var/lib/kaidi-update/processing/request.json' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path can automatically repeat a claimed switching transaction' || 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. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \ grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256' || fail 'installer no longer verifies the Java runtime SHA-256'
@@ -390,9 +423,11 @@ grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
# shellcheck disable=SC2016 # Match literal installer source. # 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" \ 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' || 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.34.tar.gz' "$ROOT/README.md" \
|| fail 'README does not document the public manual-deployment artifact' || fail 'README does not document the public manual-deployment artifact'
grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \ grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \
|| fail 'README does not document the local Baota initialization command' || 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' 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' || 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" \ grep -Fq 'External MySQL data and reverse-proxy configuration will not be modified' "$ROOT/deploy/purge.sh" \
|| fail 'purge boundary is no longer explicit' || 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' printf 'Local purge and recovery fixture passed\n'
+142 -12
View File
@@ -65,10 +65,14 @@ write_mock_commands() {
output= output=
url= url=
header_file= header_file=
write_out=
http_status=200
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}" printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in 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) --header|-H)
shift shift
case "${1:-}" in @*) header_file=${1#@} ;; esac case "${1:-}" in @*) header_file=${1#@} ;; esac
@@ -90,6 +94,26 @@ esac
if [ -n "$output" ]; then if [ -n "$output" ]; then
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output" 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 elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then
cp "$MOCK_APP_ROOT/current/public/index.html" "$output" cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
else else
@@ -104,6 +128,7 @@ elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
else else
exit 22 exit 22
fi fi
[ -z "$write_out" ] || printf '%s' "$http_status"
SH SH
cat > "$mock_bin/systemctl" <<'SH' cat > "$mock_bin/systemctl" <<'SH'
@@ -116,6 +141,11 @@ SH
cat > "$mock_bin/flock" <<'SH' cat > "$mock_bin/flock" <<'SH'
#!/bin/sh #!/bin/sh
exit 0 exit 0
SH
cat > "$mock_bin/setsid" <<'SH'
#!/bin/sh
exec "$@"
SH SH
cat > "$mock_bin/systemd-analyze" <<'SH' cat > "$mock_bin/systemd-analyze" <<'SH'
@@ -191,10 +221,14 @@ build_release() {
local unit_prefix=${3:-new} local unit_prefix=${3:-new}
local stage="$fixture/stage" local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops" mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ if [ ! -s "$WORK/fixture-private.pem" ]; then
-out "$fixture/private.pem" >/dev/null 2>&1 "$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \ -out "$WORK/fixture-private.pem" >/dev/null 2>&1
-out "$fixture/release-public.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 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html" printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION" printf '%s\n' "$version" > "$stage/VERSION"
@@ -211,10 +245,12 @@ build_release() {
local artifact="kaidi-finance-$version.tar.gz" local artifact="kaidi-finance-$version.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" . 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}') sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \ size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]')
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \ 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" > "$fixture/release/release-manifest.json"
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \ "$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json" -out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
@@ -269,6 +305,7 @@ write_request() {
download_and_prepare_install() { download_and_prepare_install() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY' || fail 'download phase did not persist READY'
@@ -276,6 +313,16 @@ download_and_prepare_install() {
|| fail 'download phase changed the active application' || fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \ [ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|| fail 'download phase did not persist the verified artifact cache' || 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 write_request "$fixture" "$version" INSTALL
} }
@@ -407,7 +454,7 @@ assert_identical_systemd_operations_case() {
prepare_installation "$fixture" "$version" prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version" download_and_prepare_install "$fixture" "$version"
: > "$fixture/systemctl.log" truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application' || 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" prepare_installation "$fixture" "$version"
download_and_prepare_install "$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' fail 'incomplete rollback case unexpectedly succeeded'
fi fi
grep -Fq 'manual recovery is required' "$fixture/update.log" \ grep -Fq 'manual recovery is required' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery' || fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request' || fail 'incomplete rollback case left an automatically retriggered processing request'
[ -d "$fixture/state/transactions/active" ] \ [ ! -e "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case did not preserve transaction evidence' || 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 . \ find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request' || 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() { assert_download_failure_case() {
@@ -526,6 +589,58 @@ assert_database_failure_case() {
|| fail 'database failure did not persist FAILED' || 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() { assert_symlink_request_rejected() {
local fixture="$WORK/symlink-request" local fixture="$WORK/symlink-request"
local version='1.0.0-preview.2' 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' || fail 'install without cache changed the active application'
} }
printf '[update-fixture] success\n'
assert_success_case assert_success_case
printf '[update-fixture] identical-systemd-operations\n'
assert_identical_systemd_operations_case assert_identical_systemd_operations_case
printf '[update-fixture] path-watcher-failure\n'
assert_update_path_failure_keeps_application_case assert_update_path_failure_keeps_application_case
printf '[update-fixture] rollback\n'
assert_rollback_case assert_rollback_case
printf '[update-fixture] incomplete-rollback-recovery-lock\n'
assert_incomplete_rollback_requires_manual_recovery_case assert_incomplete_rollback_requires_manual_recovery_case
printf '[update-fixture] download-failure\n'
assert_download_failure_case assert_download_failure_case
printf '[update-fixture] database-backup-failure\n'
assert_database_failure_case 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 assert_symlink_request_rejected
printf '[update-fixture] missing-verified-cache\n'
assert_install_without_verified_cache_rejected assert_install_without_verified_cache_rejected
printf '[update-fixture] private-gitea\n'
assert_private_gitea_release_case assert_private_gitea_release_case
printf '[update-fixture] cross-origin-gitea\n'
assert_cross_origin_gitea_browser_url_ignored assert_cross_origin_gitea_browser_url_ignored
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n' 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) release-manifest.json)
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \ [ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|| { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; } || { 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 ] \ [ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; } || { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
jq -e --arg version "$VERSION" \ 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 printf 'Release archive contains private key material\n' >&2
exit 1 exit 1
fi 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" ] \ [ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; } || { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar" tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"
(cd "$WORK" && jar -xf 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' \ JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties") "$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r') "$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; } || { 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() { compare_archive_file() {
archive_path=$1 archive_path=$1