Compare commits

..
Author SHA1 Message Date
Qiufeng cea46b494d fix: keep online update recovery available
Release / release (push) Canceled after 0s
2026-08-17 23:36:37 +08:00
Qiufeng b679381946 fix: keep brand navigation in active workbench
Release / release (push) Canceled after 0s
2026-08-17 23:03:03 +08:00
Qiufeng 3480d810d4 fix: recover MySQL collation migration
Release / release (push) Canceled after 0s
2026-08-17 22:32:57 +08:00
Qiufeng 271eac382e fix: harden first-run database setup
Release / release (push) Canceled after 0s
2026-08-17 22:05:10 +08:00
19 changed files with 733 additions and 84 deletions
+15 -13
View File
@@ -49,7 +49,7 @@ npm run build
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用 Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。 Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有 本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 发布范围。 PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java 执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的 由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的
@@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12
### 直接 curl 安装 ### 直接 curl 安装
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/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 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: 安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/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 | 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`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: 时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/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 - printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c -
sudo bash /tmp/kaidi-install.sh sudo bash /tmp/kaidi-install.sh
rm -f /tmp/kaidi-install.sh rm -f /tmp/kaidi-install.sh
@@ -86,7 +86,7 @@ rm -f /tmp/kaidi-install.sh
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
```bash ```bash
git clone --branch v1.0.0-preview.17 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
cd kaidi-preview cd kaidi-preview
./deploy/install-from-git.sh ./deploy/install-from-git.sh
``` ```
@@ -100,7 +100,7 @@ cd kaidi-preview
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。 - 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。 - 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。 - 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。 - 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。 - 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。 - 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 - 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
@@ -141,11 +141,13 @@ sudo cat /root/kaidi-first-login.txt
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: 32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/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、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。 完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
排序规则,全部通过后 Flyway 才会建表。
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: 数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
```sql ```sql
@@ -161,7 +163,7 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
管理员数据和运维人员新增的环境变量: 管理员数据和运维人员新增的环境变量:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh \ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
``` ```
@@ -171,7 +173,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh \ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
| sudo env KAIDI_REINSTALL=true bash | sudo env KAIDI_REINSTALL=true bash
``` ```
@@ -207,8 +209,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.17 git tag v1.0.0-preview.19
git push origin v1.0.0-preview.17 git push origin v1.0.0-preview.19
``` ```
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
@@ -260,7 +262,7 @@ cat /var/lib/kaidi-update/status.json
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.17 ./scripts/package-release.sh 1.0.0-preview.19
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./scripts/verify-release.sh dist/release
``` ```
@@ -17,6 +17,6 @@ public class SetupLockedController {
@GetMapping("/status") @GetMapping("/status")
@PreAuthorize("true") @PreAuthorize("true")
public ApiResponse<SetupViews.Status> status() { public ApiResponse<SetupViews.Status> status() {
return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装")); return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装"));
} }
} }
@@ -25,6 +25,9 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -37,8 +40,15 @@ import org.springframework.transaction.support.TransactionTemplate;
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") @ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
public class SetupService { public class SetupService {
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
private static final String MYSQL_PREFIX = "jdbc:mysql://"; private static final String MYSQL_PREFIX = "jdbc:mysql://";
private static final String MYSQL_CHARACTER_SET = "utf8mb4";
private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci";
private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql";
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name()); private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10;
private final SetupProperties properties; private final SetupProperties properties;
private final AtomicBoolean locked = new AtomicBoolean(false); private final AtomicBoolean locked = new AtomicBoolean(false);
@@ -56,7 +66,7 @@ public class SetupService {
public SetupViews.Status status() { public SetupViews.Status status() {
boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile())); boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile()));
return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES, return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES,
isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化"); isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化");
} }
@@ -74,8 +84,10 @@ public class SetupService {
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
} }
ConnectionResult connection = testConnection(settings); ConnectionResult connection = testConnection(settings);
prepareDatabaseForInstallation(settings);
validateSchemaForInstallation(settings);
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
"MySQL 8.4 连接和 DDL/DML 权限验证通过"); "MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
} }
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -106,7 +118,7 @@ public class SetupService {
if (properties.restartAfterComplete()) { if (properties.restartAfterComplete()) {
Thread restart = new Thread(() -> { Thread restart = new Thread(() -> {
try { try {
Thread.sleep(750L); Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
} catch (InterruptedException exception) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
@@ -116,18 +128,14 @@ public class SetupService {
restart.start(); restart.start();
} }
return new SetupViews.Completed(true, request.adminUsername(), return new SetupViews.Completed(true, request.adminUsername(),
"安装完成,系统正在切换到正式模式", 10); "安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
} }
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) { private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
try { try {
ensureCompatibleSchema(settings); prepareDatabaseForInstallation(settings);
Flyway.configure() validateSchemaForInstallation(settings);
.dataSource(settings.jdbcUrl(), settings.username(), settings.password()) flyway(settings).migrate();
.locations("classpath:db/migration")
.cleanDisabled(true)
.load()
.migrate();
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()); settings.password());
JdbcTemplate jdbc = new JdbcTemplate(dataSource); JdbcTemplate jdbc = new JdbcTemplate(dataSource);
@@ -137,12 +145,112 @@ public class SetupService {
if (exception instanceof SetupException setupException) { if (exception instanceof SetupException setupException) {
throw setupException; throw setupException;
} }
LOGGER.error("Database migration or administrator initialization failed", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限"); "DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
} }
} }
private void ensureCompatibleSchema(DatabaseSettings settings) { private void prepareDatabaseForInstallation(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()));
try {
DatabaseCollation current = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) {
jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database())
+ " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION);
DatabaseCollation updated = databaseCollation(jdbc, settings.database());
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet())
|| !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
}
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
}
recoverV068CollationFailure(jdbc, settings.database());
} catch (SetupException exception) {
throw exception;
} catch (RuntimeException exception) {
LOGGER.error("Database collation preparation failed", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
: "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_COLLATION_PREPARATION_FAILED", detail);
}
}
private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) {
return jdbc.queryForObject("""
SELECT default_character_set_name, default_collation_name
FROM information_schema.schemata
WHERE schema_name = ?
""", (result, row) -> new DatabaseCollation(
result.getString("default_character_set_name"), result.getString("default_collation_name")),
database);
}
private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) {
Integer historyCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = ? AND table_name = 'flyway_schema_history'
""", Integer.class, database);
if (historyCount == null || historyCount == 0) {
return;
}
List<FailedMigration> failures = jdbc.query("""
SELECT installed_rank, version, script
FROM flyway_schema_history
WHERE success = FALSE
ORDER BY installed_rank
""", (result, row) -> new FailedMigration(result.getInt("installed_rank"),
result.getString("version"), result.getString("script")));
if (failures.size() != 1) {
return;
}
FailedMigration failure = failures.get(0);
if (!("68".equals(failure.version()) || "068".equals(failure.version()))
|| !RECOVERABLE_V068_SCRIPT.equals(failure.script())) {
return;
}
Integer predecessorCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version IN ('67', '067') AND success = TRUE
""", Integer.class);
if (predecessorCount == null || predecessorCount != 1) {
return;
}
Integer laterSuccessCount = jdbc.queryForObject("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE installed_rank > ? AND success = TRUE
""", Integer.class, failure.installedRank());
if (laterSuccessCount != null && laterSuccessCount > 0) {
return;
}
int deleted = jdbc.update("""
DELETE FROM flyway_schema_history
WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE
""", failure.installedRank(), failure.version(), failure.script());
if (deleted != 1) {
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
}
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
RECOVERABLE_V068_SCRIPT);
}
private String quoteIdentifier(String identifier) {
return "`" + identifier.replace("`", "``") + "`";
}
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password())); settings.password()));
Integer tableCount = jdbc.queryForObject(""" Integer tableCount = jdbc.queryForObject("""
@@ -160,7 +268,42 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库"); "DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库");
} }
return true;
} }
return false;
}
private void validateSchemaForInstallation(DatabaseSettings settings) {
try {
if (!ensureCompatibleSchema(settings)) {
return;
}
flyway(settings).validate();
} catch (SetupException exception) {
throw exception;
} catch (FlywayException exception) {
LOGGER.error("Database migration history validation failed", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_MIGRATION_STATE_INVALID",
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
} catch (RuntimeException exception) {
LOGGER.error("Database schema inspection failed", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库结构检查失败:" + safeErrorMessage(exception)
: databaseConnectionFailureMessage(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
}
}
private Flyway flyway(DatabaseSettings settings) {
return Flyway.configure()
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
.locations("classpath:db/migration")
.ignoreMigrationPatterns("*:pending")
.cleanDisabled(true)
.load();
} }
private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) { private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) {
@@ -280,18 +423,148 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); "MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
} }
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", ""); verifyMigrationPrivileges(connection, statement, settings.type());
try {
statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + probe + " (id) VALUES (1)");
statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1");
} finally {
statement.execute("DROP TABLE IF EXISTS " + probe);
}
return new ConnectionResult(true, version); return new ConnectionResult(true, version);
} catch (SQLException exception) { } catch (SQLException exception) {
LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}",
exception.getSQLState(), exception.getErrorCode(), exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败"); "DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
}
}
private String databaseConnectionFailureMessage(SQLException exception) {
if (isPermissionError(exception)) {
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
}
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
return "数据库连接超时或中断" + sqlErrorSummary(exception);
}
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
}
private String databaseInitializationFailureMessage(Throwable exception) {
SQLException sqlException = findSqlException(exception);
if (sqlException != null) {
if (isPermissionError(sqlException)) {
return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException);
}
if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) {
return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception);
}
private SQLException findSqlException(Throwable exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof SQLException sqlException) {
return sqlException;
}
current = current.getCause();
}
return null;
}
private boolean isPermissionError(SQLException exception) {
return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode());
}
private String sqlErrorSummary(SQLException exception) {
String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState();
return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):"
+ safeErrorMessage(exception);
}
private String safeErrorMessage(Throwable exception) {
Throwable current = exception;
while (current.getCause() != null) {
current = current.getCause();
}
String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***")
.replaceAll("\\s+", " ").trim();
return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "...";
}
private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType)
throws SQLException {
String suffix = UUID.randomUUID().toString().replace("-", "");
String parentTable = "kaidi_setup_probe_parent_" + suffix;
String childTable = "kaidi_setup_probe_child_" + suffix;
String foreignKey = "fk_setup_probe_" + suffix;
String index = "idx_setup_probe_" + suffix;
String routine = "kaidi_setup_probe_routine_" + suffix;
String temporaryTable = "kaidi_setup_probe_temp_" + suffix;
try {
statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)");
if (databaseType == DatabaseType.MYSQL) {
statement.execute("CREATE TABLE " + childTable
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))");
statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL");
statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)");
statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value");
try (Statement call = connection.createStatement();
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
if (!result.next() || result.getInt(1) != 1) {
throw new SQLException("Migration privilege routine probe returned an invalid result");
}
}
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
if (!result.next() || !"probe".equals(result.getString(1))) {
throw new SQLException("Migration privilege SELECT probe returned an invalid result");
}
}
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
+ " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)");
statement.execute("DROP TEMPORARY TABLE " + temporaryTable);
statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1");
statement.execute("DELETE FROM " + childTable + " WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 1");
} else {
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 2");
}
} catch (SQLException exception) {
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception);
throw exception;
}
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null);
}
private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine,
String temporaryTable, String childTable, String parentTable,
SQLException original)
throws SQLException {
SQLException cleanupFailure = null;
List<String> cleanupStatements = databaseType == DatabaseType.MYSQL
? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable,
"DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable)
: List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable);
for (String sql : cleanupStatements) {
try {
statement.execute(sql);
} catch (SQLException exception) {
if (cleanupFailure == null) {
cleanupFailure = exception;
} else {
cleanupFailure.addSuppressed(exception);
}
}
}
if (cleanupFailure != null) {
if (original != null) {
original.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
} }
} }
@@ -411,6 +684,12 @@ public class SetupService {
private record ConnectionResult(boolean successful, String version) { private record ConnectionResult(boolean successful, String version) {
} }
private record DatabaseCollation(String characterSet, String collation) {
}
private record FailedMigration(int installedRank, String version, String script) {
}
private record InstallationState(String setupTokenSha256, long administratorUserId, private record InstallationState(String setupTokenSha256, long administratorUserId,
String administratorUsername) { String administratorUsername) {
} }
@@ -10,6 +10,7 @@ public final class SetupViews {
public record Status( public record Status(
boolean required, boolean required,
boolean locked, boolean locked,
boolean ready,
List<String> supportedDatabaseTypes, List<String> supportedDatabaseTypes,
String message String message
) { ) {
@@ -1,6 +1,7 @@
package com.kaidi.finance.setup; package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.kaidi.setup.SetupApplication; import com.kaidi.setup.SetupApplication;
@@ -13,6 +14,7 @@ import java.sql.Statement;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -60,9 +62,12 @@ class SetupApplicationIntegrationTest {
@Test @Test
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception { void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
seedRecoverableV068CollationFailure();
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue(); assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse();
Map<String, Object> database = databaseRequest("wrong-code"); Map<String, Object> database = databaseRequest("wrong-code");
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"), ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
@@ -76,6 +81,39 @@ class SetupApplicationIntegrationTest {
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue(); assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue(); assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("message").asText())
.contains("排序规则和完整迁移权限验证通过");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery("""
SELECT
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
+
(SELECT COUNT(*) FROM information_schema.routines
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
try (ResultSet result = statement.executeQuery("""
SELECT default_collation_name
FROM information_schema.schemata
WHERE schema_name = DATABASE()
""")) {
result.next();
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
}
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
}
Map<String, Object> complete = new LinkedHashMap<>(database); Map<String, Object> complete = new LinkedHashMap<>(database);
complete.put("adminUsername", "setup-admin"); complete.put("adminUsername", "setup-admin");
@@ -121,6 +159,34 @@ class SetupApplicationIntegrationTest {
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse(); assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue(); assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
}
private void seedRecoverableV068CollationFailure() throws Exception {
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName()
+ "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
}
assertThatThrownBy(() -> Flyway.configure()
.dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())
.locations("classpath:db/migration")
.target("68")
.load()
.migrate())
.hasMessageContaining("V068__source_table_column_validation_metadata.sql");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE version = '68'
AND script = 'V068__source_table_column_validation_metadata.sql'
AND success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isEqualTo(1);
}
} }
private Map<String, Object> databaseRequest(String setupCode) { private Map<String, Object> databaseRequest(String setupCode) {
@@ -0,0 +1,31 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SetupReadinessTest {
private static final String TOKEN_SHA256 =
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
@TempDir
private Path stateRoot;
@Test
void distinguishesLockedSetupModeFromReadyApplicationMode() {
SetupProperties properties = new SetupProperties(true, TOKEN_SHA256,
stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false);
SetupViews.Status setupMode = new SetupService(properties).status();
SetupViews.Status applicationMode = new SetupLockedController().status().data();
assertThat(setupMode.required()).isTrue();
assertThat(setupMode.ready()).isFalse();
assertThat(applicationMode.required()).isFalse();
assertThat(applicationMode.locked()).isTrue();
assertThat(applicationMode.ready()).isTrue();
}
}
+49 -20
View File
@@ -18,6 +18,7 @@ RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-} RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache} CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service} SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi} SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi} SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
@@ -272,6 +273,22 @@ atomic_install() {
mv -f "$install_temporary" "$install_destination" mv -f "$install_temporary" "$install_destination"
} }
install_operation() {
operation_source=$1
operation_destination=$2
operation_mode=$3
operation_is_systemd=$4
if [ -f "$operation_destination" ] && [ ! -L "$operation_destination" ] \
&& cmp -s "$operation_source" "$operation_destination"; then
chmod "$operation_mode" "$operation_destination"
return
fi
atomic_install "$operation_source" "$operation_destination" "$operation_mode" || return 1
if [ "$operation_is_systemd" = true ]; then
SYSTEMD_OPERATIONS_CHANGED=true
fi
}
backup_managed_file() { backup_managed_file() {
backup_source=$1 backup_source=$1
backup_name=$2 backup_name=$2
@@ -311,14 +328,16 @@ backup_operations() {
} }
apply_operations() { apply_operations() {
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \ SYSTEMD_OPERATIONS_CHANGED=false
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \ install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false \
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \ "$SYSTEMD_ROOT/kaidi-finance.service" 0644 true \
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \ "$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \ && install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
&& systemctl daemon-reload "$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|| return 1
[ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
} }
restore_operations() { restore_operations() {
@@ -357,24 +376,28 @@ rollback_active_transaction() {
previous_target=$(transaction_value previous-target) previous_target=$(transaction_value previous-target)
failed_release=$(transaction_value release-dir) failed_release=$(transaction_value release-dir)
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
rollback_ok=true link_restored=true
operations_restored=true
systemctl stop "$SERVICE_NAME" || true systemctl stop "$SERVICE_NAME" || true
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \ if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then || ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_ok=false link_restored=false
fi fi
else else
rollback_ok=false link_restored=false
fi fi
restore_operations || rollback_ok=false restore_operations || operations_restored=false
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
systemctl start "$SERVICE_NAME" || rollback_ok=false systemctl start "$SERVICE_NAME" || true
if [ "$rollback_ok" = true ] && verify_app_surface; then if [ "$link_restored" = true ] && verify_app_surface; then
remove_failed_release "$failed_release" remove_failed_release "$failed_release"
rm -rf "$ACTIVE_TRANSACTION" rm -rf "$ACTIVE_TRANSACTION"
fail "$reason; previous release was restored and verified" if [ "$operations_restored" = true ]; then
fail "$reason; previous release was restored and verified"
fi
fail "$reason; previous release is running, but operations restoration requires manual review"
fi fi
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}" status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}"
@@ -600,15 +623,21 @@ write_transaction_value phase APP_SWITCHED
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION"
write_transaction_value phase HEALTH_CHECKING write_transaction_value phase HEALTH_CHECKING
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
if systemctl start "$SERVICE_NAME" \ systemctl start "$SERVICE_NAME" || true
&& systemctl is-active --quiet kaidi-update.path \ if verify_app_surface; then
&& 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 write_transaction_value phase COMMITTED
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION" status SUCCEEDED "$success_message" "$TARGET_VERSION"
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
complete_request complete_request
rm -rf "$ACTIVE_TRANSACTION" rm -rf "$ACTIVE_TRANSACTION"
exit 0 exit 0
fi fi
rollback_active_transaction "Release health or 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)"
+61 -2
View File
@@ -11,6 +11,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
data: { data: {
required: !completed, required: !completed,
locked: completed, locked: completed,
ready: completed,
supportedDatabaseTypes: ['MYSQL'], supportedDatabaseTypes: ['MYSQL'],
message: completed ? '系统已完成安装' : '请完成安装', message: completed ? '系统已完成安装' : '请完成安装',
}, },
@@ -30,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
databaseType: 'MYSQL', databaseType: 'MYSQL',
serverVersion: '8.4.6', serverVersion: '8.4.6',
schemaReady: true, schemaReady: true,
message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过', message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
}, },
}, },
}); });
@@ -58,7 +59,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
await page.getByLabel('数据库密码').fill('fixture-db-password'); await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code'); await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click(); await page.getByRole('button', { name: '测试连接' }).click();
await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible(); await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible();
await page.getByRole('button', { name: '下一步' }).click(); await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
@@ -74,3 +75,61 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
})); }));
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
}); });
test('confirms completion after the setup response is interrupted by a service restart', async ({ page }) => {
let completionStarted = false;
let recoveryChecks = 0;
await page.route('**/api/v1/setup/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
if (completionStarted) recoveryChecks += 1;
const ready = completionStarted && recoveryChecks >= 2;
await route.fulfill({
json: {
data: {
required: !completionStarted,
locked: completionStarted,
ready,
supportedDatabaseTypes: ['MYSQL'],
message: ready ? '系统已完成安装' : '请完成安装',
},
},
});
return;
}
const body = request.postDataJSON() as Record<string, unknown>;
if (pathname.endsWith('/test-connection')) {
await route.fulfill({
json: {
data: {
successful: true,
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: true,
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
},
},
});
return;
}
expect(body.adminUsername).toBe('admin');
completionStarted = true;
await route.abort('connectionreset');
});
await page.goto('/setup');
await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click();
await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
await expect(page.getByText('网络请求失败')).toHaveCount(0);
});
+10
View File
@@ -178,6 +178,16 @@ test('system administrator checks and queues a signed online update', async ({ p
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]); expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
}); });
test('brand logo returns the active identity to its workbench', async ({ page }) => {
await installFixture(page, [], []);
await page.goto('/governance/update');
await page.locator('.brand-logo').click();
await expect(page).toHaveURL(/\/governance\/settings$/);
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
});
test('disabled online updates expose status without an executable check action', async ({ page }) => { test('disabled online updates expose status without an executable check action', async ({ page }) => {
await installFixture(page, [], [], false); await installFixture(page, [], [], false);
await page.goto('/governance/update'); await page.goto('/governance/update');
+7 -1
View File
@@ -1,8 +1,11 @@
import { request } from '@/utils/request'; import { request } from '@/utils/request';
const SETUP_COMPLETION_TIMEOUT_MS = 120_000;
export interface SetupStatus { export interface SetupStatus {
required: boolean; required: boolean;
locked: boolean; locked: boolean;
ready: boolean;
supportedDatabaseTypes: string[]; supportedDatabaseTypes: string[];
message: string; message: string;
} }
@@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus {
return { return {
required: Boolean(value?.required), required: Boolean(value?.required),
locked: Boolean(value?.locked), locked: Boolean(value?.locked),
ready: Boolean(value?.ready),
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [], supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
message: value?.message || '', message: value?.message || '',
}; };
@@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) {
} }
export function completeSetup(data: SetupCompleteRequest) { export function completeSetup(data: SetupCompleteRequest) {
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value); return request
.post<SetupCompleted>({ url: '/setup/complete', data, timeout: SETUP_COMPLETION_TIMEOUT_MS })
.then((value) => value);
} }
+1 -1
View File
@@ -2,7 +2,7 @@
<div :class="layoutCls"> <div :class="layoutCls">
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active"> <t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
<template #logo> <template #logo>
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')"> <span v-if="showLogo" class="header-logo-container" @click="handleNav(user.workbenchRoute)">
<span class="header-brand">财务系统</span> <span class="header-brand">财务系统</span>
</span> </span>
<div v-else class="header-operate-left"> <div v-else class="header-operate-left">
+3 -2
View File
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
import { prefix } from '@/config/global'; import { prefix } from '@/config/global';
import { t } from '@/locales'; import { t } from '@/locales';
import { getActive } from '@/router'; import { getActive } from '@/router';
import { useSettingStore } from '@/store'; import { useSettingStore, useUserStore } from '@/store';
import type { MenuRoute, ModeType } from '@/types/interface'; import type { MenuRoute, ModeType } from '@/types/interface';
import pgk from '../../../package.json'; import pgk from '../../../package.json';
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const userStore = useUserStore();
const autoCollapsed = () => { const autoCollapsed = () => {
const isCompact = window.innerWidth <= MIN_POINT; const isCompact = window.innerWidth <= MIN_POINT;
@@ -175,7 +176,7 @@ onUnmounted(() => {
}); });
const goHome = () => { const goHome = () => {
router.push('/role-select'); router.push(userStore.workbenchRoute);
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
+1 -10
View File
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
try { try {
await userStore.selectRole(roleCode); await userStore.selectRole(roleCode);
permissionStore.initRoutes(); permissionStore.initRoutes();
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute; await router.replace(userStore.workbenchRoute);
const fallback =
roleCode === 'PROJECT_MANAGER'
? '/workbench/project'
: roleCode === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: roleCode === 'SYSTEM_ADMIN'
? '/governance/settings'
: '/workbench/finance';
await router.replace(target || fallback);
} catch (error) { } catch (error) {
MessagePlugin.error((error as Error).message || '身份选择失败,请重试'); MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
} finally { } finally {
+61 -11
View File
@@ -162,6 +162,10 @@
</t-form-item> </t-form-item>
</div> </div>
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
{{ completionProgressMessage }}
</t-alert>
<div class="setup-actions"> <div class="setup-actions">
<t-button variant="outline" :disabled="completing" @click="currentStep = 0"> <t-button variant="outline" :disabled="completing" @click="currentStep = 0">
<template #icon><t-icon name="chevron-left" /></template> <template #icon><t-icon name="chevron-left" /></template>
@@ -223,8 +227,13 @@ const errorMessage = ref('');
const connectionResult = ref<SetupConnection | null>(null); const connectionResult = ref<SetupConnection | null>(null);
const verifiedFingerprint = ref(''); const verifiedFingerprint = ref('');
const completionMessage = ref('系统正在重启,请稍候。'); const completionMessage = ref('系统正在重启,请稍候。');
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
const countdown = ref(10); const countdown = ref(10);
let countdownTimer: number | undefined; let countdownTimer: number | undefined;
let unmounted = false;
const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000;
const SETUP_CONFIRMATION_INTERVAL_MS = 2_000;
const databaseForm = reactive<SetupDatabaseRequest>({ const databaseForm = reactive<SetupDatabaseRequest>({
setupCode: '', setupCode: '',
@@ -317,21 +326,51 @@ async function finishSetup(context?: SubmitContext) {
return; return;
} }
completing.value = true; completing.value = true;
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
errorMessage.value = ''; errorMessage.value = '';
try { try {
const result = await completeSetup({ ...databaseForm, ...adminForm }); const result = await completeSetup({ ...databaseForm, ...adminForm });
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试'); if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
completionMessage.value = result.message; showCompleted(result.message, result.restartAfterSeconds);
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
} catch (error) { } catch (error) {
errorMessage.value = (error as Error).message || '安装初始化失败'; const requestError = error as Error & { status?: number };
if (requestError.status === undefined || requestError.status >= 500) {
completionProgressMessage.value = '连接暂时中断,正在确认安装结果。';
if (await waitForSetupCompletion()) {
showCompleted('安装已完成,系统正在切换到正式模式', 10);
return;
}
errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。';
} else {
errorMessage.value = requestError.message || '安装初始化失败';
}
} finally { } finally {
completing.value = false; completing.value = false;
} }
} }
async function waitForSetupCompletion() {
const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS;
while (Date.now() < deadline) {
if (unmounted) return false;
try {
const status = await getSetupStatus();
if (status.ready) return true;
} catch {
// A short connection failure is expected while systemd restarts the service.
}
await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS));
}
return false;
}
function showCompleted(message: string, restartAfterSeconds: number) {
completionMessage.value = message;
countdown.value = Math.max(1, restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
}
function confirmSetup() { function confirmSetup() {
void finishSetup(); void finishSetup();
} }
@@ -340,13 +379,18 @@ function startCountdown() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => { countdownTimer = window.setInterval(() => {
countdown.value -= 1; countdown.value -= 1;
if (countdown.value <= 0) openLogin(); if (countdown.value <= 0) void openLogin();
}, 1000); }, 1000);
} }
function openLogin() { async function openLogin() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
window.location.assign('/login'); completionMessage.value = '系统正在启动,请稍候。';
if (await waitForSetupCompletion()) {
window.location.assign('/login');
return;
}
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
} }
onMounted(async () => { onMounted(async () => {
@@ -364,7 +408,10 @@ onMounted(async () => {
} }
}); });
onBeforeUnmount(() => window.clearInterval(countdownTimer)); onBeforeUnmount(() => {
unmounted = true;
window.clearInterval(countdownTimer);
});
</script> </script>
<style scoped> <style scoped>
.setup-page { .setup-page {
@@ -382,8 +429,7 @@ onBeforeUnmount(() => window.clearInterval(countdownTimer));
} }
.setup-panel { .setup-panel {
align-self: center; place-self: center;
justify-self: center;
width: min(760px, 100%); width: min(760px, 100%);
min-width: 0; min-width: 0;
padding: 32px; padding: 32px;
@@ -445,6 +491,10 @@ h1 {
margin-top: 8px; margin-top: 8px;
} }
.setup-progress {
margin-top: 20px;
}
.setup-complete { .setup-complete {
min-height: 280px; min-height: 280px;
display: flex; display: flex;
+2
View File
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
import { usePermissionStore } from '@/store'; import { usePermissionStore } from '@/store';
import { hasPermissionAccess } from './menu-access'; import { hasPermissionAccess } from './menu-access';
import { resolveWorkbenchRoute } from './workbench-route';
const emptyUser: UserView = { const emptyUser: UserView = {
publicId: '', publicId: '',
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
getters: { getters: {
displayName: (state) => state.user.displayName || state.user.username, displayName: (state) => state.user.displayName || state.user.username,
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '', roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode), hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
hasPermission: (state) => (permission: string) => hasPermission: (state) => (permission: string) =>
hasPermissionAccess(state.currentRole, state.permissions, permission), hasPermissionAccess(state.currentRole, state.permissions, permission),
@@ -0,0 +1,13 @@
import type { RoleView } from '@/api/auth';
const fallbackWorkbenchRoutes: Record<string, string> = {
PROJECT_MANAGER: '/workbench/project',
FINANCE_MANAGER: '/workbench/finance',
ARCHIVE_MANAGER: '/workbench/archive',
SYSTEM_ADMIN: '/governance/settings',
};
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
const configured = roles.find((role) => role.code === currentRole)?.workbenchRoute?.trim();
return configured || fallbackWorkbenchRoutes[currentRole || ''] || '/role-select';
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const requestMocks = vi.hoisted(() => ({
post: vi.fn(),
}));
vi.mock('@/utils/request', () => ({
request: {
post: requestMocks.post,
},
}));
import { completeSetup } from '../src/api/setup';
describe('setup API', () => {
beforeEach(() => {
requestMocks.post.mockReset();
requestMocks.post.mockResolvedValue({ completed: true });
});
it('allows enough time for the first database migration', async () => {
const request = {
setupCode: 'fixture-code',
databaseType: 'MYSQL' as const,
host: '127.0.0.1',
port: 3306,
database: 'kaidi_finance',
username: 'kaidi',
password: 'fixture-password',
adminUsername: 'admin',
adminDisplayName: '系统管理员',
adminPassword: 'SetupAdmin@2026Strong',
adminPasswordConfirmation: 'SetupAdmin@2026Strong',
};
await completeSetup(request);
expect(requestMocks.post).toHaveBeenCalledWith({
url: '/setup/complete',
data: request,
timeout: 120_000,
});
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import type { RoleView } from '../src/api/auth';
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
const roles: RoleView[] = [
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
];
describe('active identity workbench route', () => {
it('uses the route supplied for the active role', () => {
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
});
it('falls back to the built-in role workbench when the server route is absent', () => {
expect(resolveWorkbenchRoute('PROJECT_MANAGER', [])).toBe('/workbench/project');
expect(resolveWorkbenchRoute('ARCHIVE_MANAGER', [])).toBe('/workbench/archive');
});
it('keeps role selection for sessions without an active identity', () => {
expect(resolveWorkbenchRoute(null, roles)).toBe('/role-select');
});
});
+41 -1
View File
@@ -92,6 +92,7 @@ SH
cat > "$mock_bin/systemctl" <<'SH' cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh #!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
exit 0 exit 0
SH SH
@@ -170,6 +171,7 @@ SH
build_release() { build_release() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
local unit_prefix=${3:-new}
local stage="$fixture/stage" local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops" mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
printf 'new application\n' > "$stage/app.jar" printf 'new application\n' > "$stage/app.jar"
@@ -178,7 +180,7 @@ build_release() {
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh" printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
chmod 0755 "$stage/ops/update.sh" chmod 0755 "$stage/ops/update.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do 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 done
local artifact="kaidi-finance-$version.tar.gz" local artifact="kaidi-finance-$version.tar.gz"
@@ -272,6 +274,7 @@ run_update() {
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \ MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \ MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \ MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
KAIDI_SERVICE_USER="$(id -un)" \ KAIDI_SERVICE_USER="$(id -un)" \
KAIDI_SERVICE_GROUP="$(id -gn)" \ KAIDI_SERVICE_GROUP="$(id -gn)" \
KAIDI_APP_ROOT="$fixture/app" \ KAIDI_APP_ROOT="$fixture/app" \
@@ -368,6 +371,41 @@ assert_success_case() {
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750' [ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
} }
assert_identical_systemd_operations_case() {
local fixture="$WORK/identical-systemd"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version" old
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
: > "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|| fail 'identical systemd units triggered an unnecessary daemon reload'
}
assert_update_path_failure_keeps_application_case() {
local fixture="$WORK/path-watcher-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state'
grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \
|| fail 'path watcher failure did not preserve its diagnostic'
}
assert_rollback_case() { assert_rollback_case() {
local fixture="$WORK/rollback" local fixture="$WORK/rollback"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -480,6 +518,8 @@ assert_install_without_verified_cache_rejected() {
} }
assert_success_case assert_success_case
assert_identical_systemd_operations_case
assert_update_path_failure_keeps_application_case
assert_rollback_case assert_rollback_case
assert_download_failure_case assert_download_failure_case
assert_database_failure_case assert_database_failure_case