Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2d472b5c | ||
|
|
7cf71ba7c3 | ||
|
|
ec939fa1fd | ||
|
|
36f1a8b00d | ||
|
|
69f738465e | ||
|
|
2712c5b8c1 | ||
|
|
5a48993d26 | ||
|
|
0b91e1fc5f | ||
|
|
95cd1daeb8 | ||
|
|
d6e97f1b4a | ||
|
|
a975c75827 | ||
|
|
8eb1cbad83 | ||
|
|
cea7048f6c | ||
|
|
10d2e2f0d1 | ||
|
|
bb5e63aaf0 | ||
|
|
2cf29c030a | ||
|
|
6d4ae00ae0 | ||
|
|
3c7847c8f5 | ||
|
|
d61bdf9da4 | ||
|
|
7237792424 | ||
|
|
cfdc0fed44 |
@@ -10,7 +10,9 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-24.04
|
# The release runner is an explicitly registered host label. Keeping the
|
||||||
|
# label stable avoids silently queueing when the server has no ubuntu VM.
|
||||||
|
runs-on: kaidi-release
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -18,16 +20,25 @@ 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 ls-files --others --exclude-standard)"
|
||||||
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
|
git diff --quiet --ignore-space-at-eol --ignore-cr-at-eol \
|
||||||
|
|| { printf 'Release checkout has non-whitespace tracked changes:\n' >&2; git diff --stat >&2; exit 1; }
|
||||||
|
{
|
||||||
|
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 +66,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
|
||||||
|
|
||||||
@@ -68,7 +83,7 @@ jobs:
|
|||||||
./scripts/test-baota-start-fixture.sh
|
./scripts/test-baota-start-fixture.sh
|
||||||
./scripts/test-purge-fixture.sh
|
./scripts/test-purge-fixture.sh
|
||||||
./scripts/test-gitea-publish-fixture.sh
|
./scripts/test-gitea-publish-fixture.sh
|
||||||
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh \
|
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh deploy/baota-init.sh \
|
||||||
deploy/purge.sh \
|
deploy/purge.sh \
|
||||||
scripts/check-semver.sh \
|
scripts/check-semver.sh \
|
||||||
scripts/package-release.sh scripts/publish-gitea-release.sh \
|
scripts/package-release.sh scripts/publish-gitea-release.sh \
|
||||||
@@ -83,14 +98,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 +117,20 @@ 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's runner leaves github.server_url/repository empty on some
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
# host-mode tag events; keep the release destination explicit.
|
||||||
GITEA_REF_NAME: ${{ github.ref_name }}
|
GITEA_SERVER_URL: https://git.awaioi.com
|
||||||
GITEA_SHA: ${{ github.sha }}
|
GITEA_REPOSITORY: ERP-Team/kaidi
|
||||||
|
GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }}
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,20 +44,43 @@ npm run build
|
|||||||
./scripts/export-openapi.sh http://127.0.0.1:18080
|
./scripts/export-openapi.sh http://127.0.0.1:18080
|
||||||
```
|
```
|
||||||
|
|
||||||
## R1 Preview 一键安装
|
## R1 Preview 部署方式
|
||||||
|
|
||||||
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取
|
代码仓库和 Release 已公开,提供两种互斥方式:
|
||||||
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。
|
|
||||||
安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。
|
|
||||||
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
|
|
||||||
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
|
|
||||||
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
|
|
||||||
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
|
|
||||||
|
|
||||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
|
1. **systemd 一键安装(推荐)**:程序独占 `/opt/kaidi` 和 `kaidi-finance.service`,不创建宝塔 Java 项目。
|
||||||
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
|
2. **宝塔手动部署**:下载压缩包后解压并运行本地初始化脚本,再创建 Spring Boot 项目。
|
||||||
外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
|
||||||
`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
|
两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x,
|
||||||
|
PostgreSQL 18 兼容工作继续冻结。
|
||||||
|
|
||||||
|
### 生产部署契约(先看这里)
|
||||||
|
|
||||||
|
- **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是
|
||||||
|
`https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。
|
||||||
|
- 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。
|
||||||
|
- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才备份数据库、切换 `current` 并重启服务。更新器下载的是 Release 制品,不是 Git 工作树。
|
||||||
|
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
|
||||||
|
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
|
||||||
|
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
|
||||||
|
|
||||||
|
### 32 位 Linux 支持边界
|
||||||
|
|
||||||
|
当前支持的是 **i386/i486/i586/i686 + glibc + systemd + 可运行的 32 位 Java 17**。安装器会校验用户空间位数、Java 架构、glibc 和 `/lib/ld-linux.so.2`(兼容 `/lib32`、`/lib/i386-linux-gnu` 路径);musl 或缺少 32 位加载器的系统会在安装前明确失败,不会安装后才出现无法启动。
|
||||||
|
|
||||||
|
32 位服务器应使用首次访问 `/setup` 的向导输入外部 MySQL 8.4 连接信息,这条路径不需要在服务器安装 MySQL 客户端。在线更新前仍需准备与数据库兼容的 `mysqldump`;如果 32 位系统无法提供该客户端,应先关闭在线更新或从独立备份机执行数据库备份,不要在更新过程中临时安装数据库。
|
||||||
|
|
||||||
|
### systemd 一键安装(推荐)
|
||||||
|
|
||||||
|
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
|
||||||
|
`/www/server/java/*/bin/java`);没有可用 Java 时才下载匹配架构的运行时。它不会安装 MySQL,也不会要求宝塔面板提供
|
||||||
|
`kaidi` 用户。安装完成后查看 `/root/kaidi-first-login.txt`,通过反向代理域名进入 `/setup`。
|
||||||
|
|
||||||
### 彻底清理旧安装
|
### 彻底清理旧安装
|
||||||
|
|
||||||
@@ -62,12 +88,13 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发
|
|||||||
随后执行一键清理:
|
随后执行一键清理:
|
||||||
|
|
||||||
```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.41/purge.sh \
|
||||||
-o /tmp/kaidi-purge.sh \
|
-o /tmp/kaidi-purge.sh \
|
||||||
&& printf '%s %s\n' c49c5460260d2c67d88aced99463e3d98a67f8ef0309f5c8d5d8a8f5e4addf36 /tmp/kaidi-purge.sh \
|
&& curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/SHA256SUMS \
|
||||||
| sha256sum -c - \
|
-o /tmp/kaidi-SHA256SUMS \
|
||||||
|
&& (cd /tmp && grep ' purge.sh$' kaidi-SHA256SUMS | 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 /tmp/kaidi-SHA256SUMS
|
||||||
```
|
```
|
||||||
|
|
||||||
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
|
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
|
||||||
@@ -76,201 +103,131 @@ 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 数据库,旧数据库保留用于回滚。
|
||||||
|
|
||||||
### 直接 curl 安装
|
### Preview.40 直链与宝塔手动部署
|
||||||
|
|
||||||
|
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||||
|
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
|
||||||
|
|
||||||
|
下载地址:
|
||||||
|
|
||||||
|
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/kaidi-finance-1.0.0-preview.41.tar.gz`
|
||||||
|
|
||||||
|
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/SHA256SUMS`
|
||||||
|
|
||||||
|
服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机
|
||||||
|
已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
|
||||||
|
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser`
|
||||||
|
以及与外部 MySQL 兼容的 `mysqldump`。安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的
|
||||||
|
`/www/server/mysql/bin/` 路径;如工具安装在其他位置,可分别设置 `KAIDI_MYSQL_CLIENT` 和
|
||||||
|
`KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。
|
||||||
|
|
||||||
|
#### 1. 下载、校验、解压
|
||||||
|
|
||||||
|
以下命令只做文件下载和解压,不执行远程脚本。`VERSION` 必须替换成发布版本;归档内的 `app.jar`、`public/`、`ops/`
|
||||||
|
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash
|
VERSION=1.0.0-preview.41
|
||||||
|
APP_ROOT=/www/wwwroot/kaidi
|
||||||
|
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
||||||
|
sudo install -d -m 0755 "$RELEASE_ROOT"
|
||||||
|
wget -O "/tmp/kaidi-finance-$VERSION.tar.gz" \
|
||||||
|
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz"
|
||||||
|
wget -O /tmp/kaidi-SHA256SUMS \
|
||||||
|
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/SHA256SUMS"
|
||||||
|
(cd /tmp && grep " kaidi-finance-$VERSION.tar.gz$" kaidi-SHA256SUMS | sha256sum -c -)
|
||||||
|
sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
|
||||||
```
|
```
|
||||||
|
|
||||||
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
|
如果服务器没有 `wget`,可使用浏览器下载后上传同一文件;`curl -fL URL -o FILE` 也只用于下载压缩包,不能替代初始化脚本。
|
||||||
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
|
|
||||||
|
#### 2. 执行本地初始化
|
||||||
|
|
||||||
|
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置和一次性安装码;不会下载
|
||||||
|
Java、安装 MySQL 或修改反向代理。宝塔手动模式不会开放在线更新按钮。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \
|
sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
|
||||||
| sudo env KAIDI_APP_PORT=19090 bash
|
sudo cat /root/kaidi-first-login.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java
|
初始化不会安装或启用 `kaidi-update.service`/`kaidi-update.path`,避免宝塔守护进程与
|
||||||
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
|
systemd 更新器同时管理同一应用;宝塔模式只支持手动替换 Release,后台在线更新请使用 systemd 安装模式。
|
||||||
|
|
||||||
```bash
|
初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh -o /tmp/kaidi-install.sh
|
|
||||||
printf '%s %s\n' 6256b05025aa314d4c02e7c0f5d2ca530f28ec5d10f833504b34c09ada6e02a5 /tmp/kaidi-install.sh | sha256sum -c -
|
|
||||||
sudo bash /tmp/kaidi-install.sh
|
|
||||||
rm -f /tmp/kaidi-install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 从固定 Git tag 拉取后安装
|
#### 3. 宝塔 Spring Boot 项目字段
|
||||||
|
|
||||||
需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。公共仓库不要求凭据,
|
在宝塔面板创建项目,类型选择 **Spring Boot**,不要选择 Tomcat。填写如下:
|
||||||
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
|
||||||
|
|
||||||
```bash
|
| 字段 | 填写值 |
|
||||||
git clone --branch v1.0.0-preview.24 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
| --- | --- |
|
||||||
cd kaidi-preview
|
| 项目路径 | `/www/wwwroot/kaidi/current` |
|
||||||
./deploy/install-from-git.sh
|
| 项目名称 | `kaidi-finance` |
|
||||||
```
|
| 项目 JDK | `JDK 17` |
|
||||||
|
| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` |
|
||||||
|
| 启动用户 | `kaidi` |
|
||||||
|
| 项目端口 | `18080`(与初始化参数一致) |
|
||||||
|
| 绑定域名 | 业务域名,例如 `fi.awaioi.com` |
|
||||||
|
| 后端路由 | `/` |
|
||||||
|
| 前端资源 | 留空,Spring Boot 直接提供 `public/` |
|
||||||
|
| 进程守护 | 开启 |
|
||||||
|
| 项目意外重启 | 勾选 |
|
||||||
|
|
||||||
首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。
|
**环境变量全部留空,不要创建 DB_URL、DB_USERNAME、DB_PASSWORD 或 FIELD_ENCRYPTION_KEY。**
|
||||||
只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。
|
`ops/baota.env.example` 仅是说明文件,不能把其中的占位值粘贴到面板。启动器先读取 `/etc/kaidi/kaidi.env`,向导完成后
|
||||||
|
再读取 `/var/lib/kaidi/setup/application.env`;宝塔只有填写了非空覆盖值时才会覆盖受保护配置。数据库密码不会出现在
|
||||||
|
Java 启动命令或 `ps` 参数中。
|
||||||
|
|
||||||
安装器会完成以下动作:
|
#### 4. 首次向导与反向代理
|
||||||
|
|
||||||
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
|
启动宝塔项目后,访问 `https://业务域名/setup`,输入 `/root/kaidi-first-login.txt` 中的一次性安装码,填写外部 MySQL
|
||||||
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
|
连接信息和系统管理员账号密码。迁移完成后应用会退出一次,宝塔守护进程会自动拉起正式模式;再次访问域名根路径即可登录。
|
||||||
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。
|
首次配置数据库密码必须使用 HTTPS;没有域名时通过 SSH 隧道访问回环端口:
|
||||||
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
|
|
||||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
|
|
||||||
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
|
||||||
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
|
|
||||||
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
|
||||||
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
|
|
||||||
|
|
||||||
### 环境变量与密钥安全
|
|
||||||
|
|
||||||
首次安装不需要在命令行或宝塔项目中填写数据库环境变量。数据库地址、账号和密码只在 `/setup` 向导中提交,
|
|
||||||
完成后写入 `/var/lib/kaidi/setup/application.env`;字段加密密钥由安装器生成并写入
|
|
||||||
`/etc/kaidi/kaidi.env`。两个文件不会把秘密放进 Java 启动参数或 `ps` 输出,并由 Linux 文件权限限制为
|
|
||||||
root 或专用 `kaidi` 服务账号读取。运行中的 Java 仍必须持有这些值,因此 root 和该服务账号始终属于受信边界。
|
|
||||||
|
|
||||||
不要通过公网 HTTP 提交数据库密码。应先为域名配置有效 HTTPS,或通过 SSH 端口转发访问本机回环地址完成向导:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 18080:127.0.0.1:18080 root@SERVER_IP
|
ssh -L 18080:127.0.0.1:18080 root@SERVER_IP
|
||||||
```
|
```
|
||||||
|
|
||||||
然后在本机浏览器打开 `http://127.0.0.1:18080/setup`。向导完成后自动锁定,安装码不能再次使用。
|
本机浏览器打开 `http://127.0.0.1:18080/setup`。反向代理只需把整个 `/` 转发到 `http://127.0.0.1:18080`,并保留以下请求头:
|
||||||
|
|
||||||
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后,
|
|
||||||
通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
|
|
||||||
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
|
|
||||||
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
|
|
||||||
|
|
||||||
以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件:
|
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:19090;
|
proxy_pass http://127.0.0.1:18080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息:
|
HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为 `true`,再从宝塔面板重启项目。健康检查:
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:19090/actuator/health | jq -e '.status == "UP"'
|
|
||||||
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
|
|
||||||
sudo cat /root/kaidi-first-login.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux 32 位
|
|
||||||
|
|
||||||
应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的
|
|
||||||
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
|
||||||
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
|
|
||||||
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
|
|
||||||
排序规则,全部通过后 Flyway 才会建表。
|
|
||||||
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
|
||||||
CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD';
|
|
||||||
GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修复性重装
|
|
||||||
|
|
||||||
正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装;
|
|
||||||
安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、
|
|
||||||
管理员数据和运维人员新增的环境变量:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \
|
|
||||||
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
|
|
||||||
```
|
|
||||||
|
|
||||||
没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整,
|
|
||||||
不会把恢复失败吞掉。新版本安装器在 `KAIDI_REINSTALL=true` 时默认使用生产修复模式,显式的
|
|
||||||
`KAIDI_SETUP_WIZARD=false` 仅用于兼容旧版本安装器。
|
|
||||||
|
|
||||||
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \
|
|
||||||
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=true bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### 宝塔面板手动部署
|
|
||||||
|
|
||||||
宝塔使用 **Spring Boot** 项目类型。发布物是包含嵌入式 Tomcat 的可执行 JAR,不使用外置 Tomcat 项目。
|
|
||||||
先下载并解压签名 Release;项目目录必须指向同时包含 `app.jar`、`VERSION`、`public/` 和 `ops/` 的目录。
|
|
||||||
推荐采用版本目录加 `current` 软链接:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
VERSION=1.0.0-preview.24
|
|
||||||
RELEASE_ROOT=/www/wwwroot/kaidi/releases/$VERSION
|
|
||||||
sudo install -d -o root -g kaidi -m 0750 "$RELEASE_ROOT"
|
|
||||||
curl -fL "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz" \
|
|
||||||
-o "/tmp/kaidi-finance-$VERSION.tar.gz"
|
|
||||||
sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
|
|
||||||
sudo chown -R root:kaidi "$RELEASE_ROOT"
|
|
||||||
sudo find "$RELEASE_ROOT" -type d -exec chmod 0750 {} +
|
|
||||||
sudo find "$RELEASE_ROOT" -type f -exec chmod 0640 {} +
|
|
||||||
sudo chmod 0750 "$RELEASE_ROOT/ops/baota-start.sh"
|
|
||||||
sudo ln -sfn "$RELEASE_ROOT" /www/wwwroot/kaidi/current.next
|
|
||||||
sudo mv -Tf /www/wwwroot/kaidi/current.next /www/wwwroot/kaidi/current
|
|
||||||
```
|
|
||||||
|
|
||||||
宝塔 Java 项目字段:
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
| --- | --- |
|
|
||||||
| 项目类型 | Spring Boot |
|
|
||||||
| 项目路径 | `/www/wwwroot/kaidi/current` |
|
|
||||||
| 项目名称 | `kaidi-finance` |
|
|
||||||
| 项目 JDK | JDK 17 |
|
|
||||||
| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` |
|
|
||||||
| 启动用户 | `kaidi`,禁止使用 root |
|
|
||||||
| 项目端口 | `18080` |
|
|
||||||
| 绑定域名 | 实际业务域名,例如 `fi.awaioi.com` |
|
|
||||||
| 后端路由 | `/` |
|
|
||||||
| 前端资源 | 留空,由 Spring Boot 提供 `public/` |
|
|
||||||
| 进程守护 | 开启 |
|
|
||||||
| 项目意外重启 | 勾选 |
|
|
||||||
|
|
||||||
环境变量按 `ops/baota.env.example` 填入宝塔。已完成安装的服务器必须沿用
|
|
||||||
`/var/lib/kaidi/setup/application.env` 中的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD`,以及
|
|
||||||
`/etc/kaidi/kaidi.env` 中的 `FIELD_ENCRYPTION_KEY`;不要生成新的字段加密密钥。HTTP Preview 使用
|
|
||||||
`SESSION_COOKIE_SECURE=false`,切换到 HTTPS 后改为 `true`。宝塔模式固定设置
|
|
||||||
`FINANCE_UPDATE_ENABLED=false`,版本更新通过上传新目录、切换 `current`、重启项目完成。
|
|
||||||
|
|
||||||
启用宝塔守护前停用原 systemd 进程,避免两个进程管理器反复争用端口或互相重启:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl disable --now kaidi-update.path kaidi-finance.service
|
|
||||||
sudo systemctl stop kaidi-update.service
|
|
||||||
```
|
|
||||||
|
|
||||||
启动后在服务器验证 Java 上游,再配置域名反向代理:
|
|
||||||
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 5. 手动部署模式的更新边界
|
||||||
|
|
||||||
|
宝塔手动部署不启用 Preview 的在线更新契约。需要升级时,先停止宝塔项目,下载并校验新的 Release 压缩包,
|
||||||
|
解压到新的 `releases/<VERSION>`,再由宝塔项目切换路径并启动;确认健康检查通过后再删除旧版本。
|
||||||
|
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
|
||||||
|
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
|
||||||
|
|
||||||
|
手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
|
||||||
|
|
||||||
|
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
|
||||||
|
|
||||||
|
### Linux 32 位
|
||||||
|
|
||||||
|
压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
|
||||||
|
MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的
|
||||||
|
`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。
|
||||||
|
|
||||||
### 停用并移除程序
|
### 停用并移除程序
|
||||||
|
|
||||||
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
|
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
|
||||||
@@ -296,67 +253,68 @@ 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.24
|
git tag v1.0.0-preview.41
|
||||||
git push origin v1.0.0-preview.24
|
git push origin v1.0.0-preview.41
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
||||||
更新源由 root 在 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交
|
`/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。
|
||||||
URL、Token、脚本或命令。更新流程固定为:
|
**Preview 的稳定在线更新契约只支持 systemd 一键安装模式**:更新器由
|
||||||
|
`kaidi-update.service` 执行,直接停止、切换并重启 `kaidi-finance.service`,不依赖宝塔面板的意外重启。
|
||||||
|
宝塔手动部署仅用于手动启动和反向代理;其面板进程管理与 systemd 更新器不是同一套生命周期,暂不作为在线更新的稳定路径。
|
||||||
|
|
||||||
1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、
|
更新流程固定为:
|
||||||
SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/<version>`。业务服务不停机。
|
|
||||||
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
|
|
||||||
的包不能进入安装。
|
|
||||||
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
|
|
||||||
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
|
|
||||||
`mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
|
|
||||||
6. 校验更新脚本和 systemd 单元后,在旧 Java 仍运行时原子切换版本链接,再由 systemd 执行一次 `restart`,不修改反向代理。
|
|
||||||
7. 同时检查 Java 健康端点和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
|
|
||||||
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
|
|
||||||
|
|
||||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
|
1. 点击“获取版本”,只实时请求 Gitea Latest Release 版本、发布时间和发布说明,不排队下载。
|
||||||
不会延迟服务端切换。失败请求会被归档且不会由 systemd 自动重试,避免同一事务反复停止业务服务。
|
2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、
|
||||||
|
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
|
||||||
|
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
|
||||||
|
4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
|
||||||
|
5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
|
||||||
|
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
|
||||||
|
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
|
||||||
|
保留数据库备份和事务证据供人工处理。
|
||||||
|
|
||||||
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为
|
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
|
||||||
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像,
|
查看状态和日志:
|
||||||
可由运维人员在这两个文件中配置独立只读 Token,Token 不写入页面、状态 JSON、审计参数或更新日志。
|
|
||||||
|
|
||||||
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl status kaidi-finance kaidi-update.path
|
sudo systemctl status kaidi-update.path 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
|
||||||
```
|
```
|
||||||
|
|
||||||
如果 `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.24
|
./scripts/package-release.sh 1.0.0-preview.41
|
||||||
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
|
||||||
```
|
```
|
||||||
@@ -367,5 +325,4 @@ SBOM、首次安装指纹文件和完整应用压缩包。
|
|||||||
JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
|
JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
|
||||||
签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
|
签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
|
||||||
`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
|
`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
|
||||||
发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入
|
发布命令同时输出 Release 公钥和归档校验值;将压缩包 SHA-256 写入直链部署说明后再发布。
|
||||||
受控部署记录,再替换上述一键安装命令中的占位符。
|
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-7
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+75
@@ -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);
|
||||||
|
|||||||
Executable
+216
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
SERVICE_USER=kaidi
|
||||||
|
SERVICE_GROUP=kaidi
|
||||||
|
CONFIG_ROOT=/etc/kaidi
|
||||||
|
STATE_ROOT=/var/lib/kaidi
|
||||||
|
UPDATE_STATE_ROOT=/var/lib/kaidi-update
|
||||||
|
LOG_ROOT=/var/log/kaidi
|
||||||
|
PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
|
||||||
|
RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
|
||||||
|
INIT_ARMED=false
|
||||||
|
INIT_COMMITTED=false
|
||||||
|
INIT_RELEASE_ROOT=
|
||||||
|
INIT_APP_ROOT=
|
||||||
|
|
||||||
|
log() { printf '[kaidi-baota-init] %s\n' "$*"; }
|
||||||
|
die() { printf '[kaidi-baota-init] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
rollback_initialization() {
|
||||||
|
local rc=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then
|
||||||
|
systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true
|
||||||
|
systemctl stop kaidi-update.service >/dev/null 2>&1 || true
|
||||||
|
rm -f /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
rm -f "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" \
|
||||||
|
"$CONFIG_ROOT/release-public.pem" \
|
||||||
|
"$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt
|
||||||
|
rm -f "$INIT_APP_ROOT/current.next"
|
||||||
|
if [ -n "$INIT_RELEASE_ROOT" ] \
|
||||||
|
&& [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then
|
||||||
|
rm -f "$INIT_APP_ROOT/current"
|
||||||
|
fi
|
||||||
|
log "Initialization failed; files created by this attempt were rolled back and the command can be retried"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap rollback_initialization EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
sha256_file() {
|
||||||
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
random_secret() {
|
||||||
|
openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36
|
||||||
|
}
|
||||||
|
|
||||||
|
write_env_file() {
|
||||||
|
local output=$1 temporary name value escaped
|
||||||
|
shift
|
||||||
|
temporary="${output}.next.$$"
|
||||||
|
: > "$temporary"
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
name=$1
|
||||||
|
value=$2
|
||||||
|
shift 2
|
||||||
|
case "$value" in *$'\n'*|*$'\r'*) die "$name contains a line break" ;; esac
|
||||||
|
escaped=${value//\\/\\\\}
|
||||||
|
escaped=${escaped//\"/\\\"}
|
||||||
|
printf '%s="%s"\n' "$name" "$escaped" >> "$temporary"
|
||||||
|
done
|
||||||
|
mv -f "$temporary" "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_service_identity() {
|
||||||
|
local existing_home nologin_path
|
||||||
|
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||||
|
groupadd --system "$SERVICE_GROUP"
|
||||||
|
fi
|
||||||
|
if id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
|
||||||
|
[ "$existing_home" = "$STATE_ROOT" ] \
|
||||||
|
|| die "Existing user $SERVICE_USER has unexpected home directory $existing_home"
|
||||||
|
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|
||||||
|
|| die "Existing user $SERVICE_USER is not a member of group $SERVICE_GROUP"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
nologin_path=$(command -v nologin 2>/dev/null || true)
|
||||||
|
[ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
|
||||||
|
[ -x "$nologin_path" ] || die "A nologin shell is required"
|
||||||
|
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|
||||||
|
[ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
|
||||||
|
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \
|
||||||
|
|| die "systemd is required for the privileged background updater"
|
||||||
|
for command in find openssl sha256sum; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || die "$command is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
|
||||||
|
release_root=$(cd "$script_dir/.." && pwd -P)
|
||||||
|
releases_root=$(dirname "$release_root")
|
||||||
|
app_root=$(dirname "$releases_root")
|
||||||
|
[ "$(basename "$releases_root")" = releases ] \
|
||||||
|
|| die "Extract the release into APP_ROOT/releases/VERSION before initialization"
|
||||||
|
[ "$app_root" = /www/wwwroot/kaidi ] \
|
||||||
|
|| die "The supported Baota project root is /www/wwwroot/kaidi"
|
||||||
|
INIT_RELEASE_ROOT=$release_root
|
||||||
|
INIT_APP_ROOT=$app_root
|
||||||
|
version=$(tr -d '\r\n' < "$release_root/VERSION")
|
||||||
|
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.+-]+)?$ ]] \
|
||||||
|
&& [ -s "$release_root/app.jar" ] && [ -s "$release_root/public/index.html" ] \
|
||||||
|
|| die "The extracted release is incomplete"
|
||||||
|
[ -x "$release_root/ops/update.sh" ] && [ -x "$release_root/ops/baota-start.sh" ] \
|
||||||
|
&& [ -x "$release_root/ops/baota-init.sh" ] \
|
||||||
|
|| die "The extracted operations scripts are incomplete"
|
||||||
|
[ -s "$release_root/ops/release-public.pem" ] \
|
||||||
|
|| die "The release public key is missing"
|
||||||
|
[ "$(sha256_file "$release_root/ops/release-public.pem")" = "$PUBLIC_KEY_SHA256" ] \
|
||||||
|
|| die "The release public-key fingerprint is invalid"
|
||||||
|
[ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \
|
||||||
|
&& [ ! -e "$STATE_ROOT/setup/locked" ] \
|
||||||
|
|| die "Kaidi is already initialized; run the published purge workflow before a fresh installation"
|
||||||
|
! systemctl cat kaidi-finance.service >/dev/null 2>&1 \
|
||||||
|
|| die "The old kaidi-finance.service still exists; run the published purge workflow first"
|
||||||
|
[ ! -e /etc/systemd/system/kaidi-update.service ] \
|
||||||
|
&& [ ! -e /etc/systemd/system/kaidi-update.path ] \
|
||||||
|
|| die "Old Kaidi update units still exist; run the published purge workflow first"
|
||||||
|
[ ! -e "$app_root/current" ] && [ ! -L "$app_root/current" ] \
|
||||||
|
|| die "The Baota current release link already exists; run the published purge workflow first"
|
||||||
|
|
||||||
|
app_port=${1:-18080}
|
||||||
|
[[ "$app_port" =~ ^[0-9]{1,5}$ ]] && [ "$app_port" -ge 1024 ] && [ "$app_port" -le 65535 ] \
|
||||||
|
|| die "Port must be an integer between 1024 and 65535"
|
||||||
|
|
||||||
|
INIT_ARMED=true
|
||||||
|
ensure_service_identity
|
||||||
|
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root"
|
||||||
|
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
|
||||||
|
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT"
|
||||||
|
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup"
|
||||||
|
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"
|
||||||
|
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT/inbox"
|
||||||
|
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$CONFIG_ROOT"
|
||||||
|
|
||||||
|
chown -R root:"$SERVICE_GROUP" "$release_root"
|
||||||
|
find "$release_root" -type d -exec chmod 0750 {} +
|
||||||
|
find "$release_root" -type f -exec chmod 0640 {} +
|
||||||
|
chmod 0750 "$release_root/ops/update.sh" "$release_root/ops/baota-start.sh" "$release_root/ops/baota-init.sh"
|
||||||
|
ln -sfn "$release_root" "$app_root/current.next"
|
||||||
|
mv -Tf "$app_root/current.next" "$app_root/current"
|
||||||
|
|
||||||
|
field_key=$(openssl rand -base64 32 | tr -d '\n')
|
||||||
|
setup_code="KD-$(random_secret)"
|
||||||
|
setup_hash=$(printf '%s' "$setup_code" | sha256sum | awk '{print $1}')
|
||||||
|
# The Baota process manager does not own a restartable application unit.
|
||||||
|
# Keep its updater files for diagnostics, but do not expose online update
|
||||||
|
# actions until the panel lifecycle is integrated with the transaction state machine.
|
||||||
|
# The setup context has no datasource. Empty values avoid Baota treating
|
||||||
|
# a development placeholder as a real local MySQL dependency.
|
||||||
|
write_env_file "$CONFIG_ROOT/kaidi.env" \
|
||||||
|
SPRING_PROFILES_ACTIVE production \
|
||||||
|
SERVER_ADDRESS 127.0.0.1 \
|
||||||
|
SERVER_PORT "$app_port" \
|
||||||
|
SESSION_COOKIE_SECURE false \
|
||||||
|
DB_URL '' \
|
||||||
|
DB_USERNAME '' \
|
||||||
|
DB_PASSWORD '' \
|
||||||
|
FIELD_ENCRYPTION_KEY "$field_key" \
|
||||||
|
FILE_STORAGE_ROOT "$STATE_ROOT/files" \
|
||||||
|
FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \
|
||||||
|
FILE_SCANNER_ENABLED false \
|
||||||
|
FINANCE_BOOTSTRAP_ENABLED false \
|
||||||
|
FINANCE_BOOTSTRAP_PASSWORD '' \
|
||||||
|
FINANCE_SETUP_ENABLED true \
|
||||||
|
FINANCE_SETUP_TOKEN_SHA256 "$setup_hash" \
|
||||||
|
FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \
|
||||||
|
FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \
|
||||||
|
FINANCE_SETUP_RESTART_AFTER_COMPLETE true \
|
||||||
|
FINANCE_UPDATE_ENABLED false \
|
||||||
|
UPDATE_RELEASE_BASE_URL '' \
|
||||||
|
UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \
|
||||||
|
UPDATE_RELEASE_TOKEN '' \
|
||||||
|
UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \
|
||||||
|
UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \
|
||||||
|
KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid"
|
||||||
|
chown root:"$SERVICE_GROUP" "$CONFIG_ROOT/kaidi.env"
|
||||||
|
chmod 0640 "$CONFIG_ROOT/kaidi.env"
|
||||||
|
|
||||||
|
# Baota owns the Java process in this mode. Do not install a second
|
||||||
|
# systemd updater; online update is intentionally systemd-only until the
|
||||||
|
# panel lifecycle can participate in the transaction state machine.
|
||||||
|
install -m 0644 "$release_root/ops/release-public.pem" "$CONFIG_ROOT/release-public.pem"
|
||||||
|
|
||||||
|
printf '{"state":"CURRENT","message":"Baota release is prepared","targetVersion":"%s","updatedAt":"%s"}\n' \
|
||||||
|
"$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json"
|
||||||
|
chmod 0644 "$UPDATE_STATE_ROOT/status.json"
|
||||||
|
cat > /root/kaidi-first-login.txt <<EOF
|
||||||
|
Project path: $app_root/current
|
||||||
|
Start command: $app_root/current/ops/baota-start.sh
|
||||||
|
Reverse proxy target: http://127.0.0.1:$app_port
|
||||||
|
Setup URL: /setup
|
||||||
|
Setup code: $setup_code
|
||||||
|
Version: $version
|
||||||
|
EOF
|
||||||
|
chmod 0600 /root/kaidi-first-login.txt
|
||||||
|
|
||||||
|
INIT_COMMITTED=true
|
||||||
|
log "Manual deployment is initialized for Kaidi Finance $version"
|
||||||
|
log "Create the Baota Spring Boot project with $app_root/current"
|
||||||
|
log "Baota environment variables: leave empty"
|
||||||
|
log "Setup code: /root/kaidi-first-login.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+103
-4
@@ -1,25 +1,107 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
umask 027
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf '[kaidi-baota] ERROR: %s\n' "$1" >&2
|
printf '[kaidi-baota] ERROR: %s\n' "$1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd -P)
|
||||||
APP_JAR="$RELEASE_ROOT/app.jar"
|
APP_JAR="$RELEASE_ROOT/app.jar"
|
||||||
PUBLIC_INDEX="$RELEASE_ROOT/public/index.html"
|
PUBLIC_INDEX="$RELEASE_ROOT/public/index.html"
|
||||||
VERSION_FILE="$RELEASE_ROOT/VERSION"
|
VERSION_FILE="$RELEASE_ROOT/VERSION"
|
||||||
|
CONFIG_FILE=${KAIDI_CONFIG_FILE:-/etc/kaidi/kaidi.env}
|
||||||
|
RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env}
|
||||||
|
|
||||||
|
decode_env_value() {
|
||||||
|
local raw=$1 result='' char next index=0 length
|
||||||
|
if [[ "$raw" == \"*\" && ${#raw} -ge 2 ]]; then
|
||||||
|
raw=${raw:1:${#raw}-2}
|
||||||
|
length=${#raw}
|
||||||
|
while [ "$index" -lt "$length" ]; do
|
||||||
|
char=${raw:index:1}
|
||||||
|
if [ "$char" = "\\" ] && [ $((index + 1)) -lt "$length" ]; then
|
||||||
|
next=${raw:index+1:1}
|
||||||
|
if [ "$next" = "\\" ] || [ "$next" = '"' ]; then
|
||||||
|
result+="$next"
|
||||||
|
index=$((index + 2))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
result+="$char"
|
||||||
|
index=$((index + 1))
|
||||||
|
done
|
||||||
|
printf '%s' "$result"
|
||||||
|
elif [[ "$raw" == \'*\' && ${#raw} -ge 2 ]]; then
|
||||||
|
printf '%s' "${raw:1:${#raw}-2}"
|
||||||
|
else
|
||||||
|
printf '%s' "$raw"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_managed_env_name() {
|
||||||
|
case "$1" in
|
||||||
|
SPRING_PROFILES_ACTIVE|SERVER_ADDRESS|SERVER_PORT|SESSION_COOKIE_SECURE|\
|
||||||
|
DB_URL|DB_USERNAME|DB_PASSWORD|FIELD_ENCRYPTION_KEY|\
|
||||||
|
FILE_STORAGE_ROOT|FILE_STORAGE_TEMP|FILE_SCANNER_ENABLED|FILE_SCANNER_HOST|FILE_SCANNER_PORT|\
|
||||||
|
FINANCE_BOOTSTRAP_ENABLED|FINANCE_BOOTSTRAP_PASSWORD|FINANCE_SETUP_ENABLED|\
|
||||||
|
FINANCE_SETUP_TOKEN_SHA256|FINANCE_SETUP_ENV_FILE|FINANCE_SETUP_MARKER_FILE|\
|
||||||
|
FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\
|
||||||
|
UPDATE_RELEASE_BASE_URL|UPDATE_RELEASE_API_URL|UPDATE_RELEASE_TOKEN|\
|
||||||
|
UPDATE_REQUEST_FILE|UPDATE_STATUS_FILE|UPDATE_CURRENT_VERSION_FILE|APP_VERSION|KAIDI_PID_FILE|KAIDI_JAVA_BIN)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file() {
|
||||||
|
local file=$1 line name raw value size
|
||||||
|
[ -e "$file" ] || return 0
|
||||||
|
[ -f "$file" ] && [ ! -L "$file" ] || die "Configuration file is not a regular file: $file"
|
||||||
|
[ -r "$file" ] || die "Startup user cannot read configuration file: $file"
|
||||||
|
size=$(wc -c < "$file" | tr -d '[:space:]')
|
||||||
|
[ "$size" -le 65536 ] || die "Configuration file is too large: $file"
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
case "$line" in ''|'#'*) continue ;; esac
|
||||||
|
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] \
|
||||||
|
|| die "Configuration file contains an invalid line: $file"
|
||||||
|
name=${BASH_REMATCH[1]}
|
||||||
|
raw=${BASH_REMATCH[2]}
|
||||||
|
is_managed_env_name "$name" || continue
|
||||||
|
[ -n "${!name:-}" ] && continue
|
||||||
|
value=$(decode_env_value "$raw")
|
||||||
|
printf -v "$name" '%s' "$value"
|
||||||
|
export "${name?}"
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
[ -s "$APP_JAR" ] || die "app.jar is missing from $RELEASE_ROOT"
|
[ -s "$APP_JAR" ] || die "app.jar is missing from $RELEASE_ROOT"
|
||||||
[ -s "$PUBLIC_INDEX" ] || die "public/index.html is missing from $RELEASE_ROOT"
|
[ -s "$PUBLIC_INDEX" ] || die "public/index.html is missing from $RELEASE_ROOT"
|
||||||
[ -s "$VERSION_FILE" ] || die "VERSION is missing from $RELEASE_ROOT"
|
[ -s "$VERSION_FILE" ] || die "VERSION is missing from $RELEASE_ROOT"
|
||||||
|
|
||||||
|
# The setup-generated runtime file has precedence over the base file. Non-empty
|
||||||
|
# variables supplied by Baota have precedence over both; empty panel fields do not
|
||||||
|
# mask the protected configuration written by the setup wizard. The systemd unit
|
||||||
|
# loads both files itself as root, then sets KAIDI_ENV_PRELOADED so the service
|
||||||
|
# user never needs traversal permission for /etc/kaidi (which is intentionally
|
||||||
|
# root-only).
|
||||||
|
if [ "${KAIDI_ENV_PRELOADED:-false}" != true ]; then
|
||||||
|
load_env_file "$RUNTIME_ENV_FILE"
|
||||||
|
load_env_file "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
JAVA_BIN=${KAIDI_JAVA_BIN:-}
|
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
|
||||||
@@ -29,21 +111,25 @@ 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}
|
||||||
export SERVER_PORT=${SERVER_PORT:-18080}
|
export SERVER_PORT=${SERVER_PORT:-18080}
|
||||||
export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false}
|
export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false}
|
||||||
export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false}
|
export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false}
|
||||||
export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-false}
|
export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-true}
|
||||||
export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false}
|
export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false}
|
||||||
export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/}
|
export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/}
|
||||||
export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE}
|
export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE}
|
||||||
export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")}
|
export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")}
|
||||||
|
export KAIDI_PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
|
||||||
|
|
||||||
if [ "$FINANCE_SETUP_ENABLED" != true ]; then
|
if [ "$FINANCE_SETUP_ENABLED" != true ]; then
|
||||||
for name in DB_URL DB_USERNAME DB_PASSWORD FIELD_ENCRYPTION_KEY FILE_STORAGE_ROOT FILE_STORAGE_TEMP; do
|
for name in DB_URL DB_USERNAME DB_PASSWORD FIELD_ENCRYPTION_KEY FILE_STORAGE_ROOT FILE_STORAGE_TEMP; do
|
||||||
[ -n "${!name:-}" ] || die "$name must be configured in the Baota project environment"
|
[ -n "${!name:-}" ] || die "$name is missing from the protected Kaidi configuration"
|
||||||
done
|
done
|
||||||
for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do
|
for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do
|
||||||
[ -d "$directory" ] || die "Storage directory $directory does not exist"
|
[ -d "$directory" ] || die "Storage directory $directory does not exist"
|
||||||
@@ -51,5 +137,18 @@ if [ "$FINANCE_SETUP_ENABLED" != true ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$KAIDI_PID_FILE" in /*) ;; *) die "KAIDI_PID_FILE must be an absolute path" ;; esac
|
||||||
|
[ -d "$(dirname "$KAIDI_PID_FILE")" ] || die "PID directory does not exist"
|
||||||
|
[ -w "$(dirname "$KAIDI_PID_FILE")" ] || die "Startup user cannot write the PID directory"
|
||||||
|
[ ! -L "$KAIDI_PID_FILE" ] || die "PID file must not be a symbolic link"
|
||||||
|
PID_START_TIME=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null || true)
|
||||||
|
[[ "$PID_START_TIME" =~ ^[0-9]+$ ]] || die "Process start time could not be read from /proc"
|
||||||
|
PID_TEMP="${KAIDI_PID_FILE}.next.$$"
|
||||||
|
printf '%s %s\n' "$$" "$PID_START_TIME" > "$PID_TEMP"
|
||||||
|
chmod 0640 "$PID_TEMP"
|
||||||
|
mv -f "$PID_TEMP" "$KAIDI_PID_FILE"
|
||||||
|
|
||||||
cd "$RELEASE_ROOT"
|
cd "$RELEASE_ROOT"
|
||||||
exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar "$APP_JAR"
|
exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 \
|
||||||
|
"-Dkaidi.release.path=$RELEASE_ROOT" "-Dkaidi.release.version=$APP_VERSION" \
|
||||||
|
-jar "$APP_JAR"
|
||||||
|
|||||||
+11
-14
@@ -1,14 +1,11 @@
|
|||||||
SPRING_PROFILES_ACTIVE=production
|
# Baota project environment variables are intentionally empty.
|
||||||
SERVER_ADDRESS=127.0.0.1
|
# Do not paste database passwords or placeholder values into the panel.
|
||||||
SERVER_PORT=18080
|
# ops/baota-start.sh loads the protected files created by ops/baota-init.sh:
|
||||||
SESSION_COOKIE_SECURE=false
|
# /etc/kaidi/kaidi.env
|
||||||
DB_URL=jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
|
# /var/lib/kaidi/setup/application.env (after the setup wizard completes)
|
||||||
DB_USERNAME=MYSQL_USERNAME
|
#
|
||||||
DB_PASSWORD=MYSQL_PASSWORD
|
# Optional non-secret overrides may be added deliberately, for example:
|
||||||
FIELD_ENCRYPTION_KEY=EXISTING_FIELD_ENCRYPTION_KEY
|
# SERVER_PORT=18080
|
||||||
FILE_STORAGE_ROOT=/var/lib/kaidi/files
|
# SESSION_COOKIE_SECURE=true
|
||||||
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
|
# Never override DB_URL, DB_USERNAME, DB_PASSWORD, FIELD_ENCRYPTION_KEY,
|
||||||
FILE_SCANNER_ENABLED=false
|
# FINANCE_SETUP_TOKEN_SHA256, or the update paths from the panel.
|
||||||
FINANCE_BOOTSTRAP_ENABLED=false
|
|
||||||
FINANCE_SETUP_ENABLED=false
|
|
||||||
FINANCE_UPDATE_ENABLED=false
|
|
||||||
|
|||||||
@@ -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.41
|
||||||
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
|
||||||
|
|||||||
@@ -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:-fcc4c132812eaa304e2459f3b0cc176d75552afb6bd6a3be7c347fbf8f01e51b}
|
||||||
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:-}
|
||||||
|
|||||||
+107
-24
@@ -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 ;;
|
||||||
@@ -233,14 +279,8 @@ java_arch_matches_host() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
system_java_home() {
|
java_home_from_bin() {
|
||||||
local java_bin resolved_java java_major home
|
local java_bin=$1 resolved_java java_major home
|
||||||
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
|
|
||||||
home=${KAIDI_JAVA_HOME%/}
|
|
||||||
java_bin="$home/bin/java"
|
|
||||||
else
|
|
||||||
java_bin=$(command -v java 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
[ -x "$java_bin" ] || return 1
|
[ -x "$java_bin" ] || return 1
|
||||||
java_major=$(
|
java_major=$(
|
||||||
"$java_bin" -version 2>&1 \
|
"$java_bin" -version 2>&1 \
|
||||||
@@ -258,14 +298,43 @@ system_java_home() {
|
|||||||
printf '%s\n' "$home"
|
printf '%s\n' "$home"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_java_home() {
|
||||||
|
local candidate home
|
||||||
|
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
|
||||||
|
candidate="${KAIDI_JAVA_HOME%/}/bin/java"
|
||||||
|
java_home_from_bin "$candidate" && return 0
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
candidate=$(command -v java 2>/dev/null || true)
|
||||||
|
if [ -n "$candidate" ] && home=$(java_home_from_bin "$candidate"); then
|
||||||
|
printf '%s\n' "$home"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Baota commonly keeps JDKs outside PATH. Prefer an existing Java 17 there
|
||||||
|
# before consulting the remote Azul metadata endpoint.
|
||||||
|
for candidate in \
|
||||||
|
/www/server/java/*/bin/java \
|
||||||
|
/www/server/java/*/jre/bin/java \
|
||||||
|
/usr/lib/jvm/*/bin/java \
|
||||||
|
/usr/java/*/bin/java; do
|
||||||
|
[ -x "$candidate" ] || continue
|
||||||
|
if home=$(java_home_from_bin "$candidate"); then
|
||||||
|
printf '%s\n' "$home"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -290,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
|
||||||
@@ -361,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" \
|
||||||
@@ -512,9 +586,11 @@ configure_database() {
|
|||||||
if [ "$SETUP_WIZARD" = true ]; then
|
if [ "$SETUP_WIZARD" = true ]; then
|
||||||
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] \
|
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] \
|
||||||
|| die "Do not pass database credentials when KAIDI_SETUP_WIZARD=true; enter them in the browser wizard"
|
|| die "Do not pass database credentials when KAIDI_SETUP_WIZARD=true; enter them in the browser wizard"
|
||||||
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
|
# First-run mode does not create a datasource. Keep database fields empty
|
||||||
DB_USERNAME=setup_pending
|
# so hosting panels cannot mistake a placeholder for a local MySQL service.
|
||||||
DB_PASSWORD=setup_pending
|
DB_URL=
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$REINSTALL" = true ]; then
|
if [ "$REINSTALL" = true ]; then
|
||||||
@@ -539,6 +615,7 @@ configure_database() {
|
|||||||
|
|
||||||
database_host() {
|
database_host() {
|
||||||
local endpoint host_port
|
local endpoint host_port
|
||||||
|
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||||
endpoint=${DB_URL#jdbc:mysql://}
|
endpoint=${DB_URL#jdbc:mysql://}
|
||||||
host_port=${endpoint%%/*}
|
host_port=${endpoint%%/*}
|
||||||
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
|
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
|
||||||
@@ -546,6 +623,7 @@ database_host() {
|
|||||||
|
|
||||||
database_port() {
|
database_port() {
|
||||||
local endpoint host_port candidate
|
local endpoint host_port candidate
|
||||||
|
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||||
endpoint=${DB_URL#jdbc:mysql://}
|
endpoint=${DB_URL#jdbc:mysql://}
|
||||||
host_port=${endpoint%%/*}
|
host_port=${endpoint%%/*}
|
||||||
candidate=${host_port##*:}
|
candidate=${host_port##*:}
|
||||||
@@ -555,6 +633,7 @@ database_port() {
|
|||||||
|
|
||||||
database_name() {
|
database_name() {
|
||||||
local endpoint name
|
local endpoint name
|
||||||
|
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||||
endpoint=${DB_URL#jdbc:mysql://}
|
endpoint=${DB_URL#jdbc:mysql://}
|
||||||
name=${endpoint#*/}
|
name=${endpoint#*/}
|
||||||
name=${name%%\?*}
|
name=${name%%\?*}
|
||||||
@@ -568,8 +647,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)
|
||||||
@@ -806,6 +885,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)
|
||||||
@@ -904,6 +984,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" \
|
||||||
@@ -941,6 +1022,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:-}" \
|
||||||
@@ -948,7 +1030,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
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAwnPqkRpYUTBYbBaSnxBs
|
||||||
|
LqDHFPrxX6DINIiCyRJ/0f1c0noRr1X0bJSpTAR+yUwzWYTqIKAhZ4NSouYjaxtE
|
||||||
|
zuoW4cC6v+S/C310xM8qy+lE52GyFHpr7H6GqdQgxK2LmfS0jjCR9i8MEmi6QMk4
|
||||||
|
zdDtI1NOl+D50vk1nrMryplyfvqDpyPA+fiqXVo2NrqX9iaamTVkq55INk3X451I
|
||||||
|
u96/HBlSMw2EvoDPurzcjxV16swyJcuQQpBUbBmgp8i2enNyWONoBq/myYReTDem
|
||||||
|
CbrD+DTvqa+tDMQ+DAL2wyPa8JdoGOLhcftYWsyfpgTq6olNSnqgUV+Gp+NYpWuk
|
||||||
|
AxMi6TTDX4ByMkS65UGH2Opg483bxtl9bGCmjFfqFGO2NjxAsh6LaBApS2PNBKpu
|
||||||
|
06+Qni/Eq7u847egw89bL1xwtHIZBWDiG4tQMqyqW3M5WqoYoaB7PcZnfyRVChA4
|
||||||
|
pb+2yJll0cJ4RvKWeVrOAzb5JOn0BWjllm/csfwv9IeTAgMBAAE=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ Type=oneshot
|
|||||||
User=root
|
User=root
|
||||||
Group=root
|
Group=root
|
||||||
EnvironmentFile=/etc/kaidi/update.env
|
EnvironmentFile=/etc/kaidi/update.env
|
||||||
EnvironmentFile=-/var/lib/kaidi/setup/application.env
|
|
||||||
ExecStart=/opt/kaidi/bin/update.sh
|
ExecStart=/opt/kaidi/bin/update.sh
|
||||||
Restart=no
|
Restart=no
|
||||||
Nice=10
|
Nice=10
|
||||||
@@ -19,5 +18,5 @@ IOSchedulingPriority=6
|
|||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
|
ReadWritePaths=/opt/kaidi -/www/wwwroot/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
|||||||
+488
-35
@@ -12,13 +12,18 @@ 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}
|
||||||
|
PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
|
||||||
|
RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env}
|
||||||
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
|
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
|
||||||
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
|
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
|
||||||
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
||||||
@@ -30,36 +35,183 @@ SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
|
|||||||
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
|
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
|
||||||
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
|
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
|
||||||
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
|
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
|
||||||
|
SHUTDOWN_ATTEMPTS=${KAIDI_UPDATE_SHUTDOWN_ATTEMPTS:-45}
|
||||||
WORK_DIR=
|
WORK_DIR=
|
||||||
CACHE_TEMP=
|
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 ;;
|
||||||
esac
|
esac
|
||||||
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; }
|
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; }
|
||||||
|
case "$PROCESS_MANAGER" in
|
||||||
|
systemd|baota) ;;
|
||||||
|
*) printf '%s\n' "KAIDI_PROCESS_MANAGER must be systemd or baota" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$SHUTDOWN_ATTEMPTS" in
|
||||||
|
''|*[!0-9]*) printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1" >&2; exit 1; }
|
||||||
|
case "$PID_FILE" in
|
||||||
|
/*) ;;
|
||||||
|
*) printf '%s\n' "KAIDI_PID_FILE must be an absolute path" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$RUNTIME_ENV_FILE" in
|
||||||
|
/*) ;;
|
||||||
|
*) printf '%s\n' "KAIDI_RUNTIME_ENV_FILE must be an absolute path" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
bootstrap_die() {
|
bootstrap_die() {
|
||||||
|
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
|
||||||
|
fail "$1"
|
||||||
|
fi
|
||||||
printf '%s\n' "$1" >&2
|
printf '%s\n' "$1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decode_runtime_env_value() {
|
||||||
|
encoded_value=$1
|
||||||
|
case "$encoded_value" in
|
||||||
|
\"*\")
|
||||||
|
encoded_value=${encoded_value#\"}
|
||||||
|
encoded_value=${encoded_value%\"}
|
||||||
|
printf '%s' "$encoded_value" | sed 's/\\\"/"/g; s/\\\\/\\/g'
|
||||||
|
;;
|
||||||
|
\'*\')
|
||||||
|
encoded_value=${encoded_value#\'}
|
||||||
|
encoded_value=${encoded_value%\'}
|
||||||
|
printf '%s' "$encoded_value"
|
||||||
|
;;
|
||||||
|
*) printf '%s' "$encoded_value" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
load_runtime_database_env() {
|
||||||
|
[ -e "$RUNTIME_ENV_FILE" ] || return 0
|
||||||
|
[ -f "$RUNTIME_ENV_FILE" ] && [ ! -L "$RUNTIME_ENV_FILE" ] \
|
||||||
|
|| bootstrap_die "Setup runtime environment must be a regular file"
|
||||||
|
[ -r "$RUNTIME_ENV_FILE" ] \
|
||||||
|
|| bootstrap_die "Setup runtime environment is not readable"
|
||||||
|
runtime_size=$(wc -c < "$RUNTIME_ENV_FILE" | tr -d '[:space:]')
|
||||||
|
[ "$runtime_size" -le 65536 ] \
|
||||||
|
|| bootstrap_die "Setup runtime environment is too large"
|
||||||
|
runtime_owner=$(stat -c '%u' "$RUNTIME_ENV_FILE" 2>/dev/null \
|
||||||
|
|| stat -f '%u' "$RUNTIME_ENV_FILE" 2>/dev/null || true)
|
||||||
|
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
|
||||||
|
[ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \
|
||||||
|
|| bootstrap_die "Setup runtime environment has an unexpected owner"
|
||||||
|
if find "$RUNTIME_ENV_FILE" -prune -perm /022 -print -quit | grep -q .; then
|
||||||
|
bootstrap_die "Setup runtime environment must not be writable by group or other users"
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r runtime_line || [ -n "$runtime_line" ]; do
|
||||||
|
case "$runtime_line" in ''|'#'*) continue ;; esac
|
||||||
|
runtime_name=${runtime_line%%=*}
|
||||||
|
[ "$runtime_name" != "$runtime_line" ] \
|
||||||
|
|| bootstrap_die "Setup runtime environment contains an invalid line"
|
||||||
|
runtime_raw=${runtime_line#*=}
|
||||||
|
runtime_value=$(decode_runtime_env_value "$runtime_raw")
|
||||||
|
case "$runtime_name" in
|
||||||
|
KAIDI_DB_HOST) KAIDI_DB_HOST=$runtime_value; export KAIDI_DB_HOST ;;
|
||||||
|
KAIDI_DB_PORT) KAIDI_DB_PORT=$runtime_value; export KAIDI_DB_PORT ;;
|
||||||
|
KAIDI_DB_NAME) KAIDI_DB_NAME=$runtime_value; export KAIDI_DB_NAME ;;
|
||||||
|
KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;;
|
||||||
|
KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;;
|
||||||
|
DB_URL) KAIDI_DB_URL=$runtime_value; export KAIDI_DB_URL ;;
|
||||||
|
esac
|
||||||
|
done < "$RUNTIME_ENV_FILE"
|
||||||
|
|
||||||
|
case "${KAIDI_DB_PORT:-}" in
|
||||||
|
''|*[!0-9]*) bootstrap_die "Setup runtime database port is invalid" ;;
|
||||||
|
esac
|
||||||
|
[ "$KAIDI_DB_PORT" -ge 1 ] && [ "$KAIDI_DB_PORT" -le 65535 ] \
|
||||||
|
|| 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() {
|
||||||
@@ -86,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"
|
||||||
@@ -97,12 +250,22 @@ prepare_update_layout() {
|
|||||||
|| bootstrap_die "Private update directories could not be prepared"
|
|| bootstrap_die "Private update directories could not be prepared"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_queue_layout() {
|
||||||
|
if [ -d "$PROCESSING_DIR" ] && [ ! -L "$PROCESSING_DIR" ] \
|
||||||
|
&& [ -d "$FAILED_REQUEST_ROOT" ] && [ ! -L "$FAILED_REQUEST_ROOT" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
install -d -o root -g root -m 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" \
|
||||||
|
|| bootstrap_die "Private update queue directories could not be prepared"
|
||||||
|
}
|
||||||
|
|
||||||
secure_release_tree() {
|
secure_release_tree() {
|
||||||
release_dir=$1
|
release_dir=$1
|
||||||
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
|
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
|
||||||
find "$release_dir" -type d -exec chmod 0750 {} + || return 1
|
find "$release_dir" -type d -exec chmod 0750 {} + || return 1
|
||||||
find "$release_dir" -type f -exec chmod 0640 {} + || return 1
|
find "$release_dir" -type f -exec chmod 0640 {} + || return 1
|
||||||
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
|
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
|
||||||
|
[ ! -f "$release_dir/ops/baota-init.sh" ] || chmod 0750 "$release_dir/ops/baota-init.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_release_access() {
|
verify_release_access() {
|
||||||
@@ -111,17 +274,27 @@ verify_release_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 "$release_dir" \
|
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$release_dir" \
|
||||||
|| return 1
|
|| return 1
|
||||||
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|
if [ "$PROCESS_MANAGER" = baota ]; then
|
||||||
|| return 1
|
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
|
else
|
||||||
|
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" \
|
||||||
|
&& runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
|
||||||
|
&& runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_update_layout
|
|
||||||
|
|
||||||
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
|
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
|
||||||
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
|
||||||
@@ -162,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
|
||||||
@@ -193,29 +443,164 @@ 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:-}
|
||||||
|
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
|
||||||
|
running_release=$(baota_process_release) || {
|
||||||
|
SURFACE_FAILURE=PROCESS_IDENTITY_INVALID
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if [ "$running_release" != "$expected_release" ]; then
|
||||||
|
SURFACE_FAILURE=RELEASE_MISMATCH
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
baota_process_record() {
|
||||||
|
[ -f "$PID_FILE" ] && [ ! -L "$PID_FILE" ] || return 1
|
||||||
|
pid_record=$(cat "$PID_FILE" 2>/dev/null || true)
|
||||||
|
pid=${pid_record%% *}
|
||||||
|
start_time=${pid_record#* }
|
||||||
|
[ "$pid" != "$pid_record" ] || return 1
|
||||||
|
case "$pid:$start_time" in
|
||||||
|
*[!0-9:]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$pid" -ne "$$" ] && [ -r "/proc/$pid/cmdline" ] || return 1
|
||||||
|
actual_start_time=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)
|
||||||
|
[ -n "$actual_start_time" ] && [ "$actual_start_time" = "$start_time" ] || return 1
|
||||||
|
owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true)
|
||||||
|
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
|
||||||
|
[ -n "$owner_uid" ] && [ "$owner_uid" = "$service_uid" ] || return 1
|
||||||
|
process_args=$(mktemp "$STATE_ROOT/work/process-args.XXXXXX") || return 1
|
||||||
|
if ! tr '\000' '\n' < "/proc/$pid/cmdline" > "$process_args" 2>/dev/null; then
|
||||||
|
rm -f "$process_args"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
release_path=$(sed -n 's/^-Dkaidi\.release\.path=//p' "$process_args")
|
||||||
|
release_path_count=$(sed -n '/^-Dkaidi\.release\.path=/p' "$process_args" | wc -l | tr -d '[:space:]')
|
||||||
|
jar_path=$(awk 'previous == "-jar" { print; exit } { previous = $0 }' "$process_args")
|
||||||
|
rm -f "$process_args"
|
||||||
|
[ "$release_path_count" -eq 1 ] || return 1
|
||||||
|
case "$release_path" in
|
||||||
|
"$APP_ROOT"/releases/*) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ -d "$release_path" ] && [ ! -L "$release_path" ] \
|
||||||
|
&& [ "$jar_path" = "$release_path/app.jar" ] && [ -f "$jar_path" ] \
|
||||||
|
|| return 1
|
||||||
|
printf '%s\t%s\n' "$pid" "$release_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
baota_process_pid() {
|
||||||
|
baota_process_record | cut -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
baota_process_release() {
|
||||||
|
baota_process_record | cut -f2-
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_application() {
|
||||||
|
allow_missing=${1:-false}
|
||||||
|
if [ "$PROCESS_MANAGER" = systemd ]; then
|
||||||
|
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||||
|
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
old_pid=$(baota_process_pid) || {
|
||||||
|
[ "$allow_missing" = true ] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
kill -TERM "$old_pid" 2>/dev/null || return 1
|
||||||
|
attempts=0
|
||||||
|
while [ "$attempts" -lt "$SHUTDOWN_ATTEMPTS" ]; do
|
||||||
|
if ! kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
current_pid_record=$(cat "$PID_FILE" 2>/dev/null || true)
|
||||||
|
case "$current_pid_record" in "$old_pid "*) rm -f "$PID_FILE" ;; esac
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
application_state() {
|
||||||
|
if [ "$PROCESS_MANAGER" = systemd ]; then
|
||||||
|
systemctl is-active "$SERVICE_NAME" 2>/dev/null || printf unknown
|
||||||
|
elif baota_process_pid >/dev/null 2>&1; then
|
||||||
|
printf active
|
||||||
|
else
|
||||||
|
printf stopped
|
||||||
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
backup_database() {
|
backup_database() {
|
||||||
|
DATABASE_BACKUP=
|
||||||
[ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return
|
[ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return
|
||||||
mkdir -p "$BACKUP_ROOT"
|
mkdir -p "$BACKUP_ROOT"
|
||||||
chmod 0700 "$BACKUP_ROOT"
|
chmod 0700 "$BACKUP_ROOT"
|
||||||
@@ -233,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"
|
||||||
@@ -255,6 +641,7 @@ backup_database() {
|
|||||||
fi
|
fi
|
||||||
chmod 0600 "$backup_tmp"
|
chmod 0600 "$backup_tmp"
|
||||||
mv -f "$backup_tmp" "$backup"
|
mv -f "$backup_tmp" "$backup"
|
||||||
|
DATABASE_BACKUP=$backup
|
||||||
rm -f "$dump_file"
|
rm -f "$dump_file"
|
||||||
find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \
|
find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \
|
||||||
| sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f
|
| sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f
|
||||||
@@ -321,18 +708,22 @@ restore_managed_file() {
|
|||||||
backup_operations() {
|
backup_operations() {
|
||||||
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
||||||
mkdir -p "$OPS_BACKUP"
|
mkdir -p "$OPS_BACKUP"
|
||||||
backup_managed_file "$UPDATER_PATH" update.sh \
|
backup_managed_file "$UPDATER_PATH" update.sh || return 1
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \
|
if [ "$PROCESS_MANAGER" = systemd ]; then
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
|
backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || return 1
|
||||||
|
fi
|
||||||
|
backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_operations() {
|
apply_operations() {
|
||||||
SYSTEMD_OPERATIONS_CHANGED=false
|
SYSTEMD_OPERATIONS_CHANGED=false
|
||||||
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
|
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false || return 1
|
||||||
&& install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
|
if [ "$PROCESS_MANAGER" = systemd ]; then
|
||||||
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
|
install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
|
||||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
|
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true || return 1
|
||||||
|
fi
|
||||||
|
install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
|
||||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
|
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
|
||||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
|
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|
||||||
@@ -343,7 +734,9 @@ apply_operations() {
|
|||||||
restore_operations() {
|
restore_operations() {
|
||||||
restore_failed=0
|
restore_failed=0
|
||||||
restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1
|
restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1
|
||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
|
if [ "$PROCESS_MANAGER" = systemd ]; then
|
||||||
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
|
||||||
|
fi
|
||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1
|
||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1
|
||||||
systemctl daemon-reload || restore_failed=1
|
systemctl daemon-reload || restore_failed=1
|
||||||
@@ -371,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)
|
||||||
@@ -378,6 +780,7 @@ rollback_active_transaction() {
|
|||||||
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
||||||
link_restored=true
|
link_restored=true
|
||||||
operations_restored=true
|
operations_restored=true
|
||||||
|
application_restarted=true
|
||||||
|
|
||||||
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
|
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
|
||||||
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|
||||||
@@ -388,21 +791,30 @@ rollback_active_transaction() {
|
|||||||
link_restored=false
|
link_restored=false
|
||||||
fi
|
fi
|
||||||
restore_operations || operations_restored=false
|
restore_operations || operations_restored=false
|
||||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
restart_application true || application_restarted=false
|
||||||
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
|
if [ "$link_restored" = true ] && [ "$application_restarted" = true ] \
|
||||||
if [ "$link_restored" = true ] && verify_app_surface; then
|
&& verify_app_surface "$previous_target"; then
|
||||||
remove_failed_release "$failed_release"
|
remove_failed_release "$failed_release"
|
||||||
rm -rf "$ACTIVE_TRANSACTION"
|
|
||||||
if [ "$operations_restored" = true ]; then
|
if [ "$operations_restored" = true ]; then
|
||||||
fail "$reason; previous release was restored and verified"
|
rm -rf "$ACTIVE_TRANSACTION"
|
||||||
|
fail "$reason; previous application release was restored and verified; database backup was retained"
|
||||||
fi
|
fi
|
||||||
fail "$reason; previous release is running, but operations restoration requires manual review"
|
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
|
||||||
}
|
}
|
||||||
@@ -430,6 +842,10 @@ recover_interrupted_transaction() {
|
|||||||
validate_release_operations() {
|
validate_release_operations() {
|
||||||
sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1
|
sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1
|
||||||
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || return 1
|
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || return 1
|
||||||
|
bash -n "$WORK_DIR/extracted/ops/baota-init.sh" || return 1
|
||||||
|
[ -s "$WORK_DIR/extracted/ops/release-public.pem" ] \
|
||||||
|
&& cmp -s "$WORK_DIR/extracted/ops/release-public.pem" "$PUBLIC_KEY" \
|
||||||
|
|| return 1
|
||||||
command -v systemd-analyze >/dev/null 2>&1 || return 1
|
command -v systemd-analyze >/dev/null 2>&1 || return 1
|
||||||
systemd-analyze verify \
|
systemd-analyze verify \
|
||||||
"$WORK_DIR/extracted/ops/kaidi-finance.service" \
|
"$WORK_DIR/extracted/ops/kaidi-finance.service" \
|
||||||
@@ -437,6 +853,7 @@ validate_release_operations() {
|
|||||||
"$WORK_DIR/extracted/ops/kaidi-update.path" >/dev/null || return 1
|
"$WORK_DIR/extracted/ops/kaidi-update.path" >/dev/null || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepare_queue_layout
|
||||||
exec 9>"$LOCK_FILE"
|
exec 9>"$LOCK_FILE"
|
||||||
flock -n 9 || exit 0
|
flock -n 9 || exit 0
|
||||||
recover_interrupted_transaction
|
recover_interrupted_transaction
|
||||||
@@ -462,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"
|
||||||
@@ -471,6 +898,12 @@ TARGET_VERSION=$REQUESTED_VERSION
|
|||||||
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
|
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
|
||||||
|
reset_event_log
|
||||||
|
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"
|
||||||
@@ -523,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
|
||||||
@@ -535,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
|
||||||
@@ -555,6 +994,9 @@ fi
|
|||||||
[ -s "$WORK_DIR/extracted/public/index.html" ] || fail "Release frontend is missing"
|
[ -s "$WORK_DIR/extracted/public/index.html" ] || fail "Release frontend is missing"
|
||||||
[ "$(cat "$WORK_DIR/extracted/VERSION" 2>/dev/null)" = "$TARGET_VERSION" ] || fail "Release version file mismatch"
|
[ "$(cat "$WORK_DIR/extracted/VERSION" 2>/dev/null)" = "$TARGET_VERSION" ] || fail "Release version file mismatch"
|
||||||
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || fail "Release updater is missing"
|
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || fail "Release updater is missing"
|
||||||
|
[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || fail "Release Baota launcher is missing"
|
||||||
|
[ -x "$WORK_DIR/extracted/ops/baota-init.sh" ] || fail "Release Baota initializer is missing"
|
||||||
|
[ -s "$WORK_DIR/extracted/ops/release-public.pem" ] || fail "Release public key is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing"
|
||||||
@@ -574,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
|
||||||
@@ -593,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"
|
||||||
@@ -605,6 +1055,7 @@ write_transaction_value phase PREPARED
|
|||||||
|
|
||||||
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
|
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
|
||||||
backup_database
|
backup_database
|
||||||
|
[ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP"
|
||||||
if ! backup_operations; then
|
if ! backup_operations; then
|
||||||
remove_failed_release "$RELEASE_DIR"
|
remove_failed_release "$RELEASE_DIR"
|
||||||
rm -rf "$ACTIVE_TRANSACTION"
|
rm -rf "$ACTIVE_TRANSACTION"
|
||||||
@@ -622,11 +1073,12 @@ 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
|
||||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
if ! restart_application; then
|
||||||
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
|
rollback_active_transaction "Release application restart failed"
|
||||||
if verify_app_surface; then
|
fi
|
||||||
|
if verify_app_surface "$RELEASE_DIR"; then
|
||||||
success_message="Release $TARGET_VERSION is running"
|
success_message="Release $TARGET_VERSION is running"
|
||||||
if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
|
if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
|
||||||
success_message="$success_message; automatic update watcher could not be started"
|
success_message="$success_message; automatic update watcher could not be started"
|
||||||
@@ -639,7 +1091,8 @@ if verify_app_surface; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
service_state=$(systemctl is-active "$SERVICE_NAME" 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)"
|
||||||
|
|||||||
+27
-16
@@ -76,7 +76,7 @@
|
|||||||
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
|
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
|
||||||
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
||||||
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
||||||
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 |
|
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 |
|
||||||
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
||||||
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
|
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
|
||||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||||
@@ -637,7 +637,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
|||||||
|
|
||||||
### 7.3 Starter 菜单、路由与页面映射
|
### 7.3 Starter 菜单、路由与页面映射
|
||||||
|
|
||||||
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、面包屑和内容区。菜单名称、顺序和路由在第一轮固定如下;详情、编辑等隐藏路由不出现在左侧菜单,但必须保留面包屑和返回路径。
|
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、页签和内容区。菜单层级已经表达业务归属,首期不在内容区显示面包屑,避免与菜单和页签重复;详情、编辑等隐藏路由必须保留安全返回路径。
|
||||||
|
|
||||||
| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 |
|
| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
@@ -664,7 +664,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
|||||||
| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 |
|
| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 |
|
||||||
| 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 |
|
| 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 |
|
||||||
|
|
||||||
本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission`、`requiresAuth` 和面包屑信息;出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。
|
本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission` 和 `requiresAuth`;历史 `breadcrumb` 字段可保留作路由合同和返回路径元数据,但不得渲染为可见导航。出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。
|
||||||
|
|
||||||
同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。
|
同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。
|
||||||
|
|
||||||
@@ -695,7 +695,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
|||||||
| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 |
|
| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 |
|
||||||
| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 |
|
| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 |
|
||||||
| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 |
|
| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 |
|
||||||
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 |
|
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、显式下载、下载完成确认、安装重启和历史更新记录 |
|
||||||
|
|
||||||
### 8.2 PAGE-01 登录页
|
### 8.2 PAGE-01 登录页
|
||||||
|
|
||||||
@@ -1002,9 +1002,9 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
|||||||
|
|
||||||
### 8.22.1 PAGE-22 系统更新页
|
### 8.22.1 PAGE-22 系统更新页
|
||||||
|
|
||||||
系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取更新”。
|
系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取版本”。
|
||||||
|
|
||||||
更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。
|
更新固定为“检查、下载、安装”三个服务端阶段和四个明确用户反馈。管理员点击“获取版本”时只请求最新版本、发布时间和发布说明,不提交下载;发现新版本后显示“立即更新”,再次确认后才提交 `DOWNLOAD`。root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY/下载完成`,期间当前业务版本继续运行。此时页面显示“立即更新并重启”,点击后只允许安装同版本缓存,并在切换前重新验证。页面以 3 秒轮询显示排队、下载、校验、备份、安装和重启状态;命令已经被服务端接受但 HTTP 响应丢失时也会主动重读状态。健康检查成功后显示 10 秒倒计时并自动刷新;失败时保留明确状态并恢复已验证的上一版本。
|
||||||
|
|
||||||
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
|
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
|
||||||
|
|
||||||
@@ -1045,7 +1045,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
|||||||
| PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 |
|
| PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 |
|
||||||
| PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 |
|
| PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 |
|
||||||
| PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 |
|
| PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 |
|
||||||
| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取更新自动完成检查和下载;验签完成后显示立即重启;健康后 10 秒刷新;历史操作来自不可变审计日志 |
|
| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取版本只检查;立即更新才下载;下载完成后显示立即更新并重启;健康后 10 秒刷新;历史操作来自不可变审计日志 |
|
||||||
|
|
||||||
每个页面只有同时满足以下条件才算完成:
|
每个页面只有同时满足以下条件才算完成:
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
|||||||
| PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 |
|
| PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 |
|
||||||
| PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 |
|
| PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 |
|
||||||
| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 |
|
| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 |
|
||||||
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
|
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取版本 → 立即更新并下载验签 → 下载完成 → 立即更新并重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
|
||||||
|
|
||||||
所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。
|
所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。
|
||||||
|
|
||||||
@@ -1109,7 +1109,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
|||||||
| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 |
|
| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 |
|
||||||
| PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 |
|
| PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 |
|
||||||
| PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 |
|
| PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 |
|
||||||
| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 |
|
| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取版本、立即更新、立即更新并重启 |
|
||||||
|
|
||||||
列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。
|
列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。
|
||||||
|
|
||||||
@@ -1625,12 +1625,12 @@ Pull Request 只有同时满足以下条件才能合并:页面/操作合同完
|
|||||||
| 区域 | 固定规则 |
|
| 区域 | 固定规则 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 |
|
| 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 |
|
||||||
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、面包屑、页签栏和内容区 |
|
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、页签栏和内容区;不渲染面包屑 |
|
||||||
| 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 |
|
| 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 |
|
||||||
| 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 |
|
| 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 |
|
||||||
| 面包屑 | 以 `routes.json` 页面合同为唯一层级来源,从一级菜单到当前页面完整显示;隐藏详情路由必须显示来源列表和当前页面,任一级可安全返回 |
|
| 面包屑 | 首期删除可见面包屑;菜单和页签承担层级识别,隐藏详情路由通过显式返回按钮或安全返回路径返回来源列表 |
|
||||||
| 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 |
|
| 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 |
|
||||||
| 内容头 | 静态列表、工作台、台账和配置页只显示顶部面包屑,不再在内容区重复模块名、页面名或功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面可以显示业务编号、对象名称和状态,但不得重复面包屑中的静态标题 |
|
| 内容头 | 静态列表、工作台、台账和配置页直接进入紧凑操作栏、查询区或指标区,不显示面包屑,也不重复堆叠模块名、页面名和功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面显示业务编号、对象名称和状态,并提供明确返回路径 |
|
||||||
| 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 |
|
| 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 |
|
||||||
|
|
||||||
主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。
|
主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。
|
||||||
@@ -1643,12 +1643,12 @@ PAGE-01~PAGE-22 必须从以下模板组合,不允许每个开发人员各
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` |
|
| 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` |
|
||||||
| 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` |
|
| 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` |
|
||||||
| 列表/台账 | PAGE-07、09、13~16、18~20 | 合同面包屑 + 紧凑操作栏 + 查询栏 + 数据表格 + 分页;内容区不重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` |
|
| 列表/台账 | PAGE-07、09、13~16、18~20 | 紧凑操作栏 + 查询栏 + 数据表格 + 分页;不额外渲染面包屑或重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` |
|
||||||
| 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` |
|
| 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` |
|
||||||
| 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` |
|
| 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` |
|
||||||
| 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` |
|
| 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` |
|
||||||
| 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` |
|
| 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` |
|
||||||
| 系统更新 | PAGE-22 | 版本摘要 + 获取/下载进度 + 立即重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` |
|
| 系统更新 | PAGE-22 | 版本摘要 + 独立检查 + 下载进度/完成确认 + 安装重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` |
|
||||||
| 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` |
|
| 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` |
|
||||||
|
|
||||||
工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。
|
工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。
|
||||||
@@ -2932,11 +2932,11 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
|||||||
| 检查项 | 本轮结果 | 证据边界 |
|
| 检查项 | 本轮结果 | 证据边界 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 |
|
| 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 |
|
||||||
| 两阶段交互 | 管理员点击“获取更新”后自动完成检查和下载;下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即重启”确认不切换版本 |
|
| 分阶段交互 | 管理员点击“获取版本”只检查发布信息;发现新版本后点击“立即更新”才下载。下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY/下载完成`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即更新并重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即更新并重启”确认不切换版本 |
|
||||||
| 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、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 安装冒烟 |
|
||||||
|
|
||||||
@@ -3005,6 +3005,17 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
|||||||
|
|
||||||
**本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。
|
**本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。
|
||||||
|
|
||||||
|
### 13.1.17 Preview.27 安装跳转与内容区导航收敛记录(2026-08-18)
|
||||||
|
|
||||||
|
| 项目 | 结果 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 安装完成跳转 | 已修复 | 完成安装后以 `required=false` 且 `locked/ready` 状态确认服务已切换,再通过 Vue Router 进入 `/login`;不再等待永远为 `false` 的 setup `ready` 标志,也不再复用安装前缓存的 `required=true` 状态 |
|
||||||
|
| 面包屑 | 已删除可见渲染 | 菜单和路由页签已经表达层级,业务内容区不再显示“表单审批 → 表单中心”等重复导航;历史 `breadcrumb` 合同字段仅保留作元数据,不生成 DOM |
|
||||||
|
| 内容区留白 | 已收紧 | 移除面包屑占位和底部间距,页签下内容区顶部间距固定为 16px;列表、工作台和配置页直接进入操作栏/指标/查询区 |
|
||||||
|
| 自动化证据 | 通过 | TypeScript、ESLint、Stylelint、Vitest `12 files / 60 tests`、安装向导 Playwright `6/6`、全量页面可访问性与布局 Playwright `78/78`、production build/hygiene 均通过 |
|
||||||
|
|
||||||
|
**本轮结论**:业务页面不再把菜单层级重复显示为面包屑;安装完成后会稳定进入登录页,异常重启场景也通过状态确认后自动跳转。
|
||||||
|
|
||||||
### 13.2 第三方权限和自动化负向验收
|
### 13.2 第三方权限和自动化负向验收
|
||||||
|
|
||||||
**当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
|
**当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
"pageType": "business",
|
"pageType": "business",
|
||||||
"pageTemplate": "system-update",
|
"pageTemplate": "system-update",
|
||||||
"requiresAuth": true,
|
"requiresAuth": true,
|
||||||
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。",
|
"description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。",
|
||||||
"breadcrumb": ["系统治理", "系统更新"],
|
"breadcrumb": ["系统治理", "系统更新"],
|
||||||
"roles": ["SYSTEM_ADMIN"],
|
"roles": ["SYSTEM_ADMIN"],
|
||||||
"permission": "admin:update:view"
|
"permission": "admin:update:view"
|
||||||
|
|||||||
@@ -260,13 +260,7 @@ for (const route of routes) {
|
|||||||
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
|
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
|
||||||
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
|
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
|
||||||
}
|
}
|
||||||
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) {
|
await expect(page.locator('.tdesign-breadcrumb')).toHaveCount(0);
|
||||||
const breadcrumb = page.locator('.tdesign-breadcrumb');
|
|
||||||
await expect(breadcrumb).toBeVisible();
|
|
||||||
for (const label of route.breadcrumb) {
|
|
||||||
await expect(breadcrumb.getByText(label, { exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
if (route.pageId === 'PAGE-09') {
|
if (route.pageId === 'PAGE-09') {
|
||||||
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
|
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
|||||||
data: {
|
data: {
|
||||||
required: !completed,
|
required: !completed,
|
||||||
locked: completed,
|
locked: completed,
|
||||||
ready: completed,
|
// Production status is locked after setup; readiness is not the
|
||||||
|
// completion signal used by the redirect flow.
|
||||||
|
ready: false,
|
||||||
supportedDatabaseTypes: ['MYSQL'],
|
supportedDatabaseTypes: ['MYSQL'],
|
||||||
message: completed ? '系统已完成安装' : '请完成安装',
|
message: completed ? '系统已完成安装' : '请完成安装',
|
||||||
},
|
},
|
||||||
@@ -69,6 +71,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
|||||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
|
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
|
||||||
|
|
||||||
const layout = await page.evaluate(() => ({
|
const layout = await page.evaluate(() => ({
|
||||||
clientWidth: document.documentElement.clientWidth,
|
clientWidth: document.documentElement.clientWidth,
|
||||||
scrollWidth: document.documentElement.scrollWidth,
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
@@ -128,8 +132,74 @@ test('confirms completion after the setup response is interrupted by a service r
|
|||||||
await page.getByRole('button', { name: '完成安装' }).click();
|
await page.getByRole('button', { name: '完成安装' }).click();
|
||||||
await page.getByRole('button', { name: '确定', exact: true }).click();
|
await page.getByRole('button', { name: '确定', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible();
|
|
||||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
|
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
|
||||||
await expect(page.getByText('网络请求失败')).toHaveCount(0);
|
await expect(page.getByText('网络请求失败')).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the completed wizard when post-completion status polling is unavailable', async ({ page }) => {
|
||||||
|
let completionStarted = false;
|
||||||
|
await page.route('**/api/v1/setup/**', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const pathname = new URL(request.url()).pathname;
|
||||||
|
if (pathname === '/api/v1/setup/status') {
|
||||||
|
if (completionStarted) {
|
||||||
|
await route.abort('connectionreset');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
data: {
|
||||||
|
required: true,
|
||||||
|
locked: false,
|
||||||
|
ready: false,
|
||||||
|
supportedDatabaseTypes: ['MYSQL'],
|
||||||
|
message: '请完成安装',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = request.postDataJSON() as Record<string, unknown>;
|
||||||
|
if (pathname.endsWith('/test-connection')) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
data: {
|
||||||
|
successful: true,
|
||||||
|
databaseType: 'MYSQL',
|
||||||
|
serverVersion: '8.4.6',
|
||||||
|
schemaReady: true,
|
||||||
|
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(body.adminUsername).toBe('admin');
|
||||||
|
completionStarted = true;
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
data: {
|
||||||
|
completed: true,
|
||||||
|
username: 'admin',
|
||||||
|
message: '安装完成,系统正在切换到正式模式',
|
||||||
|
restartAfterSeconds: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/setup');
|
||||||
|
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||||
|
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||||
|
await page.getByRole('button', { name: '测试连接' }).click();
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click();
|
||||||
|
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||||
|
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
|
||||||
|
await page.getByRole('button', { name: '完成安装' }).click();
|
||||||
|
await page.getByRole('button', { name: '确定', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/login$/, { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function envelope(data: unknown) {
|
|||||||
return { data, requestId: '01M00000000000000000000090' };
|
return { data, requestId: '01M00000000000000000000090' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function session() {
|
function session(canExecute = true) {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
user: {
|
user: {
|
||||||
@@ -19,14 +19,16 @@ function session() {
|
|||||||
},
|
},
|
||||||
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
|
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
|
||||||
activeRole: 'SYSTEM_ADMIN',
|
activeRole: 'SYSTEM_ADMIN',
|
||||||
permissions,
|
permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
|
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
|
||||||
const busy = [
|
const busy = [
|
||||||
|
'QUEUED',
|
||||||
'DOWNLOAD_QUEUED',
|
'DOWNLOAD_QUEUED',
|
||||||
'INSTALL_QUEUED',
|
'INSTALL_QUEUED',
|
||||||
|
'PRECHECKING',
|
||||||
'VERIFYING',
|
'VERIFYING',
|
||||||
'DOWNLOADING',
|
'DOWNLOADING',
|
||||||
'BACKING_UP',
|
'BACKING_UP',
|
||||||
@@ -58,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
|
||||||
? []
|
? []
|
||||||
@@ -69,6 +79,15 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FixtureOptions {
|
||||||
|
canExecute?: boolean;
|
||||||
|
checkResponseState?: string;
|
||||||
|
downloadProgressReads?: number;
|
||||||
|
failDownloadResponse?: boolean;
|
||||||
|
failInstallResponse?: boolean;
|
||||||
|
restartOfflineReads?: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function installFixture(
|
async function installFixture(
|
||||||
page: Page,
|
page: Page,
|
||||||
downloadBodies: unknown[],
|
downloadBodies: unknown[],
|
||||||
@@ -78,9 +97,20 @@ async function installFixture(
|
|||||||
completeAfterInstall = false,
|
completeAfterInstall = false,
|
||||||
initialState?: string,
|
initialState?: string,
|
||||||
completeBusyAfterFirstRead = false,
|
completeBusyAfterFirstRead = false,
|
||||||
|
options: FixtureOptions = {},
|
||||||
) {
|
) {
|
||||||
|
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({
|
||||||
@@ -93,7 +123,7 @@ async function installFixture(
|
|||||||
objectType: 'SYSTEM_UPDATE',
|
objectType: 'SYSTEM_UPDATE',
|
||||||
objectPublicId: 'SYSTEM_UPDATE',
|
objectPublicId: 'SYSTEM_UPDATE',
|
||||||
resultCode: 'SUCCESS',
|
resultCode: 'SUCCESS',
|
||||||
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null,
|
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
|
||||||
beforeJson: null,
|
beforeJson: null,
|
||||||
afterJson: JSON.stringify({ targetVersion }),
|
afterJson: JSON.stringify({ targetVersion }),
|
||||||
occurredAt: '2026-08-16T00:02:00Z',
|
occurredAt: '2026-08-16T00:02:00Z',
|
||||||
@@ -103,9 +133,9 @@ async function installFixture(
|
|||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const pathname = new URL(request.url()).pathname;
|
const pathname = new URL(request.url()).pathname;
|
||||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
|
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session(canExecute)) });
|
||||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
|
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session(canExecute).user) });
|
||||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
|
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) });
|
||||||
if (pathname === '/api/v1/auth/csrf') {
|
if (pathname === '/api/v1/auth/csrf') {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
|
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
|
||||||
@@ -122,26 +152,43 @@ 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');
|
||||||
return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) });
|
if (checkResponseState) state = checkResponseState;
|
||||||
|
const response = updateView(state, checked, enabled, recoveryPending);
|
||||||
|
if (!canExecute) response.allowedActions = ['CHECK'];
|
||||||
|
return route.fulfill({ json: envelope(response) });
|
||||||
}
|
}
|
||||||
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');
|
||||||
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
|
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
|
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
|
||||||
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 = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
|
restartDeadline = Date.now() + restartOfflineReads * 1000;
|
||||||
|
state =
|
||||||
|
restartOfflineReads > 0
|
||||||
|
? 'RUNNING'
|
||||||
|
: failInstallResponse || completeAfterInstall
|
||||||
|
? 'SUCCEEDED'
|
||||||
|
: 'INSTALL_QUEUED';
|
||||||
|
if (failInstallResponse) return route.abort('connectionreset');
|
||||||
return route.fulfill({ json: envelope(queued) });
|
return route.fulfill({ json: envelope(queued) });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
|
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
|
||||||
@@ -158,7 +205,7 @@ async function installFixture(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('system administrator checks and queues a signed online update', async ({ page }) => {
|
test('system administrator checks, downloads, and explicitly installs a signed online update', async ({ page }) => {
|
||||||
const downloadBodies: unknown[] = [];
|
const downloadBodies: unknown[] = [];
|
||||||
const installBodies: unknown[] = [];
|
const installBodies: unknown[] = [];
|
||||||
await installFixture(page, downloadBodies, installBodies);
|
await installFixture(page, downloadBodies, installBodies);
|
||||||
@@ -166,16 +213,142 @@ test('system administrator checks and queues a signed online update', async ({ p
|
|||||||
|
|
||||||
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
|
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
|
||||||
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
|
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
|
||||||
await page.getByRole('button', { name: '获取更新', exact: true }).click();
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
|
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
|
||||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 });
|
||||||
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible();
|
expect(downloadBodies).toEqual([]);
|
||||||
|
|
||||||
await page.getByRole('button', { name: '立即重启', exact: true }).click();
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
||||||
|
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
||||||
|
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
|
||||||
|
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
||||||
|
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
|
||||||
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
|
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
|
||||||
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 }) => {
|
||||||
|
await installFixture(page, [], [], true, false, false, 'READY');
|
||||||
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('更新包已下载完成,请点击“立即更新并重启”安装新版本', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('view-only administrators see a download permission message', async ({ page }) => {
|
||||||
|
await installFixture(page, [], [], true, false, false, undefined, false, { canExecute: false });
|
||||||
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toBeVisible();
|
||||||
|
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 }) => {
|
||||||
|
await installFixture(page, [], [], true, false, false, undefined, false, {
|
||||||
|
failDownloadResponse: true,
|
||||||
|
});
|
||||||
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 8_000 });
|
||||||
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepted install continues to the refresh countdown after its response is lost', async ({ page }) => {
|
||||||
|
await installFixture(page, [], [], true, false, false, undefined, false, {
|
||||||
|
failInstallResponse: true,
|
||||||
|
});
|
||||||
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 8_000 });
|
||||||
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||||
|
timeout: 8_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
@@ -193,8 +366,9 @@ test('disabled online updates expose status without an executable check action',
|
|||||||
await page.goto('/governance/update');
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
|
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled();
|
await expect(page.getByRole('button', { name: '获取版本', exact: true })).toBeDisabled();
|
||||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
|
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
|
||||||
@@ -202,15 +376,19 @@ test('rollback recovery keeps the failure visible without an install action', as
|
|||||||
await page.goto('/governance/update');
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
|
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
|
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
|
||||||
await installFixture(page, [], [], true, false, true);
|
await installFixture(page, [], [], true, false, true);
|
||||||
await page.goto('/governance/update');
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '获取更新', exact: true }).click();
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 });
|
await page.getByRole('button', { name: '立即更新', exact: true }).click({ timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 8_000 });
|
||||||
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
@@ -221,7 +399,9 @@ test('reloading during installation resumes polling and starts the refresh count
|
|||||||
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
|
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
|
||||||
await page.goto('/governance/update');
|
await page.goto('/governance/update');
|
||||||
|
|
||||||
await expect(page.getByText('安装中', { exact: true })).toBeVisible();
|
await expect(
|
||||||
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('安装中', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||||
timeout: 6_000,
|
timeout: 6_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
showFooter: false,
|
showFooter: false,
|
||||||
isSidebarCompact: false,
|
isSidebarCompact: false,
|
||||||
showBreadcrumb: true,
|
|
||||||
menuAutoCollapsed: false,
|
menuAutoCollapsed: false,
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
layout: 'side',
|
layout: 'side',
|
||||||
|
|||||||
@@ -61,7 +61,6 @@
|
|||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
</t-tabs>
|
</t-tabs>
|
||||||
<t-content :class="`${prefix}-content-layout`">
|
<t-content :class="`${prefix}-content-layout`">
|
||||||
<l-breadcrumb v-if="settingStore.showBreadcrumb" />
|
|
||||||
<l-content />
|
<l-content />
|
||||||
</t-content>
|
</t-content>
|
||||||
<t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`">
|
<t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`">
|
||||||
@@ -81,7 +80,6 @@ import { useLocale } from '@/locales/useLocale';
|
|||||||
import { useSettingStore, useTabsRouterStore } from '@/store';
|
import { useSettingStore, useTabsRouterStore } from '@/store';
|
||||||
import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
|
import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
|
||||||
|
|
||||||
import LBreadcrumb from './Breadcrumb.vue';
|
|
||||||
import LContent from './Content.vue';
|
import LContent from './Content.vue';
|
||||||
import LFooter from './Footer.vue';
|
import LFooter from './Footer.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -29,45 +29,73 @@
|
|||||||
<div class="update-actions">
|
<div class="update-actions">
|
||||||
<t-button
|
<t-button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:loading="fetchingUpdate"
|
:loading="checkingUpdate"
|
||||||
:disabled="!canFetchUpdate || updateBusy || restarting"
|
:disabled="!canCheckUpdate || checkingUpdate || downloadingUpdate || installingUpdate"
|
||||||
@click="fetchUpdate"
|
@click="checkLatest"
|
||||||
>
|
>
|
||||||
<template #icon><t-icon name="cloud-download" /></template>
|
<template #icon><t-icon name="refresh" /></template>
|
||||||
获取更新
|
获取版本
|
||||||
</t-button>
|
</t-button>
|
||||||
<t-button
|
<t-button
|
||||||
v-if="canRestart"
|
v-if="canDownloadUpdate"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
:loading="restarting"
|
:loading="downloadingUpdate"
|
||||||
:disabled="updateBusy || fetchingUpdate"
|
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
|
||||||
@click="restartNow"
|
@click="downloadNow"
|
||||||
|
>
|
||||||
|
<template #icon><t-icon name="cloud-download" /></template>
|
||||||
|
立即更新
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="canInstall && !updateDialogVisible"
|
||||||
|
theme="primary"
|
||||||
|
:loading="installingUpdate"
|
||||||
|
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
|
||||||
|
@click="installNow"
|
||||||
>
|
>
|
||||||
<template #icon><t-icon name="poweroff" /></template>
|
<template #icon><t-icon name="poweroff" /></template>
|
||||||
立即重启
|
立即更新并重启
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
|
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
|
||||||
<t-step-item title="获取版本" content="检查最新发布" />
|
<t-step-item title="获取版本" content="检查最新发布" />
|
||||||
<t-step-item title="下载校验" content="下载并验证签名" />
|
<t-step-item title="下载更新" content="下载并验证签名" />
|
||||||
<t-step-item title="立即重启" content="备份并切换版本" />
|
<t-step-item title="安装重启" content="备份并切换版本" />
|
||||||
<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>
|
||||||
{{ updateStatus?.message || '正在读取系统版本状态' }}
|
</div>
|
||||||
</t-alert>
|
|
||||||
|
<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>
|
||||||
@@ -109,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,
|
||||||
@@ -129,35 +240,63 @@ 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);
|
||||||
const statusLoading = ref(false);
|
const statusLoading = ref(false);
|
||||||
const fetchingUpdate = ref(false);
|
const checkingUpdate = ref(false);
|
||||||
const restarting = ref(false);
|
const downloadingUpdate = ref(false);
|
||||||
|
const installingUpdate = ref(false);
|
||||||
const statusError = ref('');
|
const statusError = ref('');
|
||||||
const historyRows = ref<AuditLog[]>([]);
|
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(
|
||||||
() =>
|
() =>
|
||||||
@@ -165,16 +304,29 @@ 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 canFetchUpdate = computed(
|
const canCheckUpdate = computed(
|
||||||
() =>
|
() =>
|
||||||
updateStatus.value?.enabled !== false &&
|
updateStatus.value?.enabled !== false &&
|
||||||
updateStatus.value?.state !== 'READY' &&
|
!updateBusy.value &&
|
||||||
(updateStatus.value?.allowedActions.includes('CHECK') === true ||
|
updateStatus.value?.allowedActions.includes('CHECK') === true,
|
||||||
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true),
|
);
|
||||||
|
const canDownloadUpdate = computed(
|
||||||
|
() =>
|
||||||
|
updateStatus.value?.enabled !== false &&
|
||||||
|
!updateBusy.value &&
|
||||||
|
Boolean(updateStatus.value?.latestVersion) &&
|
||||||
|
updateStatus.value?.updateAvailable === true &&
|
||||||
|
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true,
|
||||||
|
);
|
||||||
|
const canInstall = computed(
|
||||||
|
() =>
|
||||||
|
updateStatus.value?.enabled !== false &&
|
||||||
|
!updateBusy.value &&
|
||||||
|
updateStatus.value?.state === 'READY' &&
|
||||||
|
updateStatus.value?.allowedActions.includes('INSTALL') === true,
|
||||||
);
|
);
|
||||||
const canRestart = computed(() => updateStatus.value?.allowedActions.includes('INSTALL') === true);
|
|
||||||
const updateStep = computed(() => {
|
const updateStep = computed(() => {
|
||||||
const state = updateStatus.value?.state || 'IDLE';
|
const state = updateStatus.value?.state || 'IDLE';
|
||||||
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
|
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
|
||||||
@@ -190,26 +342,43 @@ const updateStateLabel = computed(() => {
|
|||||||
DISABLED: '未启用',
|
DISABLED: '未启用',
|
||||||
CURRENT: '已是最新',
|
CURRENT: '已是最新',
|
||||||
QUEUED: '已排队',
|
QUEUED: '已排队',
|
||||||
DOWNLOAD_QUEUED: '等待下载',
|
DOWNLOAD_QUEUED: '下载已排队',
|
||||||
INSTALL_QUEUED: '等待重启',
|
INSTALL_QUEUED: '等待重启',
|
||||||
|
PRECHECKING: '安装预检',
|
||||||
VERIFYING: '校验中',
|
VERIFYING: '校验中',
|
||||||
DOWNLOADING: '下载中',
|
DOWNLOADING: '下载中',
|
||||||
READY: '等待重启',
|
READY: '下载完成',
|
||||||
BACKING_UP: '备份中',
|
BACKING_UP: '备份中',
|
||||||
INSTALLING: '安装中',
|
INSTALLING: '安装中',
|
||||||
RUNNING: '重启中',
|
RUNNING: '重启中',
|
||||||
SUCCEEDED: '更新成功',
|
SUCCEEDED: '更新成功',
|
||||||
FAILED: '更新失败',
|
FAILED: '更新失败',
|
||||||
|
RECOVERY_REQUIRED: '需要人工恢复',
|
||||||
UNKNOWN: '状态未知',
|
UNKNOWN: '状态未知',
|
||||||
}[updateStatus.value?.state || 'IDLE'] ||
|
}[updateStatus.value?.state || 'IDLE'] ||
|
||||||
updateStatus.value?.state ||
|
updateStatus.value?.state ||
|
||||||
'待获取'
|
'待获取'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const updateActionHint = computed(() => {
|
||||||
|
const state = updateStatus.value?.state || 'IDLE';
|
||||||
|
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
|
||||||
|
if (['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
|
||||||
|
return '更新包已确认,系统正在预检、备份、切换并重启。';
|
||||||
|
}
|
||||||
|
if (state === 'RECOVERY_REQUIRED') return '自动回滚未完成,新的更新操作已锁定。';
|
||||||
|
if (state === 'READY') {
|
||||||
|
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
|
||||||
|
}
|
||||||
|
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
|
||||||
|
if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。';
|
||||||
|
if (state === 'IDLE' && !updateStatus.value?.latestVersion) 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';
|
||||||
});
|
});
|
||||||
@@ -230,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)) 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;
|
||||||
}
|
}
|
||||||
@@ -266,9 +459,9 @@ async function refreshPage() {
|
|||||||
await Promise.all([loadStatus(), loadHistory()]);
|
await Promise.all([loadStatus(), loadHistory()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUpdate() {
|
async function checkLatest() {
|
||||||
if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return;
|
if (checkingUpdate.value || updateBusy.value || !canCheckUpdate.value) return;
|
||||||
fetchingUpdate.value = true;
|
checkingUpdate.value = true;
|
||||||
statusError.value = '';
|
statusError.value = '';
|
||||||
try {
|
try {
|
||||||
const checked = await checkSystemUpdate();
|
const checked = await checkSystemUpdate();
|
||||||
@@ -278,42 +471,88 @@ async function fetchUpdate() {
|
|||||||
MessagePlugin.info('当前已是最新版本');
|
MessagePlugin.info('当前已是最新版本');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) {
|
if (checked.state === 'READY') {
|
||||||
throw new Error('发现新版本,但当前账号没有下载更新包的权限');
|
MessagePlugin.success(
|
||||||
|
checked.allowedActions.includes('INSTALL')
|
||||||
|
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
|
||||||
|
: '更新包已下载完成,但当前账号没有安装权限',
|
||||||
|
);
|
||||||
|
} else if (busyStates.has(checked.state)) {
|
||||||
|
updateDialogVisible.value = true;
|
||||||
|
startPolling();
|
||||||
|
MessagePlugin.info(checked.message || '更新任务正在处理中,请查看当前进度');
|
||||||
|
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
|
||||||
|
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
|
||||||
|
} else {
|
||||||
|
MessagePlugin.success(`发现新版本 ${checked.latestVersion},请点击“立即更新”开始下载`);
|
||||||
}
|
}
|
||||||
updateStatus.value = await downloadSystemUpdate(checked.latestVersion);
|
|
||||||
await loadHistory(true);
|
|
||||||
MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启');
|
|
||||||
startPolling();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusError.value = friendlyError(error, '获取更新失败');
|
statusError.value = friendlyError(error, '获取版本失败');
|
||||||
} finally {
|
} finally {
|
||||||
fetchingUpdate.value = false;
|
checkingUpdate.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartNow() {
|
async function downloadNow() {
|
||||||
const version = updateStatus.value?.latestVersion;
|
const version = updateStatus.value?.latestVersion;
|
||||||
if (!version || restarting.value || !canRestart.value) return;
|
if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
|
||||||
restarting.value = true;
|
downloadingUpdate.value = true;
|
||||||
|
updateDialogVisible.value = true;
|
||||||
|
localEvents.value = [];
|
||||||
|
statusError.value = '';
|
||||||
|
startPolling();
|
||||||
|
try {
|
||||||
|
updateStatus.value = await downloadSystemUpdate(version);
|
||||||
|
appendLocalEvent('INFO', 'DOWNLOAD_QUEUED', `版本 ${version} 的下载请求已提交`);
|
||||||
|
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
|
||||||
|
void loadHistory(true);
|
||||||
|
} catch (error) {
|
||||||
|
statusError.value = friendlyError(error, '下载更新包失败');
|
||||||
|
void loadStatus(true);
|
||||||
|
} finally {
|
||||||
|
downloadingUpdate.value = false;
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installNow() {
|
||||||
|
const version = updateStatus.value?.latestVersion;
|
||||||
|
if (!version || installingUpdate.value || !canInstall.value) return;
|
||||||
|
installingUpdate.value = true;
|
||||||
|
updateDialogVisible.value = true;
|
||||||
statusError.value = '';
|
statusError.value = '';
|
||||||
stopRefreshCountdown();
|
stopRefreshCountdown();
|
||||||
|
stopRestartCountdown();
|
||||||
|
awaitingRefresh.value = true;
|
||||||
|
installRequestUncertain.value = false;
|
||||||
|
setPendingUpdate(true);
|
||||||
|
startPolling();
|
||||||
try {
|
try {
|
||||||
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启');
|
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
|
||||||
awaitingRefresh.value = true;
|
appendLocalEvent('INFO', 'INSTALL_QUEUED', `已确认安装 ${version},等待更新服务执行`);
|
||||||
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新');
|
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
|
||||||
await loadHistory(true);
|
void loadHistory(true);
|
||||||
startPolling();
|
|
||||||
} 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);
|
||||||
} finally {
|
} finally {
|
||||||
restarting.value = false;
|
installingUpdate.value = false;
|
||||||
|
startPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
||||||
@@ -323,18 +562,66 @@ function stopPolling() {
|
|||||||
|
|
||||||
function handleCompletion(status: SystemUpdateView) {
|
function handleCompletion(status: SystemUpdateView) {
|
||||||
if (!awaitingRefresh.value) return;
|
if (!awaitingRefresh.value) return;
|
||||||
if (status.state === 'SUCCEEDED') {
|
if (status.state === 'READY') {
|
||||||
awaitingRefresh.value = false;
|
awaitingRefresh.value = false;
|
||||||
|
installRequestUncertain.value = false;
|
||||||
|
setPendingUpdate(false);
|
||||||
|
stopRestartCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === 'SUCCEEDED') {
|
||||||
|
statusError.value = '';
|
||||||
|
awaitingRefresh.value = false;
|
||||||
|
connectionInterrupted.value = false;
|
||||||
|
installRequestUncertain.value = false;
|
||||||
|
setPendingUpdate(false);
|
||||||
|
stopRestartCountdown();
|
||||||
startRefreshCountdown();
|
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;
|
||||||
@@ -355,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;
|
||||||
@@ -374,7 +719,7 @@ function actionLabel(action: string) {
|
|||||||
{
|
{
|
||||||
SYSTEM_UPDATE_CHECK: '获取版本',
|
SYSTEM_UPDATE_CHECK: '获取版本',
|
||||||
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
|
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
|
||||||
SYSTEM_UPDATE_REQUEST: '立即重启',
|
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
|
||||||
}[action] || action
|
}[action] || action
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,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;
|
||||||
}
|
}
|
||||||
@@ -407,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>
|
||||||
@@ -503,6 +861,17 @@ onBeforeUnmount(() => {
|
|||||||
border-top: 1px solid var(--td-component-border);
|
border-top: 1px solid var(--td-component-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-status-message {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-message small {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.release-notes span,
|
.release-notes span,
|
||||||
.section-heading p,
|
.section-heading p,
|
||||||
.section-heading > span {
|
.section-heading > span {
|
||||||
@@ -534,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;
|
||||||
@@ -571,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>
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ async function waitForSetupCompletion() {
|
|||||||
if (unmounted) return false;
|
if (unmounted) return false;
|
||||||
try {
|
try {
|
||||||
const status = await getSetupStatus();
|
const status = await getSetupStatus();
|
||||||
if (status.ready) return true;
|
if (!status.required && (status.ready || status.locked)) return true;
|
||||||
} catch {
|
} catch {
|
||||||
// A short connection failure is expected while systemd restarts the service.
|
// A short connection failure is expected while systemd restarts the service.
|
||||||
}
|
}
|
||||||
@@ -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 = '正在进入登录页。';
|
||||||
window.location.assign('/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 () => {
|
||||||
|
|||||||
@@ -17,7 +17,20 @@ const isPublic = (to: RouteLocationNormalized) =>
|
|||||||
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
|
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
|
||||||
|
|
||||||
function loadSetupStatus() {
|
function loadSetupStatus() {
|
||||||
setupStatusRequest ||= getSetupStatus();
|
if (setupStatusRequest) return setupStatusRequest;
|
||||||
|
|
||||||
|
// Share only an in-flight request. Keeping the resolved setup state would
|
||||||
|
// send the user back to /setup after the installation marker is written.
|
||||||
|
const request = getSetupStatus();
|
||||||
|
setupStatusRequest = request;
|
||||||
|
request.then(
|
||||||
|
() => {
|
||||||
|
if (setupStatusRequest === request) setupStatusRequest = null;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (setupStatusRequest === request) setupStatusRequest = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
return setupStatusRequest;
|
return setupStatusRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
&-tabs-nav + .@{starter-prefix}-content-layout {
|
&-tabs-nav + .@{starter-prefix}-content-layout {
|
||||||
padding-top: var(--td-comp-paddingTB-xxl);
|
padding-top: var(--td-comp-paddingTB-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -92,17 +103,29 @@ cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
|||||||
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
||||||
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
|
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
|
||||||
cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh"
|
cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh"
|
||||||
|
cp "$ROOT/deploy/baota-init.sh" "$STAGE/ops/baota-init.sh"
|
||||||
|
cp "$ROOT/deploy/release-public.pem" "$STAGE/ops/release-public.pem"
|
||||||
cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example"
|
cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example"
|
||||||
# Kept in the archive so Preview.9's updater can complete the one-time transition.
|
# Kept in the archive so Preview.9's updater can complete the one-time transition.
|
||||||
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
|
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
|
||||||
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
|
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
|
||||||
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
|
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"
|
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"
|
||||||
@@ -165,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" \
|
||||||
@@ -177,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: {
|
||||||
|
|||||||
@@ -3,24 +3,58 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
WORK=$(mktemp -d)
|
WORK=$(mktemp -d)
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
EXECUTION_MARKER=/tmp/kaidi-baota-fixture-executed-$$
|
||||||
|
trap 'rm -rf "$WORK" "$EXECUTION_MARKER"' EXIT
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'Baota start fixture failed: %s\n' "$1" >&2
|
printf 'Baota start fixture failed: %s\n' "$1" >&2
|
||||||
|
[ ! -f "$WORK/java.log" ] || sed -n '1,40p' "$WORK/java.log" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
RELEASE="$WORK/release"
|
RELEASE="$WORK/release"
|
||||||
mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp"
|
mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp" "$WORK/state" "$WORK/mock-bin"
|
||||||
|
RELEASE_PHYSICAL=$(cd "$RELEASE" && pwd -P)
|
||||||
printf 'fixture jar\n' > "$RELEASE/app.jar"
|
printf 'fixture jar\n' > "$RELEASE/app.jar"
|
||||||
printf '<!doctype html>\n' > "$RELEASE/public/index.html"
|
printf '<!doctype html>\n' > "$RELEASE/public/index.html"
|
||||||
printf '1.0.0-preview.24\n' > "$RELEASE/VERSION"
|
printf '1.0.0-preview.25\n' > "$RELEASE/VERSION"
|
||||||
cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh"
|
cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh"
|
||||||
chmod 0755 "$RELEASE/ops/baota-start.sh"
|
chmod 0755 "$RELEASE/ops/baota-start.sh"
|
||||||
|
|
||||||
|
cat > "$WORK/mock-bin/awk" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${2:-}" in
|
||||||
|
/proc/*/stat) printf '424242\n' ;;
|
||||||
|
*) exec /usr/bin/awk "$@" ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod 0755 "$WORK/mock-bin/awk"
|
||||||
|
|
||||||
|
cat > "$WORK/base.env" <<EOF
|
||||||
|
SPRING_PROFILES_ACTIVE="production"
|
||||||
|
SERVER_ADDRESS="127.0.0.1"
|
||||||
|
SERVER_PORT="18080"
|
||||||
|
FIELD_ENCRYPTION_KEY="fixture-field-key"
|
||||||
|
FILE_STORAGE_ROOT="$WORK/files"
|
||||||
|
FILE_STORAGE_TEMP="$WORK/tmp"
|
||||||
|
FINANCE_UPDATE_ENABLED="true"
|
||||||
|
KAIDI_PID_FILE="$WORK/state/kaidi.pid"
|
||||||
|
EOF
|
||||||
|
cat > "$WORK/runtime.env" <<EOF
|
||||||
|
FINANCE_SETUP_ENABLED="false"
|
||||||
|
SERVER_PORT="19090"
|
||||||
|
DB_URL="jdbc:mysql://db.fixture/kaidi_finance"
|
||||||
|
DB_USERNAME="fixture-user"
|
||||||
|
DB_PASSWORD='\$(touch $EXECUTION_MARKER)'
|
||||||
|
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' "$*"
|
||||||
@@ -29,39 +63,91 @@ set -Eeuo pipefail
|
|||||||
printf 'port=%s\n' "$SERVER_PORT"
|
printf 'port=%s\n' "$SERVER_PORT"
|
||||||
printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED"
|
printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED"
|
||||||
printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS"
|
printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS"
|
||||||
|
printf 'username=%s\n' "$DB_USERNAME"
|
||||||
|
printf 'password=%s\n' "$DB_PASSWORD"
|
||||||
|
printf 'pid-record=%s\n' "$(cat "$KAIDI_PID_FILE")"
|
||||||
|
printf 'self-pid=%s\n' "$$"
|
||||||
} > "$MOCK_JAVA_LOG"
|
} > "$MOCK_JAVA_LOG"
|
||||||
SH
|
SH
|
||||||
chmod 0755 "$WORK/java"
|
chmod 0755 "$WORK/java"
|
||||||
|
|
||||||
MOCK_JAVA_LOG="$WORK/java.log" \
|
MOCK_JAVA_LOG="$WORK/java.log" \
|
||||||
|
PATH="$WORK/mock-bin:$PATH" \
|
||||||
KAIDI_JAVA_BIN="$WORK/java" \
|
KAIDI_JAVA_BIN="$WORK/java" \
|
||||||
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
|
KAIDI_CONFIG_FILE="$WORK/base.env" \
|
||||||
DB_USERNAME=fixture \
|
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
|
||||||
DB_PASSWORD='fixture-password' \
|
DB_URL='' DB_USERNAME='' DB_PASSWORD='' \
|
||||||
FIELD_ENCRYPTION_KEY='fixture-field-key' \
|
|
||||||
FILE_STORAGE_ROOT="$WORK/files" \
|
|
||||||
FILE_STORAGE_TEMP="$WORK/tmp" \
|
|
||||||
"$RELEASE/ops/baota-start.sh"
|
"$RELEASE/ops/baota-start.sh"
|
||||||
|
|
||||||
grep -Fqx "cwd=$RELEASE" "$WORK/java.log" || fail 'launcher did not use the release working directory'
|
grep -Fqx "cwd=$RELEASE_PHYSICAL" "$WORK/java.log" || fail 'launcher did not use the physical release directory'
|
||||||
grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar $RELEASE/app.jar" "$WORK/java.log" \
|
grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -Dkaidi.release.path=$RELEASE_PHYSICAL -Dkaidi.release.version=1.0.0-preview.25 -jar $RELEASE_PHYSICAL/app.jar" "$WORK/java.log" \
|
||||||
|| fail 'launcher did not execute the packaged Spring Boot jar'
|
|| fail 'launcher did not identify and execute the packaged Spring Boot release'
|
||||||
grep -Fqx 'version=1.0.0-preview.24' "$WORK/java.log" || fail 'launcher did not read VERSION'
|
grep -Fqx 'version=1.0.0-preview.25' "$WORK/java.log" || fail 'launcher did not read VERSION'
|
||||||
grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback'
|
grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback'
|
||||||
grep -Fqx 'port=18080' "$WORK/java.log" || fail 'launcher did not default to port 18080'
|
grep -Fqx 'port=19090' "$WORK/java.log" || fail 'runtime environment did not override the base port'
|
||||||
grep -Fqx 'update=false' "$WORK/java.log" || fail 'launcher did not disable the systemd updater'
|
grep -Fqx 'update=true' "$WORK/java.log" || fail 'launcher did not keep online update enabled'
|
||||||
grep -Fqx "static=file:$RELEASE/public/" "$WORK/java.log" \
|
grep -Fqx "static=file:$RELEASE_PHYSICAL/public/" "$WORK/java.log" \
|
||||||
|| fail 'launcher did not bind static resources to the release directory'
|
|| fail 'launcher did not bind static resources to the release directory'
|
||||||
|
grep -Fqx 'username=fixture-user' "$WORK/java.log" || fail 'launcher did not load setup database values'
|
||||||
|
grep -Fqx "password=\$(touch $EXECUTION_MARKER)" "$WORK/java.log" \
|
||||||
|
|| fail 'launcher changed a literal password while parsing the protected environment'
|
||||||
|
[ ! -e "$EXECUTION_MARKER" ] || fail 'launcher executed command syntax from a database password'
|
||||||
|
PID_RECORD=$(sed -n 's/^pid-record=//p' "$WORK/java.log")
|
||||||
|
SELF_PID=$(sed -n 's/^self-pid=//p' "$WORK/java.log")
|
||||||
|
[ "$PID_RECORD" = "$SELF_PID 424242" ] || fail 'PID file did not bind the PID to its proc start time'
|
||||||
|
|
||||||
|
# systemd reads the root-only EnvironmentFile before dropping to User=kaidi.
|
||||||
|
# The launcher must therefore be able to start with those files intentionally
|
||||||
|
# unreadable by the service user.
|
||||||
|
chmod 000 "$WORK/base.env" "$WORK/runtime.env"
|
||||||
|
MOCK_JAVA_LOG="$WORK/preloaded.log" \
|
||||||
|
PATH="$WORK/mock-bin:$PATH" \
|
||||||
|
KAIDI_ENV_PRELOADED=true \
|
||||||
|
KAIDI_JAVA_BIN="$WORK/java" \
|
||||||
|
SPRING_PROFILES_ACTIVE=production \
|
||||||
|
SERVER_ADDRESS=127.0.0.1 \
|
||||||
|
SERVER_PORT=21000 \
|
||||||
|
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
|
||||||
|
DB_USERNAME=fixture-user \
|
||||||
|
DB_PASSWORD='fixture-password' \
|
||||||
|
FIELD_ENCRYPTION_KEY=fixture-field-key \
|
||||||
|
FILE_STORAGE_ROOT="$WORK/files" \
|
||||||
|
FILE_STORAGE_TEMP="$WORK/tmp" \
|
||||||
|
FINANCE_SETUP_ENABLED=false \
|
||||||
|
FINANCE_UPDATE_ENABLED=true \
|
||||||
|
FILE_SCANNER_ENABLED=false \
|
||||||
|
KAIDI_PID_FILE="$WORK/state/preloaded.pid" \
|
||||||
|
KAIDI_CONFIG_FILE="$WORK/base.env" \
|
||||||
|
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
|
||||||
|
"$RELEASE/ops/baota-start.sh"
|
||||||
|
grep -Fqx 'port=21000' "$WORK/preloaded.log" \
|
||||||
|
|| fail 'launcher still depended on the root-only configuration files under systemd'
|
||||||
|
chmod 0644 "$WORK/base.env" "$WORK/runtime.env"
|
||||||
|
|
||||||
|
MOCK_JAVA_LOG="$WORK/explicit.log" \
|
||||||
|
PATH="$WORK/mock-bin:$PATH" \
|
||||||
|
KAIDI_JAVA_BIN="$WORK/java" \
|
||||||
|
KAIDI_CONFIG_FILE="$WORK/base.env" \
|
||||||
|
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
|
||||||
|
SERVER_PORT=20000 \
|
||||||
|
"$RELEASE/ops/baota-start.sh"
|
||||||
|
grep -Fqx 'port=20000' "$WORK/explicit.log" || fail 'non-empty Baota environment did not take precedence'
|
||||||
|
|
||||||
|
cat > "$WORK/missing-db.env" <<EOF
|
||||||
|
FIELD_ENCRYPTION_KEY="fixture-field-key"
|
||||||
|
FILE_STORAGE_ROOT="$WORK/files"
|
||||||
|
FILE_STORAGE_TEMP="$WORK/tmp"
|
||||||
|
KAIDI_PID_FILE="$WORK/state/missing.pid"
|
||||||
|
EOF
|
||||||
if KAIDI_JAVA_BIN="$WORK/java" \
|
if KAIDI_JAVA_BIN="$WORK/java" \
|
||||||
DB_USERNAME=fixture DB_PASSWORD=fixture FIELD_ENCRYPTION_KEY=fixture \
|
KAIDI_CONFIG_FILE="$WORK/missing-db.env" \
|
||||||
FILE_STORAGE_ROOT="$WORK/files" FILE_STORAGE_TEMP="$WORK/tmp" \
|
KAIDI_RUNTIME_ENV_FILE="$WORK/absent.env" \
|
||||||
MOCK_JAVA_LOG="$WORK/missing-db.log" \
|
MOCK_JAVA_LOG="$WORK/missing-db.log" \
|
||||||
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
|
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
|
||||||
fail 'launcher accepted a production configuration without DB_URL'
|
fail 'launcher accepted a production configuration without DB_URL'
|
||||||
fi
|
fi
|
||||||
grep -Fq 'DB_URL must be configured' "$WORK/missing-db.out" \
|
grep -Fq 'DB_URL is missing from the protected Kaidi configuration' "$WORK/missing-db.out" \
|
||||||
|| fail 'launcher did not report the missing database URL'
|
|| fail 'launcher did not report the missing database URL'
|
||||||
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
|
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
|
||||||
|
|
||||||
printf 'Baota Spring Boot launcher fixture passed\n'
|
printf 'Baota Spring Boot launcher configuration, PID identity, and injection fixtures passed\n'
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ chmod 0755 "$FIXTURE/bin/sudo" "$FIXTURE/repo/deploy/install.sh" "$FIXTURE/repo/
|
|||||||
printf '%s' 'fixture-read-token' > "$FIXTURE/token"
|
printf '%s' 'fixture-read-token' > "$FIXTURE/token"
|
||||||
chmod 0600 "$FIXTURE/token"
|
chmod 0600 "$FIXTURE/token"
|
||||||
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
||||||
|
default_installer_sha256=$(sed -n 's/^INSTALLER_SHA256=.*:-\([0-9A-Fa-f]*\)}$/\1/p' \
|
||||||
|
"$ROOT/deploy/install-from-git.sh")
|
||||||
|
[ "$default_installer_sha256" = "$(sha256sum "$ROOT/deploy/install.sh" | awk '{print $1}')" ] \
|
||||||
|
|| { printf 'Git installer default checksum is stale\n' >&2; exit 1; }
|
||||||
|
|
||||||
PATH="$FIXTURE/bin:$PATH" \
|
PATH="$FIXTURE/bin:$PATH" \
|
||||||
EXPECT_PORT=true \
|
EXPECT_PORT=true \
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ mode_of() {
|
|||||||
sed -n '/^port_is_listening()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^port_is_listening()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^valid_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^valid_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^configure_database()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^database_host()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^database_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^database_name()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
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 +41,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"
|
||||||
@@ -45,6 +51,25 @@ 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'
|
||||||
|
REINSTALL=false
|
||||||
|
# shellcheck disable=SC2034 # Consumed by the extracted configure_database helper.
|
||||||
|
KAIDI_DB_URL='' KAIDI_DB_USERNAME='' KAIDI_DB_PASSWORD=''
|
||||||
|
configure_database
|
||||||
|
[ -z "$DB_URL$DB_USERNAME$DB_PASSWORD" ] \
|
||||||
|
|| fail 'setup wizard retained a fake database configuration'
|
||||||
|
[ -z "$(database_host)$(database_port)$(database_name)" ] \
|
||||||
|
|| fail 'setup wizard exposed placeholder database coordinates to the updater'
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -192,7 +217,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 +238,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 +311,26 @@ 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 'jdbc:mysql://setup.invalid' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer still writes a fake setup database URL'
|
||||||
|
! grep -Fq 'jdbc:mysql://setup.invalid' "$ROOT/deploy/baota-init.sh" \
|
||||||
|
|| fail 'Baota initializer still writes a fake setup database URL'
|
||||||
|
! grep -Fq 'KAIDI_DB_HOST setup.invalid' "$ROOT/deploy/baota-init.sh" \
|
||||||
|
|| fail 'Baota updater still points at a fake setup database host'
|
||||||
|
! grep -Fq 'systemctl enable --now kaidi-update.path' "$ROOT/deploy/baota-init.sh" \
|
||||||
|
|| fail 'Baota initializer still enables the systemd updater'
|
||||||
|
# shellcheck disable=SC2016 # Match literal initializer source.
|
||||||
|
! grep -Fq 'install -m 0644 "$release_root/ops/kaidi-update.service"' "$ROOT/deploy/baota-init.sh" \
|
||||||
|
|| fail 'Baota initializer still installs the systemd updater unit'
|
||||||
|
grep -Fq '32-bit Linux deployment requires glibc' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not reject unsupported musl 32-bit hosts before downloading Java'
|
||||||
|
grep -Fq '32-bit Linux deployment requires the glibc loader' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not reject a 32-bit host without the glibc loader'
|
||||||
|
grep -Fq 'preflight_runtime_commands' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not validate required runtime commands'
|
||||||
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
|
grep -Fq '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 +340,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,15 +368,25 @@ 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 '/var/lib/kaidi-update/processing' "$ROOT/deploy/systemd/kaidi-update.path" \
|
grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \
|
||||||
|| fail 'update path can automatically repeat a claimed switching transaction'
|
|| fail 'update service requires the Baota application path on a systemd-only installation'
|
||||||
|
grep -Fqx 'PathExists=/var/lib/kaidi-update/processing/request.json' "$ROOT/deploy/systemd/kaidi-update.path" \
|
||||||
|
|| fail 'update path does not resume an interrupted claimed request'
|
||||||
|
grep -Fqx 'PathExists=/var/lib/kaidi-update/transactions/active' "$ROOT/deploy/systemd/kaidi-update.path" \
|
||||||
|
|| fail 'update path does not resume an interrupted switching transaction'
|
||||||
# shellcheck disable=SC2016 # Match literal installer source.
|
# 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'
|
||||||
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer default release source is not the public Gitea latest API'
|
|| fail 'installer default release source is not the public Gitea latest API'
|
||||||
|
grep -Fq '/www/server/java/*/bin/java' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not search the common Baota Java installation path'
|
||||||
# shellcheck disable=SC2016 # Match literal installer source.
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \
|
grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer does not pin the default release public-key fingerprint'
|
|| fail 'installer does not pin the default release public-key fingerprint'
|
||||||
@@ -378,13 +439,22 @@ grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \
|
|||||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||||
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||||
|| fail 'application service no longer loads the setup-completion environment'
|
|| fail 'application service no longer loads the setup-completion environment'
|
||||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
! grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||||
"$ROOT/deploy/systemd/kaidi-update.service" \
|
"$ROOT/deploy/systemd/kaidi-update.service" \
|
||||||
|| fail 'update service no longer loads setup database overrides'
|
|| fail 'root update service must not load an application-owned environment file'
|
||||||
|
grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
|
||||||
|
|| fail 'update script no longer imports only the setup database fields'
|
||||||
# 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 'install.sh | sudo bash' "$ROOT/README.md" \
|
manual_version=$(sed -n 's/^VERSION=\(1\.0\.0-preview\.[0-9][0-9]*\)$/\1/p' "$ROOT/README.md" | sed -n '1p')
|
||||||
|| fail 'README does not document the public one-line setup-wizard install path'
|
[ -n "$manual_version" ] \
|
||||||
|
|| fail 'README does not declare a preview version for the public manual-deployment artifact'
|
||||||
|
grep -Fq "kaidi-finance-$manual_version.tar.gz" "$ROOT/README.md" \
|
||||||
|
|| fail 'README does not document the public manual-deployment artifact for its declared version'
|
||||||
|
grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \
|
||||||
|
|| fail 'README does not document the local Baota initialization command'
|
||||||
|
grep -Fq 'FINANCE_UPDATE_ENABLED false' "$ROOT/deploy/baota-init.sh" \
|
||||||
|
|| fail 'Baota manual mode still exposes the unstable online updater'
|
||||||
|
|
||||||
printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
|
printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+166
-15
@@ -26,18 +26,35 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
missing_service_user="kaidi-fixture-missing-$$"
|
missing_service_user="kaidi-fixture-missing-$$"
|
||||||
|
mkdir -p "$WORK/bootstrap/app" "$WORK/bootstrap/state/inbox" \
|
||||||
|
"$WORK/bootstrap/state/processing" "$WORK/bootstrap/state/failed" "$WORK/bootstrap/log" \
|
||||||
|
"$WORK/bootstrap/bin"
|
||||||
|
cat > "$WORK/bootstrap/bin/flock" <<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod 0755 "$WORK/bootstrap/bin/flock"
|
||||||
|
jq -n \
|
||||||
|
'{action:"DOWNLOAD",version:"1.0.0-preview.2",reason:"bootstrap fixture"}' \
|
||||||
|
> "$WORK/bootstrap/state/inbox/request.json"
|
||||||
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
|
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
|
||||||
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
|
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
|
||||||
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
|
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
|
||||||
KAIDI_SERVICE_USER="$missing_service_user" \
|
KAIDI_SERVICE_USER="$missing_service_user" \
|
||||||
KAIDI_SERVICE_GROUP="$missing_service_user" \
|
KAIDI_SERVICE_GROUP="$missing_service_user" \
|
||||||
|
PATH="$WORK/bootstrap/bin:$PATH" \
|
||||||
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
|
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
|
||||||
fail 'updater accepted a missing service identity during bootstrap'
|
fail 'updater accepted a missing service identity during bootstrap'
|
||||||
fi
|
fi
|
||||||
grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \
|
grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \
|
||||||
|| fail 'updater bootstrap failure did not preserve its diagnostic'
|
|| fail 'updater bootstrap failure did not preserve its diagnostic'
|
||||||
! grep -Eq 'No such file|nonexistent directory|cannot create' "$WORK/bootstrap.log" \
|
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|
||||||
|| fail 'updater bootstrap failure was masked by an unavailable status directory'
|
|| fail 'updater bootstrap failure did not persist FAILED'
|
||||||
|
[ ! -e "$WORK/bootstrap/state/inbox/request.json" ] \
|
||||||
|
&& [ ! -e "$WORK/bootstrap/state/processing/request.json" ] \
|
||||||
|
|| fail 'updater bootstrap failure left a request permanently queued'
|
||||||
|
find "$WORK/bootstrap/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||||
|
|| fail 'updater bootstrap failure did not archive the claimed request'
|
||||||
|
|
||||||
write_mock_commands() {
|
write_mock_commands() {
|
||||||
local mock_bin=$1
|
local mock_bin=$1
|
||||||
@@ -48,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
|
||||||
@@ -73,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
|
||||||
@@ -87,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'
|
||||||
@@ -99,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'
|
||||||
@@ -174,28 +221,37 @@ 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"
|
||||||
|
if [ ! -s "$WORK/fixture-private.pem" ]; then
|
||||||
|
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||||
|
-out "$WORK/fixture-private.pem" >/dev/null 2>&1
|
||||||
|
"$REAL_OPENSSL" pkey -in "$WORK/fixture-private.pem" -pubout \
|
||||||
|
-out "$WORK/fixture-public.pem" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
cp "$WORK/fixture-private.pem" "$fixture/private.pem"
|
||||||
|
cp "$WORK/fixture-public.pem" "$fixture/release-public.pem"
|
||||||
printf 'new application\n' > "$stage/app.jar"
|
printf '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"
|
||||||
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh"
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-init.sh"
|
||||||
|
cp "$fixture/release-public.pem" "$stage/ops/release-public.pem"
|
||||||
chmod 0755 "$stage/ops/update.sh"
|
chmod 0755 "$stage/ops/update.sh"
|
||||||
chmod 0755 "$stage/ops/baota-start.sh"
|
chmod 0755 "$stage/ops/baota-start.sh"
|
||||||
|
chmod 0755 "$stage/ops/baota-init.sh"
|
||||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||||
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
|
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
|
||||||
done
|
done
|
||||||
|
|
||||||
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" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
|
||||||
-out "$fixture/private.pem" >/dev/null 2>&1
|
|
||||||
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \
|
|
||||||
-out "$fixture/release-public.pem" >/dev/null 2>&1
|
|
||||||
"$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"
|
||||||
}
|
}
|
||||||
@@ -249,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'
|
||||||
@@ -256,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +351,7 @@ run_update() {
|
|||||||
KAIDI_LOG_ROOT="$fixture/log" \
|
KAIDI_LOG_ROOT="$fixture/log" \
|
||||||
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
||||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||||
|
KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \
|
||||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||||
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
|
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
|
||||||
@@ -386,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'
|
||||||
@@ -424,7 +492,7 @@ assert_rollback_case() {
|
|||||||
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
|
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
|
||||||
fail 'rollback case unexpectedly succeeded'
|
fail 'rollback case unexpectedly succeeded'
|
||||||
fi
|
fi
|
||||||
grep -q 'previous release was restored and verified' "$fixture/update.log" \
|
grep -q 'previous application release was restored and verified' "$fixture/update.log" \
|
||||||
|| fail 'rollback case did not report a complete restoration'
|
|| fail 'rollback case did not report a complete restoration'
|
||||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||||
|| fail 'rollback case did not restore the previous application'
|
|| fail 'rollback case did not restore the previous application'
|
||||||
@@ -453,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() {
|
||||||
@@ -505,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'
|
||||||
@@ -549,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'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -169,6 +192,8 @@ compare_archive_file() {
|
|||||||
|
|
||||||
compare_archive_file ./ops/update.sh deploy/update.sh
|
compare_archive_file ./ops/update.sh deploy/update.sh
|
||||||
compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh
|
compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh
|
||||||
|
compare_archive_file ./ops/baota-init.sh deploy/baota-init.sh
|
||||||
|
compare_archive_file ./ops/release-public.pem deploy/release-public.pem
|
||||||
compare_archive_file ./ops/baota.env.example deploy/baota.env.example
|
compare_archive_file ./ops/baota.env.example deploy/baota.env.example
|
||||||
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
|
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
|
||||||
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
|
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
|
||||||
@@ -176,7 +201,10 @@ compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.se
|
|||||||
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
|
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
|
||||||
tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|
tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|
||||||
|| { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; }
|
|| { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; }
|
||||||
|
tar -tvzf "$ARTIFACT" ./ops/baota-init.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|
||||||
|
|| { printf 'Baota initializer is not executable\n' >&2; exit 1; }
|
||||||
bash -n "$ROOT/deploy/baota-start.sh"
|
bash -n "$ROOT/deploy/baota-start.sh"
|
||||||
|
bash -n "$ROOT/deploy/baota-init.sh"
|
||||||
cmp install.sh "$ROOT/deploy/install.sh" \
|
cmp install.sh "$ROOT/deploy/install.sh" \
|
||||||
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
|
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
|
||||||
bash -n "$ROOT/deploy/purge.sh"
|
bash -n "$ROOT/deploy/purge.sh"
|
||||||
|
|||||||
Reference in New Issue
Block a user