Compare commits

...
Author SHA1 Message Date
Qiufeng 2c73ad7eb0 fix: support panel-managed deployment safely
Release / release (push) Canceled after 0s
2026-08-18 00:02:45 +08:00
Qiufeng c35ef3d383 fix: default repair installs to production mode
Release / release (push) Canceled after 0s
2026-08-17 23:43:57 +08:00
Qiufeng cea46b494d fix: keep online update recovery available
Release / release (push) Canceled after 0s
2026-08-17 23:36:37 +08:00
Qiufeng b679381946 fix: keep brand navigation in active workbench
Release / release (push) Canceled after 0s
2026-08-17 23:03:03 +08:00
Qiufeng 3480d810d4 fix: recover MySQL collation migration
Release / release (push) Canceled after 0s
2026-08-17 22:32:57 +08:00
23 changed files with 596 additions and 73 deletions
+4 -1
View File
@@ -65,12 +65,15 @@ jobs:
./scripts/test-install-fixture.sh
./scripts/test-git-install-fixture.sh
./scripts/test-update-fixture.sh
./scripts/test-baota-start-fixture.sh
./scripts/test-gitea-publish-fixture.sh
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh scripts/check-semver.sh \
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh \
scripts/check-semver.sh \
scripts/package-release.sh scripts/publish-gitea-release.sh \
scripts/generate-release-key.sh scripts/test-install-fixture.sh \
scripts/test-git-install-fixture.sh \
scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
scripts/test-baota-start-fixture.sh \
scripts/verify-release.sh
- name: Build and sign release assets
+79 -19
View File
@@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发
### 直接 curl 安装
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
```
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
| sudo env KAIDI_APP_PORT=19090 bash
```
@@ -74,7 +74,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh -o /tmp/kaidi-install.sh
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh -o /tmp/kaidi-install.sh
printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c -
sudo bash /tmp/kaidi-install.sh
rm -f /tmp/kaidi-install.sh
@@ -86,7 +86,7 @@ rm -f /tmp/kaidi-install.sh
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
```bash
git clone --branch v1.0.0-preview.18 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
cd kaidi-preview
./deploy/install-from-git.sh
```
@@ -141,12 +141,13 @@ sudo cat /root/kaidi-first-login.txt
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash
```
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
临时表及读写权限,全部通过后 Flyway 才会建表。
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
排序规则,全部通过后 Flyway 才会建表。
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
```sql
@@ -162,18 +163,77 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
管理员数据和运维人员新增的环境变量:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
```
没有显式设置 `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.18/install.sh \
| sudo env KAIDI_REINSTALL=true bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=true bash
```
### 宝塔面板手动部署
宝塔使用 **Spring Boot** 项目类型。发布物是包含嵌入式 Tomcat 的可执行 JAR,不使用外置 Tomcat 项目。
先下载并解压签名 Release;项目目录必须指向同时包含 `app.jar`、`VERSION`、`public/` 和 `ops/` 的目录。
推荐采用版本目录加 `current` 软链接:
```bash
VERSION=1.0.0-preview.23
RELEASE_ROOT=/www/wwwroot/kaidi/releases/$VERSION
sudo install -d -o root -g kaidi -m 0750 "$RELEASE_ROOT"
curl -fL "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz" \
-o "/tmp/kaidi-finance-$VERSION.tar.gz"
sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
sudo chown -R root:kaidi "$RELEASE_ROOT"
sudo find "$RELEASE_ROOT" -type d -exec chmod 0750 {} +
sudo find "$RELEASE_ROOT" -type f -exec chmod 0640 {} +
sudo chmod 0750 "$RELEASE_ROOT/ops/baota-start.sh"
sudo ln -sfn "$RELEASE_ROOT" /www/wwwroot/kaidi/current.next
sudo mv -Tf /www/wwwroot/kaidi/current.next /www/wwwroot/kaidi/current
```
宝塔 Java 项目字段:
| 字段 | 值 |
| --- | --- |
| 项目类型 | Spring Boot |
| 项目路径 | `/www/wwwroot/kaidi/current` |
| 项目名称 | `kaidi-finance` |
| 项目 JDK | JDK 17 |
| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` |
| 启动用户 | `kaidi`,禁止使用 root |
| 项目端口 | `18080` |
| 绑定域名 | 实际业务域名,例如 `fi.awaioi.com` |
| 后端路由 | `/` |
| 前端资源 | 留空,由 Spring Boot 提供 `public/` |
| 进程守护 | 开启 |
| 项目意外重启 | 勾选 |
环境变量按 `ops/baota.env.example` 填入宝塔。已完成安装的服务器必须沿用
`/var/lib/kaidi/setup/application.env` 中的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD`,以及
`/etc/kaidi/kaidi.env` 中的 `FIELD_ENCRYPTION_KEY`;不要生成新的字段加密密钥。HTTP Preview 使用
`SESSION_COOKIE_SECURE=false`,切换到 HTTPS 后改为 `true`。宝塔模式固定设置
`FINANCE_UPDATE_ENABLED=false`,版本更新通过上传新目录、切换 `current`、重启项目完成。
启用宝塔守护前停用原 systemd 进程,避免两个进程管理器反复争用端口或互相重启:
```bash
sudo systemctl disable --now kaidi-update.path kaidi-finance.service
sudo systemctl stop kaidi-update.service
```
启动后在服务器验证 Java 上游,再配置域名反向代理:
```bash
curl -fsS http://127.0.0.1:18080/actuator/health
curl -fsS http://127.0.0.1:18080/ | head
```
### 停用并移除程序
@@ -208,8 +268,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash
git tag v1.0.0-preview.18
git push origin v1.0.0-preview.18
git tag v1.0.0-preview.19
git push origin v1.0.0-preview.19
```
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
@@ -223,12 +283,12 @@ URL、Token、脚本或命令。更新流程固定为:
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
`mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd 和应用版本,不修改反向代理。
7. 同时检查 Java 健康端点、更新 path unit 和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
6. 校验更新脚本和 systemd 单元后,在旧 Java 仍运行时原子切换版本链接,再由 systemd 执行一次 `restart`,不修改反向代理。
7. 同时检查 Java 健康端点和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
不会延迟服务端切换。失败请求会被归档且不会由 systemd 自动重试,避免同一事务反复停止业务服务。
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像,
@@ -242,9 +302,9 @@ sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json
```
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
修复日志所示的磁盘、权限或旧版本健康问题后执行:
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,失败请求会归档到
`/var/lib/kaidi-update/failed`,活动事务保留在 `transactions/active`,系统不会自动再次执行。修复日志所示的
磁盘、权限或旧版本健康问题后,才明确执行一次:
```bash
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
@@ -261,7 +321,7 @@ cat /var/lib/kaidi-update/status.json
```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.18
./scripts/package-release.sh 1.0.0-preview.19
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release
```
@@ -42,6 +42,9 @@ public class SetupService {
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
private static final String MYSQL_PREFIX = "jdbc:mysql://";
private static final String MYSQL_CHARACTER_SET = "utf8mb4";
private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci";
private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql";
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
@@ -81,9 +84,10 @@ public class SetupService {
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
}
ConnectionResult connection = testConnection(settings);
prepareDatabaseForInstallation(settings);
validateSchemaForInstallation(settings);
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
"MySQL 8.4 连接和完整迁移权限验证通过");
"MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
}
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -129,6 +133,7 @@ public class SetupService {
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
try {
prepareDatabaseForInstallation(settings);
validateSchemaForInstallation(settings);
flyway(settings).migrate();
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
@@ -146,6 +151,105 @@ public class SetupService {
}
}
private void prepareDatabaseForInstallation(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()));
try {
DatabaseCollation current = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) {
jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database())
+ " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION);
DatabaseCollation updated = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
}
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
}
recoverV068CollationFailure(jdbc, settings.database());
} catch (SetupException exception) {
throw exception;
} catch (RuntimeException exception) {
LOGGER.error("Database collation preparation failed", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
: "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_PREPARATION_FAILED", detail);
}
}
private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) {
return jdbc.queryForObject("""
SELECT default_character_set_name, default_collation_name
FROM information_schema.schemata
WHERE schema_name = ?
""", (result, row) -> new DatabaseCollation(
result.getString("default_character_set_name"), result.getString("default_collation_name")),
database);
}
private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) {
Integer historyCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = ? AND table_name = 'flyway_schema_history'
""", Integer.class, database);
if (historyCount == null || historyCount == 0) {
return;
}
List<FailedMigration> failures = jdbc.query("""
SELECT installed_rank, version, script
FROM flyway_schema_history
WHERE success = FALSE
ORDER BY installed_rank
""", (result, row) -> new FailedMigration(result.getInt("installed_rank"),
result.getString("version"), result.getString("script")));
if (failures.size() != 1) {
return;
}
FailedMigration failure = failures.get(0);
if (!("68".equals(failure.version()) || "068".equals(failure.version()))
|| !RECOVERABLE_V068_SCRIPT.equals(failure.script())) {
return;
}
Integer predecessorCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version IN ('67', '067') AND success = TRUE
""", Integer.class);
if (predecessorCount == null || predecessorCount != 1) {
return;
}
Integer laterSuccessCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE installed_rank > ? AND success = TRUE
""", Integer.class, failure.installedRank());
if (laterSuccessCount != null && laterSuccessCount > 0) {
return;
}
int deleted = jdbc.update("""
DELETE FROM flyway_schema_history
WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE
""", failure.installedRank(), failure.version(), failure.script());
if (deleted != 1) {
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
}
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
RECOVERABLE_V068_SCRIPT);
}
private String quoteIdentifier(String identifier) {
return "`" + identifier.replace("`", "``") + "`";
}
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()));
@@ -580,6 +684,12 @@ public class SetupService {
private record ConnectionResult(boolean successful, String version) {
}
private record DatabaseCollation(String characterSet, String collation) {
}
private record FailedMigration(int installedRank, String version, String script) {
}
private record InstallationState(String setupTokenSha256, long administratorUserId,
String administratorUsername) {
}
@@ -1,6 +1,7 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode;
import com.kaidi.setup.SetupApplication;
@@ -13,6 +14,7 @@ import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -60,6 +62,8 @@ class SetupApplicationIntegrationTest {
@Test
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
seedRecoverableV068CollationFailure();
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
@@ -77,7 +81,8 @@ class SetupApplicationIntegrationTest {
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("message").asText()).contains("完整迁移权限验证通过");
assertThat(tested.getBody().path("data").path("message").asText())
.contains("排序规则和完整迁移权限验证通过");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
@@ -92,6 +97,22 @@ class SetupApplicationIntegrationTest {
result.next();
assertThat(result.getInt(1)).isZero();
}
try (ResultSet result = statement.executeQuery("""
SELECT default_collation_name
FROM information_schema.schemata
WHERE schema_name = DATABASE()
""")) {
result.next();
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
}
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
}
Map<String, Object> complete = new LinkedHashMap<>(database);
@@ -141,6 +162,33 @@ class SetupApplicationIntegrationTest {
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
}
private void seedRecoverableV068CollationFailure() throws Exception {
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName()
+ "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
}
assertThatThrownBy(() -> Flyway.configure()
.dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())
.locations("classpath:db/migration")
.target("68")
.load()
.migrate())
.hasMessageContaining("V068__source_table_column_validation_metadata.sql");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version = '68'
AND script = 'V068__source_table_column_validation_metadata.sql'
AND success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isEqualTo(1);
}
}
private Map<String, Object> databaseRequest(String setupCode) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("setupCode", setupCode);
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -Eeuo pipefail
die() {
printf '[kaidi-baota] ERROR: %s\n' "$1" >&2
exit 1
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
APP_JAR="$RELEASE_ROOT/app.jar"
PUBLIC_INDEX="$RELEASE_ROOT/public/index.html"
VERSION_FILE="$RELEASE_ROOT/VERSION"
[ -s "$APP_JAR" ] || die "app.jar is missing from $RELEASE_ROOT"
[ -s "$PUBLIC_INDEX" ] || die "public/index.html is missing from $RELEASE_ROOT"
[ -s "$VERSION_FILE" ] || die "VERSION is missing from $RELEASE_ROOT"
JAVA_BIN=${KAIDI_JAVA_BIN:-}
if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then
JAVA_BIN="$JAVA_HOME/bin/java"
fi
if [ -z "$JAVA_BIN" ]; then
JAVA_BIN=$(command -v java 2>/dev/null || true)
fi
[ -n "$JAVA_BIN" ] || die "Java 17 or newer was not found"
if [[ "$JAVA_BIN" == */* ]]; then
[ -x "$JAVA_BIN" ] || die "Java executable is not available at $JAVA_BIN"
else
command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available"
fi
export SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-production}
export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1}
export SERVER_PORT=${SERVER_PORT:-18080}
export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false}
export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false}
export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-false}
export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false}
export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/}
export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE}
export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")}
if [ "$FINANCE_SETUP_ENABLED" != true ]; then
for name in DB_URL DB_USERNAME DB_PASSWORD FIELD_ENCRYPTION_KEY FILE_STORAGE_ROOT FILE_STORAGE_TEMP; do
[ -n "${!name:-}" ] || die "$name must be configured in the Baota project environment"
done
for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do
[ -d "$directory" ] || die "Storage directory $directory does not exist"
[ -w "$directory" ] || die "Startup user cannot write storage directory $directory"
done
fi
cd "$RELEASE_ROOT"
exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar "$APP_JAR"
+14
View File
@@ -0,0 +1,14 @@
SPRING_PROFILES_ACTIVE=production
SERVER_ADDRESS=127.0.0.1
SERVER_PORT=18080
SESSION_COOKIE_SECURE=false
DB_URL=jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=MYSQL_USERNAME
DB_PASSWORD=MYSQL_PASSWORD
FIELD_ENCRYPTION_KEY=EXISTING_FIELD_ENCRYPTION_KEY
FILE_STORAGE_ROOT=/var/lib/kaidi/files
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
FILE_SCANNER_ENABLED=false
FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_SETUP_ENABLED=false
FINANCE_UPDATE_ENABLED=false
+10 -2
View File
@@ -18,7 +18,13 @@ SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-127.0.0.1}
PROXY_TARGET=
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
REINSTALL=${KAIDI_REINSTALL:-false}
SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}
if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then
SETUP_WIZARD=$KAIDI_SETUP_WIZARD
elif [ "$REINSTALL" = true ]; then
SETUP_WIZARD=false
else
SETUP_WIZARD=true
fi
WORK_DIR=
RELEASE_AUTH_HEADER_FILE=
INSTALL_TRANSACTION_ARMED=false
@@ -338,7 +344,7 @@ secure_release_tree() {
chown -R root:"$SERVICE_GROUP" "$release_dir"
find "$release_dir" -type d -exec chmod 0750 {} +
find "$release_dir" -type f -exec chmod 0640 {} +
chmod 0750 "$release_dir/ops/update.sh"
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
}
restore_security_contexts() {
@@ -842,6 +848,8 @@ find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \
[ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing"
[ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch"
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing"
[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || die "Baota Spring Boot launcher is missing"
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || die "Baota Spring Boot launcher is invalid"
configure_database
preflight_database
-2
View File
@@ -4,8 +4,6 @@ Description=Watch for Kaidi Finance update requests
[Path]
PathChanged=/var/lib/kaidi-update/inbox
PathExists=/var/lib/kaidi-update/inbox/request.json
PathChanged=/var/lib/kaidi-update/processing
PathExists=/var/lib/kaidi-update/processing/request.json
Unit=kaidi-update.service
[Install]
+1 -2
View File
@@ -12,8 +12,7 @@ Group=root
EnvironmentFile=/etc/kaidi/update.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env
ExecStart=/opt/kaidi/bin/update.sh
Restart=on-failure
RestartSec=15
Restart=no
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
+56 -25
View File
@@ -18,6 +18,7 @@ RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
@@ -101,7 +102,7 @@ secure_release_tree() {
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
find "$release_dir" -type d -exec chmod 0750 {} + || return 1
find "$release_dir" -type f -exec chmod 0640 {} + || return 1
chmod 0750 "$release_dir/ops/update.sh"
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
}
verify_release_access() {
@@ -272,6 +273,22 @@ atomic_install() {
mv -f "$install_temporary" "$install_destination"
}
install_operation() {
operation_source=$1
operation_destination=$2
operation_mode=$3
operation_is_systemd=$4
if [ -f "$operation_destination" ] && [ ! -L "$operation_destination" ] \
&& cmp -s "$operation_source" "$operation_destination"; then
chmod "$operation_mode" "$operation_destination"
return
fi
atomic_install "$operation_source" "$operation_destination" "$operation_mode" || return 1
if [ "$operation_is_systemd" = true ]; then
SYSTEMD_OPERATIONS_CHANGED=true
fi
}
backup_managed_file() {
backup_source=$1
backup_name=$2
@@ -311,14 +328,16 @@ backup_operations() {
}
apply_operations() {
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
&& systemctl daemon-reload
SYSTEMD_OPERATIONS_CHANGED=false
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
&& install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
&& install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|| return 1
[ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
}
restore_operations() {
@@ -357,28 +376,34 @@ rollback_active_transaction() {
previous_target=$(transaction_value previous-target)
failed_release=$(transaction_value release-dir)
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
rollback_ok=true
link_restored=true
operations_restored=true
systemctl stop "$SERVICE_NAME" || true
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_ok=false
link_restored=false
fi
else
rollback_ok=false
link_restored=false
fi
restore_operations || rollback_ok=false
restore_operations || operations_restored=false
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
systemctl start "$SERVICE_NAME" || rollback_ok=false
if [ "$rollback_ok" = true ] && verify_app_surface; then
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
if [ "$link_restored" = true ] && verify_app_surface; then
remove_failed_release "$failed_release"
rm -rf "$ACTIVE_TRANSACTION"
fail "$reason; previous release was restored and verified"
if [ "$operations_restored" = true ]; then
fail "$reason; previous release was restored and verified"
fi
fail "$reason; previous release is running, but operations restoration requires manual review"
fi
TERMINAL_STATUS_WRITTEN=true
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}"
printf '%s\n' "$reason; rollback is incomplete and will be retried" >&2
status FAILED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
if ! archive_processing_request; then
printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2
fi
printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2
exit 1
}
@@ -404,6 +429,7 @@ recover_interrupted_transaction() {
validate_release_operations() {
sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || return 1
command -v systemd-analyze >/dev/null 2>&1 || return 1
systemd-analyze verify \
"$WORK_DIR/extracted/ops/kaidi-finance.service" \
@@ -590,7 +616,6 @@ apply_operations || rollback_active_transaction "Release operations files could
write_transaction_value phase OPS_APPLIED
write_transaction_value phase APP_SWITCHING
systemctl stop "$SERVICE_NAME" || rollback_active_transaction "Application service could not be stopped"
if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_active_transaction "Release application link could not be activated"
@@ -600,15 +625,21 @@ 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
if systemctl start "$SERVICE_NAME" \
&& systemctl is-active --quiet kaidi-update.path \
&& verify_app_surface; then
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME" || true
if verify_app_surface; then
success_message="Release $TARGET_VERSION is running"
if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
success_message="$success_message; automatic update watcher could not be started"
fi
write_transaction_value phase COMMITTED
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
status SUCCEEDED "$success_message" "$TARGET_VERSION"
TERMINAL_STATUS_WRITTEN=true
complete_request
rm -rf "$ACTIVE_TRANSACTION"
exit 0
fi
rollback_active_transaction "Release health or application-surface verification failed"
service_state=$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)
[ -n "$service_state" ] || service_state=unknown
rollback_active_transaction \
"Release health or application-surface verification failed (service state: $service_state)"
+3 -3
View File
@@ -31,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: true,
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
},
},
});
@@ -59,7 +59,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click();
await expect(page.getByText('MySQL 8.4 连接和完整迁移权限验证通过')).toBeVisible();
await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible();
await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
@@ -107,7 +107,7 @@ test('confirms completion after the setup response is interrupted by a service r
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: true,
message: 'MySQL 8.4 连接和完整迁移权限验证通过',
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
},
},
});
+10
View File
@@ -178,6 +178,16 @@ test('system administrator checks and queues a signed online update', async ({ p
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
});
test('brand logo returns the active identity to its workbench', async ({ page }) => {
await installFixture(page, [], []);
await page.goto('/governance/update');
await page.locator('.brand-logo').click();
await expect(page).toHaveURL(/\/governance\/settings$/);
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
});
test('disabled online updates expose status without an executable check action', async ({ page }) => {
await installFixture(page, [], [], false);
await page.goto('/governance/update');
+1 -1
View File
@@ -2,7 +2,7 @@
<div :class="layoutCls">
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
<template #logo>
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')">
<span v-if="showLogo" class="header-logo-container" @click="handleNav(user.workbenchRoute)">
<span class="header-brand">财务系统</span>
</span>
<div v-else class="header-operate-left">
+3 -2
View File
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
import { prefix } from '@/config/global';
import { t } from '@/locales';
import { getActive } from '@/router';
import { useSettingStore } from '@/store';
import { useSettingStore, useUserStore } from '@/store';
import type { MenuRoute, ModeType } from '@/types/interface';
import pgk from '../../../package.json';
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
const router = useRouter();
const settingStore = useSettingStore();
const userStore = useUserStore();
const autoCollapsed = () => {
const isCompact = window.innerWidth <= MIN_POINT;
@@ -175,7 +176,7 @@ onUnmounted(() => {
});
const goHome = () => {
router.push('/role-select');
router.push(userStore.workbenchRoute);
};
</script>
<style lang="less" scoped>
+1 -10
View File
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
try {
await userStore.selectRole(roleCode);
permissionStore.initRoutes();
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute;
const fallback =
roleCode === 'PROJECT_MANAGER'
? '/workbench/project'
: roleCode === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: roleCode === 'SYSTEM_ADMIN'
? '/governance/settings'
: '/workbench/finance';
await router.replace(target || fallback);
await router.replace(userStore.workbenchRoute);
} catch (error) {
MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
} finally {
+2
View File
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
import { usePermissionStore } from '@/store';
import { hasPermissionAccess } from './menu-access';
import { resolveWorkbenchRoute } from './workbench-route';
const emptyUser: UserView = {
publicId: '',
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
getters: {
displayName: (state) => state.user.displayName || state.user.username,
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
hasPermission: (state) => (permission: string) =>
hasPermissionAccess(state.currentRole, state.permissions, permission),
@@ -0,0 +1,13 @@
import type { RoleView } from '@/api/auth';
const fallbackWorkbenchRoutes: Record<string, string> = {
PROJECT_MANAGER: '/workbench/project',
FINANCE_MANAGER: '/workbench/finance',
ARCHIVE_MANAGER: '/workbench/archive',
SYSTEM_ADMIN: '/governance/settings',
};
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
const configured = roles.find((role) => role.code === currentRole)?.workbenchRoute?.trim();
return configured || fallbackWorkbenchRoutes[currentRole || ''] || '/role-select';
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import type { RoleView } from '../src/api/auth';
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
const roles: RoleView[] = [
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
];
describe('active identity workbench route', () => {
it('uses the route supplied for the active role', () => {
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
});
it('falls back to the built-in role workbench when the server route is absent', () => {
expect(resolveWorkbenchRoute('PROJECT_MANAGER', [])).toBe('/workbench/project');
expect(resolveWorkbenchRoute('ARCHIVE_MANAGER', [])).toBe('/workbench/archive');
});
it('keeps role selection for sessions without an active identity', () => {
expect(resolveWorkbenchRoute(null, roles)).toBe('/role-select');
});
});
+3 -1
View File
@@ -91,12 +91,14 @@ cp "$JAR" "$STAGE/app.jar"
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh"
cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example"
# Kept in the archive so Preview.9's updater can complete the one-time transition.
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
chmod 0755 "$STAGE/ops/update.sh"
chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh"
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
fail() {
printf 'Baota start fixture failed: %s\n' "$1" >&2
exit 1
}
RELEASE="$WORK/release"
mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp"
printf 'fixture jar\n' > "$RELEASE/app.jar"
printf '<!doctype html>\n' > "$RELEASE/public/index.html"
printf '1.0.0-preview.23\n' > "$RELEASE/VERSION"
cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh"
chmod 0755 "$RELEASE/ops/baota-start.sh"
cat > "$WORK/java" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
{
printf 'cwd=%s\n' "$PWD"
printf 'args=%s\n' "$*"
printf 'version=%s\n' "$APP_VERSION"
printf 'address=%s\n' "$SERVER_ADDRESS"
printf 'port=%s\n' "$SERVER_PORT"
printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED"
printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS"
} > "$MOCK_JAVA_LOG"
SH
chmod 0755 "$WORK/java"
MOCK_JAVA_LOG="$WORK/java.log" \
KAIDI_JAVA_BIN="$WORK/java" \
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
DB_USERNAME=fixture \
DB_PASSWORD='fixture-password' \
FIELD_ENCRYPTION_KEY='fixture-field-key' \
FILE_STORAGE_ROOT="$WORK/files" \
FILE_STORAGE_TEMP="$WORK/tmp" \
"$RELEASE/ops/baota-start.sh"
grep -Fqx "cwd=$RELEASE" "$WORK/java.log" || fail 'launcher did not use the release working directory'
grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar $RELEASE/app.jar" "$WORK/java.log" \
|| fail 'launcher did not execute the packaged Spring Boot jar'
grep -Fqx 'version=1.0.0-preview.23' "$WORK/java.log" || fail 'launcher did not read VERSION'
grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback'
grep -Fqx 'port=18080' "$WORK/java.log" || fail 'launcher did not default to port 18080'
grep -Fqx 'update=false' "$WORK/java.log" || fail 'launcher did not disable the systemd updater'
grep -Fqx "static=file:$RELEASE/public/" "$WORK/java.log" \
|| fail 'launcher did not bind static resources to the release directory'
if KAIDI_JAVA_BIN="$WORK/java" \
DB_USERNAME=fixture DB_PASSWORD=fixture FIELD_ENCRYPTION_KEY=fixture \
FILE_STORAGE_ROOT="$WORK/files" FILE_STORAGE_TEMP="$WORK/tmp" \
MOCK_JAVA_LOG="$WORK/missing-db.log" \
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
fail 'launcher accepted a production configuration without DB_URL'
fi
grep -Fq 'DB_URL must be configured' "$WORK/missing-db.out" \
|| fail 'launcher did not report the missing database URL'
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
printf 'Baota Spring Boot launcher fixture passed\n'
+10 -2
View File
@@ -51,6 +51,7 @@ mkdir -p "$release_permissions/public" "$release_permissions/ops"
printf 'jar\n' > "$release_permissions/app.jar"
printf 'html\n' > "$release_permissions/public/index.html"
printf '#!/bin/sh\n' > "$release_permissions/ops/update.sh"
printf '#!/usr/bin/env bash\n' > "$release_permissions/ops/baota-start.sh"
chmod -R 0777 "$release_permissions"
(
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
@@ -316,6 +317,10 @@ grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh
|| fail 'installer can execute before the complete curl stream is parsed'
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer has a bounded restart burst'
grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service can automatically repeat a failed switching transaction'
! grep -Fq '/var/lib/kaidi-update/processing' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path can automatically repeat a claimed switching transaction'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256'
@@ -325,8 +330,11 @@ grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$
grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \
|| fail 'installer does not pin the default release public-key fingerprint'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}' "$ROOT/deploy/install.sh" \
|| fail 'installer does not enable the first-run setup wizard by default'
grep -Fq 'if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer distinguishes an explicit setup-wizard selection'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'elif [ "$REINSTALL" = true ]; then' "$ROOT/deploy/install.sh" \
|| fail 'repair reinstall no longer defaults to production mode'
! grep -Fq 'docker run' "$ROOT/deploy/install.sh" \
|| fail 'installer must not create a MySQL container'
! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \
+74 -1
View File
@@ -92,6 +92,7 @@ SH
cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
exit 0
SH
@@ -170,15 +171,18 @@ SH
build_release() {
local fixture=$1
local version=$2
local unit_prefix=${3:-new}
local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
printf 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION"
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh"
chmod 0755 "$stage/ops/update.sh"
chmod 0755 "$stage/ops/baota-start.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
printf 'new %s\n' "$name" > "$stage/ops/$name"
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
done
local artifact="kaidi-finance-$version.tar.gz"
@@ -272,6 +276,7 @@ run_update() {
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
KAIDI_SERVICE_USER="$(id -un)" \
KAIDI_SERVICE_GROUP="$(id -gn)" \
KAIDI_APP_ROOT="$fixture/app" \
@@ -361,6 +366,10 @@ assert_success_case() {
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
grep -qx 'restart kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case did not restart the application after the atomic switch'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case stopped the application before switching releases'
! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx'
[ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group'
[ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750'
@@ -368,6 +377,41 @@ assert_success_case() {
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
}
assert_identical_systemd_operations_case() {
local fixture="$WORK/identical-systemd"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version" old
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
: > "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|| fail 'identical systemd units triggered an unnecessary daemon reload'
}
assert_update_path_failure_keeps_application_case() {
local fixture="$WORK/path-watcher-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state'
grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \
|| fail 'path watcher failure did not preserve its diagnostic'
}
assert_rollback_case() {
local fixture="$WORK/rollback"
local version='1.0.0-preview.2'
@@ -388,6 +432,10 @@ assert_rollback_case() {
|| fail 'rollback case did not restore the previous updater'
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|| fail 'rollback case did not restore the previous path unit'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'rollback case stopped the application before restoring the previous release'
[ "$(grep -c '^restart kaidi-finance.service$' "$fixture/systemctl.log")" -ge 2 ] \
|| fail 'rollback case did not restart both the candidate and restored releases'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist FAILED'
[ ! -e "$fixture/app/releases/$version" ] \
@@ -396,6 +444,28 @@ assert_rollback_case() {
|| fail 'rollback case did not archive the failed request'
}
assert_incomplete_rollback_requires_manual_recovery_case() {
local fixture="$WORK/incomplete-rollback"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded'
fi
grep -Fq 'manual recovery is required' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request'
[ -d "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case did not preserve transaction evidence'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request'
}
assert_download_failure_case() {
local fixture="$WORK/download-failure"
local version='1.0.0-preview.2'
@@ -480,7 +550,10 @@ assert_install_without_verified_cache_rejected() {
}
assert_success_case
assert_identical_systemd_operations_case
assert_update_path_failure_keeps_application_case
assert_rollback_case
assert_incomplete_rollback_requires_manual_recovery_case
assert_download_failure_case
assert_database_failure_case
assert_symlink_request_rejected
+5
View File
@@ -160,10 +160,15 @@ compare_archive_file() {
}
compare_archive_file ./ops/update.sh deploy/update.sh
compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh
compare_archive_file ./ops/baota.env.example deploy/baota.env.example
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|| { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; }
bash -n "$ROOT/deploy/baota-start.sh"
cmp install.sh "$ROOT/deploy/install.sh" \
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }