Compare commits

...
Author SHA1 Message Date
Qiufeng 2c73ad7eb0 fix: support panel-managed deployment safely
Release / release (push) Canceled after 0s
2026-08-18 00:02:45 +08:00
Qiufeng c35ef3d383 fix: default repair installs to production mode
Release / release (push) Canceled after 0s
2026-08-17 23:43:57 +08:00
Qiufeng cea46b494d fix: keep online update recovery available
Release / release (push) Canceled after 0s
2026-08-17 23:36:37 +08:00
Qiufeng b679381946 fix: keep brand navigation in active workbench
Release / release (push) Canceled after 0s
2026-08-17 23:03:03 +08:00
Qiufeng 3480d810d4 fix: recover MySQL collation migration
Release / release (push) Canceled after 0s
2026-08-17 22:32:57 +08:00
Qiufeng 271eac382e fix: harden first-run database setup
Release / release (push) Canceled after 0s
2026-08-17 22:05:10 +08:00
Qiufeng 52403c6f95 fix: prevent production TDesign chunk cycles
Release / release (push) Canceled after 0s
2026-08-17 20:46:20 +08:00
Qiufeng 4f626da9db fix: make Linux installation permissions deterministic
Release / release (push) Successful in 3s
2026-08-17 20:16:04 +08:00
Qiufeng dc35a48b44 fix: make installer health checks quiet and diagnostic
Release / release (push) Failing after 10s
2026-08-17 19:29:08 +08:00
Qiufeng e5b419920e fix: retry Gitea release uploads 2026-08-17 18:04:21 +08:00
Qiufeng 8d63d60df5 fix: keep setup wizard preflight successful
Release / release (push) Failing after 37s
2026-08-17 17:56:57 +08:00
Qiufeng 74585a5f07 fix: reuse local Java before downloading runtime
Release / release (push) Failing after 20s
2026-08-17 17:44:13 +08:00
Qiufeng b19716aef9 fix: stop installing database clients
Release / release (push) Failing after 14s
2026-08-17 17:02:07 +08:00
Qiufeng f9912ade7e fix: normalize Gitea release asset URLs
Release / release (push) Failing after 8s
2026-08-17 16:42:03 +08:00
Qiufeng 7394c9e7e4 feat: make reverse proxy operator-managed
Release / release (push) Failing after 10s
2026-08-17 16:24:32 +08:00
52 changed files with 1985 additions and 330 deletions
+5 -1
View File
@@ -57,6 +57,7 @@ jobs:
npm run build npm run build
npx playwright install --with-deps chromium npx playwright install --with-deps chromium
npm run test:e2e npm run test:e2e
npm run test:dist
- name: Verify release contracts and scripts - name: Verify release contracts and scripts
run: | run: |
@@ -64,12 +65,15 @@ jobs:
./scripts/test-install-fixture.sh ./scripts/test-install-fixture.sh
./scripts/test-git-install-fixture.sh ./scripts/test-git-install-fixture.sh
./scripts/test-update-fixture.sh ./scripts/test-update-fixture.sh
./scripts/test-baota-start-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 scripts/check-semver.sh \ shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh \
scripts/check-semver.sh \
scripts/package-release.sh scripts/publish-gitea-release.sh \ scripts/package-release.sh scripts/publish-gitea-release.sh \
scripts/generate-release-key.sh scripts/test-install-fixture.sh \ scripts/generate-release-key.sh scripts/test-install-fixture.sh \
scripts/test-git-install-fixture.sh \ scripts/test-git-install-fixture.sh \
scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \ scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
scripts/test-baota-start-fixture.sh \
scripts/verify-release.sh scripts/verify-release.sh
- name: Build and sign release assets - name: Build and sign release assets
+2
View File
@@ -4,6 +4,8 @@ frontend/dist/
frontend/node_modules/ frontend/node_modules/
frontend/playwright-report/ frontend/playwright-report/
frontend/test-results/ frontend/test-results/
frontend/playwright-report-dist/
frontend/test-results-dist/
.idea/ .idea/
.vscode/ .vscode/
*.iml *.iml
+126 -39
View File
@@ -46,25 +46,36 @@ npm run build
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取 代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。
安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。 安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有 本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.9 发布范围。 PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、 执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、 由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且 外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
80 端口可用;安装器会接管默认 HTTP 站点。 `/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
### 直接 curl 安装 ### 直接 curl 安装
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh | sudo bash curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
``` ```
这条命令会安装最新签名 Release,并默认进入 `/setup` 安装向导。需要在执行前独立校验安装脚本时使用: 这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh -o /tmp/kaidi-install.sh curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
printf '%s %s\n' faf52cc902abbc2bd48571caee3f4207de50ce339b82ce88fe23f9c4ce8f89ef /tmp/kaidi-install.sh | sha256sum -c - | sudo env KAIDI_APP_PORT=19090 bash
```
端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh -o /tmp/kaidi-install.sh
printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c -
sudo bash /tmp/kaidi-install.sh sudo bash /tmp/kaidi-install.sh
rm -f /tmp/kaidi-install.sh rm -f /tmp/kaidi-install.sh
``` ```
@@ -75,7 +86,7 @@ rm -f /tmp/kaidi-install.sh
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
```bash ```bash
git clone --branch v1.0.0-preview.9 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
cd kaidi-preview cd kaidi-preview
./deploy/install-from-git.sh ./deploy/install-from-git.sh
``` ```
@@ -87,23 +98,39 @@ cd kaidi-preview
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。 - 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。 - 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。 - 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。 - 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。 - 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。 - 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。 - 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 - 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。 - 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问 安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后,
`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置 通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为 `SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。 `SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
安装后执行以下命令确认应用、反向代理和首次登录信息: 以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件:
```nginx
location / {
proxy_pass http://127.0.0.1:19090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
client_max_body_size 500m;
}
```
宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息:
```bash ```bash
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' 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 systemctl --no-pager --full status kaidi-finance kaidi-update.path
sudo cat /root/kaidi-first-login.txt sudo cat /root/kaidi-first-login.txt
``` ```
@@ -114,11 +141,13 @@ sudo cat /root/kaidi-first-login.txt
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: 32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh | sudo bash curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
``` ```
32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。 完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
排序规则,全部通过后 Flyway 才会建表。
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: 数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
```sql ```sql
@@ -134,32 +163,89 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
管理员数据和运维人员新增的环境变量: 管理员数据和运维人员新增的环境变量:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh \ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
``` ```
重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整, 没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整,
不会把恢复失败吞掉。 不会把恢复失败吞掉。新版本安装器在 `KAIDI_REINSTALL=true` 时默认使用生产修复模式,显式的
`KAIDI_SETUP_WIZARD=false` 仅用于兼容旧版本安装器。
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh \ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \
| sudo env KAIDI_REINSTALL=true bash | 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.23
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
curl -fsS http://127.0.0.1:18080/actuator/health
curl -fsS http://127.0.0.1:18080/ | head
``` ```
### 停用并移除程序 ### 停用并移除程序
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库, 以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。 便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。
安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。 反向代理由运维人员独立管理,停用程序不会修改其配置。
```bash ```bash
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
sudo rm -f /etc/nginx/conf.d/kaidi-finance.conf
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo nginx -t && sudo systemctl reload nginx
sudo rm -rf /opt/kaidi sudo rm -rf /opt/kaidi
``` ```
@@ -182,8 +268,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.9 git tag v1.0.0-preview.19
git push origin v1.0.0-preview.9 git push origin v1.0.0-preview.19
``` ```
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
@@ -195,13 +281,14 @@ URL、Token、脚本或命令。更新流程固定为:
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致 3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
的包不能进入安装。 的包不能进入安装。
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。 4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。 5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。 `mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动 6. 校验更新脚本和 systemd 单元后,在旧 Java 仍运行时原子切换版本链接,再由 systemd 执行一次 `restart`,不修改反向代理。
7. 同时检查 Java 健康端点和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。 刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新, 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。 不会延迟服务端切换。失败请求会被归档且不会由 systemd 自动重试,避免同一事务反复停止业务服务。
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为 安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像, `0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像,
@@ -215,9 +302,9 @@ 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` 且日志提示回滚未完成,不要删除 如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试, `/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动再次执行。修复日志所示的
修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行: 磁盘、权限或旧版本健康问题后,才明确执行一次:
```bash ```bash
sudo systemctl reset-failed kaidi-update.service kaidi-update.path sudo systemctl reset-failed kaidi-update.service kaidi-update.path
@@ -234,7 +321,7 @@ cat /var/lib/kaidi-update/status.json
```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.9 ./scripts/package-release.sh 1.0.0-preview.19
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
``` ```
@@ -17,6 +17,6 @@ public class SetupLockedController {
@GetMapping("/status") @GetMapping("/status")
@PreAuthorize("true") @PreAuthorize("true")
public ApiResponse<SetupViews.Status> status() { public ApiResponse<SetupViews.Status> status() {
return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装")); return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装"));
} }
} }
@@ -3,9 +3,11 @@ package com.kaidi.finance.setup;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.RequestMatcher;
@Configuration @Configuration
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") @ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
@@ -19,8 +21,20 @@ public class SetupSecurityConfig {
.formLogin(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error").permitAll() .requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error",
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
.anyRequest().denyAll()); .anyRequest().denyAll());
return http.build(); return http.build();
} }
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
&& !path.equals("/error");
}
} }
@@ -25,6 +25,9 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -37,8 +40,15 @@ import org.springframework.transaction.support.TransactionTemplate;
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") @ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
public class SetupService { public class SetupService {
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
private static final String MYSQL_PREFIX = "jdbc:mysql://"; private static final String MYSQL_PREFIX = "jdbc:mysql://";
private static final String MYSQL_CHARACTER_SET = "utf8mb4";
private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci";
private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql";
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name()); private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10;
private final SetupProperties properties; private final SetupProperties properties;
private final AtomicBoolean locked = new AtomicBoolean(false); private final AtomicBoolean locked = new AtomicBoolean(false);
@@ -56,7 +66,7 @@ public class SetupService {
public SetupViews.Status status() { public SetupViews.Status status() {
boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile())); boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile()));
return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES, return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES,
isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化"); isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化");
} }
@@ -74,8 +84,10 @@ public class SetupService {
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
} }
ConnectionResult connection = testConnection(settings); ConnectionResult connection = testConnection(settings);
prepareDatabaseForInstallation(settings);
validateSchemaForInstallation(settings);
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
"MySQL 8.4 连接和 DDL/DML 权限验证通过"); "MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
} }
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -106,7 +118,7 @@ public class SetupService {
if (properties.restartAfterComplete()) { if (properties.restartAfterComplete()) {
Thread restart = new Thread(() -> { Thread restart = new Thread(() -> {
try { try {
Thread.sleep(750L); Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
} catch (InterruptedException exception) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
@@ -116,18 +128,14 @@ public class SetupService {
restart.start(); restart.start();
} }
return new SetupViews.Completed(true, request.adminUsername(), return new SetupViews.Completed(true, request.adminUsername(),
"安装完成,系统正在切换到正式模式", 10); "安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
} }
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) { private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
try { try {
ensureCompatibleSchema(settings); prepareDatabaseForInstallation(settings);
Flyway.configure() validateSchemaForInstallation(settings);
.dataSource(settings.jdbcUrl(), settings.username(), settings.password()) flyway(settings).migrate();
.locations("classpath:db/migration")
.cleanDisabled(true)
.load()
.migrate();
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()); settings.password());
JdbcTemplate jdbc = new JdbcTemplate(dataSource); JdbcTemplate jdbc = new JdbcTemplate(dataSource);
@@ -137,12 +145,112 @@ public class SetupService {
if (exception instanceof SetupException setupException) { if (exception instanceof SetupException setupException) {
throw setupException; throw setupException;
} }
LOGGER.error("Database migration or administrator initialization failed", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限"); "DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
} }
} }
private void ensureCompatibleSchema(DatabaseSettings settings) { private void prepareDatabaseForInstallation(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()));
try {
DatabaseCollation current = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) {
jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database())
+ " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION);
DatabaseCollation updated = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
}
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
}
recoverV068CollationFailure(jdbc, settings.database());
} catch (SetupException exception) {
throw exception;
} catch (RuntimeException exception) {
LOGGER.error("Database collation preparation failed", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
: "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_PREPARATION_FAILED", detail);
}
}
private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) {
return jdbc.queryForObject("""
SELECT default_character_set_name, default_collation_name
FROM information_schema.schemata
WHERE schema_name = ?
""", (result, row) -> new DatabaseCollation(
result.getString("default_character_set_name"), result.getString("default_collation_name")),
database);
}
private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) {
Integer historyCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = ? AND table_name = 'flyway_schema_history'
""", Integer.class, database);
if (historyCount == null || historyCount == 0) {
return;
}
List<FailedMigration> failures = jdbc.query("""
SELECT installed_rank, version, script
FROM flyway_schema_history
WHERE success = FALSE
ORDER BY installed_rank
""", (result, row) -> new FailedMigration(result.getInt("installed_rank"),
result.getString("version"), result.getString("script")));
if (failures.size() != 1) {
return;
}
FailedMigration failure = failures.get(0);
if (!("68".equals(failure.version()) || "068".equals(failure.version()))
|| !RECOVERABLE_V068_SCRIPT.equals(failure.script())) {
return;
}
Integer predecessorCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version IN ('67', '067') AND success = TRUE
""", Integer.class);
if (predecessorCount == null || predecessorCount != 1) {
return;
}
Integer laterSuccessCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE installed_rank > ? AND success = TRUE
""", Integer.class, failure.installedRank());
if (laterSuccessCount != null && laterSuccessCount > 0) {
return;
}
int deleted = jdbc.update("""
DELETE FROM flyway_schema_history
WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE
""", failure.installedRank(), failure.version(), failure.script());
if (deleted != 1) {
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
}
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
RECOVERABLE_V068_SCRIPT);
}
private String quoteIdentifier(String identifier) {
return "`" + identifier.replace("`", "``") + "`";
}
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password())); settings.password()));
Integer tableCount = jdbc.queryForObject(""" Integer tableCount = jdbc.queryForObject("""
@@ -160,7 +268,42 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库"); "DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库");
} }
return true;
} }
return false;
}
private void validateSchemaForInstallation(DatabaseSettings settings) {
try {
if (!ensureCompatibleSchema(settings)) {
return;
}
flyway(settings).validate();
} catch (SetupException exception) {
throw exception;
} catch (FlywayException exception) {
LOGGER.error("Database migration history validation failed", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_MIGRATION_STATE_INVALID",
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
} catch (RuntimeException exception) {
LOGGER.error("Database schema inspection failed", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库结构检查失败:" + safeErrorMessage(exception)
: databaseConnectionFailureMessage(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
}
}
private Flyway flyway(DatabaseSettings settings) {
return Flyway.configure()
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
.locations("classpath:db/migration")
.ignoreMigrationPatterns("*:pending")
.cleanDisabled(true)
.load();
} }
private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) { private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) {
@@ -280,18 +423,148 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); "MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
} }
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", ""); verifyMigrationPrivileges(connection, statement, settings.type());
try {
statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + probe + " (id) VALUES (1)");
statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1");
} finally {
statement.execute("DROP TABLE IF EXISTS " + probe);
}
return new ConnectionResult(true, version); return new ConnectionResult(true, version);
} catch (SQLException exception) { } catch (SQLException exception) {
LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}",
exception.getSQLState(), exception.getErrorCode(), exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败"); "DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
}
}
private String databaseConnectionFailureMessage(SQLException exception) {
if (isPermissionError(exception)) {
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
}
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
return "数据库连接超时或中断" + sqlErrorSummary(exception);
}
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
}
private String databaseInitializationFailureMessage(Throwable exception) {
SQLException sqlException = findSqlException(exception);
if (sqlException != null) {
if (isPermissionError(sqlException)) {
return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException);
}
if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) {
return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception);
}
private SQLException findSqlException(Throwable exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof SQLException sqlException) {
return sqlException;
}
current = current.getCause();
}
return null;
}
private boolean isPermissionError(SQLException exception) {
return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode());
}
private String sqlErrorSummary(SQLException exception) {
String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState();
return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):"
+ safeErrorMessage(exception);
}
private String safeErrorMessage(Throwable exception) {
Throwable current = exception;
while (current.getCause() != null) {
current = current.getCause();
}
String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***")
.replaceAll("\\s+", " ").trim();
return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "...";
}
private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType)
throws SQLException {
String suffix = UUID.randomUUID().toString().replace("-", "");
String parentTable = "kaidi_setup_probe_parent_" + suffix;
String childTable = "kaidi_setup_probe_child_" + suffix;
String foreignKey = "fk_setup_probe_" + suffix;
String index = "idx_setup_probe_" + suffix;
String routine = "kaidi_setup_probe_routine_" + suffix;
String temporaryTable = "kaidi_setup_probe_temp_" + suffix;
try {
statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)");
if (databaseType == DatabaseType.MYSQL) {
statement.execute("CREATE TABLE " + childTable
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))");
statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL");
statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)");
statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value");
try (Statement call = connection.createStatement();
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
if (!result.next() || result.getInt(1) != 1) {
throw new SQLException("Migration privilege routine probe returned an invalid result");
}
}
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
if (!result.next() || !"probe".equals(result.getString(1))) {
throw new SQLException("Migration privilege SELECT probe returned an invalid result");
}
}
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
+ " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)");
statement.execute("DROP TEMPORARY TABLE " + temporaryTable);
statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1");
statement.execute("DELETE FROM " + childTable + " WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 1");
} else {
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 2");
}
} catch (SQLException exception) {
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception);
throw exception;
}
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null);
}
private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine,
String temporaryTable, String childTable, String parentTable,
SQLException original)
throws SQLException {
SQLException cleanupFailure = null;
List<String> cleanupStatements = databaseType == DatabaseType.MYSQL
? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable,
"DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable)
: List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable);
for (String sql : cleanupStatements) {
try {
statement.execute(sql);
} catch (SQLException exception) {
if (cleanupFailure == null) {
cleanupFailure = exception;
} else {
cleanupFailure.addSuppressed(exception);
}
}
}
if (cleanupFailure != null) {
if (original != null) {
original.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
} }
} }
@@ -411,6 +684,12 @@ public class SetupService {
private record ConnectionResult(boolean successful, String version) { private record ConnectionResult(boolean successful, String version) {
} }
private record DatabaseCollation(String characterSet, String collation) {
}
private record FailedMigration(int installedRank, String version, String script) {
}
private record InstallationState(String setupTokenSha256, long administratorUserId, private record InstallationState(String setupTokenSha256, long administratorUserId,
String administratorUsername) { String administratorUsername) {
} }
@@ -10,6 +10,7 @@ public final class SetupViews {
public record Status( public record Status(
boolean required, boolean required,
boolean locked, boolean locked,
boolean ready,
List<String> supportedDatabaseTypes, List<String> supportedDatabaseTypes,
String message String message
) { ) {
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
@Configuration @Configuration
@EnableMethodSecurity @EnableMethodSecurity
@@ -37,7 +38,9 @@ public class SecurityConfig {
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**", .requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**",
"/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error").permitAll() "/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error",
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
.requestMatchers("/api/v1/**").permitAll() .requestMatchers("/api/v1/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(exceptions -> exceptions .exceptionHandling(exceptions -> exceptions
@@ -48,6 +51,18 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
&& !path.equals("/api-docs") && !path.startsWith("/api-docs/")
&& !path.equals("/swagger-ui.html") && !path.startsWith("/swagger-ui/")
&& !path.equals("/error");
}
@Bean @Bean
PasswordEncoder passwordEncoder() { PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@@ -0,0 +1,51 @@
package com.kaidi.finance.shared.web;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Forwards extensionless browser routes to the bundled Vue entry point. */
@Component
public class SpaForwardFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isBrowserRoute(request)) {
request.getRequestDispatcher("/index.html").forward(request, response);
return;
}
filterChain.doFilter(request, response);
}
private boolean isBrowserRoute(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
if (path.isEmpty()) {
path = "/";
}
if (isReserved(path) || path.contains(".")) {
return false;
}
return DispatcherType.REQUEST.equals(request.getDispatcherType());
}
private boolean isReserved(String path) {
return path.equals("/api") || path.startsWith("/api/")
|| path.equals("/actuator") || path.startsWith("/actuator/")
|| path.equals("/api-docs") || path.startsWith("/api-docs/")
|| path.equals("/swagger-ui.html") || path.startsWith("/swagger-ui/")
|| path.equals("/error");
}
}
@@ -15,6 +15,7 @@ import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
@@ -52,6 +53,9 @@ public class SystemUpdateApplicationService {
+ "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"); + "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"); private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$");
private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$"); private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$");
private static final Pattern GITEA_RELEASE_API_PATH = Pattern.compile(
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
private static final List<String> BUSY_STATES = List.of( private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP", "QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
"INSTALLING", "RUNNING"); "INSTALLING", "RUNNING");
@@ -195,20 +199,42 @@ public class SystemUpdateApplicationService {
URI api = releaseApiUri(); URI api = releaseApiUri();
try { try {
JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release")); JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release"));
boolean assetPresent = false;
for (JsonNode asset : release.path("assets")) { for (JsonNode asset : release.path("assets")) {
if (!assetName.equals(asset.path("name").asText())) continue; if (assetName.equals(asset.path("name").asText())) {
URI uri = validatedReleaseUri(asset.path("browser_download_url").asText(null), "Release 资源地址"); assetPresent = true;
if (!sameOrigin(api, uri)) { break;
throw releaseUnavailable("Gitea Release 资源必须与 API 使用同一来源");
} }
return uri;
} }
throw releaseUnavailable("Gitea Release 缺少 " + assetName); if (!assetPresent) throw releaseUnavailable("Gitea Release 缺少 " + assetName);
String tag = release.path("tag_name").asText(null);
if (tag == null || !GITEA_RELEASE_TAG.matcher(tag).matches()) {
throw releaseUnavailable("Gitea Release tag 格式无效");
}
return trustedGiteaAssetUri(api, tag, assetName);
} catch (IOException exception) { } catch (IOException exception) {
throw releaseUnavailable("Gitea Release 响应读取失败"); throw releaseUnavailable("Gitea Release 响应读取失败");
} }
} }
private URI trustedGiteaAssetUri(URI api, String tag, String assetName) {
if (api.getRawQuery() != null || api.getRawFragment() != null) {
throw releaseUnavailable("Gitea Release API 地址格式无效");
}
Matcher matcher = GITEA_RELEASE_API_PATH.matcher(api.getPath());
if (!matcher.matches()) {
throw releaseUnavailable("Gitea Release API 地址必须指向仓库 Release 端点");
}
String prefix = matcher.group(1) == null ? "/" : matcher.group(1);
String path = prefix + matcher.group(2) + "/" + matcher.group(3)
+ "/releases/download/" + tag + "/" + assetName;
try {
return new URI(api.getScheme(), null, api.getHost(), api.getPort(), path, null, null);
} catch (URISyntaxException exception) {
throw releaseUnavailable("Gitea Release 资源地址格式无效");
}
}
private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) { private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) {
String token = releaseToken(); String token = releaseToken();
URI current = uri; URI current = uri;
@@ -1,5 +1,7 @@
package com.kaidi.setup; package com.kaidi.setup;
import com.kaidi.finance.setup.SetupProperties;
import com.kaidi.finance.shared.web.SpaForwardFilter;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -9,7 +11,7 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.kaidi.finance.setup.SetupProperties; import org.springframework.context.annotation.Import;
/** /**
* Minimal first-run context. It deliberately does not create a business DataSource or run * Minimal first-run context. It deliberately does not create a business DataSource or run
@@ -28,6 +30,7 @@ import com.kaidi.finance.setup.SetupProperties;
} }
) )
@EnableConfigurationProperties(SetupProperties.class) @EnableConfigurationProperties(SetupProperties.class)
@Import(SpaForwardFilter.class)
public class SetupApplication { public class SetupApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -1,5 +1,6 @@
server: server:
port: ${SERVER_PORT:18080} port: ${SERVER_PORT:18080}
address: ${SERVER_ADDRESS:127.0.0.1}
shutdown: graceful shutdown: graceful
servlet: servlet:
session: session:
@@ -12,6 +13,9 @@ server:
spring: spring:
application: application:
name: kaidi-finance name: kaidi-finance
web:
resources:
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
profiles: profiles:
default: local default: local
datasource: datasource:
@@ -1,6 +1,7 @@
package com.kaidi.finance.setup; package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.kaidi.setup.SetupApplication; import com.kaidi.setup.SetupApplication;
@@ -13,6 +14,7 @@ import java.sql.Statement;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -60,9 +62,12 @@ class SetupApplicationIntegrationTest {
@Test @Test
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception { void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
seedRecoverableV068CollationFailure();
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue(); assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse();
Map<String, Object> database = databaseRequest("wrong-code"); Map<String, Object> database = databaseRequest("wrong-code");
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"), ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
@@ -76,6 +81,39 @@ class SetupApplicationIntegrationTest {
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue(); assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue(); assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("message").asText())
.contains("排序规则和完整迁移权限验证通过");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery("""
SELECT
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
+
(SELECT COUNT(*) FROM information_schema.routines
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
try (ResultSet result = statement.executeQuery("""
SELECT default_collation_name
FROM information_schema.schemata
WHERE schema_name = DATABASE()
""")) {
result.next();
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
}
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
}
Map<String, Object> complete = new LinkedHashMap<>(database); Map<String, Object> complete = new LinkedHashMap<>(database);
complete.put("adminUsername", "setup-admin"); complete.put("adminUsername", "setup-admin");
@@ -121,6 +159,34 @@ class SetupApplicationIntegrationTest {
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse(); assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue(); assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
}
private void seedRecoverableV068CollationFailure() throws Exception {
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName()
+ "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
}
assertThatThrownBy(() -> Flyway.configure()
.dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())
.locations("classpath:db/migration")
.target("68")
.load()
.migrate())
.hasMessageContaining("V068__source_table_column_validation_metadata.sql");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version = '68'
AND script = 'V068__source_table_column_validation_metadata.sql'
AND success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isEqualTo(1);
}
} }
private Map<String, Object> databaseRequest(String setupCode) { private Map<String, Object> databaseRequest(String setupCode) {
@@ -31,6 +31,7 @@ class SetupContextSmokeTest {
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString()); registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString()); registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
registry.add("finance.setup.restart-after-complete", () -> false); registry.add("finance.setup.restart-after-complete", () -> false);
registry.add("spring.web.resources.static-locations", () -> "classpath:/spa-fixture/");
} }
@Autowired @Autowired
@@ -49,6 +50,14 @@ class SetupContextSmokeTest {
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString()) assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
.isEqualTo("[\"MYSQL\"]"); .isEqualTo("[\"MYSQL\"]");
ResponseEntity<String> setupPage = rest.getForEntity(url("/setup"), String.class);
assertThat(setupPage.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(setupPage.getBody()).contains("kaidi-spa-fixture");
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class); ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
} }
@@ -0,0 +1,31 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SetupReadinessTest {
private static final String TOKEN_SHA256 =
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
@TempDir
private Path stateRoot;
@Test
void distinguishesLockedSetupModeFromReadyApplicationMode() {
SetupProperties properties = new SetupProperties(true, TOKEN_SHA256,
stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false);
SetupViews.Status setupMode = new SetupService(properties).status();
SetupViews.Status applicationMode = new SetupLockedController().status().data();
assertThat(setupMode.required()).isTrue();
assertThat(setupMode.ready()).isFalse();
assertThat(applicationMode.required()).isFalse();
assertThat(applicationMode.locked()).isTrue();
assertThat(applicationMode.ready()).isTrue();
}
}
@@ -0,0 +1,43 @@
package com.kaidi.finance.shared.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class SpaForwardFilterTest {
private final SpaForwardFilter filter = new SpaForwardFilter();
@Test
void forwardsExtensionlessBrowserRouteToVueEntryPoint() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/finance/workbench");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(response.getForwardedUrl()).isEqualTo("/index.html");
assertThat(continued).isFalse();
}
@Test
void leavesApiAndStaticAssetRequestsToSpringMvc() throws Exception {
assertContinues("/api/v1/unknown-resource");
assertContinues("/actuator/health");
assertContinues("/assets/index-a1b2c3.js");
}
private void assertContinues(String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(continued).isTrue();
assertThat(response.getForwardedUrl()).isNull();
}
}
@@ -47,18 +47,17 @@ class SystemUpdateApplicationServiceTest {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> { server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization")); assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/assets/release-manifest.json";
byte[] body = (""" byte[] body = ("""
{"tag_name":"v1.0.0-preview.2","assets":[ {"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"} {"name":"release-manifest.json","browser_download_url":"%s"}
]} ]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8); """).formatted("http://10.0.0.8:3000/internal/release-manifest.json")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length); exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body); exchange.getResponseBody().write(body);
exchange.close(); exchange.close();
}); });
server.createContext("/assets/release-manifest.json", exchange -> { server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization")); assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
byte[] body = """ byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz", {"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
@@ -98,7 +97,7 @@ class SystemUpdateApplicationServiceTest {
} }
@Test @Test
void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception { void ignoresCrossOriginBrowserAssetUrlWithoutSendingTokenThere() throws Exception {
AtomicInteger assetRequests = new AtomicInteger(); AtomicInteger assetRequests = new AtomicInteger();
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
assetServer.createContext("/release-manifest.json", exchange -> { assetServer.createContext("/release-manifest.json", exchange -> {
@@ -112,12 +111,25 @@ class SystemUpdateApplicationServiceTest {
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort() String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
+ "/release-manifest.json"; + "/release-manifest.json";
byte[] body = (""" byte[] body = ("""
{"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]} {"tag_name":"v1.0.0-preview.2",
"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8); """).formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length); exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body); exchange.getResponseBody().write(body);
exchange.close(); exchange.close();
}); });
apiServer.createContext(
"/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Gitea Preview update"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
assetServer.start(); assetServer.start();
apiServer.start(); apiServer.start();
try { try {
@@ -125,7 +137,7 @@ class SystemUpdateApplicationServiceTest {
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox, SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
tempDir.resolve("cross-origin-status.json")); tempDir.resolve("cross-origin-status.json"));
assertThrows(BusinessException.class, service::check); assertEquals("1.0.0-preview.2", service.check().latestVersion());
assertEquals(0, assetRequests.get()); assertEquals(0, assetRequests.get());
} finally { } finally {
apiServer.stop(0); apiServer.stop(0);
@@ -0,0 +1 @@
window.__KAIDI_SPA_FIXTURE__ = 'kaidi-spa-fixture';
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>kaidi-spa-fixture</title></head>
<body><div id="app">kaidi-spa-fixture</div></body>
</html>
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -Eeuo pipefail
die() {
printf '[kaidi-baota] ERROR: %s\n' "$1" >&2
exit 1
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
APP_JAR="$RELEASE_ROOT/app.jar"
PUBLIC_INDEX="$RELEASE_ROOT/public/index.html"
VERSION_FILE="$RELEASE_ROOT/VERSION"
[ -s "$APP_JAR" ] || die "app.jar 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"
JAVA_BIN=${KAIDI_JAVA_BIN:-}
if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then
JAVA_BIN="$JAVA_HOME/bin/java"
fi
if [ -z "$JAVA_BIN" ]; then
JAVA_BIN=$(command -v java 2>/dev/null || true)
fi
[ -n "$JAVA_BIN" ] || die "Java 17 or newer was not found"
if [[ "$JAVA_BIN" == */* ]]; then
[ -x "$JAVA_BIN" ] || die "Java executable is not available at $JAVA_BIN"
else
command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available"
fi
export SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-production}
export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1}
export SERVER_PORT=${SERVER_PORT:-18080}
export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false}
export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false}
export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-false}
export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false}
export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/}
export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE}
export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")}
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
[ -n "${!name:-}" ] || die "$name must be configured in the Baota project environment"
done
for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do
[ -d "$directory" ] || die "Storage directory $directory does not exist"
[ -w "$directory" ] || die "Startup user cannot write storage directory $directory"
done
fi
cd "$RELEASE_ROOT"
exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar "$APP_JAR"
+14
View File
@@ -0,0 +1,14 @@
SPRING_PROFILES_ACTIVE=production
SERVER_ADDRESS=127.0.0.1
SERVER_PORT=18080
SESSION_COOKIE_SECURE=false
DB_URL=jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=MYSQL_USERNAME
DB_PASSWORD=MYSQL_PASSWORD
FIELD_ENCRYPTION_KEY=EXISTING_FIELD_ENCRYPTION_KEY
FILE_STORAGE_ROOT=/var/lib/kaidi/files
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
FILE_SCANNER_ENABLED=false
FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_SETUP_ENABLED=false
FINANCE_UPDATE_ENABLED=false
+1
View File
@@ -1,5 +1,6 @@
SPRING_PROFILES_ACTIVE=production SPRING_PROFILES_ACTIVE=production
SERVER_PORT=18080 SERVER_PORT=18080
SERVER_ADDRESS=127.0.0.1
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
+5 -2
View File
@@ -5,7 +5,7 @@ umask 077
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
INSTALLER="$ROOT/deploy/install.sh" INSTALLER="$ROOT/deploy/install.sh"
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-faf52cc902abbc2bd48571caee3f4207de50ce339b82ce88fe23f9c4ce8f89ef} INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7}
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:-}
@@ -44,6 +44,8 @@ fi
KAIDI_REINSTALL=${KAIDI_REINSTALL:-} KAIDI_REINSTALL=${KAIDI_REINSTALL:-}
KAIDI_SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-} KAIDI_SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-}
KAIDI_APP_PORT=${KAIDI_APP_PORT:-}
KAIDI_SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-}
KAIDI_DB_URL=${KAIDI_DB_URL:-} KAIDI_DB_URL=${KAIDI_DB_URL:-}
KAIDI_DB_USERNAME=${KAIDI_DB_USERNAME:-} KAIDI_DB_USERNAME=${KAIDI_DB_USERNAME:-}
KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-} KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-}
@@ -54,7 +56,8 @@ KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-}
KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-} KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-}
KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-} KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-}
for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \ for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_APP_PORT KAIDI_SERVER_ADDRESS \
KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \ KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do
value=${!name} value=${!name}
+354 -93
View File
@@ -11,10 +11,20 @@ RELEASE_BASE_URL=${KAIDI_RELEASE_BASE_URL:-}
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}
RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-} RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-}
RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} HEALTH_URL=${KAIDI_HEALTH_URL:-}
APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-}
APP_PORT=${KAIDI_APP_PORT:-}
SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-127.0.0.1}
PROXY_TARGET=
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
REINSTALL=${KAIDI_REINSTALL:-false} REINSTALL=${KAIDI_REINSTALL:-false}
SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true} if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then
SETUP_WIZARD=$KAIDI_SETUP_WIZARD
elif [ "$REINSTALL" = true ]; then
SETUP_WIZARD=false
else
SETUP_WIZARD=true
fi
WORK_DIR= WORK_DIR=
RELEASE_AUTH_HEADER_FILE= RELEASE_AUTH_HEADER_FILE=
INSTALL_TRANSACTION_ARMED=false INSTALL_TRANSACTION_ARMED=false
@@ -24,6 +34,9 @@ BACKUP_DIR=
JAVA_STAGED_DIR= JAVA_STAGED_DIR=
JAVA_PREVIOUS_DIR= JAVA_PREVIOUS_DIR=
JAVA_ACTIVATED=false JAVA_ACTIVATED=false
SERVICE_USER=kaidi
SERVICE_GROUP=kaidi
HOST_ARCH=
log() { printf '[kaidi-install] %s\n' "$*"; } log() { printf '[kaidi-install] %s\n' "$*"; }
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; } die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
@@ -32,14 +45,42 @@ sha256_file() {
sha256sum "$1" | awk '{print $1}' sha256sum "$1" | awk '{print $1}'
} }
normalized_host_arch() {
local machine host_bits
machine=$(uname -m)
host_bits=$(getconf LONG_BIT 2>/dev/null || true)
case "$machine" in
x86_64|amd64)
[ "$host_bits" = 32 ] && printf x86 || printf x86_64
;;
i386|i486|i586|i686) printf x86 ;;
aarch64|arm64)
[ "$host_bits" = 32 ] && printf arm || printf aarch64
;;
armv7l|armv6l) printf arm ;;
*) die "Unsupported CPU architecture: $machine" ;;
esac
}
preflight_host() { preflight_host() {
local host_bits expected_bits
[ "$(uname -s)" = Linux ] || die "The installer only supports Linux" [ "$(uname -s)" = Linux ] || die "The installer only supports Linux"
[ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \ [ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \
&& [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \ && [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \
|| die "Custom installation roots are not supported by the packaged systemd and Nginx configuration" || die "Custom installation roots are not supported by the packaged systemd configuration"
command -v systemctl >/dev/null 2>&1 || die "systemd is required" command -v systemctl >/dev/null 2>&1 || die "systemd is required"
command -v systemd-analyze >/dev/null 2>&1 || die "systemd-analyze is required"
command -v getconf >/dev/null 2>&1 || die "getconf is required"
[ -d /run/systemd/system ] || die "systemd is not running as PID 1" [ -d /run/systemd/system ] || die "systemd is not running as PID 1"
[ -d /etc/systemd/system ] || die "/etc/systemd/system is missing" [ -d /etc/systemd/system ] || die "/etc/systemd/system is missing"
HOST_ARCH=$(normalized_host_arch)
host_bits=$(getconf LONG_BIT 2>/dev/null || true)
case "$HOST_ARCH" in
x86_64|aarch64) expected_bits=64 ;;
x86|arm) expected_bits=32 ;;
esac
[ "$host_bits" = "$expected_bits" ] \
|| die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit"
[[ "$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
@@ -58,40 +99,47 @@ preflight_host() {
[ ! -e "$STATE_ROOT/setup/locked" ] \ [ ! -e "$STATE_ROOT/setup/locked" ] \
|| die "The setup wizard is already locked; use a normal repair reinstall" || die "The setup wizard is already locked; use a normal repair reinstall"
fi fi
log "Environment verified: $(uname -sr), architecture=$HOST_ARCH, userspace=${host_bits}-bit, systemd"
} }
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" validate_inputs() {
[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \ [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|| die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE" [ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \
if [ -n "$RELEASE_TOKEN_FILE" ]; then || die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE"
[ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \ if [ -n "$RELEASE_TOKEN_FILE" ]; then
|| die "KAIDI_RELEASE_TOKEN_FILE must be a regular file" [ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \
[ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \ || die "KAIDI_RELEASE_TOKEN_FILE must be a regular file"
|| die "KAIDI_RELEASE_TOKEN_FILE is too large" [ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \
RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE") || die "KAIDI_RELEASE_TOKEN_FILE is too large"
fi RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE")
[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \ fi
|| die "Kaidi Finance is already installed; use the system update page" [ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \
if [ -z "$RELEASE_API_URL" ]; then || die "Kaidi Finance is already installed; use the system update page"
case "$RELEASE_BASE_URL" in if [ -z "$RELEASE_API_URL" ]; then
*OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;; case "$RELEASE_BASE_URL" in
esac *OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;;
fi esac
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \ fi
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \ [ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|| die "KAIDI_RELEASE_TOKEN is invalid" && ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|| die "KAIDI_RELEASE_TOKEN is invalid"
}
download() { download() {
local url=$1 output=$2 local url=$1 output=$2
case "$url" in case "$url" in
https://*) https://*)
curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
--tlsv1.2 "$url" -o "$output" --tlsv1.2 --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
die "Download failed: $url"
fi
;; ;;
*) *)
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS"
curl --fail --silent --show-error --location --proto '=http,https' \ if ! curl --fail --silent --show-error --location --proto '=http,https' \
--proto-redir '=http,https' "$url" -o "$output" --proto-redir '=http,https' --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
die "Download failed: $url"
fi
;; ;;
esac esac
} }
@@ -122,17 +170,20 @@ download_release_url() {
} }
release_asset_url() { release_asset_url() {
local asset_name=$1 asset_url api_origin local asset_name=$1 api_origin repo_path release_tag encoded_tag encoded_asset
if [ -n "$RELEASE_API_URL" ]; then if [ -n "$RELEASE_API_URL" ]; then
asset_url=$(jq -er --arg name "$asset_name" \ jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
'.assets[] | select(.name == $name) | .browser_download_url "$WORK_DIR/release-api.json" >/dev/null || die "Release asset $asset_name is missing"
| strings | select(startswith("https://") or startswith("http://"))' \ release_tag=$(jq -er '.tag_name | strings
"$WORK_DIR/release-api.json" | head -n 1) || die "Release asset $asset_name is missing" | select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \
"$WORK_DIR/release-api.json") || die "Gitea Release tag is invalid"
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
case "$asset_url" in repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
"$api_origin"/*) printf '%s\n' "$asset_url" ;; 's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
*) die "Release asset $asset_name must use the Gitea API host" ;; [ -n "$repo_path" ] || die "KAIDI_RELEASE_API_URL must be a Gitea repository Release API endpoint"
esac encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri')
encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri')
printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset"
else else
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name" printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
fi fi
@@ -148,28 +199,77 @@ install_packages() {
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux default-mysql-client apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
elif command -v dnf >/dev/null 2>&1; then elif command -v dnf >/dev/null 2>&1; then
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
elif command -v yum >/dev/null 2>&1; then elif command -v yum >/dev/null 2>&1; then
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
else else
die "Supported package managers are apt, dnf, and yum" die "Supported package managers are apt, dnf, and yum"
fi fi
} }
azul_arch() { azul_arch() {
case "$(uname -m)" in case "${HOST_ARCH:-$(normalized_host_arch)}" in
x86_64|amd64) printf x86 ;; x86_64) printf x86 ;;
i386|i486|i586|i686) printf i686 ;; x86) printf i686 ;;
aarch64|arm64) printf arm_64 ;; aarch64) printf arm_64 ;;
armv7l|armv6l) printf arm ;; arm) printf arm ;;
*) die "Unsupported CPU architecture: $(uname -m)" ;;
esac esac
} }
java_arch_matches_host() {
local java_bin=$1 java_arch host_arch
host_arch=${HOST_ARCH:-$(normalized_host_arch)}
java_arch=$(
"$java_bin" -XshowSettings:properties -version 2>&1 \
| sed -n 's/^[[:space:]]*os\.arch = [[:space:]]*//p' \
| head -n 1
)
case "$host_arch:$java_arch" in
x86_64:amd64|x86_64:x86_64|x86:x86|x86:i386|x86:i486|x86:i586|x86:i686|\
aarch64:aarch64|aarch64:arm64|arm:arm) return 0 ;;
*) return 1 ;;
esac
}
system_java_home() {
local java_bin 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
java_major=$(
"$java_bin" -version 2>&1 \
| sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' \
| head -n 1
)
[[ "$java_major" =~ ^[0-9]+$ ]] && [ "$java_major" -ge 17 ] || return 1
java_arch_matches_host "$java_bin" || return 1
resolved_java=$(readlink -f "$java_bin" 2>/dev/null || printf '%s' "$java_bin")
case "$resolved_java" in
*/bin/java) home=${resolved_java%/bin/java} ;;
*) return 1 ;;
esac
[ -x "$home/bin/java" ] || return 1
printf '%s\n' "$home"
}
prepare_java() { prepare_java() {
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line
if system_home=$(system_java_home); then
JAVA_STAGED_DIR="$WORK_DIR/java.next"
mkdir -p "$JAVA_STAGED_DIR"
log "Copying existing Java 17 runtime from $system_home into the managed application directory"
COPYFILE_DISABLE=1 cp -R "$system_home/." "$JAVA_STAGED_DIR/"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
log "Local Java verified: $java_line"
return 0
fi
log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime"
api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true" api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true"
java_metadata="$WORK_DIR/zulu-metadata.json" java_metadata="$WORK_DIR/zulu-metadata.json"
download "$api" "$java_metadata" download "$api" "$java_metadata"
@@ -188,18 +288,88 @@ prepare_java() {
[ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed" [ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed"
mkdir -p "$JAVA_STAGED_DIR" mkdir -p "$JAVA_STAGED_DIR"
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_STAGED_DIR/bin/java" -version java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \
|| die "Downloaded Java runtime architecture does not match $HOST_ARCH"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
log "Downloaded Java verified: $java_line"
} }
activate_java() { activate_java() {
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" ]; then if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then
mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR" mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR"
fi fi
mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java" mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java"
chown -R root:"$SERVICE_GROUP" "$APP_ROOT/runtime/java"
chmod -R u=rwX,g=rX,o= "$APP_ROOT/runtime/java"
JAVA_ACTIVATED=true JAVA_ACTIVATED=true
"$APP_ROOT/runtime/java/bin/java" -version }
ensure_service_identity() {
local existing_home nologin_path
command -v getent >/dev/null 2>&1 || die "getent is required"
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"
if ! id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP"; then
usermod --append --groups "$SERVICE_GROUP" "$SERVICE_USER"
fi
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"
}
prepare_managed_layout() {
command -v runuser >/dev/null 2>&1 || die "runuser is required"
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" /var/log/kaidi
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 root -m 0700 "$CONFIG_ROOT"
}
secure_release_tree() {
local release_dir=$1
chown -R root:"$SERVICE_GROUP" "$release_dir"
find "$release_dir" -type d -exec chmod 0750 {} +
find "$release_dir" -type f -exec chmod 0640 {} +
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
}
restore_security_contexts() {
command -v restorecon >/dev/null 2>&1 || return 0
restorecon -RF "$APP_ROOT" "$STATE_ROOT" "$UPDATE_STATE_ROOT" /var/log/kaidi \
/etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \
/etc/systemd/system/kaidi-update.path >/dev/null 2>&1 \
|| log "SELinux context restoration reported a warning; service access checks will decide whether installation can continue"
}
verify_service_access() {
# shellcheck disable=SC2016 # Positional parameters are expanded by the child shell.
runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \
|| die "$SERVICE_USER cannot traverse or read the active release"
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|| die "$SERVICE_USER cannot execute the managed Java runtime"
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 \
|| die "The managed Java runtime cannot execute as $SERVICE_USER"
runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \
|| die "$SERVICE_USER cannot write the file-storage directory"
runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \
|| die "$SERVICE_USER cannot write the update inbox"
log "Service-user filesystem and Java access checks passed"
} }
random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36; } random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36; }
@@ -264,6 +434,72 @@ read_reinstall_env() {
fi fi
} }
port_is_listening() {
local port_hex files=(/proc/net/tcp)
port_hex=$(printf '%04X' "$APP_PORT")
[ -r /proc/net/tcp ] || return 1
[ ! -r /proc/net/tcp6 ] || files+=(/proc/net/tcp6)
awk -v port="$port_hex" '
toupper($2) ~ (":" port "$") && $4 == "0A" { found=1 }
END { exit(found ? 0 : 1) }
' "${files[@]}" 2>/dev/null
}
valid_app_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
configure_app_port() {
local default_port=18080 existing_port entered probe_host probe_authority
if [ -z "$APP_PORT" ]; then
default_port=$(read_reinstall_env SERVER_PORT || true)
[[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080
if [ -t 1 ] && [ -r /dev/tty ]; then
printf '[kaidi-install] Application port [%s]: ' "$default_port" > /dev/tty
if IFS= read -r entered < /dev/tty; then
APP_PORT=${entered:-$default_port}
else
APP_PORT=$default_port
fi
else
APP_PORT=$default_port
log "No interactive terminal detected; using application port $APP_PORT"
fi
fi
valid_app_port "$APP_PORT" \
|| die "KAIDI_APP_PORT must be an integer between 1024 and 65535"
case "$SERVER_ADDRESS" in
''|*[!A-Za-z0-9_.:-]*) die "KAIDI_SERVER_ADDRESS contains unsupported characters" ;;
esac
if port_is_listening; then
existing_port=$(read_existing_env SERVER_PORT || true)
if [ "$REINSTALL" = true ] && [ "$existing_port" = "$APP_PORT" ] \
&& systemctl is-active --quiet kaidi-finance.service; then
log "Application port $APP_PORT is already held by the existing Kaidi service"
else
die "Application port $APP_PORT is already in use; choose another port with KAIDI_APP_PORT"
fi
fi
probe_host=${KAIDI_HEALTH_HOST:-$SERVER_ADDRESS}
case "$probe_host" in
0.0.0.0) probe_host=127.0.0.1 ;;
::) probe_host=::1 ;;
esac
case "$probe_host" in
*:*) probe_authority="[$probe_host]" ;;
*) probe_authority="$probe_host" ;;
esac
if [ -z "$HEALTH_URL" ]; then
HEALTH_URL="http://${probe_authority}:${APP_PORT}/actuator/health"
fi
if [ -z "$APP_INDEX_URL" ]; then
APP_INDEX_URL="http://${probe_authority}:${APP_PORT}/"
fi
PROXY_TARGET="http://${probe_authority}:${APP_PORT}"
log "Application will bind ${SERVER_ADDRESS}:${APP_PORT}"
log "Reverse proxy target: $PROXY_TARGET"
}
is_semver() { is_semver() {
[ "${#1}" -le 128 ] \ [ "${#1}" -le 128 ] \
&& LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(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-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \ && LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(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-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \
@@ -279,7 +515,7 @@ configure_database() {
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance' DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
DB_USERNAME=setup_pending DB_USERNAME=setup_pending
DB_PASSWORD=setup_pending DB_PASSWORD=setup_pending
return return 0
fi fi
if [ "$REINSTALL" = true ]; then if [ "$REINSTALL" = true ]; then
[ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env" [ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env"
@@ -294,7 +530,7 @@ configure_database() {
if [ -n "$DB_URL" ]; then if [ -n "$DB_URL" ]; then
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \ [ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL" || die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
return return 0
fi fi
[ "$REINSTALL" != true ] \ [ "$REINSTALL" != true ] \
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment" || die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
@@ -327,7 +563,7 @@ database_name() {
preflight_database() { preflight_database() {
local client host port name version table local client host port name version table
[ "$SETUP_WIZARD" != true ] || return [ "$SETUP_WIZARD" != true ] || return 0
case "$DB_URL" in case "$DB_URL" in
jdbc:mysql://*) ;; jdbc:mysql://*) ;;
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;; *) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
@@ -429,12 +665,25 @@ write_env_file_preserving_unknown() {
} }
wait_for_health() { wait_for_health() {
local attempts=0 local attempts=0 active_state restart_count
log "Waiting for application startup at $HEALTH_URL"
while [ "$attempts" -lt 60 ]; do while [ "$attempts" -lt 60 ]; do
if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then if curl --fail --silent --connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null \
return | jq -e '.status == "UP"' >/dev/null 2>&1; then
log "Application health check is UP"
return 0
fi
active_state=$(systemctl show kaidi-finance.service --property=ActiveState --value 2>/dev/null || printf unknown)
restart_count=$(systemctl show kaidi-finance.service --property=NRestarts --value 2>/dev/null || printf 0)
[[ "$restart_count" =~ ^[0-9]+$ ]] || restart_count=0
if [ "$active_state" = failed ] || [ "$restart_count" -ge 3 ]; then
journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true
die "Application service failed during startup (state=$active_state, restarts=$restart_count)"
fi fi
attempts=$((attempts + 1)) attempts=$((attempts + 1))
if [ $((attempts % 5)) -eq 0 ]; then
log "Application is still starting (state=$active_state, elapsed=$((attempts * 2))s)"
fi
sleep 2 sleep 2
done done
journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true
@@ -454,8 +703,6 @@ backup_managed_state() {
/etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-finance.service
/etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.service
/etc/systemd/system/kaidi-update.path /etc/systemd/system/kaidi-update.path
/etc/nginx/conf.d/kaidi-finance.conf
/etc/nginx/sites-enabled/default
) )
BACKUP_DIR="$WORK_DIR/rollback" BACKUP_DIR="$WORK_DIR/rollback"
mkdir -p "$BACKUP_DIR/files" mkdir -p "$BACKUP_DIR/files"
@@ -466,17 +713,25 @@ backup_managed_state() {
fi fi
index=$((index + 1)) index=$((index + 1))
done done
systemctl is-active --quiet kaidi-finance.service && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false systemctl is-active --quiet kaidi-finance.service >/dev/null 2>&1 \
systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false
systemctl is-active --quiet kaidi-update.path && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false systemctl is-enabled --quiet kaidi-finance.service >/dev/null 2>&1 \
systemctl is-enabled --quiet kaidi-update.path && PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false
systemctl is-active --quiet nginx && PREVIOUS_NGINX_ACTIVE=true || PREVIOUS_NGINX_ACTIVE=false systemctl is-active --quiet kaidi-update.path >/dev/null 2>&1 \
systemctl is-enabled --quiet nginx && PREVIOUS_NGINX_ENABLED=true || PREVIOUS_NGINX_ENABLED=false && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false
systemctl is-enabled --quiet kaidi-update.path >/dev/null 2>&1 \
&& PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false
INSTALL_TRANSACTION_ARMED=true INSTALL_TRANSACTION_ARMED=true
} }
restore_unit_state() { restore_unit_state() {
local unit=$1 was_enabled=$2 was_active=$3 local unit=$1 was_enabled=$2 was_active=$3
if ! systemctl cat "$unit" >/dev/null 2>&1; then
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
[ "$was_enabled" = false ] && [ "$was_active" = false ]
return
fi
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
if [ "$was_enabled" = true ]; then if [ "$was_enabled" = true ]; then
systemctl enable "$unit" >/dev/null 2>&1 systemctl enable "$unit" >/dev/null 2>&1
else else
@@ -509,7 +764,7 @@ rollback_install() {
local index=0 path rollback_failed=false local index=0 path rollback_failed=false
set +e set +e
log "Installation failed; restoring the previous managed state" log "Installation failed; restoring the previous managed state"
systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || rollback_failed=true systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true
for path in "${MANAGED_PATHS[@]}"; do for path in "${MANAGED_PATHS[@]}"; do
restore_managed_path "$path" "$index" || rollback_failed=true restore_managed_path "$path" "$index" || rollback_failed=true
index=$((index + 1)) index=$((index + 1))
@@ -517,7 +772,7 @@ rollback_install() {
if [ -n "$RELEASE_DIR" ]; then if [ -n "$RELEASE_DIR" ]; then
rm -rf -- "$RELEASE_DIR" || rollback_failed=true rm -rf -- "$RELEASE_DIR" || rollback_failed=true
fi fi
if [ -d "$JAVA_PREVIOUS_DIR" ]; then if [ -d "$JAVA_PREVIOUS_DIR" ] || [ -L "$JAVA_PREVIOUS_DIR" ]; then
rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true
mkdir -p "$APP_ROOT/runtime" || rollback_failed=true mkdir -p "$APP_ROOT/runtime" || rollback_failed=true
mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true
@@ -529,10 +784,6 @@ rollback_install() {
|| rollback_failed=true || rollback_failed=true
restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \ restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \
|| rollback_failed=true || rollback_failed=true
restore_unit_state nginx "$PREVIOUS_NGINX_ENABLED" "$PREVIOUS_NGINX_ACTIVE" || rollback_failed=true
if [ "$PREVIOUS_NGINX_ACTIVE" = true ]; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || rollback_failed=true
fi
set -e set -e
[ "$rollback_failed" = false ] [ "$rollback_failed" = false ]
} }
@@ -542,14 +793,17 @@ cleanup() {
trap - EXIT trap - EXIT
if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \ if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \
&& [ "$INSTALL_COMMITTED" != true ]; then && [ "$INSTALL_COMMITTED" != true ]; then
rollback_install || log "ERROR: rollback was incomplete; inspect systemd and Nginx state" rollback_install || log "ERROR: rollback was incomplete; inspect the managed systemd state"
fi fi
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
exit "$result" exit "$result"
} }
main() {
trap cleanup EXIT trap cleanup EXIT
validate_inputs
configure_app_port
preflight_host preflight_host
install_packages install_packages
@@ -594,26 +848,21 @@ find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \
[ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing" [ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing"
[ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch" [ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch"
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing" [ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing"
[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || die "Baota Spring Boot launcher is missing"
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || die "Baota Spring Boot launcher is invalid"
prepare_java
configure_database configure_database
preflight_database preflight_database
ensure_service_identity
id kaidi >/dev/null 2>&1 || useradd --system --home "$STATE_ROOT" --shell /usr/sbin/nologin kaidi prepare_managed_layout
mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \ prepare_java
"$STATE_ROOT/setup" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT" /var/log/kaidi
chown -R kaidi:kaidi "$STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" /var/log/kaidi
chown root:kaidi "$UPDATE_STATE_ROOT"
chmod 0750 "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox"
chmod 0700 "$STATE_ROOT/setup"
RELEASE_DIR="$APP_ROOT/releases/$VERSION" RELEASE_DIR="$APP_ROOT/releases/$VERSION"
[ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)" [ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)"
backup_managed_state backup_managed_state
activate_java activate_java
mv "$WORK_DIR/extracted" "$RELEASE_DIR" mv "$WORK_DIR/extracted" "$RELEASE_DIR"
chown -R root:root "$RELEASE_DIR" secure_release_tree "$RELEASE_DIR"
chmod -R go-w "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next"
mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current" mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"
install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh" install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh"
@@ -655,7 +904,8 @@ 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 \
SERVER_PORT 18080 \ SERVER_PORT "$APP_PORT" \
SERVER_ADDRESS "$SERVER_ADDRESS" \
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \ SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
DB_URL "$DB_URL" \ DB_URL "$DB_URL" \
DB_USERNAME "$DB_USERNAME" \ DB_USERNAME "$DB_USERNAME" \
@@ -692,6 +942,7 @@ write_env_file "$CONFIG_ROOT/update.env" \
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_HEALTH_URL "$HEALTH_URL" \ KAIDI_HEALTH_URL "$HEALTH_URL" \
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \ KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
KAIDI_DB_HOST "$(database_host)" \ KAIDI_DB_HOST "$(database_host)" \
KAIDI_DB_PORT "$(database_port)" \ KAIDI_DB_PORT "$(database_port)" \
@@ -703,17 +954,20 @@ 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
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.conf" /etc/nginx/conf.d/kaidi-finance.conf restore_security_contexts
rm -f /etc/nginx/sites-enabled/default verify_service_access
nginx -t systemd-analyze verify \
/etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \
/etc/systemd/system/kaidi-update.path >/dev/null \
|| die "Installed systemd units failed validation"
systemctl daemon-reload systemctl daemon-reload
systemctl reset-failed kaidi-finance.service >/dev/null 2>&1 || true
systemctl enable kaidi-finance.service systemctl enable kaidi-finance.service
systemctl restart kaidi-finance.service systemctl restart kaidi-finance.service
wait_for_health wait_for_health
systemctl enable --now nginx curl -fsS "$APP_INDEX_URL" | grep -Eiq '<!doctype|<html' \
systemctl reload nginx || die "Application frontend entry point is unavailable"
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' >/dev/null \
|| die "Nginx proxy health check failed"
if [ "$SETUP_WIZARD" != true ]; then if [ "$SETUP_WIZARD" != true ]; then
sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env" sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env"
@@ -727,14 +981,16 @@ systemctl enable --now kaidi-update.path
if [ "$SETUP_WIZARD" = true ]; then if [ "$SETUP_WIZARD" = true ]; then
cat > /root/kaidi-first-login.txt <<EOF cat > /root/kaidi-first-login.txt <<EOF
URL: http://SERVER_IP/setup URL after reverse proxy: http://SERVER_IP/setup
Reverse proxy target: $PROXY_TARGET
Setup code: $SETUP_CODE Setup code: $SETUP_CODE
Version: $VERSION Version: $VERSION
EOF EOF
chmod 0600 /root/kaidi-first-login.txt chmod 0600 /root/kaidi-first-login.txt
elif [ "$REINSTALL" != true ]; then elif [ "$REINSTALL" != true ]; then
cat > /root/kaidi-first-login.txt <<EOF cat > /root/kaidi-first-login.txt <<EOF
URL: http://SERVER_IP/ URL after reverse proxy: http://SERVER_IP/
Reverse proxy target: $PROXY_TARGET
Username: admin Username: admin
Temporary password: $ADMIN_PASSWORD Temporary password: $ADMIN_PASSWORD
Version: $VERSION Version: $VERSION
@@ -746,12 +1002,17 @@ INSTALL_TRANSACTION_ARMED=false
log "Kaidi Finance $VERSION is installed" log "Kaidi Finance $VERSION is installed"
if [ "$SETUP_WIZARD" = true ]; then if [ "$SETUP_WIZARD" = true ]; then
log "Open http://SERVER_IP/setup and complete the first-run wizard" log "Configure your reverse proxy to $PROXY_TARGET"
log "Open /setup through your reverse-proxy domain and complete the first-run wizard"
log "Setup code: /root/kaidi-first-login.txt" log "Setup code: /root/kaidi-first-login.txt"
else else
log "Open http://SERVER_IP/ and sign in as admin" log "Configure your reverse proxy to $PROXY_TARGET"
log "Open the reverse-proxy domain and sign in as admin"
fi fi
if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
log "Temporary credentials: /root/kaidi-first-login.txt" log "Temporary credentials: /root/kaidi-first-login.txt"
log "Change the temporary password immediately after first sign-in" log "Change the temporary password immediately after first sign-in"
fi fi
}
main "$@"
+3 -24
View File
@@ -2,12 +2,11 @@ server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
server_name _; server_name _;
root /opt/kaidi/current/public;
index index.html;
client_max_body_size 500m; client_max_body_size 500m;
location /api/v1/ { # Optional example. The installer does not install or modify Nginx.
# Replace 18080 with the KAIDI_APP_PORT selected during installation.
location / {
proxy_pass http://127.0.0.1:18080; 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;
@@ -17,24 +16,4 @@ server {
proxy_connect_timeout 10s; proxy_connect_timeout 10s;
proxy_read_timeout 120s; proxy_read_timeout 120s;
} }
location = /actuator/health {
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options SAMEORIGIN always;
}
location ~* \.(?:js|css|woff2?|png|jpe?g|gif|svg|ico)$ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
} }
+2
View File
@@ -2,6 +2,8 @@
Description=Kaidi Finance System Description=Kaidi Finance System
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=3
[Service] [Service]
Type=simple Type=simple
-2
View File
@@ -4,8 +4,6 @@ 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
PathChanged=/var/lib/kaidi-update/processing
PathExists=/var/lib/kaidi-update/processing/request.json
Unit=kaidi-update.service Unit=kaidi-update.service
[Install] [Install]
+2 -3
View File
@@ -12,13 +12,12 @@ Group=root
EnvironmentFile=/etc/kaidi/update.env EnvironmentFile=/etc/kaidi/update.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env EnvironmentFile=-/var/lib/kaidi/setup/application.env
ExecStart=/opt/kaidi/bin/update.sh ExecStart=/opt/kaidi/bin/update.sh
Restart=on-failure Restart=no
RestartSec=15
Nice=10 Nice=10
IOSchedulingClass=best-effort IOSchedulingClass=best-effort
IOSchedulingPriority=6 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 /etc/nginx/conf.d ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
UMask=0077 UMask=0077
+124 -56
View File
@@ -18,14 +18,15 @@ 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}
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
SERVICE_USER=${KAIDI_SERVICE_USER:-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}
PUBLIC_HEALTH_URL=${KAIDI_PUBLIC_HEALTH_URL:-http://127.0.0.1/actuator/health} APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
PUBLIC_INDEX_URL=${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}
LOCK_FILE=$STATE_ROOT/update.lock LOCK_FILE=$STATE_ROOT/update.lock
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups} BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh} UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system} SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
NGINX_CONFIG=${KAIDI_NGINX_CONFIG:-/etc/nginx/conf.d/kaidi-finance.conf}
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}
@@ -40,9 +41,10 @@ case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
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; }
mkdir -p "$STATE_ROOT/inbox" "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" \ bootstrap_die() {
"$STATE_ROOT/work" "$APP_ROOT/releases" "$LOG_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" printf '%s\n' "$1" >&2
chmod 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" exit 1
}
status() { status() {
state=$1 state=$1
@@ -79,6 +81,43 @@ fail() {
exit 1 exit 1
} }
prepare_update_layout() {
id "$SERVICE_USER" >/dev/null 2>&1 || bootstrap_die "Service user $SERVICE_USER is missing"
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|| bootstrap_die "Service user $SERVICE_USER is not a member of group $SERVICE_GROUP"
command -v runuser >/dev/null 2>&1 || bootstrap_die "runuser is required"
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \
|| bootstrap_die "Managed application or update directories could not be prepared"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$STATE_ROOT/inbox" "$LOG_ROOT" \
|| bootstrap_die "Writable update directories could not be prepared"
install -d -o root -g root -m 0700 \
"$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$STATE_ROOT/work" \
"$BACKUP_ROOT" "$CACHE_ROOT" \
|| bootstrap_die "Private update directories could not be prepared"
}
secure_release_tree() {
release_dir=$1
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
find "$release_dir" -type d -exec chmod 0750 {} + || 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"
}
verify_release_access() {
release_dir=$1
# shellcheck disable=SC2016 # Positional parameters are expanded by the child shell.
runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$release_dir" \
|| return 1
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|| return 1
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1
}
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=$?
@@ -126,15 +165,18 @@ download() {
release_asset_url() { release_asset_url() {
asset_name=$1 asset_name=$1
if [ -n "$RELEASE_API_URL" ]; then if [ -n "$RELEASE_API_URL" ]; then
asset_url=$(jq -er --arg name "$asset_name" \ jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
'.assets[] | select(.name == $name) | .browser_download_url "$WORK_DIR/release-api.json" >/dev/null || return 1
| strings | select(startswith("https://") or startswith("http://"))' \ release_tag=$(jq -er '.tag_name | strings
"$WORK_DIR/release-api.json" | head -n 1) || return 1 | select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \
"$WORK_DIR/release-api.json") || return 1
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
case "$asset_url" in repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
"$api_origin"/*) printf '%s\n' "$asset_url" ;; 's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
*) return 1 ;; [ -n "$repo_path" ] || return 1
esac encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri')
encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri')
printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset"
else else
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name" printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
fi fi
@@ -161,10 +203,10 @@ wait_for_health() {
return 1 return 1
} }
verify_public_surface() { verify_app_surface() {
wait_for_health "$PUBLIC_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 "$PUBLIC_INDEX_URL" -o "$index_file" \ if curl -fsS "$APP_INDEX_URL" -o "$index_file" \
&& grep -Eiq '<!doctype|<html' "$index_file"; then && grep -Eiq '<!doctype|<html' "$index_file"; then
rm -f "$index_file" rm -f "$index_file"
return 0 return 0
@@ -231,6 +273,22 @@ atomic_install() {
mv -f "$install_temporary" "$install_destination" mv -f "$install_temporary" "$install_destination"
} }
install_operation() {
operation_source=$1
operation_destination=$2
operation_mode=$3
operation_is_systemd=$4
if [ -f "$operation_destination" ] && [ ! -L "$operation_destination" ] \
&& cmp -s "$operation_source" "$operation_destination"; then
chmod "$operation_mode" "$operation_destination"
return
fi
atomic_install "$operation_source" "$operation_destination" "$operation_mode" || return 1
if [ "$operation_is_systemd" = true ]; then
SYSTEMD_OPERATIONS_CHANGED=true
fi
}
backup_managed_file() { backup_managed_file() {
backup_source=$1 backup_source=$1
backup_name=$2 backup_name=$2
@@ -266,21 +324,20 @@ backup_operations() {
backup_managed_file "$UPDATER_PATH" update.sh \ backup_managed_file "$UPDATER_PATH" update.sh \
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \ && backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \ && 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
&& backup_managed_file "$NGINX_CONFIG" kaidi-finance.conf
} }
apply_operations() { apply_operations() {
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \ SYSTEMD_OPERATIONS_CHANGED=false
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \ install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \ "$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \ "$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.conf" "$NGINX_CONFIG" 0644 \ "$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
&& systemctl daemon-reload \ || return 1
&& nginx -t [ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
} }
restore_operations() { restore_operations() {
@@ -289,13 +346,7 @@ restore_operations() {
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1 restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.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.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
restore_managed_file "$NGINX_CONFIG" kaidi-finance.conf || restore_failed=1
systemctl daemon-reload || restore_failed=1 systemctl daemon-reload || restore_failed=1
if nginx -t; then
systemctl reload nginx || restore_failed=1
else
restore_failed=1
fi
[ "$restore_failed" -eq 0 ] [ "$restore_failed" -eq 0 ]
} }
@@ -325,27 +376,34 @@ rollback_active_transaction() {
previous_target=$(transaction_value previous-target) previous_target=$(transaction_value previous-target)
failed_release=$(transaction_value release-dir) failed_release=$(transaction_value release-dir)
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
rollback_ok=true link_restored=true
operations_restored=true
systemctl stop "$SERVICE_NAME" || 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" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then || ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_ok=false link_restored=false
fi fi
else else
rollback_ok=false link_restored=false
fi fi
restore_operations || rollback_ok=false restore_operations || operations_restored=false
systemctl start "$SERVICE_NAME" || rollback_ok=false systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
if [ "$rollback_ok" = true ] && wait_for_health "$HEALTH_URL" && verify_public_surface; then systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
if [ "$link_restored" = true ] && verify_app_surface; then
remove_failed_release "$failed_release" remove_failed_release "$failed_release"
rm -rf "$ACTIVE_TRANSACTION" rm -rf "$ACTIVE_TRANSACTION"
fail "$reason; previous release was restored and verified" if [ "$operations_restored" = true ]; then
fail "$reason; previous release was restored and verified"
fi
fail "$reason; previous release is running, but operations restoration requires manual review"
fi fi
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}" status FAILED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
printf '%s\n' "$reason; rollback is incomplete and will be retried" >&2 if ! archive_processing_request; then
printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2
fi
printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2
exit 1 exit 1
} }
@@ -371,6 +429,7 @@ 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
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" \
@@ -499,7 +558,6 @@ fi
[ -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"
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.conf" ] || fail "Release Nginx configuration is missing"
validate_release_operations || fail "Release operations validation failed" validate_release_operations || fail "Release operations validation failed"
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
@@ -529,8 +587,14 @@ if [ -e "$RELEASE_DIR" ]; then
rm -rf "$RELEASE_DIR" || fail "Failed release staging directory could not be cleaned" rm -rf "$RELEASE_DIR" || fail "Failed release staging directory could not be cleaned"
fi fi
mv "$WORK_DIR/extracted" "$RELEASE_DIR" || fail "Release directory could not be activated" mv "$WORK_DIR/extracted" "$RELEASE_DIR" || fail "Release directory could not be activated"
chown -R root:root "$RELEASE_DIR" || fail "Release ownership could not be secured" secure_release_tree "$RELEASE_DIR" || fail "Release ownership or permissions could not be secured"
chmod -R go-w "$RELEASE_DIR" || fail "Release permissions could not be secured" if command -v restorecon >/dev/null 2>&1; then
restorecon -RF "$APP_ROOT" >/dev/null 2>&1 || true
fi
if ! verify_release_access "$RELEASE_DIR"; then
rm -rf "$RELEASE_DIR"
fail "Service user cannot access the release or managed Java runtime"
fi
PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true) PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true)
mkdir "$ACTIVE_TRANSACTION" mkdir "$ACTIVE_TRANSACTION"
@@ -552,7 +616,6 @@ apply_operations || rollback_active_transaction "Release operations files could
write_transaction_value phase OPS_APPLIED write_transaction_value phase OPS_APPLIED
write_transaction_value phase APP_SWITCHING write_transaction_value phase APP_SWITCHING
systemctl stop "$SERVICE_NAME" || rollback_active_transaction "Application service could not be stopped"
if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \ if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then || ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_active_transaction "Release application link could not be activated" rollback_active_transaction "Release application link could not be activated"
@@ -561,17 +624,22 @@ 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"
write_transaction_value phase HEALTH_CHECKING write_transaction_value phase HEALTH_CHECKING
if systemctl start "$SERVICE_NAME" \ systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
&& wait_for_health "$HEALTH_URL" \ systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
&& systemctl reload nginx \ if verify_app_surface; then
&& systemctl is-active --quiet kaidi-update.path \ success_message="Release $TARGET_VERSION is running"
&& verify_public_surface; then if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
success_message="$success_message; automatic update watcher could not be started"
fi
write_transaction_value phase COMMITTED write_transaction_value phase COMMITTED
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION" status SUCCEEDED "$success_message" "$TARGET_VERSION"
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
complete_request complete_request
rm -rf "$ACTIVE_TRANSACTION" rm -rf "$ACTIVE_TRANSACTION"
exit 0 exit 0
fi fi
rollback_active_transaction "Release health or public-surface verification failed" service_state=$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)
[ -n "$service_state" ] || service_state=unknown
rollback_active_transaction \
"Release health or application-surface verification failed (service state: $service_state)"
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
test('release build boots the setup wizard without runtime errors', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
await page.route('**/api/v1/setup/status', async (route) => {
await route.fulfill({
json: {
data: {
required: true,
locked: false,
supportedDatabaseTypes: ['MYSQL'],
message: '请完成安装',
},
},
});
});
const response = await page.goto('/setup');
expect(response?.ok()).toBe(true);
await expect(page.getByRole('heading', { name: '安装向导', exact: true })).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
+61 -2
View File
@@ -11,6 +11,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
data: { data: {
required: !completed, required: !completed,
locked: completed, locked: completed,
ready: completed,
supportedDatabaseTypes: ['MYSQL'], supportedDatabaseTypes: ['MYSQL'],
message: completed ? '系统已完成安装' : '请完成安装', message: completed ? '系统已完成安装' : '请完成安装',
}, },
@@ -30,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
databaseType: 'MYSQL', databaseType: 'MYSQL',
serverVersion: '8.4.6', serverVersion: '8.4.6',
schemaReady: true, schemaReady: true,
message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过', message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
}, },
}, },
}); });
@@ -58,7 +59,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
await page.getByLabel('数据库密码').fill('fixture-db-password'); await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code'); await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click(); await page.getByRole('button', { name: '测试连接' }).click();
await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible(); await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible();
await page.getByRole('button', { name: '下一步' }).click(); await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
@@ -74,3 +75,61 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
})); }));
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
}); });
test('confirms completion after the setup response is interrupted by a service restart', async ({ page }) => {
let completionStarted = false;
let recoveryChecks = 0;
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) recoveryChecks += 1;
const ready = completionStarted && recoveryChecks >= 2;
await route.fulfill({
json: {
data: {
required: !completionStarted,
locked: completionStarted,
ready,
supportedDatabaseTypes: ['MYSQL'],
message: ready ? '系统已完成安装' : '请完成安装',
},
},
});
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.abort('connectionreset');
});
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('连接暂时中断,正在确认安装结果。')).toBeVisible();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
await expect(page.getByText('网络请求失败')).toHaveCount(0);
});
+10
View File
@@ -178,6 +178,16 @@ test('system administrator checks and queues a signed online update', async ({ p
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]); expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
}); });
test('brand logo returns the active identity to its workbench', async ({ page }) => {
await installFixture(page, [], []);
await page.goto('/governance/update');
await page.locator('.brand-logo').click();
await expect(page).toHaveURL(/\/governance\/settings$/);
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
});
test('disabled online updates expose status without an executable check action', async ({ page }) => { test('disabled online updates expose status without an executable check action', async ({ page }) => {
await installFixture(page, [], [], false); await installFixture(page, [], [], false);
await page.goto('/governance/update'); await page.goto('/governance/update');
+1
View File
@@ -18,6 +18,7 @@
"site:preview": "npm run build && cp -r dist _site", "site:preview": "npm run build && cp -r dist _site",
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:dist": "playwright test --config playwright.dist.config.ts",
"test:a11y": "playwright test e2e/accessibility.e2e.ts", "test:a11y": "playwright test e2e/accessibility.e2e.ts",
"audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org", "audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org",
"check:production": "node scripts/check-production-hygiene.mjs", "check:production": "node scripts/check-production-hygiene.mjs",
+1
View File
@@ -6,6 +6,7 @@ const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
testMatch: '**/*.e2e.ts', testMatch: '**/*.e2e.ts',
testIgnore: 'dist-smoke.e2e.ts',
outputDir: 'test-results', outputDir: 'test-results',
timeout: 30_000, timeout: 30_000,
expect: { timeout: 8_000 }, expect: { timeout: 8_000 },
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from '@playwright/test';
const port = Number(process.env.PLAYWRIGHT_DIST_PORT || 3012);
const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: './e2e',
testMatch: 'dist-smoke.e2e.ts',
outputDir: 'test-results-dist',
timeout: 30_000,
expect: { timeout: 8_000 },
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-dist' }]] : 'list',
use: {
baseURL,
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [{ name: 'chromium-dist', use: { browserName: 'chromium', viewport: { width: 1366, height: 768 } } }],
});
+7 -1
View File
@@ -1,8 +1,11 @@
import { request } from '@/utils/request'; import { request } from '@/utils/request';
const SETUP_COMPLETION_TIMEOUT_MS = 120_000;
export interface SetupStatus { export interface SetupStatus {
required: boolean; required: boolean;
locked: boolean; locked: boolean;
ready: boolean;
supportedDatabaseTypes: string[]; supportedDatabaseTypes: string[];
message: string; message: string;
} }
@@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus {
return { return {
required: Boolean(value?.required), required: Boolean(value?.required),
locked: Boolean(value?.locked), locked: Boolean(value?.locked),
ready: Boolean(value?.ready),
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [], supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
message: value?.message || '', message: value?.message || '',
}; };
@@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) {
} }
export function completeSetup(data: SetupCompleteRequest) { export function completeSetup(data: SetupCompleteRequest) {
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value); return request
.post<SetupCompleted>({ url: '/setup/complete', data, timeout: SETUP_COMPLETION_TIMEOUT_MS })
.then((value) => value);
} }
+1 -1
View File
@@ -2,7 +2,7 @@
<div :class="layoutCls"> <div :class="layoutCls">
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active"> <t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
<template #logo> <template #logo>
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')"> <span v-if="showLogo" class="header-logo-container" @click="handleNav(user.workbenchRoute)">
<span class="header-brand">财务系统</span> <span class="header-brand">财务系统</span>
</span> </span>
<div v-else class="header-operate-left"> <div v-else class="header-operate-left">
+3 -2
View File
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
import { prefix } from '@/config/global'; import { prefix } from '@/config/global';
import { t } from '@/locales'; import { t } from '@/locales';
import { getActive } from '@/router'; import { getActive } from '@/router';
import { useSettingStore } from '@/store'; import { useSettingStore, useUserStore } from '@/store';
import type { MenuRoute, ModeType } from '@/types/interface'; import type { MenuRoute, ModeType } from '@/types/interface';
import pgk from '../../../package.json'; import pgk from '../../../package.json';
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const userStore = useUserStore();
const autoCollapsed = () => { const autoCollapsed = () => {
const isCompact = window.innerWidth <= MIN_POINT; const isCompact = window.innerWidth <= MIN_POINT;
@@ -175,7 +176,7 @@ onUnmounted(() => {
}); });
const goHome = () => { const goHome = () => {
router.push('/role-select'); router.push(userStore.workbenchRoute);
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
+1 -10
View File
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
try { try {
await userStore.selectRole(roleCode); await userStore.selectRole(roleCode);
permissionStore.initRoutes(); permissionStore.initRoutes();
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute; await router.replace(userStore.workbenchRoute);
const fallback =
roleCode === 'PROJECT_MANAGER'
? '/workbench/project'
: roleCode === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: roleCode === 'SYSTEM_ADMIN'
? '/governance/settings'
: '/workbench/finance';
await router.replace(target || fallback);
} catch (error) { } catch (error) {
MessagePlugin.error((error as Error).message || '身份选择失败,请重试'); MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
} finally { } finally {
+61 -11
View File
@@ -162,6 +162,10 @@
</t-form-item> </t-form-item>
</div> </div>
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
{{ completionProgressMessage }}
</t-alert>
<div class="setup-actions"> <div class="setup-actions">
<t-button variant="outline" :disabled="completing" @click="currentStep = 0"> <t-button variant="outline" :disabled="completing" @click="currentStep = 0">
<template #icon><t-icon name="chevron-left" /></template> <template #icon><t-icon name="chevron-left" /></template>
@@ -223,8 +227,13 @@ const errorMessage = ref('');
const connectionResult = ref<SetupConnection | null>(null); const connectionResult = ref<SetupConnection | null>(null);
const verifiedFingerprint = ref(''); const verifiedFingerprint = ref('');
const completionMessage = ref('系统正在重启,请稍候。'); const completionMessage = ref('系统正在重启,请稍候。');
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
const countdown = ref(10); const countdown = ref(10);
let countdownTimer: number | undefined; let countdownTimer: number | undefined;
let unmounted = false;
const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000;
const SETUP_CONFIRMATION_INTERVAL_MS = 2_000;
const databaseForm = reactive<SetupDatabaseRequest>({ const databaseForm = reactive<SetupDatabaseRequest>({
setupCode: '', setupCode: '',
@@ -317,21 +326,51 @@ async function finishSetup(context?: SubmitContext) {
return; return;
} }
completing.value = true; completing.value = true;
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
errorMessage.value = ''; errorMessage.value = '';
try { try {
const result = await completeSetup({ ...databaseForm, ...adminForm }); const result = await completeSetup({ ...databaseForm, ...adminForm });
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试'); if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
completionMessage.value = result.message; showCompleted(result.message, result.restartAfterSeconds);
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
} catch (error) { } catch (error) {
errorMessage.value = (error as Error).message || '安装初始化失败'; const requestError = error as Error & { status?: number };
if (requestError.status === undefined || requestError.status >= 500) {
completionProgressMessage.value = '连接暂时中断,正在确认安装结果。';
if (await waitForSetupCompletion()) {
showCompleted('安装已完成,系统正在切换到正式模式', 10);
return;
}
errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。';
} else {
errorMessage.value = requestError.message || '安装初始化失败';
}
} finally { } finally {
completing.value = false; completing.value = false;
} }
} }
async function waitForSetupCompletion() {
const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS;
while (Date.now() < deadline) {
if (unmounted) return false;
try {
const status = await getSetupStatus();
if (status.ready) return true;
} catch {
// A short connection failure is expected while systemd restarts the service.
}
await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS));
}
return false;
}
function showCompleted(message: string, restartAfterSeconds: number) {
completionMessage.value = message;
countdown.value = Math.max(1, restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
}
function confirmSetup() { function confirmSetup() {
void finishSetup(); void finishSetup();
} }
@@ -340,13 +379,18 @@ function startCountdown() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => { countdownTimer = window.setInterval(() => {
countdown.value -= 1; countdown.value -= 1;
if (countdown.value <= 0) openLogin(); if (countdown.value <= 0) void openLogin();
}, 1000); }, 1000);
} }
function openLogin() { async function openLogin() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
window.location.assign('/login'); completionMessage.value = '系统正在启动,请稍候。';
if (await waitForSetupCompletion()) {
window.location.assign('/login');
return;
}
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
} }
onMounted(async () => { onMounted(async () => {
@@ -364,7 +408,10 @@ onMounted(async () => {
} }
}); });
onBeforeUnmount(() => window.clearInterval(countdownTimer)); onBeforeUnmount(() => {
unmounted = true;
window.clearInterval(countdownTimer);
});
</script> </script>
<style scoped> <style scoped>
.setup-page { .setup-page {
@@ -382,8 +429,7 @@ onBeforeUnmount(() => window.clearInterval(countdownTimer));
} }
.setup-panel { .setup-panel {
align-self: center; place-self: center;
justify-self: center;
width: min(760px, 100%); width: min(760px, 100%);
min-width: 0; min-width: 0;
padding: 32px; padding: 32px;
@@ -445,6 +491,10 @@ h1 {
margin-top: 8px; margin-top: 8px;
} }
.setup-progress {
margin-top: 20px;
}
.setup-complete { .setup-complete {
min-height: 280px; min-height: 280px;
display: flex; display: flex;
+2
View File
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
import { usePermissionStore } from '@/store'; import { usePermissionStore } from '@/store';
import { hasPermissionAccess } from './menu-access'; import { hasPermissionAccess } from './menu-access';
import { resolveWorkbenchRoute } from './workbench-route';
const emptyUser: UserView = { const emptyUser: UserView = {
publicId: '', publicId: '',
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
getters: { getters: {
displayName: (state) => state.user.displayName || state.user.username, displayName: (state) => state.user.displayName || state.user.username,
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '', roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode), hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
hasPermission: (state) => (permission: string) => hasPermission: (state) => (permission: string) =>
hasPermissionAccess(state.currentRole, state.permissions, permission), hasPermissionAccess(state.currentRole, state.permissions, permission),
@@ -0,0 +1,13 @@
import type { RoleView } from '@/api/auth';
const fallbackWorkbenchRoutes: Record<string, string> = {
PROJECT_MANAGER: '/workbench/project',
FINANCE_MANAGER: '/workbench/finance',
ARCHIVE_MANAGER: '/workbench/archive',
SYSTEM_ADMIN: '/governance/settings',
};
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
const configured = roles.find((role) => role.code === currentRole)?.workbenchRoute?.trim();
return configured || fallbackWorkbenchRoutes[currentRole || ''] || '/role-select';
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const requestMocks = vi.hoisted(() => ({
post: vi.fn(),
}));
vi.mock('@/utils/request', () => ({
request: {
post: requestMocks.post,
},
}));
import { completeSetup } from '../src/api/setup';
describe('setup API', () => {
beforeEach(() => {
requestMocks.post.mockReset();
requestMocks.post.mockResolvedValue({ completed: true });
});
it('allows enough time for the first database migration', async () => {
const request = {
setupCode: 'fixture-code',
databaseType: 'MYSQL' as const,
host: '127.0.0.1',
port: 3306,
database: 'kaidi_finance',
username: 'kaidi',
password: 'fixture-password',
adminUsername: 'admin',
adminDisplayName: '系统管理员',
adminPassword: 'SetupAdmin@2026Strong',
adminPasswordConfirmation: 'SetupAdmin@2026Strong',
};
await completeSetup(request);
expect(requestMocks.post).toHaveBeenCalledWith({
url: '/setup/complete',
data: request,
timeout: 120_000,
});
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import type { RoleView } from '../src/api/auth';
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
const roles: RoleView[] = [
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
];
describe('active identity workbench route', () => {
it('uses the route supplied for the active role', () => {
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
});
it('falls back to the built-in role workbench when the server route is absent', () => {
expect(resolveWorkbenchRoute('PROJECT_MANAGER', [])).toBe('/workbench/project');
expect(resolveWorkbenchRoute('ARCHIVE_MANAGER', [])).toBe('/workbench/archive');
});
it('keeps role selection for sessions without an active identity', () => {
expect(resolveWorkbenchRoute(null, roles)).toBe('/role-select');
});
});
-1
View File
@@ -55,7 +55,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
name: 'tdesign-vendor', name: 'tdesign-vendor',
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/, test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
priority: 30, priority: 30,
maxSize: 480 * 1024,
}, },
{ {
name: 'vue-vendor', name: 'vue-vendor',
+5 -2
View File
@@ -91,14 +91,17 @@ cp "$JAR" "$STAGE/app.jar"
cp -R "$ROOT/frontend/dist/." "$STAGE/public/" 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.env.example" "$STAGE/ops/baota.env.example"
# 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" chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh"
ARTIFACT="kaidi-finance-$VERSION.tar.gz" ARTIFACT="kaidi-finance-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar -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")
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 \
+2
View File
@@ -48,6 +48,7 @@ request() {
local method=$1 url=$2 output=$3 input=${4:-} status local method=$1 url=$2 output=$3 input=${4:-} status
local args=( local args=(
--silent --show-error --proto '=https' --tlsv1.2 --silent --show-error --proto '=https' --tlsv1.2
--connect-timeout 15 --max-time 600 --retry 5 --retry-all-errors --retry-delay 2
--request "$method" --header "@$AUTH_HEADER" --request "$method" --header "@$AUTH_HEADER"
--output "$output" --write-out '%{http_code}' --output "$output" --write-out '%{http_code}'
) )
@@ -99,6 +100,7 @@ for name in "${ASSETS[@]}"; do
>> "$WORK/expected-assets.jsonl" >> "$WORK/expected-assets.jsonl"
encoded_name=$(jq -rn --arg value "$name" '$value | @uri') encoded_name=$(jq -rn --arg value "$name" '$value | @uri')
upload_status=$(curl --silent --show-error --proto '=https' --tlsv1.2 \ upload_status=$(curl --silent --show-error --proto '=https' --tlsv1.2 \
--connect-timeout 15 --max-time 1800 --retry 6 --retry-all-errors --retry-delay 2 \
--header "@$AUTH_HEADER" \ --header "@$AUTH_HEADER" \
--form "attachment=@$path;type=application/octet-stream" \ --form "attachment=@$path;type=application/octet-stream" \
--output "$WORK/upload.json" --write-out '%{http_code}' \ --output "$WORK/upload.json" --write-out '%{http_code}' \
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
fail() {
printf 'Baota start fixture failed: %s\n' "$1" >&2
exit 1
}
RELEASE="$WORK/release"
mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp"
printf 'fixture jar\n' > "$RELEASE/app.jar"
printf '<!doctype html>\n' > "$RELEASE/public/index.html"
printf '1.0.0-preview.23\n' > "$RELEASE/VERSION"
cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh"
chmod 0755 "$RELEASE/ops/baota-start.sh"
cat > "$WORK/java" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
{
printf 'cwd=%s\n' "$PWD"
printf 'args=%s\n' "$*"
printf 'version=%s\n' "$APP_VERSION"
printf 'address=%s\n' "$SERVER_ADDRESS"
printf 'port=%s\n' "$SERVER_PORT"
printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED"
printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS"
} > "$MOCK_JAVA_LOG"
SH
chmod 0755 "$WORK/java"
MOCK_JAVA_LOG="$WORK/java.log" \
KAIDI_JAVA_BIN="$WORK/java" \
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
DB_USERNAME=fixture \
DB_PASSWORD='fixture-password' \
FIELD_ENCRYPTION_KEY='fixture-field-key' \
FILE_STORAGE_ROOT="$WORK/files" \
FILE_STORAGE_TEMP="$WORK/tmp" \
"$RELEASE/ops/baota-start.sh"
grep -Fqx "cwd=$RELEASE" "$WORK/java.log" || fail 'launcher did not use the release working directory'
grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar $RELEASE/app.jar" "$WORK/java.log" \
|| fail 'launcher did not execute the packaged Spring Boot jar'
grep -Fqx 'version=1.0.0-preview.23' "$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 'port=18080' "$WORK/java.log" || fail 'launcher did not default to port 18080'
grep -Fqx 'update=false' "$WORK/java.log" || fail 'launcher did not disable the systemd updater'
grep -Fqx "static=file:$RELEASE/public/" "$WORK/java.log" \
|| fail 'launcher did not bind static resources to the release directory'
if KAIDI_JAVA_BIN="$WORK/java" \
DB_USERNAME=fixture DB_PASSWORD=fixture FIELD_ENCRYPTION_KEY=fixture \
FILE_STORAGE_ROOT="$WORK/files" FILE_STORAGE_TEMP="$WORK/tmp" \
MOCK_JAVA_LOG="$WORK/missing-db.log" \
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
fail 'launcher accepted a production configuration without DB_URL'
fi
grep -Fq 'DB_URL must be configured' "$WORK/missing-db.out" \
|| fail 'launcher did not report the missing database URL'
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
printf 'Baota Spring Boot launcher fixture passed\n'
+7
View File
@@ -23,6 +23,10 @@ set -Eeuo pipefail
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ] [ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
[ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ] [ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]
if [ "${EXPECT_PORT:-false}" = true ]; then
[ "$KAIDI_APP_PORT" = '19090' ]
[ "$KAIDI_SERVER_ADDRESS" = '127.0.0.1' ]
fi
if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] [ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
else else
@@ -44,9 +48,12 @@ 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}')
PATH="$FIXTURE/bin:$PATH" \ PATH="$FIXTURE/bin:$PATH" \
EXPECT_PORT=true \
KAIDI_INSTALLER_SHA256="$installer_sha256" \ KAIDI_INSTALLER_SHA256="$installer_sha256" \
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \ KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
KAIDI_APP_PORT=19090 \
KAIDI_SERVER_ADDRESS=127.0.0.1 \
KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \ KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \
KAIDI_DB_USERNAME=kaidi \ KAIDI_DB_USERNAME=kaidi \
KAIDI_DB_PASSWORD=fixture-password \ KAIDI_DB_PASSWORD=fixture-password \
+169 -12
View File
@@ -10,6 +10,10 @@ fail() {
exit 1 exit 1
} }
mode_of() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
}
# Load only pure helper functions. The installer itself must never run in this fixture. # Load only pure helper functions. The installer itself must never run in this fixture.
{ {
sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -17,18 +21,61 @@ fail() {
sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_reinstall_env()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^read_reinstall_env()/,/^}/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 '/^configure_app_port()/,/^}/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 '/^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 '/^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"
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
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 '/^preflight_database()/,/^}/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"
} > "$WORK/helpers.sh" } > "$WORK/helpers.sh"
# shellcheck disable=SC1090,SC1091 # shellcheck disable=SC1090,SC1091
source "$WORK/helpers.sh" source "$WORK/helpers.sh"
export SETUP_WIZARD=true
preflight_database || fail 'setup wizard database preflight returned a failure status'
release_permissions="$WORK/release-permissions"
mkdir -p "$release_permissions/public" "$release_permissions/ops"
printf 'jar\n' > "$release_permissions/app.jar"
printf 'html\n' > "$release_permissions/public/index.html"
printf '#!/bin/sh\n' > "$release_permissions/ops/update.sh"
printf '#!/usr/bin/env bash\n' > "$release_permissions/ops/baota-start.sh"
chmod -R 0777 "$release_permissions"
(
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
chown() { return 0; }
export SERVICE_GROUP=fixture
secure_release_tree "$release_permissions"
)
[ "$(mode_of "$release_permissions")" = 750 ] || fail 'release root mode is not 0750'
[ "$(mode_of "$release_permissions/public")" = 750 ] || fail 'release directory mode is not 0750'
[ "$(mode_of "$release_permissions/app.jar")" = 640 ] || fail 'release file mode is not 0640'
[ "$(mode_of "$release_permissions/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
(
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
systemctl() {
case "$1" in
cat) return 1 ;;
reset-failed) return 0 ;;
*) return 97 ;;
esac
}
restore_unit_state missing.service false false
) || fail 'rollback treated an absent first-install unit as an incomplete restoration'
# shellcheck disable=SC2034 # Referenced by the extracted installer helper. # shellcheck disable=SC2034 # Referenced by the extracted installer helper.
REINSTALL=true REINSTALL=true
# shellcheck disable=SC2034 # Referenced by the extracted installer helper. # shellcheck disable=SC2034 # Referenced by the extracted installer helper.
@@ -78,6 +125,31 @@ grep -qx 'jdbc:mysql://runtime/kaidi_finance' <(read_reinstall_env DB_URL) \
grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \ grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \
|| fail 'reinstall did not prefer the completed setup runtime database user' || fail 'reinstall did not prefer the completed setup runtime database user'
for port in 1024 18080 65535; do
valid_app_port "$port" || fail "installer rejected valid application port $port"
done
for port in 0 80 1023 65536 invalid 18080.0; do
! valid_app_port "$port" || fail "installer accepted invalid application port $port"
done
port_is_listening() { return 1; }
log() { printf '%s\n' "$*" >/dev/null; }
# shellcheck disable=SC2329 # Invoked by the extracted installer helper.
die() { printf '%s\n' "$*" >&2; return 1; }
export APP_PORT=19090 SERVER_ADDRESS=127.0.0.1 HEALTH_URL='' APP_INDEX_URL='' REINSTALL=false
configure_app_port
[ "$HEALTH_URL" = 'http://127.0.0.1:19090/actuator/health' ] \
|| fail 'selected application port did not reach the health URL'
[ "$APP_INDEX_URL" = 'http://127.0.0.1:19090/' ] \
|| fail 'selected application port did not reach the frontend URL'
export APP_PORT=19091 SERVER_ADDRESS=::1 HEALTH_URL='' APP_INDEX_URL=''
configure_app_port
[ "$HEALTH_URL" = 'http://[::1]:19091/actuator/health' ] \
|| fail 'IPv6 bind address did not produce a bracketed health URL'
[ "$APP_INDEX_URL" = 'http://[::1]:19091/' ] \
|| fail 'IPv6 bind address did not produce a bracketed frontend URL'
[ "$PROXY_TARGET" = 'http://[::1]:19091' ] \
|| fail 'IPv6 bind address did not produce a bracketed reverse-proxy target'
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
is_semver "$version" || fail "installer rejected valid SemVer $version" is_semver "$version" || fail "installer rejected valid SemVer $version"
"$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version" "$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version"
@@ -103,10 +175,23 @@ for arch in i386 i486 i586 i686; do
ARCH_FIXTURE=$arch ARCH_FIXTURE=$arch
[ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime" [ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime"
done done
(
ARCH_FIXTURE=x86_64
# shellcheck disable=SC2329 # Invoked indirectly by the sourced architecture helper.
getconf() { printf '32\n'; }
[ "$(normalized_host_arch)" = x86 ] \
|| fail '32-bit userspace on an x86_64 kernel was not normalized to x86'
export HOST_ARCH=x86
[ "$(azul_arch)" = i686 ] \
|| fail '32-bit userspace on an x86_64 kernel did not select the i686 Java runtime'
)
mkdir -p "$WORK/fake-jre/bin" mkdir -p "$WORK/fake-jre/bin"
cat > "$WORK/fake-jre/bin/java" <<'JAVA' cat > "$WORK/fake-jre/bin/java" <<'JAVA'
#!/usr/bin/env sh #!/usr/bin/env sh
if [ "${1:-}" = '-XshowSettings:properties' ]; then
printf ' os.arch = x86\n' >&2
fi
printf 'openjdk version "17-fixture"\n' >&2 printf 'openjdk version "17-fixture"\n' >&2
JAVA JAVA
chmod 0755 "$WORK/fake-jre/bin/java" chmod 0755 "$WORK/fake-jre/bin/java"
@@ -128,8 +213,16 @@ download() {
esac esac
} }
ARCH_FIXTURE=i686 ARCH_FIXTURE=i686
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'
rm -rf "$JAVA_STAGED_DIR"
export KAIDI_JAVA_HOME="$WORK/fake-jre"
prepare_java >/dev/null 2>&1
[ -d "$JAVA_STAGED_DIR" ] && [ ! -L "$JAVA_STAGED_DIR" ] \
|| fail 'existing Java 17 runtime was not copied into managed storage'
cmp -s "$WORK/fake-jre/bin/java" "$JAVA_STAGED_DIR/bin/java" \
|| fail 'installer staged an unexpected local Java runtime'
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
@@ -137,7 +230,7 @@ RELEASE_AUTH_HEADER_FILE=$WORK/release-auth-header
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE" printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
chmod 0600 "$RELEASE_AUTH_HEADER_FILE" chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
cat > "$WORK/release-api.json" <<'JSON' cat > "$WORK/release-api.json" <<'JSON'
{"assets":[ {"tag_name":"v1.0.0-preview.11","assets":[
{"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"} {"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"}
]} ]}
JSON JSON
@@ -160,12 +253,13 @@ curl() {
done done
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90 [ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91 grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91
[ "$url" = https://gitea.fixture.invalid/assets/release-manifest.json ] || return 92 [ "$url" = https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|| return 92
cp "$WORK/release-manifest.fixture" "$output" cp "$WORK/release-manifest.fixture" "$output"
} }
[ "$(release_asset_url release-manifest.json)" = \ [ "$(release_asset_url release-manifest.json)" = \
https://gitea.fixture.invalid/assets/release-manifest.json ] \ https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|| fail 'installer did not resolve the private Gitea release asset' || fail 'installer did not construct the trusted Gitea release asset URL'
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json" download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \ cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|| fail 'installer did not download the private Gitea release asset' || fail 'installer did not download the private Gitea release asset'
@@ -177,9 +271,9 @@ cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \ jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json" "$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json" mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
if release_asset_url release-manifest.json >/dev/null 2>&1; then [ "$(release_asset_url release-manifest.json)" = \
fail 'installer accepted a cross-origin Gitea release asset' https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
fi || fail 'installer trusted the cross-origin browser download URL'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \ grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
@@ -195,6 +289,38 @@ 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" \
|| fail 'installer no longer reuses a local Java 17 runtime'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'cp -R "$system_home/." "$JAVA_STAGED_DIR/"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer copies a local Java runtime into managed storage'
# shellcheck disable=SC2016 # Match literal installer source.
! grep -Fq 'ln -s "$system_home"' "$ROOT/deploy/install.sh" \
|| fail 'installer must not link the service to an externally managed Java directory'
# shellcheck disable=SC2016 # Match the literal installer command.
grep -Fq -- '--connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer performs a quiet bounded health check'
grep -Fq 'restart_count" -ge 3' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer stops early after repeated service restarts'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer creates traversable root-owned application directories'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'find "$release_dir" -type d -exec chmod 0750 {} +' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer secures release directory traversal permissions'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'runuser -u "$SERVICE_USER" -- sh -c' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer validates the release as the service user'
grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer resets stale systemd failure state'
[ "$(tail -n 1 "$ROOT/deploy/install.sh")" = 'main "$@"' ] \
|| fail 'installer can execute before the complete curl stream is parsed'
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer has a bounded restart burst'
grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service can automatically repeat a failed switching transaction'
! grep -Fq '/var/lib/kaidi-update/processing' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path can automatically repeat a claimed 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'
@@ -204,12 +330,35 @@ grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$
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'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}' "$ROOT/deploy/install.sh" \ grep -Fq 'if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then' "$ROOT/deploy/install.sh" \
|| fail 'installer does not enable the first-run setup wizard by default' || fail 'installer no longer distinguishes an explicit setup-wizard selection'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'elif [ "$REINSTALL" = true ]; then' "$ROOT/deploy/install.sh" \
|| fail 'repair reinstall no longer defaults to production mode'
! grep -Fq 'docker run' "$ROOT/deploy/install.sh" \ ! grep -Fq 'docker run' "$ROOT/deploy/install.sh" \
|| fail 'installer must not create a MySQL container' || fail 'installer must not create a MySQL container'
! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \ ! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \
|| fail 'installer must not install Docker or MySQL automatically' || fail 'installer must not install Docker or MySQL automatically'
package_log="$WORK/package-manager.log"
for package_manager in apt-get dnf yum; do
package_bin="$WORK/package-manager-$package_manager"
mkdir "$package_bin"
cat > "$package_bin/$package_manager" <<'SH'
#!/bin/sh
printf '%s %s\n' "${0##*/}" "$*" >> "$PACKAGE_LOG"
SH
chmod +x "$package_bin/$package_manager"
: > "$package_log"
(
PATH="$package_bin"
PACKAGE_LOG="$package_log"
export PATH PACKAGE_LOG
install_packages
)
if grep -Eqi '(^|[[:space:]])(default-mysql-client|mysql(-client|-server)?|mariadb(-client|-server)?)([[:space:]]|$)' "$package_log"; then
fail "installer asked $package_manager to install a database package"
fi
done
grep -Fq 'An external MySQL 8.4 database is required' "$ROOT/deploy/install.sh" \ grep -Fq 'An external MySQL 8.4 database is required' "$ROOT/deploy/install.sh" \
|| fail 'installer does not require an operator-managed external MySQL database' || fail 'installer does not require an operator-managed external MySQL database'
grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \ grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \
@@ -218,16 +367,24 @@ grep -Fq 'KAIDI_SETUP_WIZARD' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer supports first-run setup mode' || fail 'installer no longer supports first-run setup mode'
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \ grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer writes the one-time setup-code hash' || fail 'installer no longer writes the one-time setup-code hash'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'SERVER_PORT "$APP_PORT"' "$ROOT/deploy/install.sh" \
|| fail 'installer does not persist the selected application port'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \
|| fail 'installer does not persist the selected bind address'
! grep -Eqi 'nginx|/etc/nginx/' "$ROOT/deploy/install.sh" \
|| fail 'installer must not install, start, or modify Nginx'
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 'update service no longer loads setup database overrides'
# shellcheck disable=SC2016 # Match the literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'chown root:kaidi "$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" \ grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \
|| fail 'README does not document the public one-line setup-wizard install path' || fail 'README does not document the public one-line setup-wizard install path'
printf 'Install configuration, 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'
+148 -29
View File
@@ -11,6 +11,10 @@ fail() {
exit 1 exit 1
} }
mode_of() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
}
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh" sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh"
# shellcheck disable=SC1090,SC1091 # shellcheck disable=SC1090,SC1091
source "$WORK/update-semver.sh" source "$WORK/update-semver.sh"
@@ -21,6 +25,20 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
! is_semver "$version" || fail "updater accepted invalid SemVer $version" ! is_semver "$version" || fail "updater accepted invalid SemVer $version"
done done
missing_service_user="kaidi-fixture-missing-$$"
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
KAIDI_SERVICE_USER="$missing_service_user" \
KAIDI_SERVICE_GROUP="$missing_service_user" \
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
fail 'updater accepted a missing service identity during bootstrap'
fi
grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \
|| fail 'updater bootstrap failure did not preserve its diagnostic'
! grep -Eq 'No such file|nonexistent directory|cannot create' "$WORK/bootstrap.log" \
|| fail 'updater bootstrap failure was masked by an unavailable status directory'
write_mock_commands() { write_mock_commands() {
local mock_bin=$1 local mock_bin=$1
mkdir -p "$mock_bin" mkdir -p "$mock_bin"
@@ -55,7 +73,7 @@ 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_PUBLIC_INDEX_URL:-http://127.0.0.1/}" ]; 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
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22 [ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
@@ -74,12 +92,7 @@ SH
cat > "$mock_bin/systemctl" <<'SH' cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh #!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
exit 0 [ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
SH
cat > "$mock_bin/nginx" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_NGINX_LOG"
exit 0 exit 0
SH SH
@@ -105,6 +118,33 @@ SH
cat > "$mock_bin/chown" <<'SH' cat > "$mock_bin/chown" <<'SH'
#!/bin/sh #!/bin/sh
exit 0 exit 0
SH
cat > "$mock_bin/runuser" <<'SH'
#!/bin/sh
[ "${1:-}" = -u ] || exit 2
shift 2
[ "${1:-}" = -- ] && shift
exec "$@"
SH
cat > "$mock_bin/install" <<'SH'
#!/usr/bin/env bash
if [[ " $* " != *" -d "* ]]; then
exec /usr/bin/install "$@"
fi
mode=0755
paths=()
while [ "$#" -gt 0 ]; do
case "$1" in
-d) shift ;;
-o|-g) shift 2 ;;
-m) mode=$2; shift 2 ;;
*) paths+=("$1"); shift ;;
esac
done
mkdir -p "${paths[@]}"
chmod "$mode" "${paths[@]}"
SH SH
cat > "$mock_bin/mv" <<'SH' cat > "$mock_bin/mv" <<'SH'
@@ -131,15 +171,18 @@ SH
build_release() { build_release() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
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"
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"
chmod 0755 "$stage/ops/update.sh" chmod 0755 "$stage/ops/update.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do chmod 0755 "$stage/ops/baota-start.sh"
printf 'new %s\n' "$name" > "$stage/ops/$name" for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
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"
@@ -160,10 +203,12 @@ build_release() {
build_gitea_release_index() { build_gitea_release_index() {
local fixture=$1 local fixture=$1
local origin=${2:-https://gitea.fixture.invalid} local origin=${2:-https://gitea.fixture.invalid}
local artifact local artifact version tag
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json") artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
jq -n --arg origin "$origin" --arg artifact "$artifact" \ version=$(jq -er '.version' "$fixture/release/release-manifest.json")
'{assets:[ tag="v$version"
jq -n --arg origin "$origin" --arg artifact "$artifact" --arg tag "$tag" \
'{tag_name:$tag,assets:[
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")}, {name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")}, {name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)} {name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
@@ -174,17 +219,21 @@ prepare_installation() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \ mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
"$fixture/state/inbox" "$fixture/systemd" "$fixture/nginx" "$fixture/log" "$fixture/app/runtime/java/bin" "$fixture/state/inbox" "$fixture/systemd" "$fixture/log"
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar" printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html" printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION" printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current" ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
printf 'old update.sh\n' > "$fixture/app/bin/update.sh" printf 'old update.sh\n' > "$fixture/app/bin/update.sh"
chmod 0755 "$fixture/app/bin/update.sh" chmod 0755 "$fixture/app/bin/update.sh"
cat > "$fixture/app/runtime/java/bin/java" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$fixture/app/runtime/java/bin/java"
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 'old %s\n' "$name" > "$fixture/systemd/$name" printf 'old %s\n' "$name" > "$fixture/systemd/$name"
done done
printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf"
write_request "$fixture" "$version" DOWNLOAD write_request "$fixture" "$version" DOWNLOAD
} }
@@ -227,12 +276,13 @@ run_update() {
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \ MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \ MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \ MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_NGINX_LOG="$fixture/nginx.log" \ MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
KAIDI_SERVICE_USER="$(id -un)" \
KAIDI_SERVICE_GROUP="$(id -gn)" \
KAIDI_APP_ROOT="$fixture/app" \ KAIDI_APP_ROOT="$fixture/app" \
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \ KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
KAIDI_LOG_ROOT="$fixture/log" \ KAIDI_LOG_ROOT="$fixture/log" \
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \ KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
KAIDI_SKIP_DB_BACKUP="$skip_backup" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \ KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
@@ -243,9 +293,8 @@ run_update() {
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \ UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \ UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
UPDATE_STATUS_FILE="$fixture/state/status.json" \ UPDATE_STATUS_FILE="$fixture/state/status.json" \
KAIDI_HEALTH_URL=http://127.0.0.1:18080/actuator/health \ KAIDI_HEALTH_URL=http://127.0.0.1:19090/actuator/health \
KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \ KAIDI_APP_INDEX_URL=http://127.0.0.1:19090/ \
KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \
"$ROOT/deploy/update.sh" "$ROOT/deploy/update.sh"
} }
@@ -277,7 +326,7 @@ assert_private_gitea_release_case() {
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects' || fail 'authenticated private Gitea requests unexpectedly enabled redirects'
} }
assert_cross_origin_gitea_asset_rejected() { assert_cross_origin_gitea_browser_url_ignored() {
local fixture="$WORK/cross-origin-gitea" local fixture="$WORK/cross-origin-gitea"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
mkdir -p "$fixture" mkdir -p "$fixture"
@@ -286,18 +335,16 @@ assert_cross_origin_gitea_asset_rejected() {
build_gitea_release_index "$fixture" https://assets.fixture.invalid build_gitea_release_index "$fixture" https://assets.fixture.invalid
prepare_installation "$fixture" "$version" prepare_installation "$fixture" "$version"
if FIXTURE_RELEASE_BASE_URL='' \ FIXTURE_RELEASE_BASE_URL='' \
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \ FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \ FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \ FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
MOCK_CURL_LOG="$fixture/curl.log" \ MOCK_CURL_LOG="$fixture/curl.log" \
run_update "$fixture" success > "$fixture/update.log" 2>&1; then run_update "$fixture" success
fail 'cross-origin Gitea asset unexpectedly succeeded' [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
fi || fail 'misconfigured browser download URL prevented trusted same-origin download'
grep -q 'Release manifest asset is missing' "$fixture/update.log" \
|| fail 'cross-origin Gitea asset rejection was not reported'
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \ ! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|| fail 'cross-origin Gitea asset was requested' || fail 'cross-origin browser download URL was requested'
} }
assert_success_case() { assert_success_case() {
@@ -319,7 +366,50 @@ assert_success_case() {
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind' || fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
grep -qx 'reload nginx' "$fixture/systemctl.log" || fail 'Nginx was not reloaded' grep -qx 'restart kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case did not restart the application after the atomic switch'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case stopped the application before switching releases'
! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx'
[ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group'
[ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750'
[ "$(mode_of "$fixture/app/current/app.jar")" = 640 ] || fail 'release file mode is not 0640'
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
}
assert_identical_systemd_operations_case() {
local fixture="$WORK/identical-systemd"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version" old
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
: > "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|| fail 'identical systemd units triggered an unnecessary daemon reload'
}
assert_update_path_failure_keeps_application_case() {
local fixture="$WORK/path-watcher-failure"
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"
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state'
grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \
|| fail 'path watcher failure did not preserve its diagnostic'
} }
assert_rollback_case() { assert_rollback_case() {
@@ -342,6 +432,10 @@ assert_rollback_case() {
|| fail 'rollback case did not restore the previous updater' || fail 'rollback case did not restore the previous updater'
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \ grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|| fail 'rollback case did not restore the previous path unit' || fail 'rollback case did not restore the previous path unit'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'rollback case stopped the application before restoring the previous release'
[ "$(grep -c '^restart kaidi-finance.service$' "$fixture/systemctl.log")" -ge 2 ] \
|| fail 'rollback case did not restart both the candidate and restored releases'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist FAILED' || fail 'rollback case did not persist FAILED'
[ ! -e "$fixture/app/releases/$version" ] \ [ ! -e "$fixture/app/releases/$version" ] \
@@ -350,6 +444,28 @@ assert_rollback_case() {
|| fail 'rollback case did not archive the failed request' || fail 'rollback case did not archive the failed request'
} }
assert_incomplete_rollback_requires_manual_recovery_case() {
local fixture="$WORK/incomplete-rollback"
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"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded'
fi
grep -Fq 'manual recovery is required' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request'
[ -d "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case did not preserve transaction evidence'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request'
}
assert_download_failure_case() { assert_download_failure_case() {
local fixture="$WORK/download-failure" local fixture="$WORK/download-failure"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -434,11 +550,14 @@ assert_install_without_verified_cache_rejected() {
} }
assert_success_case assert_success_case
assert_identical_systemd_operations_case
assert_update_path_failure_keeps_application_case
assert_rollback_case assert_rollback_case
assert_incomplete_rollback_requires_manual_recovery_case
assert_download_failure_case assert_download_failure_case
assert_database_failure_case assert_database_failure_case
assert_symlink_request_rejected assert_symlink_request_rejected
assert_install_without_verified_cache_rejected assert_install_without_verified_cache_rejected
assert_private_gitea_release_case assert_private_gitea_release_case
assert_cross_origin_gitea_asset_rejected 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'
+5
View File
@@ -160,10 +160,15 @@ 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.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
compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service
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' \
|| { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; }
bash -n "$ROOT/deploy/baota-start.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; }