Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d63d60df5 | ||
|
|
74585a5f07 | ||
|
|
b19716aef9 | ||
|
|
f9912ade7e | ||
|
|
7394c9e7e4 | ||
|
|
aed561c702 |
@@ -43,90 +43,94 @@ npm run build
|
|||||||
|
|
||||||
## R1 Preview 一键安装
|
## R1 Preview 一键安装
|
||||||
|
|
||||||
Release 发布后,在 Linux 服务器执行下面一组命令即可安装。代码仓库和更新源固定为私有 Gitea
|
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取
|
||||||
`https://git.awaioi.com/ERP-Team/kaidi`;服务器读取
|
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。
|
||||||
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,不在生产机执行 `git pull` 或现场编译。
|
安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。
|
||||||
先从受信任的 CI 输出或内部发布记录取得 `RELEASE_TAG`、`INSTALLER_SHA256` 和 `PUBLIC_KEY_SHA256`。
|
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
|
||||||
后两项也会写入 Release 的 `bootstrap-checksums.txt`,但首次安装必须通过独立渠道核对,不能把同源下载值
|
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
|
||||||
直接当作信任根。私有仓库 Token 只授予仓库/Release 读取权限,不得复用 CI 的发布写 Token。
|
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
|
||||||
|
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 发布范围。
|
||||||
|
|
||||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、
|
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
|
||||||
Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
|
||||||
`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且
|
外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
||||||
80 端口可用;安装器会接管默认 HTTP 站点。
|
`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
|
||||||
|
|
||||||
### 直接 curl 安装
|
### 直接 curl 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
(
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh | sudo bash
|
||||||
set -Eeuo pipefail
|
```
|
||||||
RELEASE_TAG=v1.0.0-preview.8
|
|
||||||
INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7
|
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
|
||||||
PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
|
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
|
||||||
REINSTALL=false
|
|
||||||
SETUP_WIZARD=true
|
```bash
|
||||||
read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh \
|
||||||
token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
|
| sudo env KAIDI_APP_PORT=19090 bash
|
||||||
trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
|
```
|
||||||
printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
|
|
||||||
printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
|
端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java
|
||||||
unset GITEA_READ_TOKEN
|
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
|
||||||
chmod 0600 "$token_file" "$header_file" "$installer"
|
|
||||||
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
|
```bash
|
||||||
--header "@$header_file" \
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh -o /tmp/kaidi-install.sh
|
||||||
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" \
|
printf '%s %s\n' 824ec6eb5b8bad97485d5c8beb3af93352fd12b1ee631b15a5b9bee38fcb5e67 /tmp/kaidi-install.sh | sha256sum -c -
|
||||||
-o "$installer"
|
sudo bash /tmp/kaidi-install.sh
|
||||||
printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
|
rm -f /tmp/kaidi-install.sh
|
||||||
sudo env \
|
|
||||||
KAIDI_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest \
|
|
||||||
KAIDI_RELEASE_TOKEN_FILE="$token_file" \
|
|
||||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
|
|
||||||
KAIDI_REINSTALL="$REINSTALL" \
|
|
||||||
KAIDI_SETUP_WIZARD="$SETUP_WIZARD" \
|
|
||||||
bash "$installer"
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 从固定 Git tag 拉取后安装
|
### 从固定 Git tag 拉取后安装
|
||||||
|
|
||||||
需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。私有仓库会在 `git clone`
|
需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。公共仓库不要求凭据,
|
||||||
时要求 Gitea 凭据,包装器随后单独要求只读 Release Token;它会在 `sudo` 前校验 `deploy/install.sh`
|
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
||||||
的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --branch v1.0.0-preview.8 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
git clone --branch v1.0.0-preview.14 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||||
cd kaidi-preview
|
cd kaidi-preview
|
||||||
KAIDI_SETUP_WIZARD=true ./deploy/install-from-git.sh
|
./deploy/install-from-git.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。
|
首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。
|
||||||
只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。
|
只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。
|
||||||
|
|
||||||
```bash
|
|
||||||
KAIDI_DB_URL='jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \
|
|
||||||
KAIDI_DB_USERNAME=kaidi KAIDI_DB_PASSWORD='DB_PASSWORD' ./deploy/install-from-git.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
安装器会完成以下动作:
|
安装器会完成以下动作:
|
||||||
|
|
||||||
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
|
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
|
||||||
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。
|
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
|
||||||
- 使用安装命令固定的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
|
- 识别 `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,并在提交前验证 DDL/DML 权限。
|
||||||
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
||||||
- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。
|
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
|
||||||
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
||||||
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
|
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
|
||||||
|
|
||||||
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问
|
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后,
|
||||||
`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置
|
通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
|
||||||
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
|
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
|
||||||
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
|
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
|
||||||
|
|
||||||
安装后执行以下命令确认应用、反向代理和首次登录信息:
|
以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:19090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
client_max_body_size 500m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"'
|
curl -fsS http://127.0.0.1:19090/actuator/health | jq -e '.status == "UP"'
|
||||||
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
|
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
|
||||||
sudo cat /root/kaidi-first-login.txt
|
sudo cat /root/kaidi-first-login.txt
|
||||||
```
|
```
|
||||||
@@ -137,24 +141,10 @@ sudo cat /root/kaidi-first-login.txt
|
|||||||
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
(
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh | sudo bash
|
||||||
set -Eeuo pipefail
|
|
||||||
RELEASE_TAG=v1.0.0-preview.8; INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7; PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
|
|
||||||
read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
|
|
||||||
token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
|
|
||||||
trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
|
|
||||||
printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
|
|
||||||
printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
|
|
||||||
unset GITEA_READ_TOKEN; chmod 0600 "$token_file" "$header_file" "$installer"
|
|
||||||
curl --fail --silent --show-error --proto '=https' --tlsv1.2 --header "@$header_file" \
|
|
||||||
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" -o "$installer"
|
|
||||||
printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
|
|
||||||
sudo env KAIDI_RELEASE_TOKEN_FILE="$token_file" KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
|
|
||||||
KAIDI_SETUP_WIZARD=true bash "$installer"
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
||||||
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
|
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
|
||||||
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
|
||||||
|
|
||||||
@@ -171,27 +161,30 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
|
|||||||
管理员数据和运维人员新增的环境变量:
|
管理员数据和运维人员新增的环境变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 使用上方同一安装命令,把 REINSTALL=false 改成 REINSTALL=true。
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh \
|
||||||
|
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
|
||||||
```
|
```
|
||||||
|
|
||||||
重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整,
|
没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整,
|
||||||
不会把恢复失败吞掉。
|
不会把恢复失败吞掉。
|
||||||
|
|
||||||
如果安装器已完成但向导尚未提交,可使用同一命令同时设置 `REINSTALL=true` 和 `KAIDI_SETUP_WIZARD=true` 重新生成一次性安装码;
|
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
|
||||||
该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.14/install.sh \
|
||||||
|
| sudo env KAIDI_REINSTALL=true bash
|
||||||
|
```
|
||||||
|
|
||||||
### 停用并移除程序
|
### 停用并移除程序
|
||||||
|
|
||||||
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
|
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
|
||||||
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。
|
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。
|
||||||
安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。
|
反向代理由运维人员独立管理,停用程序不会修改其配置。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
|
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
|
||||||
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
|
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
|
||||||
sudo rm -f /etc/nginx/conf.d/kaidi-finance.conf
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
sudo rm -rf /opt/kaidi
|
sudo rm -rf /opt/kaidi
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -205,8 +198,8 @@ base64 < release-signing-private.pem | tr -d '\n'
|
|||||||
```
|
```
|
||||||
|
|
||||||
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
|
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
|
||||||
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 内建 `GITEA_TOKEN` 只用于该工作流创建
|
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建
|
||||||
Release;生产服务器使用另一个只读 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
|
Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
|
||||||
act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。
|
act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。
|
||||||
|
|
||||||
工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
|
工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
|
||||||
@@ -214,31 +207,31 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
|
|||||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.0-preview.8
|
git tag v1.0.0-preview.14
|
||||||
git push origin v1.0.0-preview.8
|
git push origin v1.0.0-preview.14
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
||||||
更新源由 root 在 `/etc/kaidi/update.env` 固定为私有 Gitea Latest Release API;页面和普通 API 都看不到 Token,
|
更新源由 root 在 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交
|
||||||
也不能提交 URL、脚本或命令。更新流程固定为:
|
URL、Token、脚本或命令。更新流程固定为:
|
||||||
|
|
||||||
1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、
|
1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、
|
||||||
SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/<version>`。业务服务不停机。
|
SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/<version>`。业务服务不停机。
|
||||||
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
|
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
|
||||||
的包不能进入安装。
|
的包不能进入安装。
|
||||||
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
|
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
|
||||||
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。
|
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
|
||||||
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。
|
`mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
|
||||||
7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动
|
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd 和应用版本,不修改反向代理。
|
||||||
|
7. 同时检查 Java 健康端点、更新 path unit 和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
|
||||||
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
|
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
|
||||||
|
|
||||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
|
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
|
||||||
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
|
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
|
||||||
|
|
||||||
安装器会把 API 地址和只读 Token 同步写入 root-only 的 `/etc/kaidi/kaidi.env` 与
|
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为
|
||||||
`/etc/kaidi/update.env`,两者权限均为 `0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。
|
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像,
|
||||||
轮换 Token 时必须同时更新两个文件,再执行 `sudo systemctl restart kaidi-finance`;Token 不写入页面、状态 JSON、
|
可由运维人员在这两个文件中配置独立只读 Token,Token 不写入页面、状态 JSON、审计参数或更新日志。
|
||||||
审计参数或更新日志。
|
|
||||||
|
|
||||||
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
|
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
|
||||||
|
|
||||||
@@ -250,7 +243,7 @@ cat /var/lib/kaidi-update/status.json
|
|||||||
|
|
||||||
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
|
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
|
||||||
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
|
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
|
||||||
修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行:
|
修复日志所示的磁盘、权限或旧版本健康问题后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
|
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
|
||||||
@@ -267,7 +260,7 @@ cat /var/lib/kaidi-update/status.json
|
|||||||
```bash
|
```bash
|
||||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||||
./scripts/package-release.sh 1.0.0-preview.8
|
./scripts/package-release.sh 1.0.0-preview.14
|
||||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||||
./scripts/verify-release.sh dist/release
|
./scripts/verify-release.sh dist/release
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.kaidi.finance.setup;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
||||||
@@ -19,8 +21,20 @@ public class SetupSecurityConfig {
|
|||||||
.formLogin(AbstractHttpConfigurer::disable)
|
.formLogin(AbstractHttpConfigurer::disable)
|
||||||
.logout(AbstractHttpConfigurer::disable)
|
.logout(AbstractHttpConfigurer::disable)
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error").permitAll()
|
.requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error",
|
||||||
|
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
|
||||||
|
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
|
||||||
.anyRequest().denyAll());
|
.anyRequest().denyAll());
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
|
||||||
|
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
|
||||||
|
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
|
||||||
|
&& !path.equals("/error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
@@ -37,7 +38,9 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**",
|
.requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**",
|
||||||
"/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error").permitAll()
|
"/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error",
|
||||||
|
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
|
||||||
|
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
|
||||||
.requestMatchers("/api/v1/**").permitAll()
|
.requestMatchers("/api/v1/**").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.exceptionHandling(exceptions -> exceptions
|
.exceptionHandling(exceptions -> exceptions
|
||||||
@@ -48,6 +51,18 @@ public class SecurityConfig {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
|
||||||
|
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
|
||||||
|
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
|
||||||
|
&& !path.equals("/api-docs") && !path.startsWith("/api-docs/")
|
||||||
|
&& !path.equals("/swagger-ui.html") && !path.startsWith("/swagger-ui/")
|
||||||
|
&& !path.equals("/error");
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
PasswordEncoder passwordEncoder() {
|
PasswordEncoder passwordEncoder() {
|
||||||
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
|
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.kaidi.finance.shared.web;
|
||||||
|
|
||||||
|
import jakarta.servlet.DispatcherType;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
/** Forwards extensionless browser routes to the bundled Vue entry point. */
|
||||||
|
@Component
|
||||||
|
public class SpaForwardFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
if (isBrowserRoute(request)) {
|
||||||
|
request.getRequestDispatcher("/index.html").forward(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBrowserRoute(HttpServletRequest request) {
|
||||||
|
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||||
|
path = path.substring(contextPath.length());
|
||||||
|
}
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
if (isReserved(path) || path.contains(".")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return DispatcherType.REQUEST.equals(request.getDispatcherType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isReserved(String path) {
|
||||||
|
return path.equals("/api") || path.startsWith("/api/")
|
||||||
|
|| path.equals("/actuator") || path.startsWith("/actuator/")
|
||||||
|
|| path.equals("/api-docs") || path.startsWith("/api-docs/")
|
||||||
|
|| path.equals("/swagger-ui.html") || path.startsWith("/swagger-ui/")
|
||||||
|
|| path.equals("/error");
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-6
@@ -15,6 +15,7 @@ import com.kaidi.finance.update.api.SystemUpdateView;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
@@ -52,6 +53,9 @@ public class SystemUpdateApplicationService {
|
|||||||
+ "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
|
+ "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
|
||||||
private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$");
|
private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$");
|
||||||
private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$");
|
private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$");
|
||||||
|
private static final Pattern GITEA_RELEASE_API_PATH = Pattern.compile(
|
||||||
|
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
|
||||||
|
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
|
||||||
private static final List<String> BUSY_STATES = List.of(
|
private static final List<String> BUSY_STATES = List.of(
|
||||||
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
|
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
|
||||||
"INSTALLING", "RUNNING");
|
"INSTALLING", "RUNNING");
|
||||||
@@ -195,20 +199,42 @@ public class SystemUpdateApplicationService {
|
|||||||
URI api = releaseApiUri();
|
URI api = releaseApiUri();
|
||||||
try {
|
try {
|
||||||
JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release"));
|
JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release"));
|
||||||
|
boolean assetPresent = false;
|
||||||
for (JsonNode asset : release.path("assets")) {
|
for (JsonNode asset : release.path("assets")) {
|
||||||
if (!assetName.equals(asset.path("name").asText())) continue;
|
if (assetName.equals(asset.path("name").asText())) {
|
||||||
URI uri = validatedReleaseUri(asset.path("browser_download_url").asText(null), "Release 资源地址");
|
assetPresent = true;
|
||||||
if (!sameOrigin(api, uri)) {
|
break;
|
||||||
throw releaseUnavailable("Gitea Release 资源必须与 API 使用同一来源");
|
|
||||||
}
|
}
|
||||||
return uri;
|
|
||||||
}
|
}
|
||||||
throw releaseUnavailable("Gitea Release 缺少 " + assetName);
|
if (!assetPresent) throw releaseUnavailable("Gitea Release 缺少 " + assetName);
|
||||||
|
String tag = release.path("tag_name").asText(null);
|
||||||
|
if (tag == null || !GITEA_RELEASE_TAG.matcher(tag).matches()) {
|
||||||
|
throw releaseUnavailable("Gitea Release tag 格式无效");
|
||||||
|
}
|
||||||
|
return trustedGiteaAssetUri(api, tag, assetName);
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
throw releaseUnavailable("Gitea Release 响应读取失败");
|
throw releaseUnavailable("Gitea Release 响应读取失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private URI trustedGiteaAssetUri(URI api, String tag, String assetName) {
|
||||||
|
if (api.getRawQuery() != null || api.getRawFragment() != null) {
|
||||||
|
throw releaseUnavailable("Gitea Release API 地址格式无效");
|
||||||
|
}
|
||||||
|
Matcher matcher = GITEA_RELEASE_API_PATH.matcher(api.getPath());
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
throw releaseUnavailable("Gitea Release API 地址必须指向仓库 Release 端点");
|
||||||
|
}
|
||||||
|
String prefix = matcher.group(1) == null ? "/" : matcher.group(1);
|
||||||
|
String path = prefix + matcher.group(2) + "/" + matcher.group(3)
|
||||||
|
+ "/releases/download/" + tag + "/" + assetName;
|
||||||
|
try {
|
||||||
|
return new URI(api.getScheme(), null, api.getHost(), api.getPort(), path, null, null);
|
||||||
|
} catch (URISyntaxException exception) {
|
||||||
|
throw releaseUnavailable("Gitea Release 资源地址格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) {
|
private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) {
|
||||||
String token = releaseToken();
|
String token = releaseToken();
|
||||||
URI current = uri;
|
URI current = uri;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.kaidi.setup;
|
package com.kaidi.setup;
|
||||||
|
|
||||||
|
import com.kaidi.finance.setup.SetupProperties;
|
||||||
|
import com.kaidi.finance.shared.web.SpaForwardFilter;
|
||||||
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
|
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -9,7 +11,7 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
|
|||||||
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import com.kaidi.finance.setup.SetupProperties;
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal first-run context. It deliberately does not create a business DataSource or run
|
* Minimal first-run context. It deliberately does not create a business DataSource or run
|
||||||
@@ -28,6 +30,7 @@ import com.kaidi.finance.setup.SetupProperties;
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@EnableConfigurationProperties(SetupProperties.class)
|
@EnableConfigurationProperties(SetupProperties.class)
|
||||||
|
@Import(SpaForwardFilter.class)
|
||||||
public class SetupApplication {
|
public class SetupApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:18080}
|
port: ${SERVER_PORT:18080}
|
||||||
|
address: ${SERVER_ADDRESS:127.0.0.1}
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
servlet:
|
servlet:
|
||||||
session:
|
session:
|
||||||
@@ -12,6 +13,9 @@ server:
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: kaidi-finance
|
name: kaidi-finance
|
||||||
|
web:
|
||||||
|
resources:
|
||||||
|
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
|
||||||
profiles:
|
profiles:
|
||||||
default: local
|
default: local
|
||||||
datasource:
|
datasource:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class SetupContextSmokeTest {
|
|||||||
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
|
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
|
||||||
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
|
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
|
||||||
registry.add("finance.setup.restart-after-complete", () -> false);
|
registry.add("finance.setup.restart-after-complete", () -> false);
|
||||||
|
registry.add("spring.web.resources.static-locations", () -> "classpath:/spa-fixture/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -49,6 +50,14 @@ class SetupContextSmokeTest {
|
|||||||
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
|
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
|
||||||
.isEqualTo("[\"MYSQL\"]");
|
.isEqualTo("[\"MYSQL\"]");
|
||||||
|
|
||||||
|
ResponseEntity<String> setupPage = rest.getForEntity(url("/setup"), String.class);
|
||||||
|
assertThat(setupPage.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(setupPage.getBody()).contains("kaidi-spa-fixture");
|
||||||
|
|
||||||
|
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
|
||||||
|
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
|
||||||
|
|
||||||
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
|
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
|
||||||
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
|
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
|
||||||
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
||||||
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
|
|
||||||
+ "/assets/release-manifest.json";
|
|
||||||
byte[] body = ("""
|
byte[] body = ("""
|
||||||
{"tag_name":"v1.0.0-preview.2","assets":[
|
{"tag_name":"v1.0.0-preview.2","assets":[
|
||||||
{"name":"release-manifest.json","browser_download_url":"%s"}
|
{"name":"release-manifest.json","browser_download_url":"%s"}
|
||||||
]}
|
]}
|
||||||
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
|
""").formatted("http://10.0.0.8:3000/internal/release-manifest.json")
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
exchange.sendResponseHeaders(200, body.length);
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
exchange.getResponseBody().write(body);
|
exchange.getResponseBody().write(body);
|
||||||
exchange.close();
|
exchange.close();
|
||||||
});
|
});
|
||||||
server.createContext("/assets/release-manifest.json", exchange -> {
|
server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
|
||||||
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
||||||
byte[] body = """
|
byte[] body = """
|
||||||
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
|
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
|
||||||
@@ -98,7 +97,7 @@ class SystemUpdateApplicationServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception {
|
void ignoresCrossOriginBrowserAssetUrlWithoutSendingTokenThere() throws Exception {
|
||||||
AtomicInteger assetRequests = new AtomicInteger();
|
AtomicInteger assetRequests = new AtomicInteger();
|
||||||
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
assetServer.createContext("/release-manifest.json", exchange -> {
|
assetServer.createContext("/release-manifest.json", exchange -> {
|
||||||
@@ -112,12 +111,25 @@ class SystemUpdateApplicationServiceTest {
|
|||||||
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
|
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
|
||||||
+ "/release-manifest.json";
|
+ "/release-manifest.json";
|
||||||
byte[] body = ("""
|
byte[] body = ("""
|
||||||
{"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
|
{"tag_name":"v1.0.0-preview.2",
|
||||||
|
"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
|
||||||
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
|
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
|
||||||
exchange.sendResponseHeaders(200, body.length);
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
exchange.getResponseBody().write(body);
|
exchange.getResponseBody().write(body);
|
||||||
exchange.close();
|
exchange.close();
|
||||||
});
|
});
|
||||||
|
apiServer.createContext(
|
||||||
|
"/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
|
||||||
|
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
|
||||||
|
byte[] body = """
|
||||||
|
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
|
||||||
|
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Gitea Preview update"}
|
||||||
|
""".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
|
exchange.getResponseBody().write(body);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
assetServer.start();
|
assetServer.start();
|
||||||
apiServer.start();
|
apiServer.start();
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +137,7 @@ class SystemUpdateApplicationServiceTest {
|
|||||||
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
|
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
|
||||||
tempDir.resolve("cross-origin-status.json"));
|
tempDir.resolve("cross-origin-status.json"));
|
||||||
|
|
||||||
assertThrows(BusinessException.class, service::check);
|
assertEquals("1.0.0-preview.2", service.check().latestVersion());
|
||||||
assertEquals(0, assetRequests.get());
|
assertEquals(0, assetRequests.get());
|
||||||
} finally {
|
} finally {
|
||||||
apiServer.stop(0);
|
apiServer.stop(0);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
window.__KAIDI_SPA_FIXTURE__ = 'kaidi-spa-fixture';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="UTF-8"><title>kaidi-spa-fixture</title></head>
|
||||||
|
<body><div id="app">kaidi-spa-fixture</div></body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
SPRING_PROFILES_ACTIVE=production
|
SPRING_PROFILES_ACTIVE=production
|
||||||
SERVER_PORT=18080
|
SERVER_PORT=18080
|
||||||
|
SERVER_ADDRESS=127.0.0.1
|
||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
|
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
|
||||||
@@ -14,10 +15,10 @@ FILE_SCANNER_ENABLED=false
|
|||||||
FINANCE_BOOTSTRAP_ENABLED=false
|
FINANCE_BOOTSTRAP_ENABLED=false
|
||||||
FINANCE_BOOTSTRAP_PASSWORD=
|
FINANCE_BOOTSTRAP_PASSWORD=
|
||||||
|
|
||||||
APP_VERSION=1.0.0-preview.8
|
APP_VERSION=1.0.0-preview.9
|
||||||
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
|
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
|
||||||
FINANCE_UPDATE_ENABLED=true
|
FINANCE_UPDATE_ENABLED=true
|
||||||
# Use either a stable direct asset base URL or the private Gitea latest-release API.
|
# Use either a stable direct asset base URL or the public Gitea latest-release API.
|
||||||
UPDATE_RELEASE_BASE_URL=
|
UPDATE_RELEASE_BASE_URL=
|
||||||
UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
|
UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
|
||||||
UPDATE_RELEASE_TOKEN=READ_ONLY_GITEA_TOKEN
|
UPDATE_RELEASE_TOKEN=READ_ONLY_GITEA_TOKEN
|
||||||
|
|||||||
+13
-28
@@ -5,20 +5,14 @@ umask 077
|
|||||||
|
|
||||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
INSTALLER="$ROOT/deploy/install.sh"
|
INSTALLER="$ROOT/deploy/install.sh"
|
||||||
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7}
|
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-824ec6eb5b8bad97485d5c8beb3af93352fd12b1ee631b15a5b9bee38fcb5e67}
|
||||||
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
||||||
PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
|
PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
|
||||||
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
||||||
TEMP_TOKEN_FILE=
|
|
||||||
|
|
||||||
log() { printf '[kaidi-git-install] %s\n' "$*"; }
|
log() { printf '[kaidi-git-install] %s\n' "$*"; }
|
||||||
die() { printf '[kaidi-git-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
die() { printf '[kaidi-git-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[ -z "$TEMP_TOKEN_FILE" ] || rm -f "$TEMP_TOKEN_FILE"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
[ -f "$INSTALLER" ] || die "deploy/install.sh is missing; run this command from the Kaidi Git checkout"
|
[ -f "$INSTALLER" ] || die "deploy/install.sh is missing; run this command from the Kaidi Git checkout"
|
||||||
command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required"
|
command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required"
|
||||||
[[ "$INSTALLER_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || die "The installer SHA-256 is invalid"
|
[[ "$INSTALLER_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || die "The installer SHA-256 is invalid"
|
||||||
@@ -29,25 +23,6 @@ normalized_installer_sha256=$(printf '%s' "$INSTALLER_SHA256" | tr '[:upper:]' '
|
|||||||
[ "$normalized_actual_installer_sha256" = "$normalized_installer_sha256" ] \
|
[ "$normalized_actual_installer_sha256" = "$normalized_installer_sha256" ] \
|
||||||
|| die "deploy/install.sh does not match the trusted SHA-256 for this Git tag"
|
|| die "deploy/install.sh does not match the trusted SHA-256 for this Git tag"
|
||||||
|
|
||||||
if [ -z "$TOKEN_FILE" ]; then
|
|
||||||
TOKEN_FILE=$(mktemp)
|
|
||||||
TEMP_TOKEN_FILE=$TOKEN_FILE
|
|
||||||
chmod 0600 "$TOKEN_FILE"
|
|
||||||
if [ -t 0 ]; then
|
|
||||||
read -rsp 'Gitea read-only token: ' token
|
|
||||||
printf '\n'
|
|
||||||
printf '%s' "$token" > "$TOKEN_FILE"
|
|
||||||
unset token
|
|
||||||
else
|
|
||||||
die "Set KAIDI_RELEASE_TOKEN_FILE or run interactively to enter the Gitea read-only token"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "The Gitea token file must be a regular file"
|
|
||||||
token_size=$(wc -c < "$TOKEN_FILE" | tr -d '[:space:]')
|
|
||||||
[ "$token_size" -gt 0 ] && [ "$token_size" -le 512 ] || die "The Gitea token file must contain 1 to 512 bytes"
|
|
||||||
LC_ALL=C grep -q '[[:cntrl:]]' "$TOKEN_FILE" && die "The Gitea token file contains invalid control characters"
|
|
||||||
|
|
||||||
root_command=(env)
|
root_command=(env)
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
command -v sudo >/dev/null 2>&1 || die "sudo is required when not running as root"
|
command -v sudo >/dev/null 2>&1 || die "sudo is required when not running as root"
|
||||||
@@ -56,12 +31,21 @@ fi
|
|||||||
|
|
||||||
install_env=(
|
install_env=(
|
||||||
"KAIDI_RELEASE_API_URL=$RELEASE_API_URL"
|
"KAIDI_RELEASE_API_URL=$RELEASE_API_URL"
|
||||||
"KAIDI_RELEASE_TOKEN_FILE=$TOKEN_FILE"
|
|
||||||
"KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256"
|
"KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if [ -n "$TOKEN_FILE" ]; then
|
||||||
|
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "The Gitea token file must be a regular file"
|
||||||
|
token_size=$(wc -c < "$TOKEN_FILE" | tr -d '[:space:]')
|
||||||
|
[ "$token_size" -gt 0 ] && [ "$token_size" -le 512 ] || die "The Gitea token file must contain 1 to 512 bytes"
|
||||||
|
LC_ALL=C grep -q '[[:cntrl:]]' "$TOKEN_FILE" && die "The Gitea token file contains invalid control characters"
|
||||||
|
install_env+=("KAIDI_RELEASE_TOKEN_FILE=$TOKEN_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
KAIDI_REINSTALL=${KAIDI_REINSTALL:-}
|
KAIDI_REINSTALL=${KAIDI_REINSTALL:-}
|
||||||
KAIDI_SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-}
|
KAIDI_SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-}
|
||||||
|
KAIDI_APP_PORT=${KAIDI_APP_PORT:-}
|
||||||
|
KAIDI_SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-}
|
||||||
KAIDI_DB_URL=${KAIDI_DB_URL:-}
|
KAIDI_DB_URL=${KAIDI_DB_URL:-}
|
||||||
KAIDI_DB_USERNAME=${KAIDI_DB_USERNAME:-}
|
KAIDI_DB_USERNAME=${KAIDI_DB_USERNAME:-}
|
||||||
KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-}
|
KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-}
|
||||||
@@ -72,7 +56,8 @@ KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-}
|
|||||||
KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-}
|
KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-}
|
||||||
KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-}
|
KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-}
|
||||||
|
|
||||||
for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \
|
for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_APP_PORT KAIDI_SERVER_ADDRESS \
|
||||||
|
KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \
|
||||||
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \
|
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \
|
||||||
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do
|
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do
|
||||||
value=${!name}
|
value=${!name}
|
||||||
|
|||||||
+151
-111
@@ -11,10 +11,14 @@ RELEASE_BASE_URL=${KAIDI_RELEASE_BASE_URL:-}
|
|||||||
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
||||||
RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-}
|
RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-}
|
||||||
RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
||||||
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
HEALTH_URL=${KAIDI_HEALTH_URL:-}
|
||||||
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-}
|
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}
|
REINSTALL=${KAIDI_REINSTALL:-false}
|
||||||
SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-false}
|
SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}
|
||||||
WORK_DIR=
|
WORK_DIR=
|
||||||
RELEASE_AUTH_HEADER_FILE=
|
RELEASE_AUTH_HEADER_FILE=
|
||||||
INSTALL_TRANSACTION_ARMED=false
|
INSTALL_TRANSACTION_ARMED=false
|
||||||
@@ -24,8 +28,6 @@ BACKUP_DIR=
|
|||||||
JAVA_STAGED_DIR=
|
JAVA_STAGED_DIR=
|
||||||
JAVA_PREVIOUS_DIR=
|
JAVA_PREVIOUS_DIR=
|
||||||
JAVA_ACTIVATED=false
|
JAVA_ACTIVATED=false
|
||||||
CREATED_DB_CONTAINER=false
|
|
||||||
CREATED_DB_VOLUME=false
|
|
||||||
|
|
||||||
log() { printf '[kaidi-install] %s\n' "$*"; }
|
log() { printf '[kaidi-install] %s\n' "$*"; }
|
||||||
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
@@ -38,7 +40,7 @@ preflight_host() {
|
|||||||
[ "$(uname -s)" = Linux ] || die "The installer only supports Linux"
|
[ "$(uname -s)" = Linux ] || die "The installer only supports Linux"
|
||||||
[ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \
|
[ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \
|
||||||
&& [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \
|
&& [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \
|
||||||
|| die "Custom installation roots are not supported by the packaged systemd and Nginx configuration"
|
|| die "Custom installation roots are not supported by the packaged systemd configuration"
|
||||||
command -v systemctl >/dev/null 2>&1 || die "systemd is required"
|
command -v systemctl >/dev/null 2>&1 || die "systemd is required"
|
||||||
[ -d /run/systemd/system ] || die "systemd is not running as PID 1"
|
[ -d /run/systemd/system ] || die "systemd is not running as PID 1"
|
||||||
[ -d /etc/systemd/system ] || die "/etc/systemd/system is missing"
|
[ -d /etc/systemd/system ] || die "/etc/systemd/system is missing"
|
||||||
@@ -87,13 +89,17 @@ download() {
|
|||||||
local url=$1 output=$2
|
local url=$1 output=$2
|
||||||
case "$url" in
|
case "$url" in
|
||||||
https://*)
|
https://*)
|
||||||
curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
||||||
--tlsv1.2 "$url" -o "$output"
|
--tlsv1.2 --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
|
||||||
|
die "Download failed: $url"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS"
|
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS"
|
||||||
curl --fail --silent --show-error --location --proto '=http,https' \
|
if ! curl --fail --silent --show-error --location --proto '=http,https' \
|
||||||
--proto-redir '=http,https' "$url" -o "$output"
|
--proto-redir '=http,https' --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
|
||||||
|
die "Download failed: $url"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -124,17 +130,20 @@ download_release_url() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
release_asset_url() {
|
release_asset_url() {
|
||||||
local asset_name=$1 asset_url api_origin
|
local asset_name=$1 api_origin repo_path release_tag encoded_tag encoded_asset
|
||||||
if [ -n "$RELEASE_API_URL" ]; then
|
if [ -n "$RELEASE_API_URL" ]; then
|
||||||
asset_url=$(jq -er --arg name "$asset_name" \
|
jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
|
||||||
'.assets[] | select(.name == $name) | .browser_download_url
|
"$WORK_DIR/release-api.json" >/dev/null || die "Release asset $asset_name is missing"
|
||||||
| strings | select(startswith("https://") or startswith("http://"))' \
|
release_tag=$(jq -er '.tag_name | strings
|
||||||
"$WORK_DIR/release-api.json" | head -n 1) || die "Release asset $asset_name is missing"
|
| select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \
|
||||||
|
"$WORK_DIR/release-api.json") || die "Gitea Release tag is invalid"
|
||||||
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
|
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
|
||||||
case "$asset_url" in
|
repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
|
||||||
"$api_origin"/*) printf '%s\n' "$asset_url" ;;
|
's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
|
||||||
*) die "Release asset $asset_name must use the Gitea API host" ;;
|
[ -n "$repo_path" ] || die "KAIDI_RELEASE_API_URL must be a Gitea repository Release API endpoint"
|
||||||
esac
|
encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri')
|
||||||
|
encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri')
|
||||||
|
printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset"
|
||||||
else
|
else
|
||||||
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
||||||
fi
|
fi
|
||||||
@@ -150,28 +159,16 @@ install_packages() {
|
|||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux default-mysql-client
|
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql
|
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||||
elif command -v yum >/dev/null 2>&1; then
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql
|
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||||
else
|
else
|
||||||
die "Supported package managers are apt, dnf, and yum"
|
die "Supported package managers are apt, dnf, and yum"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_docker() {
|
|
||||||
command -v docker >/dev/null 2>&1 && return
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get install -y -qq docker.io
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
|
||||||
dnf install -y docker
|
|
||||||
else
|
|
||||||
yum install -y docker
|
|
||||||
fi
|
|
||||||
systemctl enable --now docker
|
|
||||||
}
|
|
||||||
|
|
||||||
azul_arch() {
|
azul_arch() {
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
x86_64|amd64) printf x86 ;;
|
x86_64|amd64) printf x86 ;;
|
||||||
@@ -182,8 +179,40 @@ azul_arch() {
|
|||||||
esac
|
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
|
||||||
|
resolved_java=$(readlink -f "$java_bin" 2>/dev/null || printf '%s' "$java_bin")
|
||||||
|
case "$resolved_java" in
|
||||||
|
*/bin/java) home=${resolved_java%/bin/java} ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ -x "$home/bin/java" ] || return 1
|
||||||
|
printf '%s\n' "$home"
|
||||||
|
}
|
||||||
|
|
||||||
prepare_java() {
|
prepare_java() {
|
||||||
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256
|
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home
|
||||||
|
if system_home=$(system_java_home); then
|
||||||
|
JAVA_STAGED_DIR="$WORK_DIR/java.next"
|
||||||
|
ln -s "$system_home" "$JAVA_STAGED_DIR"
|
||||||
|
log "Using existing Java 17 runtime at $system_home"
|
||||||
|
"$JAVA_STAGED_DIR/bin/java" -version
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime"
|
||||||
api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true"
|
api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true"
|
||||||
java_metadata="$WORK_DIR/zulu-metadata.json"
|
java_metadata="$WORK_DIR/zulu-metadata.json"
|
||||||
download "$api" "$java_metadata"
|
download "$api" "$java_metadata"
|
||||||
@@ -278,6 +307,72 @@ read_reinstall_env() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
port_is_listening() {
|
||||||
|
local port_hex files=(/proc/net/tcp)
|
||||||
|
port_hex=$(printf '%04X' "$APP_PORT")
|
||||||
|
[ -r /proc/net/tcp ] || return 1
|
||||||
|
[ ! -r /proc/net/tcp6 ] || files+=(/proc/net/tcp6)
|
||||||
|
awk -v port="$port_hex" '
|
||||||
|
toupper($2) ~ (":" port "$") && $4 == "0A" { found=1 }
|
||||||
|
END { exit(found ? 0 : 1) }
|
||||||
|
' "${files[@]}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_app_port() {
|
||||||
|
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_app_port() {
|
||||||
|
local default_port=18080 existing_port entered probe_host probe_authority
|
||||||
|
if [ -z "$APP_PORT" ]; then
|
||||||
|
default_port=$(read_reinstall_env SERVER_PORT || true)
|
||||||
|
[[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080
|
||||||
|
if [ -t 1 ] && [ -r /dev/tty ]; then
|
||||||
|
printf '[kaidi-install] Application port [%s]: ' "$default_port" > /dev/tty
|
||||||
|
if IFS= read -r entered < /dev/tty; then
|
||||||
|
APP_PORT=${entered:-$default_port}
|
||||||
|
else
|
||||||
|
APP_PORT=$default_port
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
APP_PORT=$default_port
|
||||||
|
log "No interactive terminal detected; using application port $APP_PORT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
valid_app_port "$APP_PORT" \
|
||||||
|
|| die "KAIDI_APP_PORT must be an integer between 1024 and 65535"
|
||||||
|
case "$SERVER_ADDRESS" in
|
||||||
|
''|*[!A-Za-z0-9_.:-]*) die "KAIDI_SERVER_ADDRESS contains unsupported characters" ;;
|
||||||
|
esac
|
||||||
|
if port_is_listening; then
|
||||||
|
existing_port=$(read_existing_env SERVER_PORT || true)
|
||||||
|
if [ "$REINSTALL" = true ] && [ "$existing_port" = "$APP_PORT" ] \
|
||||||
|
&& systemctl is-active --quiet kaidi-finance.service; then
|
||||||
|
log "Application port $APP_PORT is already held by the existing Kaidi service"
|
||||||
|
else
|
||||||
|
die "Application port $APP_PORT is already in use; choose another port with KAIDI_APP_PORT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
probe_host=${KAIDI_HEALTH_HOST:-$SERVER_ADDRESS}
|
||||||
|
case "$probe_host" in
|
||||||
|
0.0.0.0) probe_host=127.0.0.1 ;;
|
||||||
|
::) probe_host=::1 ;;
|
||||||
|
esac
|
||||||
|
case "$probe_host" in
|
||||||
|
*:*) probe_authority="[$probe_host]" ;;
|
||||||
|
*) probe_authority="$probe_host" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$HEALTH_URL" ]; then
|
||||||
|
HEALTH_URL="http://${probe_authority}:${APP_PORT}/actuator/health"
|
||||||
|
fi
|
||||||
|
if [ -z "$APP_INDEX_URL" ]; then
|
||||||
|
APP_INDEX_URL="http://${probe_authority}:${APP_PORT}/"
|
||||||
|
fi
|
||||||
|
PROXY_TARGET="http://${probe_authority}:${APP_PORT}"
|
||||||
|
log "Application will bind ${SERVER_ADDRESS}:${APP_PORT}"
|
||||||
|
log "Reverse proxy target: $PROXY_TARGET"
|
||||||
|
}
|
||||||
|
|
||||||
is_semver() {
|
is_semver() {
|
||||||
[ "${#1}" -le 128 ] \
|
[ "${#1}" -le 128 ] \
|
||||||
&& LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \
|
&& LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \
|
||||||
@@ -293,7 +388,7 @@ configure_database() {
|
|||||||
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
|
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
|
||||||
DB_USERNAME=setup_pending
|
DB_USERNAME=setup_pending
|
||||||
DB_PASSWORD=setup_pending
|
DB_PASSWORD=setup_pending
|
||||||
return
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$REINSTALL" = true ]; then
|
if [ "$REINSTALL" = true ]; then
|
||||||
[ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env"
|
[ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env"
|
||||||
@@ -308,52 +403,11 @@ configure_database() {
|
|||||||
if [ -n "$DB_URL" ]; then
|
if [ -n "$DB_URL" ]; then
|
||||||
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|
||||||
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
|
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
|
||||||
return
|
return 0
|
||||||
fi
|
fi
|
||||||
[ "$REINSTALL" != true ] \
|
[ "$REINSTALL" != true ] \
|
||||||
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
|
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
|
||||||
case "$(uname -m)" in
|
die "An external MySQL 8.4 database is required; set KAIDI_DB_URL, KAIDI_DB_USERNAME, and KAIDI_DB_PASSWORD"
|
||||||
i386|i486|i586|i686)
|
|
||||||
die "32-bit Linux needs an external MySQL 8.4 database; set KAIDI_DB_URL, KAIDI_DB_USERNAME, and KAIDI_DB_PASSWORD"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
install_docker
|
|
||||||
DB_USERNAME=kaidi
|
|
||||||
DB_PASSWORD=$(random_secret)
|
|
||||||
DB_ROOT_PASSWORD=$(random_secret)
|
|
||||||
! docker inspect kaidi-finance-mysql >/dev/null 2>&1 \
|
|
||||||
|| die "Docker container kaidi-finance-mysql already exists; provide explicit database parameters"
|
|
||||||
! docker volume inspect kaidi_mysql_data >/dev/null 2>&1 \
|
|
||||||
|| die "Docker volume kaidi_mysql_data already exists; provide explicit database parameters"
|
|
||||||
docker volume create kaidi_mysql_data >/dev/null
|
|
||||||
CREATED_DB_VOLUME=true
|
|
||||||
docker run -d \
|
|
||||||
--name kaidi-finance-mysql \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-e MYSQL_DATABASE=kaidi_finance \
|
|
||||||
-e MYSQL_USER="$DB_USERNAME" \
|
|
||||||
-e MYSQL_PASSWORD="$DB_PASSWORD" \
|
|
||||||
-e MYSQL_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \
|
|
||||||
-e TZ=Asia/Shanghai \
|
|
||||||
-p 127.0.0.1:3307:3306 \
|
|
||||||
-v kaidi_mysql_data:/var/lib/mysql \
|
|
||||||
--health-cmd='mysqladmin ping -h localhost -uroot -p$MYSQL_ROOT_PASSWORD --silent' \
|
|
||||||
--health-interval=5s --health-timeout=5s --health-retries=30 \
|
|
||||||
mysql:8.4 \
|
|
||||||
--character-set-server=utf8mb4 \
|
|
||||||
--collation-server=utf8mb4_0900_ai_ci \
|
|
||||||
--default-time-zone=+00:00 >/dev/null
|
|
||||||
CREATED_DB_CONTAINER=true
|
|
||||||
local attempts=0 health
|
|
||||||
while [ "$attempts" -lt 60 ]; do
|
|
||||||
health=$(docker inspect --format '{{.State.Health.Status}}' kaidi-finance-mysql 2>/dev/null || true)
|
|
||||||
[ "$health" = healthy ] && break
|
|
||||||
[ "$health" = unhealthy ] && die "MySQL container failed its health check"
|
|
||||||
attempts=$((attempts + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
[ "$health" = healthy ] || die "MySQL did not become ready"
|
|
||||||
DB_URL='jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
database_host() {
|
database_host() {
|
||||||
@@ -382,7 +436,7 @@ database_name() {
|
|||||||
|
|
||||||
preflight_database() {
|
preflight_database() {
|
||||||
local client host port name version table
|
local client host port name version table
|
||||||
[ "$SETUP_WIZARD" != true ] || return
|
[ "$SETUP_WIZARD" != true ] || return 0
|
||||||
case "$DB_URL" in
|
case "$DB_URL" in
|
||||||
jdbc:mysql://*) ;;
|
jdbc:mysql://*) ;;
|
||||||
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
|
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
|
||||||
@@ -487,7 +541,7 @@ wait_for_health() {
|
|||||||
local attempts=0
|
local attempts=0
|
||||||
while [ "$attempts" -lt 60 ]; do
|
while [ "$attempts" -lt 60 ]; do
|
||||||
if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then
|
if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then
|
||||||
return
|
return 0
|
||||||
fi
|
fi
|
||||||
attempts=$((attempts + 1))
|
attempts=$((attempts + 1))
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -509,8 +563,6 @@ backup_managed_state() {
|
|||||||
/etc/systemd/system/kaidi-finance.service
|
/etc/systemd/system/kaidi-finance.service
|
||||||
/etc/systemd/system/kaidi-update.service
|
/etc/systemd/system/kaidi-update.service
|
||||||
/etc/systemd/system/kaidi-update.path
|
/etc/systemd/system/kaidi-update.path
|
||||||
/etc/nginx/conf.d/kaidi-finance.conf
|
|
||||||
/etc/nginx/sites-enabled/default
|
|
||||||
)
|
)
|
||||||
BACKUP_DIR="$WORK_DIR/rollback"
|
BACKUP_DIR="$WORK_DIR/rollback"
|
||||||
mkdir -p "$BACKUP_DIR/files"
|
mkdir -p "$BACKUP_DIR/files"
|
||||||
@@ -525,8 +577,6 @@ backup_managed_state() {
|
|||||||
systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=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-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-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
|
|
||||||
INSTALL_TRANSACTION_ARMED=true
|
INSTALL_TRANSACTION_ARMED=true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,10 +634,6 @@ rollback_install() {
|
|||||||
|| rollback_failed=true
|
|| rollback_failed=true
|
||||||
restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \
|
restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \
|
||||||
|| rollback_failed=true
|
|| rollback_failed=true
|
||||||
restore_unit_state nginx "$PREVIOUS_NGINX_ENABLED" "$PREVIOUS_NGINX_ACTIVE" || rollback_failed=true
|
|
||||||
if [ "$PREVIOUS_NGINX_ACTIVE" = true ]; then
|
|
||||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || rollback_failed=true
|
|
||||||
fi
|
|
||||||
set -e
|
set -e
|
||||||
[ "$rollback_failed" = false ]
|
[ "$rollback_failed" = false ]
|
||||||
}
|
}
|
||||||
@@ -597,15 +643,7 @@ cleanup() {
|
|||||||
trap - EXIT
|
trap - EXIT
|
||||||
if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \
|
if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \
|
||||||
&& [ "$INSTALL_COMMITTED" != true ]; then
|
&& [ "$INSTALL_COMMITTED" != true ]; then
|
||||||
rollback_install || log "ERROR: rollback was incomplete; inspect systemd and Nginx state"
|
rollback_install || log "ERROR: rollback was incomplete; inspect the managed systemd state"
|
||||||
fi
|
|
||||||
if [ "$result" -ne 0 ] && [ "$INSTALL_COMMITTED" != true ] \
|
|
||||||
&& [ "$CREATED_DB_CONTAINER" = true ]; then
|
|
||||||
docker rm -f kaidi-finance-mysql >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if [ "$result" -ne 0 ] && [ "$INSTALL_COMMITTED" != true ] \
|
|
||||||
&& [ "$CREATED_DB_VOLUME" = true ]; then
|
|
||||||
docker volume rm kaidi_mysql_data >/dev/null 2>&1 || true
|
|
||||||
fi
|
fi
|
||||||
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
|
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
|
||||||
exit "$result"
|
exit "$result"
|
||||||
@@ -613,6 +651,7 @@ cleanup() {
|
|||||||
|
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
configure_app_port
|
||||||
preflight_host
|
preflight_host
|
||||||
install_packages
|
install_packages
|
||||||
|
|
||||||
@@ -718,7 +757,8 @@ if [ "$SETUP_WIZARD" = true ]; then
|
|||||||
fi
|
fi
|
||||||
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
|
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
|
||||||
SPRING_PROFILES_ACTIVE production \
|
SPRING_PROFILES_ACTIVE production \
|
||||||
SERVER_PORT 18080 \
|
SERVER_PORT "$APP_PORT" \
|
||||||
|
SERVER_ADDRESS "$SERVER_ADDRESS" \
|
||||||
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
|
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
|
||||||
DB_URL "$DB_URL" \
|
DB_URL "$DB_URL" \
|
||||||
DB_USERNAME "$DB_USERNAME" \
|
DB_USERNAME "$DB_USERNAME" \
|
||||||
@@ -755,7 +795,8 @@ write_env_file "$CONFIG_ROOT/update.env" \
|
|||||||
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
|
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
|
||||||
KAIDI_SERVICE_NAME kaidi-finance.service \
|
KAIDI_SERVICE_NAME kaidi-finance.service \
|
||||||
KAIDI_HEALTH_URL "$HEALTH_URL" \
|
KAIDI_HEALTH_URL "$HEALTH_URL" \
|
||||||
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-kaidi-finance-mysql}" \
|
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
|
||||||
|
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
|
||||||
KAIDI_DB_HOST "$(database_host)" \
|
KAIDI_DB_HOST "$(database_host)" \
|
||||||
KAIDI_DB_PORT "$(database_port)" \
|
KAIDI_DB_PORT "$(database_port)" \
|
||||||
KAIDI_DB_NAME "$(database_name)" \
|
KAIDI_DB_NAME "$(database_name)" \
|
||||||
@@ -766,17 +807,12 @@ chmod 0600 "$CONFIG_ROOT/update.env"
|
|||||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
|
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
|
||||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service
|
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service
|
||||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
|
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
|
||||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.conf" /etc/nginx/conf.d/kaidi-finance.conf
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
nginx -t
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable kaidi-finance.service
|
systemctl enable kaidi-finance.service
|
||||||
systemctl restart kaidi-finance.service
|
systemctl restart kaidi-finance.service
|
||||||
wait_for_health
|
wait_for_health
|
||||||
systemctl enable --now nginx
|
curl -fsS "$APP_INDEX_URL" | grep -Eiq '<!doctype|<html' \
|
||||||
systemctl reload nginx
|
|| die "Application frontend entry point is unavailable"
|
||||||
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' >/dev/null \
|
|
||||||
|| die "Nginx proxy health check failed"
|
|
||||||
|
|
||||||
if [ "$SETUP_WIZARD" != true ]; then
|
if [ "$SETUP_WIZARD" != true ]; then
|
||||||
sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env"
|
sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env"
|
||||||
@@ -790,14 +826,16 @@ systemctl enable --now kaidi-update.path
|
|||||||
|
|
||||||
if [ "$SETUP_WIZARD" = true ]; then
|
if [ "$SETUP_WIZARD" = true ]; then
|
||||||
cat > /root/kaidi-first-login.txt <<EOF
|
cat > /root/kaidi-first-login.txt <<EOF
|
||||||
URL: http://SERVER_IP/setup
|
URL after reverse proxy: http://SERVER_IP/setup
|
||||||
|
Reverse proxy target: $PROXY_TARGET
|
||||||
Setup code: $SETUP_CODE
|
Setup code: $SETUP_CODE
|
||||||
Version: $VERSION
|
Version: $VERSION
|
||||||
EOF
|
EOF
|
||||||
chmod 0600 /root/kaidi-first-login.txt
|
chmod 0600 /root/kaidi-first-login.txt
|
||||||
elif [ "$REINSTALL" != true ]; then
|
elif [ "$REINSTALL" != true ]; then
|
||||||
cat > /root/kaidi-first-login.txt <<EOF
|
cat > /root/kaidi-first-login.txt <<EOF
|
||||||
URL: http://SERVER_IP/
|
URL after reverse proxy: http://SERVER_IP/
|
||||||
|
Reverse proxy target: $PROXY_TARGET
|
||||||
Username: admin
|
Username: admin
|
||||||
Temporary password: $ADMIN_PASSWORD
|
Temporary password: $ADMIN_PASSWORD
|
||||||
Version: $VERSION
|
Version: $VERSION
|
||||||
@@ -809,10 +847,12 @@ INSTALL_TRANSACTION_ARMED=false
|
|||||||
|
|
||||||
log "Kaidi Finance $VERSION is installed"
|
log "Kaidi Finance $VERSION is installed"
|
||||||
if [ "$SETUP_WIZARD" = true ]; then
|
if [ "$SETUP_WIZARD" = true ]; then
|
||||||
log "Open http://SERVER_IP/setup and complete the first-run wizard"
|
log "Configure your reverse proxy to $PROXY_TARGET"
|
||||||
|
log "Open /setup through your reverse-proxy domain and complete the first-run wizard"
|
||||||
log "Setup code: /root/kaidi-first-login.txt"
|
log "Setup code: /root/kaidi-first-login.txt"
|
||||||
else
|
else
|
||||||
log "Open http://SERVER_IP/ and sign in as admin"
|
log "Configure your reverse proxy to $PROXY_TARGET"
|
||||||
|
log "Open the reverse-proxy domain and sign in as admin"
|
||||||
fi
|
fi
|
||||||
if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
|
if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
|
||||||
log "Temporary credentials: /root/kaidi-first-login.txt"
|
log "Temporary credentials: /root/kaidi-first-login.txt"
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ server {
|
|||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /opt/kaidi/current/public;
|
|
||||||
index index.html;
|
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
|
|
||||||
location /api/v1/ {
|
# Optional example. The installer does not install or modify Nginx.
|
||||||
|
# Replace 18080 with the KAIDI_APP_PORT selected during installation.
|
||||||
|
location / {
|
||||||
proxy_pass http://127.0.0.1:18080;
|
proxy_pass http://127.0.0.1:18080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -17,24 +16,4 @@ server {
|
|||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /actuator/health {
|
|
||||||
proxy_pass http://127.0.0.1:18080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
add_header Referrer-Policy same-origin always;
|
|
||||||
add_header X-Frame-Options SAMEORIGIN always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:js|css|woff2?|png|jpe?g|gif|svg|ico)$ {
|
|
||||||
try_files $uri =404;
|
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,5 @@ IOSchedulingPriority=6
|
|||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system /etc/nginx/conf.d
|
ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
|||||||
+20
-31
@@ -19,13 +19,11 @@ RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
|
|||||||
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
|
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
|
||||||
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
|
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
|
||||||
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
||||||
PUBLIC_HEALTH_URL=${KAIDI_PUBLIC_HEALTH_URL:-http://127.0.0.1/actuator/health}
|
APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
|
||||||
PUBLIC_INDEX_URL=${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}
|
|
||||||
LOCK_FILE=$STATE_ROOT/update.lock
|
LOCK_FILE=$STATE_ROOT/update.lock
|
||||||
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
|
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
|
||||||
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
|
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
|
||||||
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
|
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
|
||||||
NGINX_CONFIG=${KAIDI_NGINX_CONFIG:-/etc/nginx/conf.d/kaidi-finance.conf}
|
|
||||||
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
|
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
|
||||||
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
|
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
|
||||||
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
|
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
|
||||||
@@ -126,15 +124,18 @@ download() {
|
|||||||
release_asset_url() {
|
release_asset_url() {
|
||||||
asset_name=$1
|
asset_name=$1
|
||||||
if [ -n "$RELEASE_API_URL" ]; then
|
if [ -n "$RELEASE_API_URL" ]; then
|
||||||
asset_url=$(jq -er --arg name "$asset_name" \
|
jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
|
||||||
'.assets[] | select(.name == $name) | .browser_download_url
|
"$WORK_DIR/release-api.json" >/dev/null || return 1
|
||||||
| strings | select(startswith("https://") or startswith("http://"))' \
|
release_tag=$(jq -er '.tag_name | strings
|
||||||
"$WORK_DIR/release-api.json" | head -n 1) || return 1
|
| select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \
|
||||||
|
"$WORK_DIR/release-api.json") || return 1
|
||||||
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
|
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
|
||||||
case "$asset_url" in
|
repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
|
||||||
"$api_origin"/*) printf '%s\n' "$asset_url" ;;
|
's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
|
||||||
*) return 1 ;;
|
[ -n "$repo_path" ] || return 1
|
||||||
esac
|
encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri')
|
||||||
|
encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri')
|
||||||
|
printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset"
|
||||||
else
|
else
|
||||||
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
|
||||||
fi
|
fi
|
||||||
@@ -161,10 +162,10 @@ wait_for_health() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_public_surface() {
|
verify_app_surface() {
|
||||||
wait_for_health "$PUBLIC_HEALTH_URL" || return 1
|
wait_for_health "$HEALTH_URL" || return 1
|
||||||
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX")
|
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX")
|
||||||
if curl -fsS "$PUBLIC_INDEX_URL" -o "$index_file" \
|
if curl -fsS "$APP_INDEX_URL" -o "$index_file" \
|
||||||
&& grep -Eiq '<!doctype|<html' "$index_file"; then
|
&& grep -Eiq '<!doctype|<html' "$index_file"; then
|
||||||
rm -f "$index_file"
|
rm -f "$index_file"
|
||||||
return 0
|
return 0
|
||||||
@@ -266,8 +267,7 @@ backup_operations() {
|
|||||||
backup_managed_file "$UPDATER_PATH" update.sh \
|
backup_managed_file "$UPDATER_PATH" update.sh \
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
|
||||||
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path \
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
|
||||||
&& backup_managed_file "$NGINX_CONFIG" kaidi-finance.conf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_operations() {
|
apply_operations() {
|
||||||
@@ -278,9 +278,7 @@ apply_operations() {
|
|||||||
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
|
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
|
||||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
|
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
|
||||||
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
|
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
|
||||||
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.conf" "$NGINX_CONFIG" 0644 \
|
&& systemctl daemon-reload
|
||||||
&& systemctl daemon-reload \
|
|
||||||
&& nginx -t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_operations() {
|
restore_operations() {
|
||||||
@@ -289,13 +287,7 @@ restore_operations() {
|
|||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
|
||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1
|
||||||
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1
|
||||||
restore_managed_file "$NGINX_CONFIG" kaidi-finance.conf || restore_failed=1
|
|
||||||
systemctl daemon-reload || restore_failed=1
|
systemctl daemon-reload || restore_failed=1
|
||||||
if nginx -t; then
|
|
||||||
systemctl reload nginx || restore_failed=1
|
|
||||||
else
|
|
||||||
restore_failed=1
|
|
||||||
fi
|
|
||||||
[ "$restore_failed" -eq 0 ]
|
[ "$restore_failed" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +330,7 @@ rollback_active_transaction() {
|
|||||||
fi
|
fi
|
||||||
restore_operations || rollback_ok=false
|
restore_operations || rollback_ok=false
|
||||||
systemctl start "$SERVICE_NAME" || rollback_ok=false
|
systemctl start "$SERVICE_NAME" || rollback_ok=false
|
||||||
if [ "$rollback_ok" = true ] && wait_for_health "$HEALTH_URL" && verify_public_surface; then
|
if [ "$rollback_ok" = true ] && verify_app_surface; then
|
||||||
remove_failed_release "$failed_release"
|
remove_failed_release "$failed_release"
|
||||||
rm -rf "$ACTIVE_TRANSACTION"
|
rm -rf "$ACTIVE_TRANSACTION"
|
||||||
fail "$reason; previous release was restored and verified"
|
fail "$reason; previous release was restored and verified"
|
||||||
@@ -499,7 +491,6 @@ fi
|
|||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing"
|
[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing"
|
||||||
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.conf" ] || fail "Release Nginx configuration is missing"
|
|
||||||
validate_release_operations || fail "Release operations validation failed"
|
validate_release_operations || fail "Release operations validation failed"
|
||||||
|
|
||||||
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
|
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
|
||||||
@@ -562,10 +553,8 @@ write_transaction_value phase APP_SWITCHED
|
|||||||
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION"
|
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION"
|
||||||
write_transaction_value phase HEALTH_CHECKING
|
write_transaction_value phase HEALTH_CHECKING
|
||||||
if systemctl start "$SERVICE_NAME" \
|
if systemctl start "$SERVICE_NAME" \
|
||||||
&& wait_for_health "$HEALTH_URL" \
|
|
||||||
&& systemctl reload nginx \
|
|
||||||
&& systemctl is-active --quiet kaidi-update.path \
|
&& systemctl is-active --quiet kaidi-update.path \
|
||||||
&& verify_public_surface; then
|
&& verify_app_surface; then
|
||||||
write_transaction_value phase COMMITTED
|
write_transaction_value phase COMMITTED
|
||||||
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
|
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
|
||||||
TERMINAL_STATUS_WRITTEN=true
|
TERMINAL_STATUS_WRITTEN=true
|
||||||
@@ -574,4 +563,4 @@ if systemctl start "$SERVICE_NAME" \
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rollback_active_transaction "Release health or public-surface verification failed"
|
rollback_active_transaction "Release health or application-surface verification failed"
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# 财务项目基础系统开发交付总方案
|
# 财务项目基础系统开发交付总方案
|
||||||
|
|
||||||
> 文档编号:KD-FIN-DELIVERY-001<br>
|
> 文档编号:KD-FIN-DELIVERY-001<br>
|
||||||
> 版本:V3.25(Preview.8 首次安装向导与部署收口)<br>
|
> 版本:V3.26(Preview.9 公共 Release 一键安装)<br>
|
||||||
> 状态:第一版 Preview 候选包已具备 22 页功能框架、核心业务链和可部署制品;PAGE-13/14 已通过模块验收,PAGE-12/16/17/18/19/20/21/22 为 `IMPLEMENTED_PENDING_ACCEPTANCE`,PAGE-15 正式业务矩阵仍为 `IN_PROGRESS`;整套 R1 尚未达到第 12.9 节 Definition of Done,不得把 Preview 上线等同于甲方最终验收<br>
|
> 状态:第一版 Preview 候选包已具备 22 页功能框架、核心业务链和可部署制品;PAGE-13/14 已通过模块验收,PAGE-12/16/17/18/19/20/21/22 为 `IMPLEMENTED_PENDING_ACCEPTANCE`,PAGE-15 正式业务矩阵仍为 `IN_PROGRESS`;整套 R1 尚未达到第 12.9 节 Definition of Done,不得把 Preview 上线等同于甲方最终验收<br>
|
||||||
> 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图<br>
|
> 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图<br>
|
||||||
> 最新项目决策:按既定 Java/TDesign 技术栈交付第一版 Preview;2026-08-17 已完成 PAGE-08 来源、记账、档案双向穿透,PAGE-20 审计稳定排序/筛选/脱敏导出、PAGE-21 权限与配置、PAGE-22 私有 Gitea 签名在线更新、OA-04/OA-06 动态附件矩阵、OA-06 财务终审付款投影、Release/SBOM/依赖门禁、Linux i386/i486/i586/i686 外部 MySQL 8.4.x 单命令安装、更新请求持久领取/中断恢复/数据库备份校验/回滚健康复核,以及隔离超级管理员全部功能权限和全局数据范围;本轮新增 PAGE-23 首次安装向导、无业务数据库启动上下文、MySQL 8.4 DDL/DML 预检、一次性安装码和安装状态锁定,数据库基线新增 `V072`。远端仓库已固定为 `https://git.awaioi.com/ERP-Team/kaidi.git`;配置 Gitea Actions 签名 Secret、公钥指纹 Variable、可用 runner 和生产只读 Token 后,推送符合 SemVer 的 `v*` tag 即生成 Release;PAGE-15 完整业务矩阵继续保持 `DRAFT`,须经 D-02/D-09 确认后才允许作为正式规则发布<br>
|
> 最新项目决策:按既定 Java/TDesign 技术栈交付第一版 Preview;2026-08-17 已完成 PAGE-08 来源、记账、档案双向穿透,PAGE-20 审计稳定排序/筛选/脱敏导出、PAGE-21 权限与配置、PAGE-22 公共 Gitea 签名在线更新、OA-04/OA-06 动态附件矩阵、OA-06 财务终审付款投影、Release/SBOM/依赖门禁、Linux i386/i486/i586/i686 外部 MySQL 8.4.x 单命令安装、更新请求持久领取/中断恢复/数据库备份校验/回滚健康复核,以及隔离超级管理员全部功能权限和全局数据范围;本轮新增 PAGE-23 首次安装向导、无业务数据库启动上下文、MySQL 8.4 DDL/DML 预检、一次性安装码和安装状态锁定,数据库基线新增 `V072`。远端仓库 `https://git.awaioi.com/ERP-Team/kaidi.git` 与 Release 均已公开;安装和在线更新默认不使用 Token,安装器内置发布公钥指纹,推送符合 SemVer 的 `v*` tag 后由带签名密钥的发布流程生成 Release;PAGE-15 完整业务矩阵继续保持 `DRAFT`,须经 D-02/D-09 确认后才允许作为正式规则发布<br>
|
||||||
> 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能
|
> 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能
|
||||||
> 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线<br>
|
> 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线<br>
|
||||||
> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训<br>
|
> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训<br>
|
||||||
@@ -72,14 +72,14 @@
|
|||||||
| 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 |
|
| 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 |
|
||||||
| OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 |
|
| OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 |
|
||||||
| OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 |
|
| OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 |
|
||||||
| 本轮运行验收 | 最近完整后端 MySQL 8.4/Testcontainers 基线 `213/213`,Failures/Errors/Skipped 均为 0,迁移基线扩展至 `V072`;本轮新增 setup context smoke、安装器/包装器夹具和 PAGE-23 Playwright 回归;最新签名候选为 `1.0.0-preview.8`,正式 Release 仍须在干净 Git tag 上由 Gitea Actions 重新生成安装器摘要 |
|
| 本轮运行验收 | 最近完整后端 MySQL 8.4/Testcontainers 基线 `213/213`,Failures/Errors/Skipped 均为 0,迁移基线扩展至 `V072`;本轮新增 setup context smoke、安装器/包装器夹具和 PAGE-23 Playwright 回归;`1.0.0-preview.8` 已发布 9 个验签资产,Preview.9 进一步将公共仓库安装入口精简为单行 `curl | sudo bash` |
|
||||||
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
|
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
|
||||||
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
||||||
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
||||||
| PAGE-22 在线更新 | 更新源固定为私有 Gitea Latest Release API;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 |
|
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 |
|
||||||
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
||||||
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
|
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
|
||||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。私有 Gitea Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,私有 Gitea、跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||||
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
||||||
| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 |
|
| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 |
|
||||||
| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 |
|
| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 |
|
||||||
@@ -1006,7 +1006,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
|||||||
|
|
||||||
更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。
|
更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。
|
||||||
|
|
||||||
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。Gitea 只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
|
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
|
||||||
|
|
||||||
### 8.23 页面通用要求
|
### 8.23 页面通用要求
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ cp "$JAR" "$STAGE/app.jar"
|
|||||||
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
||||||
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
||||||
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
|
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
|
||||||
|
# Kept in the archive so Preview.9's updater can complete the one-time transition.
|
||||||
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
|
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
|
||||||
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
|
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
|
||||||
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
|
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
|
||||||
@@ -98,7 +99,7 @@ cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
|
|||||||
chmod 0755 "$STAGE/ops/update.sh"
|
chmod 0755 "$STAGE/ops/update.sh"
|
||||||
|
|
||||||
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
|
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
|
||||||
COPYFILE_DISABLE=1 tar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
|
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
|
||||||
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT")
|
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT")
|
||||||
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
|
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
|
||||||
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
|
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
|
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
|
||||||
[ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]
|
[ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]
|
||||||
|
if [ "${EXPECT_PORT:-false}" = true ]; then
|
||||||
|
[ "$KAIDI_APP_PORT" = '19090' ]
|
||||||
|
[ "$KAIDI_SERVER_ADDRESS" = '127.0.0.1' ]
|
||||||
|
fi
|
||||||
if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then
|
if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then
|
||||||
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
|
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
|
||||||
else
|
else
|
||||||
@@ -30,7 +34,11 @@ else
|
|||||||
[ "$KAIDI_DB_USERNAME" = 'kaidi' ]
|
[ "$KAIDI_DB_USERNAME" = 'kaidi' ]
|
||||||
[ "$KAIDI_DB_PASSWORD" = 'fixture-password' ]
|
[ "$KAIDI_DB_PASSWORD" = 'fixture-password' ]
|
||||||
fi
|
fi
|
||||||
[ "$(cat "$KAIDI_RELEASE_TOKEN_FILE")" = 'fixture-read-token' ]
|
if [ "${EXPECT_TOKEN:-false}" = true ]; then
|
||||||
|
[ "$(cat "$KAIDI_RELEASE_TOKEN_FILE")" = 'fixture-read-token' ]
|
||||||
|
else
|
||||||
|
[ -z "${KAIDI_RELEASE_TOKEN_FILE:-}" ]
|
||||||
|
fi
|
||||||
printf 'fixture-installer: PASS\n'
|
printf 'fixture-installer: PASS\n'
|
||||||
SH
|
SH
|
||||||
|
|
||||||
@@ -40,16 +48,26 @@ chmod 0600 "$FIXTURE/token"
|
|||||||
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
||||||
|
|
||||||
PATH="$FIXTURE/bin:$PATH" \
|
PATH="$FIXTURE/bin:$PATH" \
|
||||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
EXPECT_PORT=true \
|
||||||
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
||||||
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
||||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||||
|
KAIDI_APP_PORT=19090 \
|
||||||
|
KAIDI_SERVER_ADDRESS=127.0.0.1 \
|
||||||
KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \
|
KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \
|
||||||
KAIDI_DB_USERNAME=kaidi \
|
KAIDI_DB_USERNAME=kaidi \
|
||||||
KAIDI_DB_PASSWORD=fixture-password \
|
KAIDI_DB_PASSWORD=fixture-password \
|
||||||
"$FIXTURE/repo/deploy/install-from-git.sh"
|
"$FIXTURE/repo/deploy/install-from-git.sh"
|
||||||
|
|
||||||
PATH="$FIXTURE/bin:$PATH" \
|
PATH="$FIXTURE/bin:$PATH" \
|
||||||
|
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_SETUP_WIZARD=true \
|
||||||
|
"$FIXTURE/repo/deploy/install-from-git.sh"
|
||||||
|
|
||||||
|
PATH="$FIXTURE/bin:$PATH" \
|
||||||
|
EXPECT_TOKEN=true \
|
||||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
||||||
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
||||||
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
||||||
@@ -58,7 +76,6 @@ KAIDI_SETUP_WIZARD=true \
|
|||||||
"$FIXTURE/repo/deploy/install-from-git.sh"
|
"$FIXTURE/repo/deploy/install-from-git.sh"
|
||||||
|
|
||||||
if PATH="$FIXTURE/bin:$PATH" \
|
if PATH="$FIXTURE/bin:$PATH" \
|
||||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
|
||||||
KAIDI_INSTALLER_SHA256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \
|
KAIDI_INSTALLER_SHA256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \
|
||||||
"$FIXTURE/repo/deploy/install-from-git.sh" > "$FIXTURE/hash-mismatch.log" 2>&1; then
|
"$FIXTURE/repo/deploy/install-from-git.sh" > "$FIXTURE/hash-mismatch.log" 2>&1; then
|
||||||
printf 'Mismatched installer SHA-256 was accepted\n' >&2
|
printf 'Mismatched installer SHA-256 was accepted\n' >&2
|
||||||
|
|||||||
@@ -17,18 +17,27 @@ fail() {
|
|||||||
sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^read_reinstall_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^read_reinstall_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^port_is_listening()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^valid_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
|
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
|
sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||||
} > "$WORK/helpers.sh"
|
} > "$WORK/helpers.sh"
|
||||||
# shellcheck disable=SC1090,SC1091
|
# shellcheck disable=SC1090,SC1091
|
||||||
source "$WORK/helpers.sh"
|
source "$WORK/helpers.sh"
|
||||||
|
|
||||||
|
export SETUP_WIZARD=true
|
||||||
|
preflight_database || fail 'setup wizard database preflight returned a failure status'
|
||||||
|
|
||||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||||
REINSTALL=true
|
REINSTALL=true
|
||||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||||
@@ -78,6 +87,31 @@ grep -qx 'jdbc:mysql://runtime/kaidi_finance' <(read_reinstall_env DB_URL) \
|
|||||||
grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \
|
grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \
|
||||||
|| fail 'reinstall did not prefer the completed setup runtime database user'
|
|| fail 'reinstall did not prefer the completed setup runtime database user'
|
||||||
|
|
||||||
|
for port in 1024 18080 65535; do
|
||||||
|
valid_app_port "$port" || fail "installer rejected valid application port $port"
|
||||||
|
done
|
||||||
|
for port in 0 80 1023 65536 invalid 18080.0; do
|
||||||
|
! valid_app_port "$port" || fail "installer accepted invalid application port $port"
|
||||||
|
done
|
||||||
|
port_is_listening() { return 1; }
|
||||||
|
log() { printf '%s\n' "$*" >/dev/null; }
|
||||||
|
# shellcheck disable=SC2329 # Invoked by the extracted installer helper.
|
||||||
|
die() { printf '%s\n' "$*" >&2; return 1; }
|
||||||
|
export APP_PORT=19090 SERVER_ADDRESS=127.0.0.1 HEALTH_URL='' APP_INDEX_URL='' REINSTALL=false
|
||||||
|
configure_app_port
|
||||||
|
[ "$HEALTH_URL" = 'http://127.0.0.1:19090/actuator/health' ] \
|
||||||
|
|| fail 'selected application port did not reach the health URL'
|
||||||
|
[ "$APP_INDEX_URL" = 'http://127.0.0.1:19090/' ] \
|
||||||
|
|| fail 'selected application port did not reach the frontend URL'
|
||||||
|
export APP_PORT=19091 SERVER_ADDRESS=::1 HEALTH_URL='' APP_INDEX_URL=''
|
||||||
|
configure_app_port
|
||||||
|
[ "$HEALTH_URL" = 'http://[::1]:19091/actuator/health' ] \
|
||||||
|
|| fail 'IPv6 bind address did not produce a bracketed health URL'
|
||||||
|
[ "$APP_INDEX_URL" = 'http://[::1]:19091/' ] \
|
||||||
|
|| fail 'IPv6 bind address did not produce a bracketed frontend URL'
|
||||||
|
[ "$PROXY_TARGET" = 'http://[::1]:19091' ] \
|
||||||
|
|| fail 'IPv6 bind address did not produce a bracketed reverse-proxy target'
|
||||||
|
|
||||||
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
|
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
|
||||||
is_semver "$version" || fail "installer rejected valid SemVer $version"
|
is_semver "$version" || fail "installer rejected valid SemVer $version"
|
||||||
"$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version"
|
"$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version"
|
||||||
@@ -128,8 +162,15 @@ download() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
ARCH_FIXTURE=i686
|
ARCH_FIXTURE=i686
|
||||||
|
export KAIDI_JAVA_HOME="$WORK/missing-java-home"
|
||||||
prepare_java >/dev/null 2>&1
|
prepare_java >/dev/null 2>&1
|
||||||
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
|
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
|
||||||
|
rm -rf "$JAVA_STAGED_DIR"
|
||||||
|
export KAIDI_JAVA_HOME="$WORK/fake-jre"
|
||||||
|
prepare_java >/dev/null 2>&1
|
||||||
|
[ -L "$JAVA_STAGED_DIR" ] || fail 'existing Java 17 runtime was not reused'
|
||||||
|
[ "$(readlink -f "$JAVA_STAGED_DIR")" = "$(readlink -f "$WORK/fake-jre")" ] \
|
||||||
|
|| fail 'installer linked an unexpected local Java runtime'
|
||||||
|
|
||||||
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
|
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
|
||||||
RELEASE_TOKEN=fixture-read-only-token
|
RELEASE_TOKEN=fixture-read-only-token
|
||||||
@@ -137,7 +178,7 @@ RELEASE_AUTH_HEADER_FILE=$WORK/release-auth-header
|
|||||||
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
|
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
|
||||||
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
|
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
|
||||||
cat > "$WORK/release-api.json" <<'JSON'
|
cat > "$WORK/release-api.json" <<'JSON'
|
||||||
{"assets":[
|
{"tag_name":"v1.0.0-preview.11","assets":[
|
||||||
{"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"}
|
{"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"}
|
||||||
]}
|
]}
|
||||||
JSON
|
JSON
|
||||||
@@ -160,12 +201,13 @@ curl() {
|
|||||||
done
|
done
|
||||||
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
|
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
|
||||||
grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91
|
grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91
|
||||||
[ "$url" = https://gitea.fixture.invalid/assets/release-manifest.json ] || return 92
|
[ "$url" = https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|
||||||
|
|| return 92
|
||||||
cp "$WORK/release-manifest.fixture" "$output"
|
cp "$WORK/release-manifest.fixture" "$output"
|
||||||
}
|
}
|
||||||
[ "$(release_asset_url release-manifest.json)" = \
|
[ "$(release_asset_url release-manifest.json)" = \
|
||||||
https://gitea.fixture.invalid/assets/release-manifest.json ] \
|
https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|
||||||
|| fail 'installer did not resolve the private Gitea release asset'
|
|| fail 'installer did not construct the trusted Gitea release asset URL'
|
||||||
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
|
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
|
||||||
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|
||||||
|| fail 'installer did not download the private Gitea release asset'
|
|| fail 'installer did not download the private Gitea release asset'
|
||||||
@@ -177,9 +219,9 @@ cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|
|||||||
jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \
|
jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \
|
||||||
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
|
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
|
||||||
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
|
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
|
||||||
if release_asset_url release-manifest.json >/dev/null 2>&1; then
|
[ "$(release_asset_url release-manifest.json)" = \
|
||||||
fail 'installer accepted a cross-origin Gitea release asset'
|
https://gitea.fixture.invalid/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/release-manifest.json ] \
|
||||||
fi
|
|| fail 'installer trusted the cross-origin browser download URL'
|
||||||
|
|
||||||
# shellcheck disable=SC2016 # Match literal installer source.
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|
grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|
||||||
@@ -195,17 +237,59 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
|
|||||||
# shellcheck disable=SC2016 # Match literal installer source.
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer no longer obtains the Java runtime SHA-256'
|
|| fail 'installer no longer obtains the Java runtime SHA-256'
|
||||||
|
grep -Fq 'Using existing Java 17 runtime' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer no longer reuses a local Java 17 runtime'
|
||||||
# shellcheck disable=SC2016 # Match literal installer source.
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer no longer verifies the Java runtime SHA-256'
|
|| fail 'installer no longer verifies the Java runtime SHA-256'
|
||||||
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer default release source is not the private Gitea latest API'
|
|| fail 'installer default release source is not the public Gitea latest API'
|
||||||
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
|
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 '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" \
|
grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer no longer supports a protected initial Gitea token file'
|
|| fail 'installer no longer supports a protected initial Gitea token file'
|
||||||
grep -Fq 'KAIDI_SETUP_WIZARD' "$ROOT/deploy/install.sh" \
|
grep -Fq 'KAIDI_SETUP_WIZARD' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer no longer supports first-run setup mode'
|
|| fail 'installer no longer supports first-run setup mode'
|
||||||
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'installer no longer writes the one-time setup-code hash'
|
|| fail 'installer no longer writes the one-time setup-code hash'
|
||||||
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
|
grep -Fq 'SERVER_PORT "$APP_PORT"' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not persist the selected application port'
|
||||||
|
# shellcheck disable=SC2016 # Match literal installer source.
|
||||||
|
grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer does not persist the selected bind address'
|
||||||
|
! grep -Eqi 'nginx|/etc/nginx/' "$ROOT/deploy/install.sh" \
|
||||||
|
|| fail 'installer must not install, start, or modify Nginx'
|
||||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||||
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||||
|| fail 'application service no longer loads the setup-completion environment'
|
|| fail 'application service no longer loads the setup-completion environment'
|
||||||
@@ -215,7 +299,7 @@ grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
|||||||
# shellcheck disable=SC2016 # Match the literal installer source.
|
# shellcheck disable=SC2016 # Match the literal installer source.
|
||||||
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
||||||
|| fail 'update state parent is not group-accessible to the application user'
|
|| fail 'update state parent is not group-accessible to the application user'
|
||||||
grep -Fq 'KAIDI_SETUP_WIZARD=true' "$ROOT/README.md" \
|
grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \
|
||||||
|| fail 'README does not document the setup-wizard install path'
|
|| fail 'README does not document the public one-line setup-wizard install path'
|
||||||
|
|
||||||
printf 'Install configuration, private Gitea, 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'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ esac
|
|||||||
if [ -n "$output" ]; then
|
if [ -n "$output" ]; then
|
||||||
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
|
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
|
||||||
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
|
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
|
||||||
elif [ "$url" = "${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}" ]; then
|
elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then
|
||||||
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
|
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
|
||||||
else
|
else
|
||||||
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
|
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
|
||||||
@@ -75,12 +75,6 @@ SH
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
||||||
exit 0
|
exit 0
|
||||||
SH
|
|
||||||
|
|
||||||
cat > "$mock_bin/nginx" <<'SH'
|
|
||||||
#!/bin/sh
|
|
||||||
printf '%s\n' "$*" >> "$MOCK_NGINX_LOG"
|
|
||||||
exit 0
|
|
||||||
SH
|
SH
|
||||||
|
|
||||||
cat > "$mock_bin/flock" <<'SH'
|
cat > "$mock_bin/flock" <<'SH'
|
||||||
@@ -138,7 +132,7 @@ build_release() {
|
|||||||
printf '%s\n' "$version" > "$stage/VERSION"
|
printf '%s\n' "$version" > "$stage/VERSION"
|
||||||
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
||||||
chmod 0755 "$stage/ops/update.sh"
|
chmod 0755 "$stage/ops/update.sh"
|
||||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do
|
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||||
printf 'new %s\n' "$name" > "$stage/ops/$name"
|
printf 'new %s\n' "$name" > "$stage/ops/$name"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -160,10 +154,12 @@ build_release() {
|
|||||||
build_gitea_release_index() {
|
build_gitea_release_index() {
|
||||||
local fixture=$1
|
local fixture=$1
|
||||||
local origin=${2:-https://gitea.fixture.invalid}
|
local origin=${2:-https://gitea.fixture.invalid}
|
||||||
local artifact
|
local artifact version tag
|
||||||
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
|
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
|
||||||
jq -n --arg origin "$origin" --arg artifact "$artifact" \
|
version=$(jq -er '.version' "$fixture/release/release-manifest.json")
|
||||||
'{assets:[
|
tag="v$version"
|
||||||
|
jq -n --arg origin "$origin" --arg artifact "$artifact" --arg tag "$tag" \
|
||||||
|
'{tag_name:$tag,assets:[
|
||||||
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
|
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
|
||||||
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
|
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
|
||||||
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
|
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
|
||||||
@@ -174,7 +170,7 @@ prepare_installation() {
|
|||||||
local fixture=$1
|
local fixture=$1
|
||||||
local version=$2
|
local version=$2
|
||||||
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
|
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
|
||||||
"$fixture/state/inbox" "$fixture/systemd" "$fixture/nginx" "$fixture/log"
|
"$fixture/state/inbox" "$fixture/systemd" "$fixture/log"
|
||||||
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
|
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
|
||||||
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
|
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
|
||||||
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
|
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
|
||||||
@@ -184,7 +180,6 @@ prepare_installation() {
|
|||||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||||
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
|
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
|
||||||
done
|
done
|
||||||
printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf"
|
|
||||||
write_request "$fixture" "$version" DOWNLOAD
|
write_request "$fixture" "$version" DOWNLOAD
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,12 +222,10 @@ run_update() {
|
|||||||
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
||||||
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||||
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||||
MOCK_NGINX_LOG="$fixture/nginx.log" \
|
|
||||||
KAIDI_APP_ROOT="$fixture/app" \
|
KAIDI_APP_ROOT="$fixture/app" \
|
||||||
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
|
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
|
||||||
KAIDI_LOG_ROOT="$fixture/log" \
|
KAIDI_LOG_ROOT="$fixture/log" \
|
||||||
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
||||||
KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \
|
|
||||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||||
@@ -243,9 +236,8 @@ run_update() {
|
|||||||
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
|
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
|
||||||
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
|
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
|
||||||
UPDATE_STATUS_FILE="$fixture/state/status.json" \
|
UPDATE_STATUS_FILE="$fixture/state/status.json" \
|
||||||
KAIDI_HEALTH_URL=http://127.0.0.1:18080/actuator/health \
|
KAIDI_HEALTH_URL=http://127.0.0.1:19090/actuator/health \
|
||||||
KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \
|
KAIDI_APP_INDEX_URL=http://127.0.0.1:19090/ \
|
||||||
KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \
|
|
||||||
"$ROOT/deploy/update.sh"
|
"$ROOT/deploy/update.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +269,7 @@ assert_private_gitea_release_case() {
|
|||||||
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects'
|
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects'
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_cross_origin_gitea_asset_rejected() {
|
assert_cross_origin_gitea_browser_url_ignored() {
|
||||||
local fixture="$WORK/cross-origin-gitea"
|
local fixture="$WORK/cross-origin-gitea"
|
||||||
local version='1.0.0-preview.2'
|
local version='1.0.0-preview.2'
|
||||||
mkdir -p "$fixture"
|
mkdir -p "$fixture"
|
||||||
@@ -286,18 +278,16 @@ assert_cross_origin_gitea_asset_rejected() {
|
|||||||
build_gitea_release_index "$fixture" https://assets.fixture.invalid
|
build_gitea_release_index "$fixture" https://assets.fixture.invalid
|
||||||
prepare_installation "$fixture" "$version"
|
prepare_installation "$fixture" "$version"
|
||||||
|
|
||||||
if FIXTURE_RELEASE_BASE_URL='' \
|
FIXTURE_RELEASE_BASE_URL='' \
|
||||||
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
|
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
|
||||||
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
|
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
|
||||||
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
|
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
|
||||||
MOCK_CURL_LOG="$fixture/curl.log" \
|
MOCK_CURL_LOG="$fixture/curl.log" \
|
||||||
run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
run_update "$fixture" success
|
||||||
fail 'cross-origin Gitea asset unexpectedly succeeded'
|
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|
||||||
fi
|
|| fail 'misconfigured browser download URL prevented trusted same-origin download'
|
||||||
grep -q 'Release manifest asset is missing' "$fixture/update.log" \
|
|
||||||
|| fail 'cross-origin Gitea asset rejection was not reported'
|
|
||||||
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|
||||||
|| fail 'cross-origin Gitea asset was requested'
|
|| fail 'cross-origin browser download URL was requested'
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_success_case() {
|
assert_success_case() {
|
||||||
@@ -319,7 +309,7 @@ assert_success_case() {
|
|||||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||||
|| fail 'success case left a claimed request behind'
|
|| fail 'success case left a claimed request behind'
|
||||||
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
|
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
|
||||||
grep -qx 'reload nginx' "$fixture/systemctl.log" || fail 'Nginx was not reloaded'
|
! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx'
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_rollback_case() {
|
assert_rollback_case() {
|
||||||
@@ -440,5 +430,5 @@ assert_database_failure_case
|
|||||||
assert_symlink_request_rejected
|
assert_symlink_request_rejected
|
||||||
assert_install_without_verified_cache_rejected
|
assert_install_without_verified_cache_rejected
|
||||||
assert_private_gitea_release_case
|
assert_private_gitea_release_case
|
||||||
assert_cross_origin_gitea_asset_rejected
|
assert_cross_origin_gitea_browser_url_ignored
|
||||||
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
|
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
|
||||||
|
|||||||
Reference in New Issue
Block a user