Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c73ad7eb0 | ||
|
|
c35ef3d383 | ||
|
|
cea46b494d | ||
|
|
b679381946 | ||
|
|
3480d810d4 | ||
|
|
271eac382e | ||
|
|
52403c6f95 | ||
|
|
4f626da9db | ||
|
|
dc35a48b44 | ||
|
|
e5b419920e | ||
|
|
8d63d60df5 | ||
|
|
74585a5f07 | ||
|
|
b19716aef9 | ||
|
|
f9912ade7e | ||
|
|
7394c9e7e4 |
@@ -57,6 +57,7 @@ jobs:
|
||||
npm run build
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:e2e
|
||||
npm run test:dist
|
||||
|
||||
- name: Verify release contracts and scripts
|
||||
run: |
|
||||
@@ -64,12 +65,15 @@ jobs:
|
||||
./scripts/test-install-fixture.sh
|
||||
./scripts/test-git-install-fixture.sh
|
||||
./scripts/test-update-fixture.sh
|
||||
./scripts/test-baota-start-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/generate-release-key.sh scripts/test-install-fixture.sh \
|
||||
scripts/test-git-install-fixture.sh \
|
||||
scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
|
||||
scripts/test-baota-start-fixture.sh \
|
||||
scripts/verify-release.sh
|
||||
|
||||
- name: Build and sign release assets
|
||||
|
||||
@@ -4,6 +4,8 @@ frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report-dist/
|
||||
frontend/test-results-dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
@@ -46,25 +46,36 @@ npm run build
|
||||
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取
|
||||
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。
|
||||
安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。
|
||||
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
|
||||
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
|
||||
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
|
||||
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.9 发布范围。
|
||||
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
|
||||
|
||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、
|
||||
Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
||||
`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且
|
||||
80 端口可用;安装器会接管默认 HTTP 站点。
|
||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
|
||||
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
|
||||
外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
||||
`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
|
||||
|
||||
### 直接 curl 安装
|
||||
|
||||
```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
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh -o /tmp/kaidi-install.sh
|
||||
printf '%s %s\n' faf52cc902abbc2bd48571caee3f4207de50ce339b82ce88fe23f9c4ce8f89ef /tmp/kaidi-install.sh | sha256sum -c -
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
|
||||
| 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
|
||||
rm -f /tmp/kaidi-install.sh
|
||||
```
|
||||
@@ -75,7 +86,7 @@ rm -f /tmp/kaidi-install.sh
|
||||
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
||||
|
||||
```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
|
||||
./deploy/install-from-git.sh
|
||||
```
|
||||
@@ -87,23 +98,39 @@ cd kaidi-preview
|
||||
|
||||
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
|
||||
- 不安装或创建数据库;在 `/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 签名。
|
||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。
|
||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
|
||||
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
||||
- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。
|
||||
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
|
||||
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
||||
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
|
||||
|
||||
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问
|
||||
`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置
|
||||
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后,
|
||||
通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
|
||||
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
|
||||
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
|
||||
|
||||
安装后执行以下命令确认应用、反向代理和首次登录信息:
|
||||
以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件:
|
||||
|
||||
```nginx
|
||||
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
|
||||
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 cat /root/kaidi-first-login.txt
|
||||
```
|
||||
@@ -114,11 +141,13 @@ sudo cat /root/kaidi-first-login.txt
|
||||
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
||||
|
||||
```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`,并授予安装账号该库的
|
||||
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
|
||||
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
||||
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
|
||||
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
|
||||
排序规则,全部通过后 Flyway 才会建表。
|
||||
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
||||
|
||||
```sql
|
||||
@@ -134,32 +163,89 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
|
||||
管理员数据和运维人员新增的环境变量:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整,
|
||||
不会把恢复失败吞掉。
|
||||
没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整,
|
||||
不会把恢复失败吞掉。新版本安装器在 `KAIDI_REINSTALL=true` 时默认使用生产修复模式,显式的
|
||||
`KAIDI_SETUP_WIZARD=false` 仅用于兼容旧版本安装器。
|
||||
|
||||
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.9/install.sh \
|
||||
| sudo env KAIDI_REINSTALL=true bash
|
||||
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=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` 以及数据库,
|
||||
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。
|
||||
安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。
|
||||
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。
|
||||
反向代理由运维人员独立管理,停用程序不会修改其配置。
|
||||
|
||||
```bash
|
||||
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/nginx/conf.d/kaidi-finance.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo rm -rf /opt/kaidi
|
||||
```
|
||||
|
||||
@@ -182,8 +268,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
|
||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-preview.9
|
||||
git push origin v1.0.0-preview.9
|
||||
git tag v1.0.0-preview.19
|
||||
git push origin v1.0.0-preview.19
|
||||
```
|
||||
|
||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
||||
@@ -195,13 +281,14 @@ URL、Token、脚本或命令。更新流程固定为:
|
||||
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
|
||||
的包不能进入安装。
|
||||
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
|
||||
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。
|
||||
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。
|
||||
7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动
|
||||
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
|
||||
`mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
|
||||
6. 校验更新脚本和 systemd 单元后,在旧 Java 仍运行时原子切换版本链接,再由 systemd 执行一次 `restart`,不修改反向代理。
|
||||
7. 同时检查 Java 健康端点和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
|
||||
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
|
||||
|
||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
|
||||
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
|
||||
不会延迟服务端切换。失败请求会被归档且不会由 systemd 自动重试,避免同一事务反复停止业务服务。
|
||||
|
||||
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为
|
||||
`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
|
||||
```
|
||||
|
||||
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
|
||||
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
|
||||
修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行:
|
||||
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到
|
||||
`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动再次执行。修复日志所示的
|
||||
磁盘、权限或旧版本健康问题后,才明确执行一次:
|
||||
|
||||
```bash
|
||||
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
|
||||
@@ -234,7 +321,7 @@ cat /var/lib/kaidi-update/status.json
|
||||
```bash
|
||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/package-release.sh 1.0.0-preview.9
|
||||
./scripts/package-release.sh 1.0.0-preview.19
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
@@ -17,6 +17,6 @@ public class SetupLockedController {
|
||||
@GetMapping("/status")
|
||||
@PreAuthorize("true")
|
||||
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.Configuration;
|
||||
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.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
||||
@@ -19,8 +21,20 @@ public class SetupSecurityConfig {
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.logout(AbstractHttpConfigurer::disable)
|
||||
.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());
|
||||
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.concurrent.atomic.AtomicBoolean;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
@@ -37,8 +40,15 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
||||
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_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 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 AtomicBoolean locked = new AtomicBoolean(false);
|
||||
@@ -56,7 +66,7 @@ public class SetupService {
|
||||
|
||||
public SetupViews.Status status() {
|
||||
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 ? "安装向导已锁定" : "请完成数据库和管理员初始化");
|
||||
}
|
||||
|
||||
@@ -74,8 +84,10 @@ public class SetupService {
|
||||
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
|
||||
}
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
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) {
|
||||
@@ -106,7 +118,7 @@ public class SetupService {
|
||||
if (properties.restartAfterComplete()) {
|
||||
Thread restart = new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(750L);
|
||||
Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
@@ -116,18 +128,14 @@ public class SetupService {
|
||||
restart.start();
|
||||
}
|
||||
return new SetupViews.Completed(true, request.adminUsername(),
|
||||
"安装完成,系统正在切换到正式模式", 10);
|
||||
"安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
|
||||
}
|
||||
|
||||
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
|
||||
try {
|
||||
ensureCompatibleSchema(settings);
|
||||
Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
|
||||
.locations("classpath:db/migration")
|
||||
.cleanDisabled(true)
|
||||
.load()
|
||||
.migrate();
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
flyway(settings).migrate();
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password());
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
@@ -137,12 +145,112 @@ public class SetupService {
|
||||
if (exception instanceof SetupException setupException) {
|
||||
throw setupException;
|
||||
}
|
||||
LOGGER.error("Database migration or administrator initialization failed", exception);
|
||||
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(),
|
||||
settings.password()));
|
||||
Integer tableCount = jdbc.queryForObject("""
|
||||
@@ -160,7 +268,42 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"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) {
|
||||
@@ -280,18 +423,148 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
|
||||
}
|
||||
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", "");
|
||||
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);
|
||||
}
|
||||
verifyMigrationPrivileges(connection, statement, settings.type());
|
||||
return new ConnectionResult(true, version);
|
||||
} 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,
|
||||
"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 DatabaseCollation(String characterSet, String collation) {
|
||||
}
|
||||
|
||||
private record FailedMigration(int installedRank, String version, String script) {
|
||||
}
|
||||
|
||||
private record InstallationState(String setupTokenSha256, long administratorUserId,
|
||||
String administratorUsername) {
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public final class SetupViews {
|
||||
public record Status(
|
||||
boolean required,
|
||||
boolean locked,
|
||||
boolean ready,
|
||||
List<String> supportedDatabaseTypes,
|
||||
String message
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
@@ -37,7 +38,9 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.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()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
@@ -48,6 +51,18 @@ public class SecurityConfig {
|
||||
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
|
||||
PasswordEncoder passwordEncoder() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+32
-6
@@ -15,6 +15,7 @@ import com.kaidi.finance.update.api.SystemUpdateView;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
@@ -52,6 +53,9 @@ public class SystemUpdateApplicationService {
|
||||
+ "(?:\\+[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 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(
|
||||
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
|
||||
"INSTALLING", "RUNNING");
|
||||
@@ -195,20 +199,42 @@ public class SystemUpdateApplicationService {
|
||||
URI api = releaseApiUri();
|
||||
try {
|
||||
JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release"));
|
||||
boolean assetPresent = false;
|
||||
for (JsonNode asset : release.path("assets")) {
|
||||
if (!assetName.equals(asset.path("name").asText())) continue;
|
||||
URI uri = validatedReleaseUri(asset.path("browser_download_url").asText(null), "Release 资源地址");
|
||||
if (!sameOrigin(api, uri)) {
|
||||
throw releaseUnavailable("Gitea Release 资源必须与 API 使用同一来源");
|
||||
if (assetName.equals(asset.path("name").asText())) {
|
||||
assetPresent = true;
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
String token = releaseToken();
|
||||
URI current = uri;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.springframework.boot.SpringApplication;
|
||||
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.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||
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
|
||||
@@ -28,6 +30,7 @@ import com.kaidi.finance.setup.SetupProperties;
|
||||
}
|
||||
)
|
||||
@EnableConfigurationProperties(SetupProperties.class)
|
||||
@Import(SpaForwardFilter.class)
|
||||
public class SetupApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:18080}
|
||||
address: ${SERVER_ADDRESS:127.0.0.1}
|
||||
shutdown: graceful
|
||||
servlet:
|
||||
session:
|
||||
@@ -12,6 +13,9 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: kaidi-finance
|
||||
web:
|
||||
resources:
|
||||
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
|
||||
profiles:
|
||||
default: local
|
||||
datasource:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.kaidi.finance.setup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kaidi.setup.SetupApplication;
|
||||
@@ -13,6 +14,7 @@ import java.sql.Statement;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -60,9 +62,12 @@ class SetupApplicationIntegrationTest {
|
||||
|
||||
@Test
|
||||
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
|
||||
seedRecoverableV068CollationFailure();
|
||||
|
||||
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
|
||||
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
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");
|
||||
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.getBody().path("data").path("successful").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);
|
||||
complete.put("adminUsername", "setup-admin");
|
||||
@@ -121,6 +159,34 @@ class SetupApplicationIntegrationTest {
|
||||
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("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) {
|
||||
|
||||
@@ -31,6 +31,7 @@ class SetupContextSmokeTest {
|
||||
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.restart-after-complete", () -> false);
|
||||
registry.add("spring.web.resources.static-locations", () -> "classpath:/spa-fixture/");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
@@ -49,6 +50,14 @@ class SetupContextSmokeTest {
|
||||
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
|
||||
.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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -47,18 +47,17 @@ class SystemUpdateApplicationServiceTest {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
|
||||
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 = ("""
|
||||
{"tag_name":"v1.0.0-preview.2","assets":[
|
||||
{"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.getResponseBody().write(body);
|
||||
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"));
|
||||
byte[] body = """
|
||||
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
|
||||
@@ -98,7 +97,7 @@ class SystemUpdateApplicationServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception {
|
||||
void ignoresCrossOriginBrowserAssetUrlWithoutSendingTokenThere() throws Exception {
|
||||
AtomicInteger assetRequests = new AtomicInteger();
|
||||
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
assetServer.createContext("/release-manifest.json", exchange -> {
|
||||
@@ -112,12 +111,25 @@ class SystemUpdateApplicationServiceTest {
|
||||
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
|
||||
+ "/release-manifest.json";
|
||||
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);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
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();
|
||||
apiServer.start();
|
||||
try {
|
||||
@@ -125,7 +137,7 @@ class SystemUpdateApplicationServiceTest {
|
||||
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
|
||||
tempDir.resolve("cross-origin-status.json"));
|
||||
|
||||
assertThrows(BusinessException.class, service::check);
|
||||
assertEquals("1.0.0-preview.2", service.check().latestVersion());
|
||||
assertEquals(0, assetRequests.get());
|
||||
} finally {
|
||||
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>
|
||||
Executable
+55
@@ -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"
|
||||
@@ -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,5 +1,6 @@
|
||||
SPRING_PROFILES_ACTIVE=production
|
||||
SERVER_PORT=18080
|
||||
SERVER_ADDRESS=127.0.0.1
|
||||
SESSION_COOKIE_SECURE=false
|
||||
|
||||
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
|
||||
|
||||
@@ -5,7 +5,7 @@ umask 077
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
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}
|
||||
PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
|
||||
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
||||
@@ -44,6 +44,8 @@ fi
|
||||
|
||||
KAIDI_REINSTALL=${KAIDI_REINSTALL:-}
|
||||
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_USERNAME=${KAIDI_DB_USERNAME:-}
|
||||
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_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_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do
|
||||
value=${!name}
|
||||
|
||||
+354
-93
@@ -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_TOKEN=${KAIDI_RELEASE_TOKEN:-}
|
||||
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}
|
||||
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=
|
||||
RELEASE_AUTH_HEADER_FILE=
|
||||
INSTALL_TRANSACTION_ARMED=false
|
||||
@@ -24,6 +34,9 @@ BACKUP_DIR=
|
||||
JAVA_STAGED_DIR=
|
||||
JAVA_PREVIOUS_DIR=
|
||||
JAVA_ACTIVATED=false
|
||||
SERVICE_USER=kaidi
|
||||
SERVICE_GROUP=kaidi
|
||||
HOST_ARCH=
|
||||
|
||||
log() { printf '[kaidi-install] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -32,14 +45,42 @@ sha256_file() {
|
||||
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() {
|
||||
local host_bits expected_bits
|
||||
[ "$(uname -s)" = Linux ] || die "The installer only supports Linux"
|
||||
[ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/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 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 /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}$ ]] \
|
||||
|| die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256"
|
||||
case "$REINSTALL" in
|
||||
@@ -58,40 +99,47 @@ preflight_host() {
|
||||
[ ! -e "$STATE_ROOT/setup/locked" ] \
|
||||
|| die "The setup wizard is already locked; use a normal repair reinstall"
|
||||
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"
|
||||
[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE"
|
||||
if [ -n "$RELEASE_TOKEN_FILE" ]; then
|
||||
[ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE must be a regular file"
|
||||
[ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE is too large"
|
||||
RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE")
|
||||
fi
|
||||
[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \
|
||||
|| die "Kaidi Finance is already installed; use the system update page"
|
||||
if [ -z "$RELEASE_API_URL" ]; then
|
||||
case "$RELEASE_BASE_URL" in
|
||||
*OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|
||||
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|
||||
|| die "KAIDI_RELEASE_TOKEN is invalid"
|
||||
validate_inputs() {
|
||||
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|
||||
[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE"
|
||||
if [ -n "$RELEASE_TOKEN_FILE" ]; then
|
||||
[ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE must be a regular file"
|
||||
[ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE is too large"
|
||||
RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE")
|
||||
fi
|
||||
[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \
|
||||
|| die "Kaidi Finance is already installed; use the system update page"
|
||||
if [ -z "$RELEASE_API_URL" ]; then
|
||||
case "$RELEASE_BASE_URL" in
|
||||
*OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|
||||
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|
||||
|| die "KAIDI_RELEASE_TOKEN is invalid"
|
||||
}
|
||||
|
||||
download() {
|
||||
local url=$1 output=$2
|
||||
case "$url" in
|
||||
https://*)
|
||||
curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
||||
--tlsv1.2 "$url" -o "$output"
|
||||
if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
||||
--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"
|
||||
curl --fail --silent --show-error --location --proto '=http,https' \
|
||||
--proto-redir '=http,https' "$url" -o "$output"
|
||||
if ! curl --fail --silent --show-error --location --proto '=http,https' \
|
||||
--proto-redir '=http,https' --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
|
||||
die "Download failed: $url"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -122,17 +170,20 @@ download_release_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
|
||||
asset_url=$(jq -er --arg name "$asset_name" \
|
||||
'.assets[] | select(.name == $name) | .browser_download_url
|
||||
| strings | select(startswith("https://") or startswith("http://"))' \
|
||||
"$WORK_DIR/release-api.json" | head -n 1) || die "Release asset $asset_name is missing"
|
||||
jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
|
||||
"$WORK_DIR/release-api.json" >/dev/null || die "Release asset $asset_name is missing"
|
||||
release_tag=$(jq -er '.tag_name | strings
|
||||
| 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#')
|
||||
case "$asset_url" in
|
||||
"$api_origin"/*) printf '%s\n' "$asset_url" ;;
|
||||
*) die "Release asset $asset_name must use the Gitea API host" ;;
|
||||
esac
|
||||
repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
|
||||
's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
|
||||
[ -n "$repo_path" ] || die "KAIDI_RELEASE_API_URL must be a Gitea repository Release API endpoint"
|
||||
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
|
||||
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
||||
fi
|
||||
@@ -148,28 +199,77 @@ install_packages() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
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
|
||||
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
|
||||
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
|
||||
die "Supported package managers are apt, dnf, and yum"
|
||||
fi
|
||||
}
|
||||
|
||||
azul_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf x86 ;;
|
||||
i386|i486|i586|i686) printf i686 ;;
|
||||
aarch64|arm64) printf arm_64 ;;
|
||||
armv7l|armv6l) printf arm ;;
|
||||
*) die "Unsupported CPU architecture: $(uname -m)" ;;
|
||||
case "${HOST_ARCH:-$(normalized_host_arch)}" in
|
||||
x86_64) printf x86 ;;
|
||||
x86) printf i686 ;;
|
||||
aarch64) printf arm_64 ;;
|
||||
arm) printf arm ;;
|
||||
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() {
|
||||
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"
|
||||
java_metadata="$WORK_DIR/zulu-metadata.json"
|
||||
download "$api" "$java_metadata"
|
||||
@@ -188,18 +288,88 @@ prepare_java() {
|
||||
[ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed"
|
||||
mkdir -p "$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() {
|
||||
mkdir -p "$APP_ROOT/runtime"
|
||||
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"
|
||||
fi
|
||||
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
|
||||
"$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; }
|
||||
@@ -264,6 +434,72 @@ read_reinstall_env() {
|
||||
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() {
|
||||
[ "${#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-]+)*)?$' \
|
||||
@@ -279,7 +515,7 @@ configure_database() {
|
||||
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
|
||||
DB_USERNAME=setup_pending
|
||||
DB_PASSWORD=setup_pending
|
||||
return
|
||||
return 0
|
||||
fi
|
||||
if [ "$REINSTALL" = true ]; then
|
||||
[ -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
|
||||
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|
||||
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
|
||||
return
|
||||
return 0
|
||||
fi
|
||||
[ "$REINSTALL" != true ] \
|
||||
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
|
||||
@@ -327,7 +563,7 @@ database_name() {
|
||||
|
||||
preflight_database() {
|
||||
local client host port name version table
|
||||
[ "$SETUP_WIZARD" != true ] || return
|
||||
[ "$SETUP_WIZARD" != true ] || return 0
|
||||
case "$DB_URL" in
|
||||
jdbc:mysql://*) ;;
|
||||
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
|
||||
@@ -429,12 +665,25 @@ write_env_file_preserving_unknown() {
|
||||
}
|
||||
|
||||
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
|
||||
if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then
|
||||
return
|
||||
if curl --fail --silent --connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null \
|
||||
| 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
|
||||
attempts=$((attempts + 1))
|
||||
if [ $((attempts % 5)) -eq 0 ]; then
|
||||
log "Application is still starting (state=$active_state, elapsed=$((attempts * 2))s)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
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-update.service
|
||||
/etc/systemd/system/kaidi-update.path
|
||||
/etc/nginx/conf.d/kaidi-finance.conf
|
||||
/etc/nginx/sites-enabled/default
|
||||
)
|
||||
BACKUP_DIR="$WORK_DIR/rollback"
|
||||
mkdir -p "$BACKUP_DIR/files"
|
||||
@@ -466,17 +713,25 @@ backup_managed_state() {
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
systemctl is-active --quiet kaidi-finance.service && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-update.path && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-update.path && PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false
|
||||
systemctl is-active --quiet nginx && PREVIOUS_NGINX_ACTIVE=true || PREVIOUS_NGINX_ACTIVE=false
|
||||
systemctl is-enabled --quiet nginx && PREVIOUS_NGINX_ENABLED=true || PREVIOUS_NGINX_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-finance.service >/dev/null 2>&1 \
|
||||
&& PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-finance.service >/dev/null 2>&1 \
|
||||
&& PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-update.path >/dev/null 2>&1 \
|
||||
&& 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
|
||||
}
|
||||
|
||||
restore_unit_state() {
|
||||
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
|
||||
systemctl enable "$unit" >/dev/null 2>&1
|
||||
else
|
||||
@@ -509,7 +764,7 @@ rollback_install() {
|
||||
local index=0 path rollback_failed=false
|
||||
set +e
|
||||
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
|
||||
restore_managed_path "$path" "$index" || rollback_failed=true
|
||||
index=$((index + 1))
|
||||
@@ -517,7 +772,7 @@ rollback_install() {
|
||||
if [ -n "$RELEASE_DIR" ]; then
|
||||
rm -rf -- "$RELEASE_DIR" || rollback_failed=true
|
||||
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
|
||||
mkdir -p "$APP_ROOT/runtime" || rollback_failed=true
|
||||
mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true
|
||||
@@ -529,10 +784,6 @@ rollback_install() {
|
||||
|| rollback_failed=true
|
||||
restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \
|
||||
|| 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
|
||||
[ "$rollback_failed" = false ]
|
||||
}
|
||||
@@ -542,14 +793,17 @@ cleanup() {
|
||||
trap - EXIT
|
||||
if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \
|
||||
&& [ "$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
|
||||
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
|
||||
exit "$result"
|
||||
}
|
||||
|
||||
main() {
|
||||
trap cleanup EXIT
|
||||
|
||||
validate_inputs
|
||||
configure_app_port
|
||||
preflight_host
|
||||
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"
|
||||
[ "$(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/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
|
||||
preflight_database
|
||||
|
||||
id kaidi >/dev/null 2>&1 || useradd --system --home "$STATE_ROOT" --shell /usr/sbin/nologin kaidi
|
||||
mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \
|
||||
"$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"
|
||||
ensure_service_identity
|
||||
prepare_managed_layout
|
||||
prepare_java
|
||||
|
||||
RELEASE_DIR="$APP_ROOT/releases/$VERSION"
|
||||
[ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
backup_managed_state
|
||||
activate_java
|
||||
mv "$WORK_DIR/extracted" "$RELEASE_DIR"
|
||||
chown -R root:root "$RELEASE_DIR"
|
||||
chmod -R go-w "$RELEASE_DIR"
|
||||
secure_release_tree "$RELEASE_DIR"
|
||||
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next"
|
||||
mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"
|
||||
install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh"
|
||||
@@ -655,7 +904,8 @@ if [ "$SETUP_WIZARD" = true ]; then
|
||||
fi
|
||||
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
|
||||
SPRING_PROFILES_ACTIVE production \
|
||||
SERVER_PORT 18080 \
|
||||
SERVER_PORT "$APP_PORT" \
|
||||
SERVER_ADDRESS "$SERVER_ADDRESS" \
|
||||
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
|
||||
DB_URL "$DB_URL" \
|
||||
DB_USERNAME "$DB_USERNAME" \
|
||||
@@ -692,6 +942,7 @@ write_env_file "$CONFIG_ROOT/update.env" \
|
||||
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
|
||||
KAIDI_SERVICE_NAME kaidi-finance.service \
|
||||
KAIDI_HEALTH_URL "$HEALTH_URL" \
|
||||
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
|
||||
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
|
||||
KAIDI_DB_HOST "$(database_host)" \
|
||||
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-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-finance.conf" /etc/nginx/conf.d/kaidi-finance.conf
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
restore_security_contexts
|
||||
verify_service_access
|
||||
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 reset-failed kaidi-finance.service >/dev/null 2>&1 || true
|
||||
systemctl enable kaidi-finance.service
|
||||
systemctl restart kaidi-finance.service
|
||||
wait_for_health
|
||||
systemctl enable --now nginx
|
||||
systemctl reload nginx
|
||||
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' >/dev/null \
|
||||
|| die "Nginx proxy health check failed"
|
||||
curl -fsS "$APP_INDEX_URL" | grep -Eiq '<!doctype|<html' \
|
||||
|| die "Application frontend entry point is unavailable"
|
||||
|
||||
if [ "$SETUP_WIZARD" != true ]; then
|
||||
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
|
||||
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
|
||||
Version: $VERSION
|
||||
EOF
|
||||
chmod 0600 /root/kaidi-first-login.txt
|
||||
elif [ "$REINSTALL" != true ]; then
|
||||
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
|
||||
Temporary password: $ADMIN_PASSWORD
|
||||
Version: $VERSION
|
||||
@@ -746,12 +1002,17 @@ INSTALL_TRANSACTION_ARMED=false
|
||||
|
||||
log "Kaidi Finance $VERSION is installed"
|
||||
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"
|
||||
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
|
||||
if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
|
||||
log "Temporary credentials: /root/kaidi-first-login.txt"
|
||||
log "Change the temporary password immediately after first sign-in"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -2,12 +2,11 @@ server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /opt/kaidi/current/public;
|
||||
index index.html;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -17,24 +16,4 @@ server {
|
||||
proxy_connect_timeout 10s;
|
||||
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,6 +2,8 @@
|
||||
Description=Kaidi Finance System
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -4,8 +4,6 @@ Description=Watch for Kaidi Finance update requests
|
||||
[Path]
|
||||
PathChanged=/var/lib/kaidi-update/inbox
|
||||
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
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -12,13 +12,12 @@ Group=root
|
||||
EnvironmentFile=/etc/kaidi/update.env
|
||||
EnvironmentFile=-/var/lib/kaidi/setup/application.env
|
||||
ExecStart=/opt/kaidi/bin/update.sh
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
Restart=no
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=6
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
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
|
||||
|
||||
+124
-56
@@ -18,14 +18,15 @@ RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
|
||||
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
|
||||
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
|
||||
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
|
||||
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}
|
||||
PUBLIC_HEALTH_URL=${KAIDI_PUBLIC_HEALTH_URL:-http://127.0.0.1/actuator/health}
|
||||
PUBLIC_INDEX_URL=${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}
|
||||
APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
|
||||
LOCK_FILE=$STATE_ROOT/update.lock
|
||||
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
|
||||
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
|
||||
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}
|
||||
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
|
||||
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
|
||||
@@ -40,9 +41,10 @@ case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
|
||||
esac
|
||||
[ "$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" \
|
||||
"$STATE_ROOT/work" "$APP_ROOT/releases" "$LOG_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT"
|
||||
chmod 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT"
|
||||
bootstrap_die() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
status() {
|
||||
state=$1
|
||||
@@ -79,6 +81,43 @@ fail() {
|
||||
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.
|
||||
cleanup() {
|
||||
rc=$?
|
||||
@@ -126,15 +165,18 @@ download() {
|
||||
release_asset_url() {
|
||||
asset_name=$1
|
||||
if [ -n "$RELEASE_API_URL" ]; then
|
||||
asset_url=$(jq -er --arg name "$asset_name" \
|
||||
'.assets[] | select(.name == $name) | .browser_download_url
|
||||
| strings | select(startswith("https://") or startswith("http://"))' \
|
||||
"$WORK_DIR/release-api.json" | head -n 1) || return 1
|
||||
jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
|
||||
"$WORK_DIR/release-api.json" >/dev/null || return 1
|
||||
release_tag=$(jq -er '.tag_name | strings
|
||||
| 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#')
|
||||
case "$asset_url" in
|
||||
"$api_origin"/*) printf '%s\n' "$asset_url" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
|
||||
's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
|
||||
[ -n "$repo_path" ] || return 1
|
||||
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
|
||||
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
||||
fi
|
||||
@@ -161,10 +203,10 @@ wait_for_health() {
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_public_surface() {
|
||||
wait_for_health "$PUBLIC_HEALTH_URL" || return 1
|
||||
verify_app_surface() {
|
||||
wait_for_health "$HEALTH_URL" || return 1
|
||||
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
|
||||
rm -f "$index_file"
|
||||
return 0
|
||||
@@ -231,6 +273,22 @@ atomic_install() {
|
||||
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_source=$1
|
||||
backup_name=$2
|
||||
@@ -266,21 +324,20 @@ backup_operations() {
|
||||
backup_managed_file "$UPDATER_PATH" update.sh \
|
||||
&& 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.path" kaidi-update.path \
|
||||
&& backup_managed_file "$NGINX_CONFIG" kaidi-finance.conf
|
||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
|
||||
}
|
||||
|
||||
apply_operations() {
|
||||
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
|
||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.conf" "$NGINX_CONFIG" 0644 \
|
||||
&& systemctl daemon-reload \
|
||||
&& nginx -t
|
||||
SYSTEMD_OPERATIONS_CHANGED=false
|
||||
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
|
||||
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|
||||
|| return 1
|
||||
[ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
|
||||
}
|
||||
|
||||
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-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 "$NGINX_CONFIG" kaidi-finance.conf || 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 ]
|
||||
}
|
||||
|
||||
@@ -325,27 +376,34 @@ rollback_active_transaction() {
|
||||
previous_target=$(transaction_value previous-target)
|
||||
failed_release=$(transaction_value release-dir)
|
||||
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 ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|
||||
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
|
||||
rollback_ok=false
|
||||
link_restored=false
|
||||
fi
|
||||
else
|
||||
rollback_ok=false
|
||||
link_restored=false
|
||||
fi
|
||||
restore_operations || rollback_ok=false
|
||||
systemctl start "$SERVICE_NAME" || rollback_ok=false
|
||||
if [ "$rollback_ok" = true ] && wait_for_health "$HEALTH_URL" && verify_public_surface; then
|
||||
restore_operations || operations_restored=false
|
||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
|
||||
if [ "$link_restored" = true ] && verify_app_surface; then
|
||||
remove_failed_release "$failed_release"
|
||||
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
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}"
|
||||
printf '%s\n' "$reason; rollback is incomplete and will be retried" >&2
|
||||
status FAILED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -371,6 +429,7 @@ recover_interrupted_transaction() {
|
||||
|
||||
validate_release_operations() {
|
||||
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
|
||||
systemd-analyze verify \
|
||||
"$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-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-finance.conf" ] || fail "Release Nginx configuration is missing"
|
||||
validate_release_operations || fail "Release operations validation failed"
|
||||
|
||||
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"
|
||||
fi
|
||||
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"
|
||||
chmod -R go-w "$RELEASE_DIR" || fail "Release permissions could not be secured"
|
||||
secure_release_tree "$RELEASE_DIR" || fail "Release ownership or 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)
|
||||
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 APP_SWITCHING
|
||||
systemctl stop "$SERVICE_NAME" || rollback_active_transaction "Application service could not be stopped"
|
||||
if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
|
||||
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
|
||||
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"
|
||||
write_transaction_value phase HEALTH_CHECKING
|
||||
if systemctl start "$SERVICE_NAME" \
|
||||
&& wait_for_health "$HEALTH_URL" \
|
||||
&& systemctl reload nginx \
|
||||
&& systemctl is-active --quiet kaidi-update.path \
|
||||
&& verify_public_surface; then
|
||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
|
||||
if verify_app_surface; then
|
||||
success_message="Release $TARGET_VERSION is running"
|
||||
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
|
||||
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
|
||||
status SUCCEEDED "$success_message" "$TARGET_VERSION"
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
complete_request
|
||||
rm -rf "$ACTIVE_TRANSACTION"
|
||||
exit 0
|
||||
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)"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
data: {
|
||||
required: !completed,
|
||||
locked: completed,
|
||||
ready: completed,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: completed ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
@@ -30,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
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-setup-code');
|
||||
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.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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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: '管理员确认立即重启' }]);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await installFixture(page, [], [], false);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"site:preview": "npm run build && cp -r dist _site",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:dist": "playwright test --config playwright.dist.config.ts",
|
||||
"test:a11y": "playwright test e2e/accessibility.e2e.ts",
|
||||
"audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org",
|
||||
"check:production": "node scripts/check-production-hygiene.mjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.e2e.ts',
|
||||
testIgnore: 'dist-smoke.e2e.ts',
|
||||
outputDir: 'test-results',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
|
||||
@@ -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 } } }],
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
const SETUP_COMPLETION_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
locked: boolean;
|
||||
ready: boolean;
|
||||
supportedDatabaseTypes: string[];
|
||||
message: string;
|
||||
}
|
||||
@@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus {
|
||||
return {
|
||||
required: Boolean(value?.required),
|
||||
locked: Boolean(value?.locked),
|
||||
ready: Boolean(value?.ready),
|
||||
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
|
||||
message: value?.message || '',
|
||||
};
|
||||
@@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="layoutCls">
|
||||
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
|
||||
<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>
|
||||
<div v-else class="header-operate-left">
|
||||
|
||||
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
|
||||
import { prefix } from '@/config/global';
|
||||
import { t } from '@/locales';
|
||||
import { getActive } from '@/router';
|
||||
import { useSettingStore } from '@/store';
|
||||
import { useSettingStore, useUserStore } from '@/store';
|
||||
import type { MenuRoute, ModeType } from '@/types/interface';
|
||||
|
||||
import pgk from '../../../package.json';
|
||||
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
|
||||
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const autoCollapsed = () => {
|
||||
const isCompact = window.innerWidth <= MIN_POINT;
|
||||
@@ -175,7 +176,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/role-select');
|
||||
router.push(userStore.workbenchRoute);
|
||||
};
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
|
||||
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
|
||||
try {
|
||||
await userStore.selectRole(roleCode);
|
||||
permissionStore.initRoutes();
|
||||
const target = userStore.roles.find((role) => role.code === roleCode)?.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);
|
||||
await router.replace(userStore.workbenchRoute);
|
||||
} catch (error) {
|
||||
MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
|
||||
} finally {
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
</t-form-item>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
|
||||
{{ completionProgressMessage }}
|
||||
</t-alert>
|
||||
|
||||
<div class="setup-actions">
|
||||
<t-button variant="outline" :disabled="completing" @click="currentStep = 0">
|
||||
<template #icon><t-icon name="chevron-left" /></template>
|
||||
@@ -223,8 +227,13 @@ const errorMessage = ref('');
|
||||
const connectionResult = ref<SetupConnection | null>(null);
|
||||
const verifiedFingerprint = ref('');
|
||||
const completionMessage = ref('系统正在重启,请稍候。');
|
||||
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
|
||||
const countdown = ref(10);
|
||||
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>({
|
||||
setupCode: '',
|
||||
@@ -317,21 +326,51 @@ async function finishSetup(context?: SubmitContext) {
|
||||
return;
|
||||
}
|
||||
completing.value = true;
|
||||
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await completeSetup({ ...databaseForm, ...adminForm });
|
||||
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
|
||||
completionMessage.value = result.message;
|
||||
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
showCompleted(result.message, result.restartAfterSeconds);
|
||||
} 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 {
|
||||
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() {
|
||||
void finishSetup();
|
||||
}
|
||||
@@ -340,13 +379,18 @@ function startCountdown() {
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) openLogin();
|
||||
if (countdown.value <= 0) void openLogin();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLogin() {
|
||||
async function openLogin() {
|
||||
window.clearInterval(countdownTimer);
|
||||
window.location.assign('/login');
|
||||
completionMessage.value = '系统正在启动,请稍候。';
|
||||
if (await waitForSetupCompletion()) {
|
||||
window.location.assign('/login');
|
||||
return;
|
||||
}
|
||||
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -364,7 +408,10 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true;
|
||||
window.clearInterval(countdownTimer);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.setup-page {
|
||||
@@ -382,8 +429,7 @@ onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
}
|
||||
|
||||
.setup-panel {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
place-self: center;
|
||||
width: min(760px, 100%);
|
||||
min-width: 0;
|
||||
padding: 32px;
|
||||
@@ -445,6 +491,10 @@ h1 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.setup-progress {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.setup-complete {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
|
||||
import { usePermissionStore } from '@/store';
|
||||
|
||||
import { hasPermissionAccess } from './menu-access';
|
||||
import { resolveWorkbenchRoute } from './workbench-route';
|
||||
|
||||
const emptyUser: UserView = {
|
||||
publicId: '',
|
||||
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
|
||||
getters: {
|
||||
displayName: (state) => state.user.displayName || state.user.username,
|
||||
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),
|
||||
hasPermission: (state) => (permission: string) =>
|
||||
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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
|
||||
name: 'tdesign-vendor',
|
||||
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
|
||||
priority: 30,
|
||||
maxSize: 480 * 1024,
|
||||
},
|
||||
{
|
||||
name: 'vue-vendor',
|
||||
|
||||
@@ -91,14 +91,17 @@ cp "$JAR" "$STAGE/app.jar"
|
||||
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
||||
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
||||
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/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.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"
|
||||
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")
|
||||
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
|
||||
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
|
||||
|
||||
@@ -48,6 +48,7 @@ request() {
|
||||
local method=$1 url=$2 output=$3 input=${4:-} status
|
||||
local args=(
|
||||
--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"
|
||||
--output "$output" --write-out '%{http_code}'
|
||||
)
|
||||
@@ -99,6 +100,7 @@ for name in "${ASSETS[@]}"; do
|
||||
>> "$WORK/expected-assets.jsonl"
|
||||
encoded_name=$(jq -rn --arg value "$name" '$value | @uri')
|
||||
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" \
|
||||
--form "attachment=@$path;type=application/octet-stream" \
|
||||
--output "$WORK/upload.json" --write-out '%{http_code}' \
|
||||
|
||||
Executable
+67
@@ -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'
|
||||
@@ -23,6 +23,10 @@ set -Eeuo pipefail
|
||||
|
||||
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
|
||||
[ "$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
|
||||
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
|
||||
else
|
||||
@@ -44,9 +48,12 @@ chmod 0600 "$FIXTURE/token"
|
||||
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
||||
|
||||
PATH="$FIXTURE/bin:$PATH" \
|
||||
EXPECT_PORT=true \
|
||||
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
||||
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
||||
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_USERNAME=kaidi \
|
||||
KAIDI_DB_PASSWORD=fixture-password \
|
||||
|
||||
+169
-12
@@ -10,6 +10,10 @@ fail() {
|
||||
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.
|
||||
{
|
||||
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_setup_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 '/^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 '/^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 '/^prepare_java()/,/^}/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 '/^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"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
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.
|
||||
REINSTALL=true
|
||||
# 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) \
|
||||
|| 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
|
||||
is_semver "$version" || fail "installer 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
|
||||
[ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime"
|
||||
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"
|
||||
cat > "$WORK/fake-jre/bin/java" <<'JAVA'
|
||||
#!/usr/bin/env sh
|
||||
if [ "${1:-}" = '-XshowSettings:properties' ]; then
|
||||
printf ' os.arch = x86\n' >&2
|
||||
fi
|
||||
printf 'openjdk version "17-fixture"\n' >&2
|
||||
JAVA
|
||||
chmod 0755 "$WORK/fake-jre/bin/java"
|
||||
@@ -128,8 +213,16 @@ download() {
|
||||
esac
|
||||
}
|
||||
ARCH_FIXTURE=i686
|
||||
export KAIDI_JAVA_HOME="$WORK/missing-java-home"
|
||||
prepare_java >/dev/null 2>&1
|
||||
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
|
||||
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
|
||||
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"
|
||||
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
|
||||
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"}
|
||||
]}
|
||||
JSON
|
||||
@@ -160,12 +253,13 @@ curl() {
|
||||
done
|
||||
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
|
||||
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"
|
||||
}
|
||||
[ "$(release_asset_url release-manifest.json)" = \
|
||||
https://gitea.fixture.invalid/assets/release-manifest.json ] \
|
||||
|| fail 'installer did not resolve the private Gitea release asset'
|
||||
https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|
||||
|| fail 'installer did not construct the trusted Gitea release asset URL'
|
||||
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
|
||||
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|
||||
|| 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"' \
|
||||
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
|
||||
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
|
||||
if release_asset_url release-manifest.json >/dev/null 2>&1; then
|
||||
fail 'installer accepted a cross-origin Gitea release asset'
|
||||
fi
|
||||
[ "$(release_asset_url release-manifest.json)" = \
|
||||
https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|
||||
|| fail 'installer trusted the cross-origin browser download URL'
|
||||
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
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.
|
||||
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer obtains the Java runtime SHA-256'
|
||||
grep -Fq 'Copying existing Java 17 runtime' "$ROOT/deploy/install.sh" \
|
||||
|| 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.
|
||||
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|
||||
|| 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" \
|
||||
|| fail 'installer does not pin the default release public-key fingerprint'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer does not enable the first-run setup wizard by default'
|
||||
grep -Fq 'if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then' "$ROOT/deploy/install.sh" \
|
||||
|| 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" \
|
||||
|| fail 'installer must not create a MySQL container'
|
||||
! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \
|
||||
|| 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" \
|
||||
|| fail 'installer does not require an operator-managed external MySQL database'
|
||||
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'
|
||||
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|
||||
|| 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' \
|
||||
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||
|| fail 'application service no longer loads the setup-completion environment'
|
||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||
"$ROOT/deploy/systemd/kaidi-update.service" \
|
||||
|| fail 'update service no longer loads setup database overrides'
|
||||
# shellcheck disable=SC2016 # Match the literal installer source.
|
||||
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'update state parent is not group-accessible to the application user'
|
||||
grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \
|
||||
|| 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
@@ -11,6 +11,10 @@ fail() {
|
||||
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"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
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"
|
||||
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() {
|
||||
local mock_bin=$1
|
||||
mkdir -p "$mock_bin"
|
||||
@@ -55,7 +73,7 @@ esac
|
||||
if [ -n "$output" ]; then
|
||||
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
|
||||
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
|
||||
elif [ "$url" = "${KAIDI_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"
|
||||
else
|
||||
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
|
||||
@@ -74,12 +92,7 @@ SH
|
||||
cat > "$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/nginx" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_NGINX_LOG"
|
||||
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
|
||||
exit 0
|
||||
SH
|
||||
|
||||
@@ -105,6 +118,33 @@ SH
|
||||
cat > "$mock_bin/chown" <<'SH'
|
||||
#!/bin/sh
|
||||
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
|
||||
|
||||
cat > "$mock_bin/mv" <<'SH'
|
||||
@@ -131,15 +171,18 @@ SH
|
||||
build_release() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local unit_prefix=${3:-new}
|
||||
local stage="$fixture/stage"
|
||||
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
|
||||
printf 'new application\n' > "$stage/app.jar"
|
||||
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
|
||||
printf '%s\n' "$version" > "$stage/VERSION"
|
||||
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"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do
|
||||
printf 'new %s\n' "$name" > "$stage/ops/$name"
|
||||
chmod 0755 "$stage/ops/baota-start.sh"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
|
||||
done
|
||||
|
||||
local artifact="kaidi-finance-$version.tar.gz"
|
||||
@@ -160,10 +203,12 @@ build_release() {
|
||||
build_gitea_release_index() {
|
||||
local fixture=$1
|
||||
local origin=${2:-https://gitea.fixture.invalid}
|
||||
local artifact
|
||||
local artifact version tag
|
||||
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
|
||||
jq -n --arg origin "$origin" --arg artifact "$artifact" \
|
||||
'{assets:[
|
||||
version=$(jq -er '.version' "$fixture/release/release-manifest.json")
|
||||
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.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
|
||||
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
|
||||
@@ -174,17 +219,21 @@ prepare_installation() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
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 '<!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"
|
||||
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
|
||||
printf 'old update.sh\n' > "$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
|
||||
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
|
||||
done
|
||||
printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf"
|
||||
write_request "$fixture" "$version" DOWNLOAD
|
||||
}
|
||||
|
||||
@@ -227,12 +276,13 @@ run_update() {
|
||||
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
||||
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||
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_UPDATE_STATE_ROOT="$fixture/state" \
|
||||
KAIDI_LOG_ROOT="$fixture/log" \
|
||||
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
||||
KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \
|
||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||
@@ -243,9 +293,8 @@ run_update() {
|
||||
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
|
||||
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
|
||||
UPDATE_STATUS_FILE="$fixture/state/status.json" \
|
||||
KAIDI_HEALTH_URL=http://127.0.0.1:18080/actuator/health \
|
||||
KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \
|
||||
KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \
|
||||
KAIDI_HEALTH_URL=http://127.0.0.1:19090/actuator/health \
|
||||
KAIDI_APP_INDEX_URL=http://127.0.0.1:19090/ \
|
||||
"$ROOT/deploy/update.sh"
|
||||
}
|
||||
|
||||
@@ -277,7 +326,7 @@ assert_private_gitea_release_case() {
|
||||
|| 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 version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
@@ -286,18 +335,16 @@ assert_cross_origin_gitea_asset_rejected() {
|
||||
build_gitea_release_index "$fixture" https://assets.fixture.invalid
|
||||
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_ORIGIN=https://gitea.fixture.invalid \
|
||||
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
|
||||
MOCK_CURL_LOG="$fixture/curl.log" \
|
||||
run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'cross-origin Gitea asset unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Release manifest asset is missing' "$fixture/update.log" \
|
||||
|| fail 'cross-origin Gitea asset rejection was not reported'
|
||||
run_update "$fixture" success
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|
||||
|| fail 'misconfigured browser download URL prevented trusted same-origin download'
|
||||
! 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() {
|
||||
@@ -319,7 +366,50 @@ assert_success_case() {
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'success case left a claimed request behind'
|
||||
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() {
|
||||
@@ -342,6 +432,10 @@ assert_rollback_case() {
|
||||
|| fail 'rollback case did not restore the previous updater'
|
||||
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|
||||
|| 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 ] \
|
||||
|| fail 'rollback case did not persist FAILED'
|
||||
[ ! -e "$fixture/app/releases/$version" ] \
|
||||
@@ -350,6 +444,28 @@ assert_rollback_case() {
|
||||
|| 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() {
|
||||
local fixture="$WORK/download-failure"
|
||||
local version='1.0.0-preview.2'
|
||||
@@ -434,11 +550,14 @@ assert_install_without_verified_cache_rejected() {
|
||||
}
|
||||
|
||||
assert_success_case
|
||||
assert_identical_systemd_operations_case
|
||||
assert_update_path_failure_keeps_application_case
|
||||
assert_rollback_case
|
||||
assert_incomplete_rollback_requires_manual_recovery_case
|
||||
assert_download_failure_case
|
||||
assert_database_failure_case
|
||||
assert_symlink_request_rejected
|
||||
assert_install_without_verified_cache_rejected
|
||||
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'
|
||||
|
||||
@@ -160,10 +160,15 @@ compare_archive_file() {
|
||||
}
|
||||
|
||||
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.service deploy/systemd/kaidi-update.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
|
||||
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" \
|
||||
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user