diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 94c11f9..9cad5cf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -68,7 +68,7 @@ jobs: ./scripts/test-baota-start-fixture.sh ./scripts/test-purge-fixture.sh ./scripts/test-gitea-publish-fixture.sh - shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh \ + shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh deploy/baota-init.sh \ deploy/purge.sh \ scripts/check-semver.sh \ scripts/package-release.sh scripts/publish-gitea-release.sh \ diff --git a/README.md b/README.md index 0b95699..1889c77 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,11 @@ npm run build ./scripts/export-openapi.sh http://127.0.0.1:18080 ``` -## R1 Preview 一键安装 +## R1 Preview 手动部署 -代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取 -`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。 -安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。 -Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用 -Gitea 响应中可能错误指向内网地址的 `browser_download_url`。 -本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有 -PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。 - -执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java -由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的 -外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、 -`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。 +代码仓库和 Release 已公开。本版交付物是签名的应用压缩包,部署方式固定为“下载、校验、解压、运行本地初始化脚本、 +在宝塔创建 Spring Boot 项目”;不再使用 `curl | bash` 一键安装。程序不会安装 MySQL、JDK、Nginx 或其他第三方服务, +也不会修改宝塔和反向代理配置。数据库只支持运维人员预先准备的外部 MySQL 8.4.x,PostgreSQL 18 兼容工作继续冻结。 ### 彻底清理旧安装 @@ -76,201 +67,125 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 -### 直接 curl 安装 +### Preview.25 直链与宝塔手动部署 + +本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 +Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 + +下载地址: + +`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.25/kaidi-finance-1.0.0-preview.25.tar.gz` + +校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.25/SHA256SUMS` + +服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机 +已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要 +`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;启用在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser` +以及与外部 MySQL 兼容的 `mysqldump`。程序不会替你安装这些依赖。 + +#### 1. 下载、校验、解压 + +以下命令只做文件下载和解压,不执行远程脚本。`VERSION` 必须替换成发布版本;归档内的 `app.jar`、`public/`、`ops/` +必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash +VERSION=1.0.0-preview.25 +APP_ROOT=/www/wwwroot/kaidi +RELEASE_ROOT="$APP_ROOT/releases/$VERSION" +sudo install -d -m 0755 "$RELEASE_ROOT" +wget -O "/tmp/kaidi-finance-$VERSION.tar.gz" \ + "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz" +wget -O /tmp/kaidi-SHA256SUMS \ + "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/SHA256SUMS" +(cd /tmp && grep " kaidi-finance-$VERSION.tar.gz$" kaidi-SHA256SUMS | sha256sum -c -) +sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT" ``` -这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup` -安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: +如果服务器没有 `wget`,可使用浏览器下载后上传同一文件;`curl -fL URL -o FILE` 也只用于下载压缩包,不能替代初始化脚本。 + +#### 2. 执行本地初始化 + +初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置、在线更新监听器和一次性 +安装码;不会下载 Java、安装 MySQL 或修改反向代理。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ - | sudo env KAIDI_APP_PORT=19090 bash +sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080 +sudo cat /root/kaidi-first-login.txt ``` -端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java -时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: +初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。 -```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh -o /tmp/kaidi-install.sh -printf '%s %s\n' 6256b05025aa314d4c02e7c0f5d2ca530f28ec5d10f833504b34c09ada6e02a5 /tmp/kaidi-install.sh | sha256sum -c - -sudo bash /tmp/kaidi-install.sh -rm -f /tmp/kaidi-install.sh -``` +#### 3. 宝塔 Spring Boot 项目字段 -### 从固定 Git tag 拉取后安装 +在宝塔面板创建项目,类型选择 **Spring Boot**,不要选择 Tomcat。填写如下: -需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。公共仓库不要求凭据, -包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 +| 字段 | 填写值 | +| --- | --- | +| 项目路径 | `/www/wwwroot/kaidi/current` | +| 项目名称 | `kaidi-finance` | +| 项目 JDK | `JDK 17` | +| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` | +| 启动用户 | `kaidi` | +| 项目端口 | `18080`(与初始化参数一致) | +| 绑定域名 | 业务域名,例如 `fi.awaioi.com` | +| 后端路由 | `/` | +| 前端资源 | 留空,Spring Boot 直接提供 `public/` | +| 进程守护 | 开启 | +| 项目意外重启 | 勾选 | -```bash -git clone --branch v1.0.0-preview.24 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview -cd kaidi-preview -./deploy/install-from-git.sh -``` +**环境变量全部留空,不要创建 DB_URL、DB_USERNAME、DB_PASSWORD 或 FIELD_ENCRYPTION_KEY。** +`ops/baota.env.example` 仅是说明文件,不能把其中的占位值粘贴到面板。启动器先读取 `/etc/kaidi/kaidi.env`,向导完成后 +再读取 `/var/lib/kaidi/setup/application.env`;宝塔只有填写了非空覆盖值时才会覆盖受保护配置。数据库密码不会出现在 +Java 启动命令或 `ps` 参数中。 -首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。 -只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。 +#### 4. 首次向导与反向代理 -安装器会完成以下动作: - -- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。 -- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。 -- 识别 `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,并在提交前验证完整迁移权限。 -- 安装签名 Release 到 `/opt/kaidi/releases/`,以 `/opt/kaidi/current` 原子切换当前版本。 -- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。 -- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 -- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。 - -### 环境变量与密钥安全 - -首次安装不需要在命令行或宝塔项目中填写数据库环境变量。数据库地址、账号和密码只在 `/setup` 向导中提交, -完成后写入 `/var/lib/kaidi/setup/application.env`;字段加密密钥由安装器生成并写入 -`/etc/kaidi/kaidi.env`。两个文件不会把秘密放进 Java 启动参数或 `ps` 输出,并由 Linux 文件权限限制为 -root 或专用 `kaidi` 服务账号读取。运行中的 Java 仍必须持有这些值,因此 root 和该服务账号始终属于受信边界。 - -不要通过公网 HTTP 提交数据库密码。应先为域名配置有效 HTTPS,或通过 SSH 端口转发访问本机回环地址完成向导: +启动宝塔项目后,访问 `https://业务域名/setup`,输入 `/root/kaidi-first-login.txt` 中的一次性安装码,填写外部 MySQL +连接信息和系统管理员账号密码。迁移完成后应用会退出一次,宝塔守护进程会自动拉起正式模式;再次访问域名根路径即可登录。 +首次配置数据库密码必须使用 HTTPS;没有域名时通过 SSH 隧道访问回环端口: ```bash ssh -L 18080:127.0.0.1:18080 root@SERVER_IP ``` -然后在本机浏览器打开 `http://127.0.0.1:18080/setup`。向导完成后自动锁定,安装码不能再次使用。 - -安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后, -通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置 -`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为 -`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。 - -以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件: +本机浏览器打开 `http://127.0.0.1:18080/setup`。反向代理只需把整个 `/` 转发到 `http://127.0.0.1:18080`,并保留以下请求头: ```nginx location / { - proxy_pass http://127.0.0.1:19090; + proxy_pass http://127.0.0.1:18080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; proxy_read_timeout 120s; client_max_body_size 500m; } ``` -宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息: - -```bash -curl -fsS http://127.0.0.1:19090/actuator/health | jq -e '.status == "UP"' -sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path -sudo cat /root/kaidi-first-login.txt -``` - -### Linux 32 位 - -应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的 -32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: - -```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash -``` - -无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 -完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、 -临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和 -排序规则,全部通过后 Flyway 才会建表。 -数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: - -```sql -CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD'; -GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP'; -``` - -### 修复性重装 - -正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装; -安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、 -管理员数据和运维人员新增的环境变量: - -```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ - | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash -``` - -没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整, -不会把恢复失败吞掉。新版本安装器在 `KAIDI_REINSTALL=true` 时默认使用生产修复模式,显式的 -`KAIDI_SETUP_WIZARD=false` 仅用于兼容旧版本安装器。 - -如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 - -```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ - | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=true bash -``` - -### 宝塔面板手动部署 - -宝塔使用 **Spring Boot** 项目类型。发布物是包含嵌入式 Tomcat 的可执行 JAR,不使用外置 Tomcat 项目。 -先下载并解压签名 Release;项目目录必须指向同时包含 `app.jar`、`VERSION`、`public/` 和 `ops/` 的目录。 -推荐采用版本目录加 `current` 软链接: - -```bash -VERSION=1.0.0-preview.24 -RELEASE_ROOT=/www/wwwroot/kaidi/releases/$VERSION -sudo install -d -o root -g kaidi -m 0750 "$RELEASE_ROOT" -curl -fL "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz" \ - -o "/tmp/kaidi-finance-$VERSION.tar.gz" -sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT" -sudo chown -R root:kaidi "$RELEASE_ROOT" -sudo find "$RELEASE_ROOT" -type d -exec chmod 0750 {} + -sudo find "$RELEASE_ROOT" -type f -exec chmod 0640 {} + -sudo chmod 0750 "$RELEASE_ROOT/ops/baota-start.sh" -sudo ln -sfn "$RELEASE_ROOT" /www/wwwroot/kaidi/current.next -sudo mv -Tf /www/wwwroot/kaidi/current.next /www/wwwroot/kaidi/current -``` - -宝塔 Java 项目字段: - -| 字段 | 值 | -| --- | --- | -| 项目类型 | Spring Boot | -| 项目路径 | `/www/wwwroot/kaidi/current` | -| 项目名称 | `kaidi-finance` | -| 项目 JDK | JDK 17 | -| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` | -| 启动用户 | `kaidi`,禁止使用 root | -| 项目端口 | `18080` | -| 绑定域名 | 实际业务域名,例如 `fi.awaioi.com` | -| 后端路由 | `/` | -| 前端资源 | 留空,由 Spring Boot 提供 `public/` | -| 进程守护 | 开启 | -| 项目意外重启 | 勾选 | - -环境变量按 `ops/baota.env.example` 填入宝塔。已完成安装的服务器必须沿用 -`/var/lib/kaidi/setup/application.env` 中的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD`,以及 -`/etc/kaidi/kaidi.env` 中的 `FIELD_ENCRYPTION_KEY`;不要生成新的字段加密密钥。HTTP Preview 使用 -`SESSION_COOKIE_SECURE=false`,切换到 HTTPS 后改为 `true`。宝塔模式固定设置 -`FINANCE_UPDATE_ENABLED=false`,版本更新通过上传新目录、切换 `current`、重启项目完成。 - -启用宝塔守护前停用原 systemd 进程,避免两个进程管理器反复争用端口或互相重启: - -```bash -sudo systemctl disable --now kaidi-update.path kaidi-finance.service -sudo systemctl stop kaidi-update.service -``` - -启动后在服务器验证 Java 上游,再配置域名反向代理: +HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为 `true`,再从宝塔面板重启项目。健康检查: ```bash curl -fsS http://127.0.0.1:18080/actuator/health curl -fsS http://127.0.0.1:18080/ | head +systemctl is-enabled kaidi-update.path ``` +#### 5. 后台在线更新 + +初始化脚本已启用 `kaidi-update.path`,后台“系统治理 → 系统更新”流程保持不变:点击“获取更新”下载并验签,确认后点击“立即重启”。 +root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java, +健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。 + +更新前请在服务器预装与外部 MySQL 兼容的 `mysqldump`,更新器只生成权限为 `0600` 的备份,不会自动安装 MySQL。迁移失败时恢复旧应用版本并保留数据库备份和事务证据,遵循至少一个版本向后兼容的迁移规则。 + +后续版本仍按同样方式直接下载并解压到新的 `releases/`;不要手工删除 `current`,在线更新负责原子切换和回滚。 + +### Linux 32 位 + +压缩包本身不绑定 CPU 架构,关键是宝塔项目选用可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装 +MySQL 8.4;提前准备外部 MySQL,完成上述手动解压、初始化和向导即可。 + ### 停用并移除程序 以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库, @@ -303,42 +218,36 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.24 -git push origin v1.0.0-preview.24 +git tag v1.0.0-preview.25 +git push origin v1.0.0-preview.25 ``` -在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 -更新源由 root 在 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 -URL、Token、脚本或命令。更新流程固定为: +在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 +`/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。 +宝塔模式下,root 更新器只负责下载、验签、切换 `current` 和停止旧 Java,**不创建或重启 +`kaidi-finance.service`**;宝塔项目的“项目意外重启”负责按新链接拉起 Java。 -1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、 - SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/`。业务服务不停机。 -3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致 - 的包不能进入安装。 -4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。 -5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装 - `mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。 -6. 校验更新脚本和 systemd 单元后,在旧 Java 仍运行时原子切换版本链接,再由 systemd 执行一次 `restart`,不修改反向代理。 -7. 同时检查 Java 健康端点和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动 - 刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。 +更新流程固定为: -忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新, -不会延迟服务端切换。失败请求会被归档且不会由 systemd 自动重试,避免同一事务反复停止业务服务。 +1. 点击“获取更新”,后端排队 `DOWNLOAD`;更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、文件名和压缩包路径校验, + 通过后缓存到 `/var/lib/kaidi-update/cache/`,业务服务继续运行。 +2. 页面显示 `READY/等待重启` 后才出现“立即重启”;未缓存或版本不一致的包不能进入安装。 +3. 管理员点击“立即重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 +4. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。 +5. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线由前端轮询恢复。任一检查失败则切回上一应用版本, + 保留数据库备份和事务证据供人工处理。 -安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为 -`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像, -可由运维人员在这两个文件中配置独立只读 Token,Token 不写入页面、状态 JSON、审计参数或更新日志。 - -数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志: +忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。 +查看状态和日志: ```bash -sudo systemctl status kaidi-finance kaidi-update.path +sudo systemctl status kaidi-update.path kaidi-update.service sudo journalctl -u kaidi-update.service -n 100 --no-pager cat /var/lib/kaidi-update/status.json ``` 如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到 -`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动再次执行。修复日志所示的 +`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动重复安装。修复日志所示的 磁盘、权限或旧版本健康问题后,才明确执行一次: ```bash @@ -356,7 +265,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.24 + ./scripts/package-release.sh 1.0.0-preview.25 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` @@ -367,5 +276,4 @@ SBOM、首次安装指纹文件和完整应用压缩包。 JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。 签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录 `source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。 -发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入 -受控部署记录,再替换上述一键安装命令中的占位符。 +发布命令同时输出 Release 公钥和归档校验值;将压缩包 SHA-256 写入直链部署说明后再发布。 diff --git a/deploy/baota-init.sh b/deploy/baota-init.sh new file mode 100755 index 0000000..faf2475 --- /dev/null +++ b/deploy/baota-init.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +SERVICE_USER=kaidi +SERVICE_GROUP=kaidi +CONFIG_ROOT=/etc/kaidi +STATE_ROOT=/var/lib/kaidi +UPDATE_STATE_ROOT=/var/lib/kaidi-update +LOG_ROOT=/var/log/kaidi +UPDATER_ROOT=/opt/kaidi/bin +PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 +RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest +INIT_ARMED=false +INIT_COMMITTED=false +INIT_RELEASE_ROOT= +INIT_APP_ROOT= + +log() { printf '[kaidi-baota-init] %s\n' "$*"; } +die() { printf '[kaidi-baota-init] ERROR: %s\n' "$*" >&2; exit 1; } + +rollback_initialization() { + local rc=$? + trap - EXIT HUP INT TERM + if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then + systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true + systemctl stop kaidi-update.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path + systemctl daemon-reload >/dev/null 2>&1 || true + rm -f "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" \ + "$CONFIG_ROOT/release-public.pem" "$UPDATER_ROOT/update.sh" \ + "$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt + rm -f "$INIT_APP_ROOT/current.next" + if [ -n "$INIT_RELEASE_ROOT" ] \ + && [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then + rm -f "$INIT_APP_ROOT/current" + fi + log "Initialization failed; files created by this attempt were rolled back and the command can be retried" + fi + exit "$rc" +} +trap rollback_initialization EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +random_secret() { + openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36 +} + +write_env_file() { + local output=$1 temporary name value escaped + shift + temporary="${output}.next.$$" + : > "$temporary" + while [ "$#" -gt 0 ]; do + name=$1 + value=$2 + shift 2 + case "$value" in *$'\n'*|*$'\r'*) die "$name contains a line break" ;; esac + escaped=${value//\\/\\\\} + escaped=${escaped//\"/\\\"} + printf '%s="%s"\n' "$name" "$escaped" >> "$temporary" + done + mv -f "$temporary" "$output" +} + +ensure_service_identity() { + local existing_home nologin_path + if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then + groupadd --system "$SERVICE_GROUP" + fi + if id "$SERVICE_USER" >/dev/null 2>&1; then + existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') + [ "$existing_home" = "$STATE_ROOT" ] \ + || die "Existing user $SERVICE_USER has unexpected home directory $existing_home" + id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \ + || die "Existing user $SERVICE_USER is not a member of group $SERVICE_GROUP" + return 0 + fi + nologin_path=$(command -v nologin 2>/dev/null || true) + [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin + [ -x "$nologin_path" ] || die "A nologin shell is required" + useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" +} + +main() { + local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash + [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" + [ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux" + command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \ + || die "systemd is required for the privileged background updater" + for command in find openssl sha256sum; do + command -v "$command" >/dev/null 2>&1 || die "$command is required" + done + + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) + release_root=$(cd "$script_dir/.." && pwd -P) + releases_root=$(dirname "$release_root") + app_root=$(dirname "$releases_root") + [ "$(basename "$releases_root")" = releases ] \ + || die "Extract the release into APP_ROOT/releases/VERSION before initialization" + [ "$app_root" = /www/wwwroot/kaidi ] \ + || die "The supported Baota project root is /www/wwwroot/kaidi" + INIT_RELEASE_ROOT=$release_root + INIT_APP_ROOT=$app_root + version=$(tr -d '\r\n' < "$release_root/VERSION") + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.+-]+)?$ ]] \ + && [ -s "$release_root/app.jar" ] && [ -s "$release_root/public/index.html" ] \ + || die "The extracted release is incomplete" + [ -x "$release_root/ops/update.sh" ] && [ -x "$release_root/ops/baota-start.sh" ] \ + && [ -x "$release_root/ops/baota-init.sh" ] \ + || die "The extracted operations scripts are incomplete" + [ -s "$release_root/ops/release-public.pem" ] \ + || die "The release public key is missing" + [ "$(sha256_file "$release_root/ops/release-public.pem")" = "$PUBLIC_KEY_SHA256" ] \ + || die "The release public-key fingerprint is invalid" + [ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \ + && [ ! -e "$STATE_ROOT/setup/locked" ] \ + || die "Kaidi is already initialized; run the published purge workflow before a fresh installation" + ! systemctl cat kaidi-finance.service >/dev/null 2>&1 \ + || die "The old kaidi-finance.service still exists; run the published purge workflow first" + [ ! -e /etc/systemd/system/kaidi-update.service ] \ + && [ ! -e /etc/systemd/system/kaidi-update.path ] \ + || die "Old Kaidi update units still exist; run the published purge workflow first" + [ ! -e "$app_root/current" ] && [ ! -L "$app_root/current" ] \ + || die "The Baota current release link already exists; run the published purge workflow first" + + app_port=${1:-18080} + [[ "$app_port" =~ ^[0-9]{1,5}$ ]] && [ "$app_port" -ge 1024 ] && [ "$app_port" -le 65535 ] \ + || die "Port must be an integer between 1024 and 65535" + + INIT_ARMED=true + ensure_service_identity + install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root" "$UPDATER_ROOT" + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ + "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT" + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup" + install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT" + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT/inbox" + install -d -o root -g "$SERVICE_GROUP" -m 0750 "$CONFIG_ROOT" + + chown -R root:"$SERVICE_GROUP" "$release_root" + find "$release_root" -type d -exec chmod 0750 {} + + find "$release_root" -type f -exec chmod 0640 {} + + chmod 0750 "$release_root/ops/update.sh" "$release_root/ops/baota-start.sh" "$release_root/ops/baota-init.sh" + ln -sfn "$release_root" "$app_root/current.next" + mv -Tf "$app_root/current.next" "$app_root/current" + + field_key=$(openssl rand -base64 32 | tr -d '\n') + setup_code="KD-$(random_secret)" + setup_hash=$(printf '%s' "$setup_code" | sha256sum | awk '{print $1}') + write_env_file "$CONFIG_ROOT/kaidi.env" \ + SPRING_PROFILES_ACTIVE production \ + SERVER_ADDRESS 127.0.0.1 \ + SERVER_PORT "$app_port" \ + SESSION_COOKIE_SECURE false \ + DB_URL 'jdbc:mysql://setup.invalid:3306/kaidi_finance' \ + DB_USERNAME setup_pending \ + DB_PASSWORD setup_pending \ + FIELD_ENCRYPTION_KEY "$field_key" \ + FILE_STORAGE_ROOT "$STATE_ROOT/files" \ + FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \ + FILE_SCANNER_ENABLED false \ + FINANCE_BOOTSTRAP_ENABLED false \ + FINANCE_BOOTSTRAP_PASSWORD '' \ + FINANCE_SETUP_ENABLED true \ + FINANCE_SETUP_TOKEN_SHA256 "$setup_hash" \ + FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \ + FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \ + FINANCE_SETUP_RESTART_AFTER_COMPLETE true \ + FINANCE_UPDATE_ENABLED true \ + UPDATE_RELEASE_BASE_URL '' \ + UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ + UPDATE_RELEASE_TOKEN '' \ + UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ + UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \ + KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid" + chown root:"$SERVICE_GROUP" "$CONFIG_ROOT/kaidi.env" + chmod 0640 "$CONFIG_ROOT/kaidi.env" + + install -m 0644 "$release_root/ops/release-public.pem" "$CONFIG_ROOT/release-public.pem" + install -m 0755 "$release_root/ops/update.sh" "$UPDATER_ROOT/update.sh" + write_env_file "$CONFIG_ROOT/update.env" \ + UPDATE_RELEASE_BASE_URL '' \ + UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ + UPDATE_RELEASE_TOKEN '' \ + UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ + UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \ + UPDATE_PUBLIC_KEY "$CONFIG_ROOT/release-public.pem" \ + KAIDI_APP_ROOT "$app_root" \ + KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \ + KAIDI_PROCESS_MANAGER baota \ + KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid" \ + KAIDI_RUNTIME_ENV_FILE "$STATE_ROOT/setup/application.env" \ + KAIDI_UPDATER_PATH "$UPDATER_ROOT/update.sh" \ + KAIDI_SERVICE_USER "$SERVICE_USER" \ + KAIDI_SERVICE_GROUP "$SERVICE_GROUP" \ + KAIDI_UPDATE_PATH_NAME kaidi-update.path \ + KAIDI_HEALTH_URL "http://127.0.0.1:$app_port/actuator/health" \ + KAIDI_APP_INDEX_URL "http://127.0.0.1:$app_port/" \ + KAIDI_DB_HOST setup.invalid \ + KAIDI_DB_PORT 3306 \ + KAIDI_DB_NAME kaidi_finance \ + KAIDI_DB_USERNAME setup_pending \ + KAIDI_DB_PASSWORD setup_pending + chmod 0600 "$CONFIG_ROOT/update.env" + + install -m 0644 "$release_root/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service + install -m 0644 "$release_root/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path + systemd-analyze verify /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path >/dev/null \ + || die "The privileged update units failed validation" + systemctl daemon-reload + systemctl enable --now kaidi-update.path + + printf '{"state":"CURRENT","message":"Baota release is prepared","targetVersion":"%s","updatedAt":"%s"}\n' \ + "$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json" + chmod 0644 "$UPDATE_STATE_ROOT/status.json" + cat > /root/kaidi-first-login.txt <&2 exit 1 } SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd -P) APP_JAR="$RELEASE_ROOT/app.jar" PUBLIC_INDEX="$RELEASE_ROOT/public/index.html" VERSION_FILE="$RELEASE_ROOT/VERSION" +CONFIG_FILE=${KAIDI_CONFIG_FILE:-/etc/kaidi/kaidi.env} +RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env} + +decode_env_value() { + local raw=$1 result='' char next index=0 length + if [[ "$raw" == \"*\" && ${#raw} -ge 2 ]]; then + raw=${raw:1:${#raw}-2} + length=${#raw} + while [ "$index" -lt "$length" ]; do + char=${raw:index:1} + if [ "$char" = "\\" ] && [ $((index + 1)) -lt "$length" ]; then + next=${raw:index+1:1} + if [ "$next" = "\\" ] || [ "$next" = '"' ]; then + result+="$next" + index=$((index + 2)) + continue + fi + fi + result+="$char" + index=$((index + 1)) + done + printf '%s' "$result" + elif [[ "$raw" == \'*\' && ${#raw} -ge 2 ]]; then + printf '%s' "${raw:1:${#raw}-2}" + else + printf '%s' "$raw" + fi +} + +is_managed_env_name() { + case "$1" in + SPRING_PROFILES_ACTIVE|SERVER_ADDRESS|SERVER_PORT|SESSION_COOKIE_SECURE|\ + DB_URL|DB_USERNAME|DB_PASSWORD|FIELD_ENCRYPTION_KEY|\ + FILE_STORAGE_ROOT|FILE_STORAGE_TEMP|FILE_SCANNER_ENABLED|FILE_SCANNER_HOST|FILE_SCANNER_PORT|\ + FINANCE_BOOTSTRAP_ENABLED|FINANCE_BOOTSTRAP_PASSWORD|FINANCE_SETUP_ENABLED|\ + FINANCE_SETUP_TOKEN_SHA256|FINANCE_SETUP_ENV_FILE|FINANCE_SETUP_MARKER_FILE|\ + FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\ + UPDATE_RELEASE_BASE_URL|UPDATE_RELEASE_API_URL|UPDATE_RELEASE_TOKEN|\ + UPDATE_REQUEST_FILE|UPDATE_STATUS_FILE|UPDATE_CURRENT_VERSION_FILE|APP_VERSION|KAIDI_PID_FILE) + return 0 + ;; + *) return 1 ;; + esac +} + +load_env_file() { + local file=$1 line name raw value size + [ -e "$file" ] || return 0 + [ -f "$file" ] && [ ! -L "$file" ] || die "Configuration file is not a regular file: $file" + [ -r "$file" ] || die "Startup user cannot read configuration file: $file" + size=$(wc -c < "$file" | tr -d '[:space:]') + [ "$size" -le 65536 ] || die "Configuration file is too large: $file" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|'#'*) continue ;; esac + [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] \ + || die "Configuration file contains an invalid line: $file" + name=${BASH_REMATCH[1]} + raw=${BASH_REMATCH[2]} + is_managed_env_name "$name" || continue + [ -n "${!name:-}" ] && continue + value=$(decode_env_value "$raw") + printf -v "$name" '%s' "$value" + export "${name?}" + done < "$file" +} [ -s "$APP_JAR" ] || die "app.jar is missing from $RELEASE_ROOT" [ -s "$PUBLIC_INDEX" ] || die "public/index.html is missing from $RELEASE_ROOT" [ -s "$VERSION_FILE" ] || die "VERSION is missing from $RELEASE_ROOT" +# The setup-generated runtime file has precedence over the base file. Non-empty +# variables supplied by Baota have precedence over both; empty panel fields do not +# mask the protected configuration written by the setup wizard. +load_env_file "$RUNTIME_ENV_FILE" +load_env_file "$CONFIG_FILE" + JAVA_BIN=${KAIDI_JAVA_BIN:-} if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then JAVA_BIN="$JAVA_HOME/bin/java" @@ -35,15 +108,16 @@ export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1} export SERVER_PORT=${SERVER_PORT:-18080} export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false} export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false} -export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-false} +export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-true} export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false} export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/} export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE} export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")} +export KAIDI_PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid} if [ "$FINANCE_SETUP_ENABLED" != true ]; then for name in DB_URL DB_USERNAME DB_PASSWORD FIELD_ENCRYPTION_KEY FILE_STORAGE_ROOT FILE_STORAGE_TEMP; do - [ -n "${!name:-}" ] || die "$name must be configured in the Baota project environment" + [ -n "${!name:-}" ] || die "$name is missing from the protected Kaidi configuration" done for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do [ -d "$directory" ] || die "Storage directory $directory does not exist" @@ -51,5 +125,18 @@ if [ "$FINANCE_SETUP_ENABLED" != true ]; then done fi +case "$KAIDI_PID_FILE" in /*) ;; *) die "KAIDI_PID_FILE must be an absolute path" ;; esac +[ -d "$(dirname "$KAIDI_PID_FILE")" ] || die "PID directory does not exist" +[ -w "$(dirname "$KAIDI_PID_FILE")" ] || die "Startup user cannot write the PID directory" +[ ! -L "$KAIDI_PID_FILE" ] || die "PID file must not be a symbolic link" +PID_START_TIME=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null || true) +[[ "$PID_START_TIME" =~ ^[0-9]+$ ]] || die "Process start time could not be read from /proc" +PID_TEMP="${KAIDI_PID_FILE}.next.$$" +printf '%s %s\n' "$$" "$PID_START_TIME" > "$PID_TEMP" +chmod 0640 "$PID_TEMP" +mv -f "$PID_TEMP" "$KAIDI_PID_FILE" + cd "$RELEASE_ROOT" -exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar "$APP_JAR" +exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 \ + "-Dkaidi.release.path=$RELEASE_ROOT" "-Dkaidi.release.version=$APP_VERSION" \ + -jar "$APP_JAR" diff --git a/deploy/baota.env.example b/deploy/baota.env.example index 5c76ca0..399f22b 100644 --- a/deploy/baota.env.example +++ b/deploy/baota.env.example @@ -1,14 +1,11 @@ -SPRING_PROFILES_ACTIVE=production -SERVER_ADDRESS=127.0.0.1 -SERVER_PORT=18080 -SESSION_COOKIE_SECURE=false -DB_URL=jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC -DB_USERNAME=MYSQL_USERNAME -DB_PASSWORD=MYSQL_PASSWORD -FIELD_ENCRYPTION_KEY=EXISTING_FIELD_ENCRYPTION_KEY -FILE_STORAGE_ROOT=/var/lib/kaidi/files -FILE_STORAGE_TEMP=/var/lib/kaidi/tmp -FILE_SCANNER_ENABLED=false -FINANCE_BOOTSTRAP_ENABLED=false -FINANCE_SETUP_ENABLED=false -FINANCE_UPDATE_ENABLED=false +# Baota project environment variables are intentionally empty. +# Do not paste database passwords or placeholder values into the panel. +# ops/baota-start.sh loads the protected files created by ops/baota-init.sh: +# /etc/kaidi/kaidi.env +# /var/lib/kaidi/setup/application.env (after the setup wizard completes) +# +# Optional non-secret overrides may be added deliberately, for example: +# SERVER_PORT=18080 +# SESSION_COOKIE_SECURE=true +# Never override DB_URL, DB_USERNAME, DB_PASSWORD, FIELD_ENCRYPTION_KEY, +# FINANCE_SETUP_TOKEN_SHA256, or the update paths from the panel. diff --git a/deploy/release-public.pem b/deploy/release-public.pem new file mode 100644 index 0000000..e542b20 --- /dev/null +++ b/deploy/release-public.pem @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAwnPqkRpYUTBYbBaSnxBs +LqDHFPrxX6DINIiCyRJ/0f1c0noRr1X0bJSpTAR+yUwzWYTqIKAhZ4NSouYjaxtE +zuoW4cC6v+S/C310xM8qy+lE52GyFHpr7H6GqdQgxK2LmfS0jjCR9i8MEmi6QMk4 +zdDtI1NOl+D50vk1nrMryplyfvqDpyPA+fiqXVo2NrqX9iaamTVkq55INk3X451I +u96/HBlSMw2EvoDPurzcjxV16swyJcuQQpBUbBmgp8i2enNyWONoBq/myYReTDem +CbrD+DTvqa+tDMQ+DAL2wyPa8JdoGOLhcftYWsyfpgTq6olNSnqgUV+Gp+NYpWuk +AxMi6TTDX4ByMkS65UGH2Opg483bxtl9bGCmjFfqFGO2NjxAsh6LaBApS2PNBKpu +06+Qni/Eq7u847egw89bL1xwtHIZBWDiG4tQMqyqW3M5WqoYoaB7PcZnfyRVChA4 +pb+2yJll0cJ4RvKWeVrOAzb5JOn0BWjllm/csfwv9IeTAgMBAAE= +-----END PUBLIC KEY----- diff --git a/deploy/systemd/kaidi-update.service b/deploy/systemd/kaidi-update.service index 5c22273..3b88c45 100644 --- a/deploy/systemd/kaidi-update.service +++ b/deploy/systemd/kaidi-update.service @@ -10,7 +10,6 @@ Type=oneshot User=root Group=root EnvironmentFile=/etc/kaidi/update.env -EnvironmentFile=-/var/lib/kaidi/setup/application.env ExecStart=/opt/kaidi/bin/update.sh Restart=no Nice=10 @@ -19,5 +18,5 @@ IOSchedulingPriority=6 PrivateTmp=true ProtectHome=true ProtectSystem=full -ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system +ReadWritePaths=/opt/kaidi /www/wwwroot/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system UMask=0077 diff --git a/deploy/update.sh b/deploy/update.sh index 27df625..2e1c45d 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -19,6 +19,9 @@ RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-} CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache} SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service} UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path} +PROCESS_MANAGER=${KAIDI_PROCESS_MANAGER:-systemd} +PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid} +RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env} SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi} SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi} HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} @@ -30,6 +33,7 @@ SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system} LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi} HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60} HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2} +SHUTDOWN_ATTEMPTS=${KAIDI_UPDATE_SHUTDOWN_ATTEMPTS:-45} WORK_DIR= CACHE_TEMP= RELEASE_AUTH_HEADER_FILE= @@ -40,12 +44,85 @@ case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in *[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;; esac [ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; } +case "$PROCESS_MANAGER" in + systemd|baota) ;; + *) printf '%s\n' "KAIDI_PROCESS_MANAGER must be systemd or baota" >&2; exit 1 ;; +esac +case "$SHUTDOWN_ATTEMPTS" in + ''|*[!0-9]*) printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer" >&2; exit 1 ;; +esac +[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1" >&2; exit 1; } +case "$PID_FILE" in + /*) ;; + *) printf '%s\n' "KAIDI_PID_FILE must be an absolute path" >&2; exit 1 ;; +esac +case "$RUNTIME_ENV_FILE" in + /*) ;; + *) printf '%s\n' "KAIDI_RUNTIME_ENV_FILE must be an absolute path" >&2; exit 1 ;; +esac bootstrap_die() { printf '%s\n' "$1" >&2 exit 1 } +decode_runtime_env_value() { + encoded_value=$1 + case "$encoded_value" in + \"*\") + encoded_value=${encoded_value#\"} + encoded_value=${encoded_value%\"} + printf '%s' "$encoded_value" | sed 's/\\\"/"/g; s/\\\\/\\/g' + ;; + \'*\') + encoded_value=${encoded_value#\'} + encoded_value=${encoded_value%\'} + printf '%s' "$encoded_value" + ;; + *) printf '%s' "$encoded_value" ;; + esac +} + +load_runtime_database_env() { + [ -e "$RUNTIME_ENV_FILE" ] || return 0 + [ -f "$RUNTIME_ENV_FILE" ] && [ ! -L "$RUNTIME_ENV_FILE" ] \ + || bootstrap_die "Setup runtime environment must be a regular file" + [ -r "$RUNTIME_ENV_FILE" ] \ + || bootstrap_die "Setup runtime environment is not readable" + runtime_size=$(wc -c < "$RUNTIME_ENV_FILE" | tr -d '[:space:]') + [ "$runtime_size" -le 65536 ] \ + || bootstrap_die "Setup runtime environment is too large" + runtime_owner=$(stat -c '%u' "$RUNTIME_ENV_FILE" 2>/dev/null || true) + service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true) + [ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \ + || bootstrap_die "Setup runtime environment has an unexpected owner" + if find "$RUNTIME_ENV_FILE" -prune -perm /022 -print -quit | grep -q .; then + bootstrap_die "Setup runtime environment must not be writable by group or other users" + fi + + while IFS= read -r runtime_line || [ -n "$runtime_line" ]; do + case "$runtime_line" in ''|'#'*) continue ;; esac + runtime_name=${runtime_line%%=*} + [ "$runtime_name" != "$runtime_line" ] \ + || bootstrap_die "Setup runtime environment contains an invalid line" + runtime_raw=${runtime_line#*=} + runtime_value=$(decode_runtime_env_value "$runtime_raw") + case "$runtime_name" in + KAIDI_DB_HOST) KAIDI_DB_HOST=$runtime_value; export KAIDI_DB_HOST ;; + KAIDI_DB_PORT) KAIDI_DB_PORT=$runtime_value; export KAIDI_DB_PORT ;; + KAIDI_DB_NAME) KAIDI_DB_NAME=$runtime_value; export KAIDI_DB_NAME ;; + KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;; + KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;; + esac + done < "$RUNTIME_ENV_FILE" + + case "${KAIDI_DB_PORT:-}" in + ''|*[!0-9]*) bootstrap_die "Setup runtime database port is invalid" ;; + esac + [ "$KAIDI_DB_PORT" -ge 1 ] && [ "$KAIDI_DB_PORT" -le 65535 ] \ + || bootstrap_die "Setup runtime database port is invalid" +} + status() { state=$1 message=$2 @@ -103,6 +180,7 @@ secure_release_tree() { find "$release_dir" -type d -exec chmod 0750 {} + || return 1 find "$release_dir" -type f -exec chmod 0640 {} + || return 1 chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh" + [ ! -f "$release_dir/ops/baota-init.sh" ] || chmod 0750 "$release_dir/ops/baota-init.sh" } verify_release_access() { @@ -111,12 +189,16 @@ verify_release_access() { runuser -u "$SERVICE_USER" -- sh -c \ 'cd "$1" && test -r app.jar && test -r public/index.html' sh "$release_dir" \ || return 1 - runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ - || return 1 - runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 + if [ "$PROCESS_MANAGER" = baota ]; then + runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" + else + runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ + && runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 + fi } prepare_update_layout +load_runtime_database_env # shellcheck disable=SC2329 # Invoked by the EXIT trap below. cleanup() { @@ -204,18 +286,103 @@ wait_for_health() { } verify_app_surface() { + expected_release=${1:-} wait_for_health "$HEALTH_URL" || return 1 index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX") if curl -fsS "$APP_INDEX_URL" -o "$index_file" \ && grep -Eiq '/dev/null || true) + pid=${pid_record%% *} + start_time=${pid_record#* } + [ "$pid" != "$pid_record" ] || return 1 + case "$pid:$start_time" in + *[!0-9:]*) return 1 ;; + esac + [ "$pid" -ne "$$" ] && [ -r "/proc/$pid/cmdline" ] || return 1 + actual_start_time=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) + [ -n "$actual_start_time" ] && [ "$actual_start_time" = "$start_time" ] || return 1 + owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true) + service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true) + [ -n "$owner_uid" ] && [ "$owner_uid" = "$service_uid" ] || return 1 + process_args=$(mktemp "$STATE_ROOT/work/process-args.XXXXXX") || return 1 + if ! tr '\000' '\n' < "/proc/$pid/cmdline" > "$process_args" 2>/dev/null; then + rm -f "$process_args" + return 1 + fi + release_path=$(sed -n 's/^-Dkaidi\.release\.path=//p' "$process_args") + release_path_count=$(sed -n '/^-Dkaidi\.release\.path=/p' "$process_args" | wc -l | tr -d '[:space:]') + jar_path=$(awk 'previous == "-jar" { print; exit } { previous = $0 }' "$process_args") + rm -f "$process_args" + [ "$release_path_count" -eq 1 ] || return 1 + case "$release_path" in + "$APP_ROOT"/releases/*) ;; + *) return 1 ;; + esac + [ -d "$release_path" ] && [ ! -L "$release_path" ] \ + && [ "$jar_path" = "$release_path/app.jar" ] && [ -f "$jar_path" ] \ + || return 1 + printf '%s\t%s\n' "$pid" "$release_path" +} + +baota_process_pid() { + baota_process_record | cut -f1 +} + +baota_process_release() { + baota_process_record | cut -f2- +} + +restart_application() { + allow_missing=${1:-false} + if [ "$PROCESS_MANAGER" = systemd ]; then + systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true + systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" + return + fi + + old_pid=$(baota_process_pid) || { + [ "$allow_missing" = true ] && return 0 + return 1 + } + kill -TERM "$old_pid" 2>/dev/null || return 1 + attempts=0 + while [ "$attempts" -lt "$SHUTDOWN_ATTEMPTS" ]; do + if ! kill -0 "$old_pid" 2>/dev/null; then + current_pid_record=$(cat "$PID_FILE" 2>/dev/null || true) + case "$current_pid_record" in "$old_pid "*) rm -f "$PID_FILE" ;; esac + return 0 + fi + attempts=$((attempts + 1)) + sleep 1 + done + return 1 +} + +application_state() { + if [ "$PROCESS_MANAGER" = systemd ]; then + systemctl is-active "$SERVICE_NAME" 2>/dev/null || printf unknown + elif baota_process_pid >/dev/null 2>&1; then + printf active + else + printf stopped + fi +} + backup_database() { + DATABASE_BACKUP= [ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return mkdir -p "$BACKUP_ROOT" chmod 0700 "$BACKUP_ROOT" @@ -255,6 +422,7 @@ backup_database() { fi chmod 0600 "$backup_tmp" mv -f "$backup_tmp" "$backup" + DATABASE_BACKUP=$backup rm -f "$dump_file" find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \ | sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f @@ -321,18 +489,22 @@ restore_managed_file() { backup_operations() { OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" mkdir -p "$OPS_BACKUP" - backup_managed_file "$UPDATER_PATH" update.sh \ - && backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \ - && backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \ + backup_managed_file "$UPDATER_PATH" update.sh || return 1 + if [ "$PROCESS_MANAGER" = systemd ]; then + backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || return 1 + fi + backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \ && backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path } apply_operations() { SYSTEMD_OPERATIONS_CHANGED=false - install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \ - && install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \ - "$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \ - && install_operation "$RELEASE_DIR/ops/kaidi-update.service" \ + install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false || return 1 + if [ "$PROCESS_MANAGER" = systemd ]; then + install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \ + "$SYSTEMD_ROOT/kaidi-finance.service" 0644 true || return 1 + fi + install_operation "$RELEASE_DIR/ops/kaidi-update.service" \ "$SYSTEMD_ROOT/kaidi-update.service" 0644 true \ && install_operation "$RELEASE_DIR/ops/kaidi-update.path" \ "$SYSTEMD_ROOT/kaidi-update.path" 0644 true \ @@ -343,7 +515,9 @@ apply_operations() { restore_operations() { restore_failed=0 restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1 - restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1 + if [ "$PROCESS_MANAGER" = systemd ]; then + restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1 + fi restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1 restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1 systemctl daemon-reload || restore_failed=1 @@ -378,6 +552,7 @@ rollback_active_transaction() { OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" link_restored=true operations_restored=true + application_restarted=true if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \ @@ -388,15 +563,15 @@ rollback_active_transaction() { link_restored=false fi restore_operations || operations_restored=false - systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true - systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true - if [ "$link_restored" = true ] && verify_app_surface; then + restart_application true || application_restarted=false + if [ "$link_restored" = true ] && [ "$application_restarted" = true ] \ + && verify_app_surface "$previous_target"; then remove_failed_release "$failed_release" - rm -rf "$ACTIVE_TRANSACTION" if [ "$operations_restored" = true ]; then - fail "$reason; previous release was restored and verified" + rm -rf "$ACTIVE_TRANSACTION" + fail "$reason; previous application release was restored and verified; database backup was retained" fi - fail "$reason; previous release is running, but operations restoration requires manual review" + fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained" fi TERMINAL_STATUS_WRITTEN=true status FAILED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}" @@ -430,6 +605,10 @@ recover_interrupted_transaction() { validate_release_operations() { sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1 bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || return 1 + bash -n "$WORK_DIR/extracted/ops/baota-init.sh" || return 1 + [ -s "$WORK_DIR/extracted/ops/release-public.pem" ] \ + && cmp -s "$WORK_DIR/extracted/ops/release-public.pem" "$PUBLIC_KEY" \ + || return 1 command -v systemd-analyze >/dev/null 2>&1 || return 1 systemd-analyze verify \ "$WORK_DIR/extracted/ops/kaidi-finance.service" \ @@ -555,6 +734,9 @@ fi [ -s "$WORK_DIR/extracted/public/index.html" ] || fail "Release frontend is missing" [ "$(cat "$WORK_DIR/extracted/VERSION" 2>/dev/null)" = "$TARGET_VERSION" ] || fail "Release version file mismatch" [ -x "$WORK_DIR/extracted/ops/update.sh" ] || fail "Release updater is missing" +[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || fail "Release Baota launcher is missing" +[ -x "$WORK_DIR/extracted/ops/baota-init.sh" ] || fail "Release Baota initializer is missing" +[ -s "$WORK_DIR/extracted/ops/release-public.pem" ] || fail "Release public key is missing" [ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing" [ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing" [ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing" @@ -605,6 +787,7 @@ write_transaction_value phase PREPARED status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION" backup_database +[ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP" if ! backup_operations; then remove_failed_release "$RELEASE_DIR" rm -rf "$ACTIVE_TRANSACTION" @@ -624,9 +807,10 @@ write_transaction_value phase APP_SWITCHED status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" write_transaction_value phase HEALTH_CHECKING -systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true -systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true -if verify_app_surface; then +if ! restart_application; then + rollback_active_transaction "Release application restart failed" +fi +if verify_app_surface "$RELEASE_DIR"; then success_message="Release $TARGET_VERSION is running" if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then success_message="$success_message; automatic update watcher could not be started" @@ -639,7 +823,7 @@ if verify_app_surface; then exit 0 fi -service_state=$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true) +service_state=$(application_state 2>/dev/null || true) [ -n "$service_state" ] || service_state=unknown rollback_active_transaction \ "Release health or application-surface verification failed (service state: $service_state)" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 2d89970..785e39b 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -92,13 +92,15 @@ cp -R "$ROOT/frontend/dist/." "$STAGE/public/" printf '%s\n' "$VERSION" > "$STAGE/VERSION" cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh" cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh" +cp "$ROOT/deploy/baota-init.sh" "$STAGE/ops/baota-init.sh" +cp "$ROOT/deploy/release-public.pem" "$STAGE/ops/release-public.pem" cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example" # Kept in the archive so Preview.9's updater can complete the one-time transition. cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf" cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service" cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service" cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path" -chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" +chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh" ARTIFACT="kaidi-finance-$VERSION.tar.gz" COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" . diff --git a/scripts/test-baota-start-fixture.sh b/scripts/test-baota-start-fixture.sh index a046aa7..9184fc7 100755 --- a/scripts/test-baota-start-fixture.sh +++ b/scripts/test-baota-start-fixture.sh @@ -3,21 +3,51 @@ set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) WORK=$(mktemp -d) -trap 'rm -rf "$WORK"' EXIT +EXECUTION_MARKER=/tmp/kaidi-baota-fixture-executed-$$ +trap 'rm -rf "$WORK" "$EXECUTION_MARKER"' EXIT fail() { printf 'Baota start fixture failed: %s\n' "$1" >&2 + [ ! -f "$WORK/java.log" ] || sed -n '1,40p' "$WORK/java.log" >&2 exit 1 } RELEASE="$WORK/release" -mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp" +mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp" "$WORK/state" "$WORK/mock-bin" +RELEASE_PHYSICAL=$(cd "$RELEASE" && pwd -P) printf 'fixture jar\n' > "$RELEASE/app.jar" printf '\n' > "$RELEASE/public/index.html" -printf '1.0.0-preview.24\n' > "$RELEASE/VERSION" +printf '1.0.0-preview.25\n' > "$RELEASE/VERSION" cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh" chmod 0755 "$RELEASE/ops/baota-start.sh" +cat > "$WORK/mock-bin/awk" <<'SH' +#!/usr/bin/env bash +case "${2:-}" in + /proc/*/stat) printf '424242\n' ;; + *) exec /usr/bin/awk "$@" ;; +esac +SH +chmod 0755 "$WORK/mock-bin/awk" + +cat > "$WORK/base.env" < "$WORK/runtime.env" < "$WORK/java" <<'SH' #!/usr/bin/env bash set -Eeuo pipefail @@ -29,39 +59,63 @@ set -Eeuo pipefail printf 'port=%s\n' "$SERVER_PORT" printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED" printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS" + printf 'username=%s\n' "$DB_USERNAME" + printf 'password=%s\n' "$DB_PASSWORD" + printf 'pid-record=%s\n' "$(cat "$KAIDI_PID_FILE")" + printf 'self-pid=%s\n' "$$" } > "$MOCK_JAVA_LOG" SH chmod 0755 "$WORK/java" MOCK_JAVA_LOG="$WORK/java.log" \ +PATH="$WORK/mock-bin:$PATH" \ KAIDI_JAVA_BIN="$WORK/java" \ -DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \ -DB_USERNAME=fixture \ -DB_PASSWORD='fixture-password' \ -FIELD_ENCRYPTION_KEY='fixture-field-key' \ -FILE_STORAGE_ROOT="$WORK/files" \ -FILE_STORAGE_TEMP="$WORK/tmp" \ +KAIDI_CONFIG_FILE="$WORK/base.env" \ +KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \ +DB_URL='' DB_USERNAME='' DB_PASSWORD='' \ "$RELEASE/ops/baota-start.sh" -grep -Fqx "cwd=$RELEASE" "$WORK/java.log" || fail 'launcher did not use the release working directory' -grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar $RELEASE/app.jar" "$WORK/java.log" \ - || fail 'launcher did not execute the packaged Spring Boot jar' -grep -Fqx 'version=1.0.0-preview.24' "$WORK/java.log" || fail 'launcher did not read VERSION' +grep -Fqx "cwd=$RELEASE_PHYSICAL" "$WORK/java.log" || fail 'launcher did not use the physical release directory' +grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -Dkaidi.release.path=$RELEASE_PHYSICAL -Dkaidi.release.version=1.0.0-preview.25 -jar $RELEASE_PHYSICAL/app.jar" "$WORK/java.log" \ + || fail 'launcher did not identify and execute the packaged Spring Boot release' +grep -Fqx 'version=1.0.0-preview.25' "$WORK/java.log" || fail 'launcher did not read VERSION' grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback' -grep -Fqx 'port=18080' "$WORK/java.log" || fail 'launcher did not default to port 18080' -grep -Fqx 'update=false' "$WORK/java.log" || fail 'launcher did not disable the systemd updater' -grep -Fqx "static=file:$RELEASE/public/" "$WORK/java.log" \ +grep -Fqx 'port=19090' "$WORK/java.log" || fail 'runtime environment did not override the base port' +grep -Fqx 'update=true' "$WORK/java.log" || fail 'launcher did not keep online update enabled' +grep -Fqx "static=file:$RELEASE_PHYSICAL/public/" "$WORK/java.log" \ || fail 'launcher did not bind static resources to the release directory' +grep -Fqx 'username=fixture-user' "$WORK/java.log" || fail 'launcher did not load setup database values' +grep -Fqx "password=\$(touch $EXECUTION_MARKER)" "$WORK/java.log" \ + || fail 'launcher changed a literal password while parsing the protected environment' +[ ! -e "$EXECUTION_MARKER" ] || fail 'launcher executed command syntax from a database password' +PID_RECORD=$(sed -n 's/^pid-record=//p' "$WORK/java.log") +SELF_PID=$(sed -n 's/^self-pid=//p' "$WORK/java.log") +[ "$PID_RECORD" = "$SELF_PID 424242" ] || fail 'PID file did not bind the PID to its proc start time' +MOCK_JAVA_LOG="$WORK/explicit.log" \ +PATH="$WORK/mock-bin:$PATH" \ +KAIDI_JAVA_BIN="$WORK/java" \ +KAIDI_CONFIG_FILE="$WORK/base.env" \ +KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \ +SERVER_PORT=20000 \ + "$RELEASE/ops/baota-start.sh" +grep -Fqx 'port=20000' "$WORK/explicit.log" || fail 'non-empty Baota environment did not take precedence' + +cat > "$WORK/missing-db.env" < "$WORK/missing-db.out" 2>&1; then fail 'launcher accepted a production configuration without DB_URL' fi -grep -Fq 'DB_URL must be configured' "$WORK/missing-db.out" \ +grep -Fq 'DB_URL is missing from the protected Kaidi configuration' "$WORK/missing-db.out" \ || fail 'launcher did not report the missing database URL' [ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed' -printf 'Baota Spring Boot launcher fixture passed\n' +printf 'Baota Spring Boot launcher configuration, PID identity, and injection fixtures passed\n' diff --git a/scripts/test-install-fixture.sh b/scripts/test-install-fixture.sh index 19b62b7..e649723 100755 --- a/scripts/test-install-fixture.sh +++ b/scripts/test-install-fixture.sh @@ -378,13 +378,17 @@ grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \ grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ "$ROOT/deploy/systemd/kaidi-finance.service" \ || fail 'application service no longer loads the setup-completion environment' -grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ +! grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ "$ROOT/deploy/systemd/kaidi-update.service" \ - || fail 'update service no longer loads setup database overrides' + || fail 'root update service must not load an application-owned environment file' +grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \ + || fail 'update script no longer imports only the setup database fields' # shellcheck disable=SC2016 # Match literal installer source. grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \ || fail 'update state parent is not group-accessible to the application user' -grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \ - || fail 'README does not document the public one-line setup-wizard install path' +grep -Fq 'kaidi-finance-1.0.0-preview.25.tar.gz' "$ROOT/README.md" \ + || fail 'README does not document the public manual-deployment artifact' +grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \ + || fail 'README does not document the local Baota initialization command' printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n' diff --git a/scripts/test-update-fixture.sh b/scripts/test-update-fixture.sh index 214c17a..8ec48da 100755 --- a/scripts/test-update-fixture.sh +++ b/scripts/test-update-fixture.sh @@ -174,13 +174,20 @@ build_release() { local unit_prefix=${3:-new} local stage="$fixture/stage" mkdir -p "$fixture/release" "$stage/public" "$stage/ops" + "$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out "$fixture/private.pem" >/dev/null 2>&1 + "$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \ + -out "$fixture/release-public.pem" >/dev/null 2>&1 printf 'new application\n' > "$stage/app.jar" printf 'new\n' > "$stage/public/index.html" printf '%s\n' "$version" > "$stage/VERSION" printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh" printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh" + printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-init.sh" + cp "$fixture/release-public.pem" "$stage/ops/release-public.pem" chmod 0755 "$stage/ops/update.sh" chmod 0755 "$stage/ops/baota-start.sh" + chmod 0755 "$stage/ops/baota-init.sh" for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name" done @@ -192,10 +199,6 @@ build_release() { jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \ '{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \ > "$fixture/release/release-manifest.json" - "$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ - -out "$fixture/private.pem" >/dev/null 2>&1 - "$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \ - -out "$fixture/release-public.pem" >/dev/null 2>&1 "$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \ -out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json" } @@ -284,6 +287,7 @@ run_update() { KAIDI_LOG_ROOT="$fixture/log" \ KAIDI_SYSTEMD_ROOT="$fixture/systemd" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ + KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \ KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \ KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \ @@ -424,7 +428,7 @@ assert_rollback_case() { if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then fail 'rollback case unexpectedly succeeded' fi - grep -q 'previous release was restored and verified' "$fixture/update.log" \ + grep -q 'previous application release was restored and verified' "$fixture/update.log" \ || fail 'rollback case did not report a complete restoration' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'rollback case did not restore the previous application' diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 426e210..f6ceade 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -169,6 +169,8 @@ compare_archive_file() { compare_archive_file ./ops/update.sh deploy/update.sh compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh +compare_archive_file ./ops/baota-init.sh deploy/baota-init.sh +compare_archive_file ./ops/release-public.pem deploy/release-public.pem compare_archive_file ./ops/baota.env.example deploy/baota.env.example compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service @@ -176,7 +178,10 @@ compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.se compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \ || { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; } +tar -tvzf "$ARTIFACT" ./ops/baota-init.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \ + || { printf 'Baota initializer is not executable\n' >&2; exit 1; } bash -n "$ROOT/deploy/baota-start.sh" +bash -n "$ROOT/deploy/baota-init.sh" cmp install.sh "$ROOT/deploy/install.sh" \ || { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; } bash -n "$ROOT/deploy/purge.sh"