资源备份恢复

This commit is contained in:
waner 2026-04-20 10:15:25 +08:00
parent 350fa4d89d
commit 2efdc047e3
26 changed files with 1712 additions and 329 deletions

View File

@ -32,11 +32,14 @@ cp target/tms-framework-*.jar /home/tms/tms-framework.jar
```bash
sudo mkdir -p /home/tms/{bin,config,scripts,logs,run}
sudo mkdir -p /home/tms/bin/resource-restore
sudo chown -R "$(whoami)":"$(whoami)" /home/tms
cp scripts/tms.sh /home/tms/scripts/tms.sh
cp scripts/standard-init/*.sh /home/tms/bin/
cp scripts/resource-restore/*.sh /home/tms/bin/resource-restore/
cp config/application.yml.example /home/tms/config/application.yml
chmod +x /home/tms/bin/*.sh
chmod +x /home/tms/bin/resource-restore/*.sh
chmod +x /home/tms/scripts/tms.sh
```
@ -55,6 +58,7 @@ chmod +x /home/tms/scripts/tms.sh
- 可通过 `JAR_PATH=/home/tms/tms-framework.jar` 覆盖 jar 路径。
- 如果部署路径不是 `/home/tms`,使用 `APP_HOME=/your/path /your/path/scripts/tms.sh start`
- 标准 CISD 初始化辅助脚本位于 `scripts/standard-init/`,部署时需复制到 `/home/tms/bin/`
- 资源恢复辅助脚本位于 `scripts/resource-restore/`,部署时需复制到 `/home/tms/bin/resource-restore/`
- `apply_standard_db.sh` 依赖预置环境变量,例如 `DB_USER`、`DB_PASSWORD`,不要直接写入 `application.yml`
- 运行目录结构、配置项说明、文件上传 `fileId` 流程和故障排查,请查看上面的完整部署手册。
@ -234,24 +238,67 @@ Open:
当前实现边界:
- 资源备份包输出为 `.tmsbak`,包内包含 `manifest.json`、`envelope.json`、`payload.enc`、`signature.sig`
- 当前第一版备份/恢复只覆盖最小资源集:
- `TMS_CONFIG`
- `TMS_DB_DUMP`
- `CMEP_DB_DUMP`
- `CP_CONFIG`
- `RECEIVER_LICENSE`
- 预检阶段只做包头解析、指纹匹配和验签,不提前解密完整 payload
- 恢复任务创建后会生成 `handoff.json`、`restore-state.json`,并进入 `HANDOFF_READY`
- `ResourceRestoreRunner` 当前已具备接管、步骤循环、`step-output/*.json`、心跳刷新、`RECONCILE_PENDING` 推进能力
- `STOP_SERVICES` / `START_SERVICES` 已对接现有 `tms.sh` 与标准版启停脚本配置
- runner 现已通过外部脚本启动独立进程,不再运行在 TMS Web 进程内
- 当前已落地的恢复步骤:
- `STOP_SERVICES`
- `RESTORE_FILES`
- `RESTORE_DATABASES`
- `START_SERVICES`
- `CONSISTENCY_VERIFY`
- `RESTORE_FILES` 当前只回写关键配置/License 文件,不做目录级全量恢复
- `RESTORE_DATABASES` 当前恢复:
- `db/TMS.sql`
- `db/CMEP.sql`(包内存在时)
- `CONSISTENCY_VERIFY` 当前只检查:
- `RESTORE_FILES` 成功
- `RESTORE_DATABASES` 成功
- `START_SERVICES` 成功
- 健康检查地址返回 `UP`
关键配置:
- `tms.backup.output-dir`
- `tms.backup.precheck-store-dir`
- `tms.backup.restore-task-root-dir`
- `tms.backup.tms-script-path`
- `tms.backup.runner-launch-script-path`
- `tms.backup.mysqldump-path`
- `tms.backup.mysql-path`
- `tms.backup.restore-db-script-path`
- `tms.backup.tms-database-name`
- `tms.backup.cmep-database-name`
- `tms.backup.health-check-url`
- `tms.backup.health-check-timeout-seconds`
- `tms.backup.runner-auto-launch-enabled`
- `tms.backup.runner-heartbeat-timeout-seconds`
恢复前置条件:
- LMK、IK、数据存储加密密钥对、数据真实性保护密钥对需先通过 UKey 恢复
- 默认不自动拉起本机 runner如需在现场机器自动执行显式开启 `tms.backup.runner-auto-launch-enabled=true`
- `/home/tms/bin/resource-restore/start-runner.sh``/home/tms/bin/resource-restore/restore-db.sh` 必须已部署并可执行
- `mysql` / `mysqldump` 客户端必须可用
当前明确不在第一版恢复范围内:
- nginx 配置
- `organization.json`
- 已发布 web 静态资源
- `/home/tms/uploads`
- RabbitMQ / TLQ / CFMQ
- `CMEP` / 机构库
- 在线恢复 / 增量恢复 / 回滚
- 升级执行日志统一写入 `tms.upgrade.log-dir`
联调清单:
- [资源备份恢复 V1 联调清单](/Users/waner/Work/CISD/文档/tms-framework/docs/plans/2026-04-18-resource-backup-restore-v1-checklist.md)
当前配置项:
- `tms.upgrade.staging-root-dir`
- `tms.upgrade.log-dir`

View File

@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
MYSQL_PATH="mysql"
HOST=""
PORT=""
USERNAME=""
PASSWORD=""
DB_NAME=""
SQL_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--mysql-path)
MYSQL_PATH="${2:-}"
shift 2
;;
--host)
HOST="${2:-}"
shift 2
;;
--port)
PORT="${2:-}"
shift 2
;;
--username)
USERNAME="${2:-}"
shift 2
;;
--password)
PASSWORD="${2:-}"
shift 2
;;
--db-name)
DB_NAME="${2:-}"
shift 2
;;
--sql-file)
SQL_FILE="${2:-}"
shift 2
;;
*)
echo "[ERROR] unsupported argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "${HOST}" || -z "${PORT}" || -z "${USERNAME}" || -z "${DB_NAME}" || -z "${SQL_FILE}" ]]; then
echo "[ERROR] missing required database restore arguments" >&2
exit 1
fi
if [[ ! -f "${SQL_FILE}" ]]; then
echo "[ERROR] sql file not found: ${SQL_FILE}" >&2
exit 1
fi
"${MYSQL_PATH}" \
--host="${HOST}" \
--port="${PORT}" \
--user="${USERNAME}" \
--password="${PASSWORD}" \
"${DB_NAME}" < "${SQL_FILE}"

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
HANDOFF_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--handoff-file)
HANDOFF_FILE="${2:-}"
shift 2
;;
*)
echo "[ERROR] unsupported argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "${HANDOFF_FILE}" ]]; then
echo "[ERROR] --handoff-file is required" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
APP_HOME="${APP_HOME:-/home/tms}"
CONF_DIR="${CONF_DIR:-${APP_HOME}/config}"
JAVA_BIN="${JAVA_BIN:-java}"
RUNNER_JAR_PATH="${RUNNER_JAR_PATH:-${APP_HOME}/tms-framework.jar}"
RUNNER_LOG_DIR="${RUNNER_LOG_DIR:-${APP_HOME}/logs}"
RUNNER_STDOUT_LOG="${RUNNER_STDOUT_LOG:-${RUNNER_LOG_DIR}/resource-restore-runner.out.log}"
if [[ -f "${SOURCE_ROOT}/target/tms-framework.jar" ]]; then
RUNNER_JAR_PATH="${SOURCE_ROOT}/target/tms-framework.jar"
fi
mkdir -p "${RUNNER_LOG_DIR}"
nohup "${JAVA_BIN}" \
-cp "${RUNNER_JAR_PATH}" \
-Dloader.main=com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerMain \
org.springframework.boot.loader.launch.PropertiesLauncher \
--spring.main.web-application-type=none \
--spring.main.banner-mode=off \
--spring.config.additional-location="optional:file:${CONF_DIR}/" \
--tms.backup.runner-handoff-file="${HANDOFF_FILE}" \
>> "${RUNNER_STDOUT_LOG}" 2>&1 &
echo $!

View File

@ -9,6 +9,14 @@ public class ResourceBackupProperties {
private String precheckStoreDir = "/home/tms/tmp/resource-restore-precheck";
private String restoreTaskRootDir = "/home/tms/tmp/resource-restore";
private String tmsScriptPath = "/home/tms/scripts/tms.sh";
private String runnerLaunchScriptPath = "/home/tms/bin/resource-restore/start-runner.sh";
private String mysqldumpPath = "mysqldump";
private String mysqlPath = "mysql";
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private String tmsDatabaseName = "";
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
private int healthCheckTimeoutSeconds = 5;
private boolean runnerAutoLaunchEnabled = false;
private int runnerHeartbeatTimeoutSeconds = 300;
private int storageEncKeyIndex = 1;
@ -46,6 +54,70 @@ public class ResourceBackupProperties {
this.tmsScriptPath = tmsScriptPath;
}
public String getRunnerLaunchScriptPath() {
return runnerLaunchScriptPath;
}
public void setRunnerLaunchScriptPath(String runnerLaunchScriptPath) {
this.runnerLaunchScriptPath = runnerLaunchScriptPath;
}
public String getMysqldumpPath() {
return mysqldumpPath;
}
public void setMysqldumpPath(String mysqldumpPath) {
this.mysqldumpPath = mysqldumpPath;
}
public String getMysqlPath() {
return mysqlPath;
}
public void setMysqlPath(String mysqlPath) {
this.mysqlPath = mysqlPath;
}
public String getRestoreDbScriptPath() {
return restoreDbScriptPath;
}
public void setRestoreDbScriptPath(String restoreDbScriptPath) {
this.restoreDbScriptPath = restoreDbScriptPath;
}
public String getTmsDatabaseName() {
return tmsDatabaseName;
}
public void setTmsDatabaseName(String tmsDatabaseName) {
this.tmsDatabaseName = tmsDatabaseName;
}
public String getCmepDatabaseName() {
return cmepDatabaseName;
}
public void setCmepDatabaseName(String cmepDatabaseName) {
this.cmepDatabaseName = cmepDatabaseName;
}
public String getHealthCheckUrl() {
return healthCheckUrl;
}
public void setHealthCheckUrl(String healthCheckUrl) {
this.healthCheckUrl = healthCheckUrl;
}
public int getHealthCheckTimeoutSeconds() {
return healthCheckTimeoutSeconds;
}
public void setHealthCheckTimeoutSeconds(int healthCheckTimeoutSeconds) {
this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds;
}
public boolean getRunnerAutoLaunchEnabled() {
return runnerAutoLaunchEnabled;
}

View File

@ -29,6 +29,11 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
List<DiscoveredResourceItem> items = new ArrayList<>();
items.add(item("FILE", "TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), true));
items.add(item("DB", "TMS_DB_DUMP", "TMS", true));
if (!"DIRECT".equals(productType)) {
// 第一版标准版迁移需要把收发器业务库一起带走DIRECT 暂时不要求这份库
items.add(item("DB", "CMEP_DB_DUMP", "CMEP", true));
}
items.add(item("FILE", "CP_CONFIG", resolveCpConfigPath(productType), true));
items.add(item("FILE", "RECEIVER_LICENSE", resolveReceiverLicensePath(productType), true));

View File

@ -0,0 +1,39 @@
package com.cisd.tms.modules.backup.packagex;
public interface DatabaseBackupCollector {
CollectedDatabaseDump collectTmsDatabaseDump();
CollectedDatabaseDump collectCmepDatabaseDump();
class CollectedDatabaseDump {
private String databaseName;
private String fileName;
private byte[] content;
public String getDatabaseName() {
return databaseName;
}
public void setDatabaseName(String databaseName) {
this.databaseName = databaseName;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public byte[] getContent() {
return content;
}
public void setContent(byte[] content) {
this.content = content;
}
}
}

View File

@ -0,0 +1,131 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
private final ResourceBackupProperties resourceBackupProperties;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public MysqlDatabaseBackupCollector(
ResourceBackupProperties resourceBackupProperties,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public CollectedDatabaseDump collectTmsDatabaseDump() {
MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getTmsDatabaseName(), "TMS");
return collectDatabaseDump(target, "TMS.sql");
}
@Override
public CollectedDatabaseDump collectCmepDatabaseDump() {
MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getCmepDatabaseName(), "CMEP");
return collectDatabaseDump(target, "CMEP.sql");
}
private CollectedDatabaseDump collectDatabaseDump(MysqlConnectionTarget target, String fileName) {
List<String> command = new ArrayList<>();
command.add(resourceBackupProperties.getMysqldumpPath());
command.add("--host=" + target.host());
command.add("--port=" + target.port());
command.add("--user=" + target.username());
if (!target.password().isEmpty()) {
command.add("--password=" + target.password());
}
command.add("--single-transaction");
command.add("--skip-lock-tables");
command.add("--databases");
command.add(target.databaseName());
try {
Process process = new ProcessBuilder(command).start();
byte[] stdout = readAllBytes(process.getInputStream());
byte[] stderr = readAllBytes(process.getErrorStream());
int exitCode = process.waitFor();
if (exitCode != 0) {
String error = new String(stderr, StandardCharsets.UTF_8).trim();
throw new IllegalStateException("mysqldump failed with exitCode=" + exitCode
+ (error.isEmpty() ? "" : ", error=" + error));
}
if (stdout.length == 0) {
throw new IllegalStateException("mysqldump produced empty output");
}
CollectedDatabaseDump dump = new CollectedDatabaseDump();
dump.setDatabaseName(target.databaseName());
dump.setFileName(fileName);
dump.setContent(stdout);
return dump;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("mysqldump interrupted", ex);
} catch (IOException ex) {
throw new IllegalStateException("failed to run mysqldump", ex);
}
}
private MysqlConnectionTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
throw new IllegalStateException("spring.datasource.url is required for database backup");
}
if (!url.startsWith("jdbc:mysql://")) {
throw new IllegalStateException("unsupported datasource url for backup: " + url);
}
try {
String normalizedUrl = url.substring("jdbc:".length());
String[] segments = normalizedUrl.split("\\?", 2);
URI uri = URI.create(segments[0]);
String databaseName = trim(configuredDatabaseName);
if (databaseName.isEmpty()) {
String path = trim(uri.getPath());
databaseName = path.startsWith("/") ? path.substring(1) : path;
}
if (databaseName.isEmpty()) {
databaseName = fallbackDatabaseName;
}
return new MysqlConnectionTarget(
uri.getHost() == null ? "127.0.0.1" : uri.getHost(),
uri.getPort() <= 0 ? 3306 : uri.getPort(),
trim(datasourceUsername),
trim(datasourcePassword),
databaseName
);
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("invalid datasource url for backup: " + url, ex);
}
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
try (inputStream; ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
inputStream.transferTo(outputStream);
return outputStream.toByteArray();
}
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlConnectionTarget(String host, int port, String username, String password, String databaseName) {
}
}

View File

@ -0,0 +1,8 @@
package com.cisd.tms.modules.backup.packagex;
public class RequiredBackupResourceMissingException extends IllegalStateException {
public RequiredBackupResourceMissingException(String message) {
super(message);
}
}

View File

@ -36,15 +36,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
private final DatabaseBackupCollector databaseBackupCollector;
private final ObjectMapper objectMapper;
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ObjectMapper objectMapper
) {
this.resourceBackupProperties = resourceBackupProperties;
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
this.databaseBackupCollector = databaseBackupCollector;
this.objectMapper = objectMapper;
}
@ -243,13 +246,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
if ("DB".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
}
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "PATH_NOT_FOUND");
return 0;
@ -275,6 +283,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
)));
}
} else {
assertOptionalResource(item, "UNSUPPORTED_FILE_TYPE");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "UNSUPPORTED_FILE_TYPE");
return 0;
@ -289,6 +298,73 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
return archivedFiles.size();
}
private int collectDatabaseItem(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
DatabaseBackupCollector.CollectedDatabaseDump dump = resolveDatabaseDump(item);
if (dump == null) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
try {
byte[] content = dump.getContent();
if (content == null || content.length == 0) {
throw new IllegalStateException("database backup collector returned empty dump");
}
String databaseName = trim(dump.getDatabaseName()).isEmpty() ? "TMS" : trim(dump.getDatabaseName());
String fileName = trim(dump.getFileName()).isEmpty() ? "TMS.sql" : trim(dump.getFileName());
String entryPath = "db/" + fileName;
addZipEntry(zipOutputStream, entryPath, content);
Map<String, Object> fileEntry = new LinkedHashMap<>();
fileEntry.put("entryPath", entryPath);
fileEntry.put("sourcePath", "database:" + databaseName);
fileEntry.put("relativeName", fileName);
fileEntry.put("size", content.length);
fileEntry.put("contentSm3", sm3Hex(content));
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("fileCount", 1);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("databaseName", databaseName);
resourceEntry.put("files", List.of(fileEntry));
return 1;
} catch (RuntimeException ex) {
assertOptionalResource(item, "DB_DUMP_FAILED");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "DB_DUMP_FAILED");
return 0;
}
}
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
String resourceCode = trim(item.getResourceCode());
if ("TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectTmsDatabaseDump();
}
if ("CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectCmepDatabaseDump();
}
return null;
}
private static void assertOptionalResource(DiscoveredResourceItem item, String reason) {
if (!item.isRequired()) {
return;
}
String resourceCode = trim(item.getResourceCode()).isEmpty() ? "UNKNOWN" : trim(item.getResourceCode());
String resolvedPath = trim(item.getResolvedPath());
String message = "required backup resource is unavailable: code=" + resourceCode + ", reason=" + reason;
if (!resolvedPath.isEmpty()) {
message += ", path=" + resolvedPath;
}
throw new RequiredBackupResourceMissingException(message);
}
private Map<String, Object> writeRegularFileEntry(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,

View File

@ -1,30 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import org.springframework.stereotype.Component;
@Component
public class AsyncResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceRestoreRunner resourceRestoreRunner;
private final ResourceBackupProperties resourceBackupProperties;
public AsyncResourceRestoreRunnerLauncher(
ResourceRestoreRunner resourceRestoreRunner,
ResourceBackupProperties resourceBackupProperties
) {
this.resourceRestoreRunner = resourceRestoreRunner;
this.resourceBackupProperties = resourceBackupProperties;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Thread thread = new Thread(() -> resourceRestoreRunner.run(handoffFile), "resource-restore-runner");
thread.setDaemon(true);
thread.start();
}
}

View File

@ -0,0 +1,109 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class ConsistencyVerifyRunnerStepExecutor implements RunnerStepExecutor {
private static final List<String> REQUIRED_SUCCESS_STEPS = List.of(
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES"
);
private final ResourceBackupProperties resourceBackupProperties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, objectMapper, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build());
}
ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper,
HttpClient httpClient
) {
this.resourceBackupProperties = resourceBackupProperties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
@Override
public boolean supports(String stepCode) {
return "CONSISTENCY_VERIFY".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
Path stepOutputDir = context.taskDir().resolve("step-output");
for (String requiredStep : REQUIRED_SUCCESS_STEPS) {
assertStepSucceeded(stepOutputDir, requiredStep);
}
verifyHealthEndpoint();
return RunnerStepExecutionResult.success("required steps succeeded and health endpoint is UP");
}
private void assertStepSucceeded(Path stepOutputDir, String stepCode) {
try {
Path stepFile = resolveRequiredStepOutput(stepOutputDir, stepCode);
Map<String, Object> payload = objectMapper.readValue(stepFile.toFile(), new TypeReference<Map<String, Object>>() {
});
String result = String.valueOf(payload.get("result"));
if (!"SUCCESS".equalsIgnoreCase(result)) {
throw new IllegalStateException("required step is not successful: " + stepCode);
}
} catch (IOException ex) {
throw new IllegalStateException("failed to read step output for " + stepCode, ex);
}
}
private Path resolveRequiredStepOutput(Path stepOutputDir, String stepCode) throws IOException {
if (!Files.isDirectory(stepOutputDir)) {
throw new IllegalStateException("step output directory missing: " + stepOutputDir);
}
try (var pathStream = Files.list(stepOutputDir)) {
return pathStream
.filter(path -> path.getFileName().toString().endsWith("-" + stepCode + ".json"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("step output missing for " + stepCode));
}
}
private void verifyHealthEndpoint() {
String healthUrl = resourceBackupProperties.getHealthCheckUrl();
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(healthUrl))
.timeout(Duration.ofSeconds(resourceBackupProperties.getHealthCheckTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 || !response.body().contains("\"status\":\"UP\"")) {
throw new IllegalStateException("health endpoint is not UP: statusCode=" + response.statusCode());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("health verification interrupted", ex);
} catch (Exception ex) {
throw new IllegalStateException("failed to verify health endpoint: " + healthUrl, ex);
}
}
}

View File

@ -0,0 +1,135 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class DatabaseRestoreRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public DatabaseRestoreRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_DATABASES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<String> restoredDatabases = new ArrayList<>();
restoreDatabase(context, "TMS.sql", resourceBackupProperties.getTmsDatabaseName(), "TMS", true, restoredDatabases);
// 标准版第一版还需要恢复收发器业务库DIRECT 当前没有这份库时允许跳过
restoreDatabase(context, "CMEP.sql", resourceBackupProperties.getCmepDatabaseName(), "CMEP", false, restoredDatabases);
return RunnerStepExecutionResult.success("restored databases " + String.join(",", restoredDatabases));
}
private void restoreDatabase(
RunnerStepContext context,
String sqlFileName,
String configuredDatabaseName,
String fallbackDatabaseName,
boolean required,
List<String> restoredDatabases
) {
Path sqlFile = context.taskDir().resolve("staging").resolve("payload").resolve("db").resolve(sqlFileName).normalize();
if (!Files.isRegularFile(sqlFile)) {
if (required) {
throw new IllegalStateException("restore database sql file missing: " + sqlFile);
}
return;
}
MysqlTarget target = resolveTarget(configuredDatabaseName, fallbackDatabaseName);
List<String> command = new ArrayList<>();
command.add("/bin/bash");
command.add(resourceBackupProperties.getRestoreDbScriptPath());
command.add("--mysql-path");
command.add(resourceBackupProperties.getMysqlPath());
command.add("--host");
command.add(target.host());
command.add("--port");
command.add(String.valueOf(target.port()));
command.add("--username");
command.add(target.username());
command.add("--password");
command.add(target.password());
command.add("--db-name");
command.add(target.databaseName());
command.add("--sql-file");
command.add(sqlFile.toString());
int exitCode = runnerCommandInvoker.run(command, resolveLogFile(context), context.taskDir());
if (exitCode != 0) {
throw new IllegalStateException("runner step RESTORE_DATABASES failed for " + target.databaseName() + ", exitCode=" + exitCode);
}
restoredDatabases.add(target.databaseName());
}
private MysqlTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
throw new IllegalStateException("spring.datasource.url is required for database restore");
}
if (!url.startsWith("jdbc:mysql://")) {
throw new IllegalStateException("unsupported datasource url for restore: " + url);
}
try {
String normalizedUrl = url.substring("jdbc:".length());
String[] segments = normalizedUrl.split("\\?", 2);
URI uri = URI.create(segments[0]);
String databaseName = trim(configuredDatabaseName);
if (databaseName.isEmpty()) {
String path = trim(uri.getPath());
databaseName = path.startsWith("/") ? path.substring(1) : path;
}
if (databaseName.isEmpty()) {
databaseName = fallbackDatabaseName;
}
return new MysqlTarget(
uri.getHost() == null ? "127.0.0.1" : uri.getHost(),
uri.getPort() <= 0 ? 3306 : uri.getPort(),
trim(datasourceUsername),
trim(datasourcePassword),
databaseName
);
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("invalid datasource url for restore: " + url, ex);
}
}
private Path resolveLogFile(RunnerStepContext context) {
return context.taskDir().resolve("steps").resolve(String.format("%02d-%s.log", context.stepNo(), context.stepCode()));
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlTarget(String host, int port, String username, String password, String databaseName) {
}
}

View File

@ -10,10 +10,12 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@ -21,6 +23,9 @@ import org.springframework.stereotype.Component;
@Component
public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
private static final Set<String> RESTORE_FILE_RESOURCE_CODES =
Set.of("TMS_CONFIG", "CP_CONFIG", "RECEIVER_LICENSE");
private final HandoffFileStore handoffFileStore;
private final ResourcePackageService resourcePackageService;
private final ObjectMapper objectMapper;
@ -37,7 +42,7 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
@Override
public boolean supports(String stepCode) {
return "RESTORE_FILES".equals(stepCode) || "CONSISTENCY_VERIFY".equals(stepCode);
return "RESTORE_FILES".equals(stepCode);
}
@Override
@ -45,9 +50,6 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
if ("RESTORE_FILES".equals(context.stepCode())) {
return restoreFiles(context);
}
if ("CONSISTENCY_VERIFY".equals(context.stepCode())) {
return verifyExtractedPayload(context);
}
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
@ -60,17 +62,12 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
Path payloadDir = context.taskDir().resolve("staging").resolve("payload").normalize();
extractPayloadArchive(payloadArchiveBytes, payloadDir);
int resourceCount = readResourceCount(payloadDir.resolve("payload-manifest.json"));
return RunnerStepExecutionResult.success("payload extracted to staging, resourceCount=" + resourceCount);
}
private RunnerStepExecutionResult verifyExtractedPayload(RunnerStepContext context) {
Path manifestFile = context.taskDir().resolve("staging").resolve("payload").resolve("payload-manifest.json").normalize();
if (!Files.isRegularFile(manifestFile)) {
throw new IllegalStateException("payload manifest missing: " + manifestFile);
}
int resourceCount = readResourceCount(manifestFile);
return RunnerStepExecutionResult.success("payload manifest verified, resourceCount=" + resourceCount);
Path payloadManifestFile = payloadDir.resolve("payload-manifest.json");
int resourceCount = readResourceCount(payloadManifestFile);
int restoredFileCount = restoreSupportedFiles(payloadDir, payloadManifestFile);
return RunnerStepExecutionResult.success(
"payload extracted to staging, resourceCount=" + resourceCount + ", restoredFileCount=" + restoredFileCount
);
}
private ResourceRestoreHandoffSnapshot readRequiredHandoff(Path handoffFile) {
@ -133,6 +130,64 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
}
}
private int restoreSupportedFiles(Path payloadDir, Path manifestFile) {
try {
Map<String, Object> manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference<Map<String, Object>>() {
});
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return 0;
}
int restoredFileCount = 0;
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
String resourceCode = String.valueOf(resource.get("resourceCode"));
if (!RESTORE_FILE_RESOURCE_CODES.contains(resourceCode)) {
continue;
}
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> rawFiles)) {
continue;
}
for (Object fileObject : rawFiles) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
Path sourceFile = payloadDir.resolve(String.valueOf(fileEntry.get("entryPath"))).normalize();
if (!sourceFile.startsWith(payloadDir) || !Files.isRegularFile(sourceFile)) {
throw new IllegalStateException("restored file source missing: " + sourceFile);
}
Path targetFile = resolveTargetFile(resourceCode, String.valueOf(resource.get("resolvedPath")), String.valueOf(fileEntry.get("relativeName")));
Files.createDirectories(targetFile.getParent());
// 第一版只回写最关键的配置/license 文件不做目录合并或增量策略
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
restoredFileCount++;
}
}
return restoredFileCount;
} catch (IOException ex) {
throw new IllegalStateException("failed to restore payload files", ex);
}
}
private Path resolveTargetFile(String resourceCode, String resolvedPath, String relativeName) {
Path targetPath = Path.of(resolvedPath).normalize();
if ("TMS_CONFIG".equals(resourceCode)) {
return targetPath.resolve(relativeName).normalize();
}
return targetPath;
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new java.util.LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
return inputStream.readAllBytes();
}

View File

@ -0,0 +1,41 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ProcessResourceRestoreRunnerLauncher(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Path normalizedHandoffFile = handoffFile.normalize().toAbsolutePath();
Path taskDir = normalizedHandoffFile.getParent();
Path launcherLog = taskDir.resolve("runner-launch.log");
List<String> command = List.of(
"/bin/bash",
resourceBackupProperties.getRunnerLaunchScriptPath(),
"--handoff-file",
normalizedHandoffFile.toString()
);
int exitCode = runnerCommandInvoker.run(command, launcherLog, taskDir);
if (exitCode != 0) {
throw new IllegalStateException("failed to launch external restore runner, exitCode=" + exitCode);
}
}
}

View File

@ -0,0 +1,38 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.TmsApplication;
import java.nio.file.Path;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
public final class ResourceRestoreRunnerMain {
private ResourceRestoreRunnerMain() {
}
public static void main(String[] args) {
ConfigurableApplicationContext context = new SpringApplicationBuilder(TmsApplication.class)
.web(WebApplicationType.NONE)
.logStartupInfo(false)
.run(args);
int exitCode = 0;
try {
Environment environment = context.getEnvironment();
String handoffFile = environment.getProperty("tms.backup.runner-handoff-file", "");
if (handoffFile == null || handoffFile.isBlank()) {
throw new IllegalStateException("tms.backup.runner-handoff-file is required");
}
context.getBean(ResourceRestoreRunner.class).run(Path.of(handoffFile));
} catch (RuntimeException ex) {
exitCode = 1;
throw ex;
} finally {
final int finalExitCode = exitCode;
int springExitCode = SpringApplication.exit(context, () -> finalExitCode);
System.exit(springExitCode);
}
}
}

View File

@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.dto.response.ResourceKeysetStatusResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.packagex.RequiredBackupResourceMissingException;
import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
@ -62,6 +63,22 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
String taskId = resourceTaskIdGenerator.nextBackupTaskId();
String backupId = resourceTaskIdGenerator.nextBackupId();
LocalDateTime now = LocalDateTime.now();
ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity();
entity.setTaskId(taskId);
entity.setBackupId(backupId);
entity.setStatus("RUNNING");
entity.setProductType(initTask.getProductType());
entity.setMqType(initTask.getMqType());
entity.setOrgCode(initTask.getOrgCode());
entity.setSourceDeviceId(resolveSourceDeviceId());
entity.setRemark(request == null ? null : request.getRemark());
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
try {
// 先根据初始化快照收敛资源范围再交给打包服务生成本地可见的 .tmsbak 文件
ResourcePackageResult packageResult = resourcePackageService.createPackage(
taskId,
@ -70,17 +87,8 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
resourceDiscoveryService.discover(initTask),
request
);
LocalDateTime now = LocalDateTime.now();
ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity();
// 第一版先按同步成功模型落库后续切换为真正异步任务时继续复用这张任务表
entity.setTaskId(taskId);
entity.setBackupId(backupId);
// 第一版先按同步模型直接落最终结果后续切换异步任务时仍复用同一张任务表
entity.setStatus("SUCCESS");
entity.setProductType(initTask.getProductType());
entity.setMqType(initTask.getMqType());
entity.setOrgCode(initTask.getOrgCode());
entity.setSourceDeviceId(resolveSourceDeviceId());
entity.setPackageName(packageResult.getPackageName());
entity.setPackagePath(packageResult.getPackagePath());
entity.setPackageSize(packageResult.getPackageSize());
@ -88,19 +96,23 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
entity.setStorageEncKeyFingerprint(packageResult.getStorageEncKeyFingerprint());
entity.setAuthenticityKeyFingerprint(packageResult.getAuthenticityKeyFingerprint());
entity.setManifestSummaryJson(packageResult.getManifestSummaryJson());
entity.setRemark(request == null ? null : request.getRemark());
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
entity.setFinishTime(now);
} catch (RequiredBackupResourceMissingException ex) {
entity.setStatus("FAILED");
entity.setErrorCode("REQUIRED_RESOURCE_MISSING");
entity.setErrorMessage(ex.getMessage());
} catch (RuntimeException ex) {
entity.setStatus("FAILED");
entity.setErrorCode("PACKAGE_CREATE_FAILED");
entity.setErrorMessage(ex.getMessage());
}
entity.setFinishTime(LocalDateTime.now());
resourceBackupTaskRepository.save(entity);
CreateResourceBackupResponse response = new CreateResourceBackupResponse();
response.setTaskId(taskId);
response.setBackupId(backupId);
response.setStatus(entity.getStatus());
response.setCreatedAt(now.toString());
response.setCreatedAt(entity.getCreateTime().toString());
return response;
}

View File

@ -186,6 +186,22 @@ tms:
output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages}
# 恢复 runner 启停 TMS 时使用的脚本路径。
tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh}
# 外部恢复 runner 启动脚本路径;创建恢复任务后由主进程调用该脚本拉起独立 runner。
runner-launch-script-path: ${TMS_BACKUP_RUNNER_LAUNCH_SCRIPT_PATH:/home/tms/bin/resource-restore/start-runner.sh}
# 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。
mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump}
# 恢复 TMS 库时使用的 mysql 客户端命令。
mysql-path: ${TMS_BACKUP_MYSQL_PATH:mysql}
# 数据库恢复脚本路径runner 在 RESTORE_DATABASES 阶段调用。
restore-db-script-path: ${TMS_BACKUP_RESTORE_DB_SCRIPT_PATH:/home/tms/bin/resource-restore/restore-db.sh}
# 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。
tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
cmep-database-name: ${TMS_BACKUP_CMEP_DATABASE_NAME:CMEP}
# 恢复完成后的最小健康检查地址。
health-check-url: ${TMS_BACKUP_HEALTH_CHECK_URL:http://127.0.0.1:8080/actuator/health}
# 健康检查请求超时(秒)。
health-check-timeout-seconds: ${TMS_BACKUP_HEALTH_CHECK_TIMEOUT_SECONDS:5}
# 是否在创建恢复任务后自动拉起本机 runner默认关闭避免开发环境误触停机恢复链。
runner-auto-launch-enabled: ${TMS_BACKUP_RUNNER_AUTO_LAUNCH_ENABLED:false}
# 恢复 runner 心跳失联超时(秒)。

View File

@ -16,6 +16,8 @@ class ResourceDiscoveryServiceTest {
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertTrue(containsCode(items, "TMS_CONFIG"));
Assertions.assertTrue(containsCode(items, "TMS_DB_DUMP"));
Assertions.assertTrue(containsCode(items, "CMEP_DB_DUMP"));
Assertions.assertTrue(containsCode(items, "CP_CONFIG"));
Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE"));
Assertions.assertTrue(containsCode(items, "RABBITMQ_DEFINITIONS"));
@ -35,6 +37,8 @@ class ResourceDiscoveryServiceTest {
Assertions.assertTrue(hasPath(items, "RECEIVER_LICENSE", "/home/cmep4d/cmep/license/cmep.license"));
Assertions.assertTrue(hasPath(items, "TLQ_LICENSE", "/home/tlq/TLQ8/license.dat"));
Assertions.assertTrue(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertTrue(hasRequired(items, "TMS_DB_DUMP"));
Assertions.assertFalse(containsCode(items, "CMEP_DB_DUMP"));
}
private static InitTaskEntity sampleInitTask(String productType, String mqType) {
@ -70,4 +74,8 @@ class ResourceDiscoveryServiceTest {
private static boolean hasPath(List<DiscoveredResourceItem> items, String code, String path) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getResolvedPath()));
}
private static boolean hasRequired(List<DiscoveredResourceItem> items, String code) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && item.isRequired());
}
}

View File

@ -0,0 +1,15 @@
package com.cisd.tms.modules.backup.packagex;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class RequiredBackupResourceMissingExceptionTest {
@Test
void shouldExposeMessage() {
RequiredBackupResourceMissingException exception =
new RequiredBackupResourceMissingException("required backup resource is unavailable");
Assertions.assertEquals("required backup resource is unavailable", exception.getMessage());
}
}

View File

@ -48,6 +48,7 @@ class ResourcePackageServiceTest {
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
mockDatabaseBackupCollector(),
new ObjectMapper()
);
@ -55,7 +56,11 @@ class ResourcePackageServiceTest {
"RBKP-TASK-20260413-000001",
"RBKP-20260413-153000-000001",
sampleInitTask(),
List.of(resource("TMS_CONFIG", payloadFile.toString())),
List.of(
resource("TMS_CONFIG", payloadFile.toString()),
databaseResource("TMS_DB_DUMP", "TMS"),
databaseResource("CMEP_DB_DUMP", "CMEP")
),
sampleRequest()
);
@ -66,7 +71,7 @@ class ResourcePackageServiceTest {
Assertions.assertFalse(result.getPackageHash().isBlank());
Assertions.assertFalse(result.getStorageEncKeyFingerprint().isBlank());
Assertions.assertFalse(result.getAuthenticityKeyFingerprint().isBlank());
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":1"));
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":3"));
try (ZipFile zipFile = new ZipFile(packagePath.toFile())) {
Assertions.assertNotNull(zipFile.getEntry("manifest.json"));
@ -84,9 +89,13 @@ class ResourcePackageServiceTest {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
Assertions.assertEquals(1, resources.size());
Assertions.assertEquals(3, resources.size());
Assertions.assertEquals(Boolean.TRUE, resources.get(0).get("collected"));
Assertions.assertTrue(String.valueOf(resources.get(0).get("entryPath")).contains("application.yml"));
Assertions.assertEquals("TMS_DB_DUMP", resources.get(1).get("resourceCode"));
Assertions.assertEquals("db/TMS.sql", resources.get(1).get("entryPath"));
Assertions.assertEquals("CMEP_DB_DUMP", resources.get(2).get("resourceCode"));
Assertions.assertEquals("db/CMEP.sql", resources.get(2).get("entryPath"));
try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) {
java.util.Map<String, String> entries = new java.util.LinkedHashMap<>();
@ -96,9 +105,40 @@ class ResourcePackageServiceTest {
}
Assertions.assertTrue(entries.containsKey("payload-manifest.json"));
Assertions.assertTrue(entries.values().stream().anyMatch(value -> value.contains("server.port=8080")));
Assertions.assertTrue(entries.containsKey("db/TMS.sql"));
Assertions.assertTrue(entries.get("db/TMS.sql").contains("INSERT INTO t_demo"));
Assertions.assertTrue(entries.containsKey("db/CMEP.sql"));
Assertions.assertTrue(entries.get("db/CMEP.sql").contains("INSERT INTO c_demo"));
}
}
@Test
void shouldFailFastWhenRequiredFileResourceIsMissing() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setOutputDir(tempDir.toString());
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
mockDatabaseBackupCollector(),
new ObjectMapper()
);
RequiredBackupResourceMissingException exception = Assertions.assertThrows(
RequiredBackupResourceMissingException.class,
() -> service.createPackage(
"RBKP-TASK-20260413-000001",
"RBKP-20260413-153000-000001",
sampleInitTask(),
List.of(resource("TMS_CONFIG", tempDir.resolve("missing-config").toString())),
sampleRequest()
)
);
Assertions.assertTrue(exception.getMessage().contains("TMS_CONFIG"));
Assertions.assertTrue(exception.getMessage().contains("PATH_NOT_FOUND"));
}
private static BackupDataResult backup(byte[] data) {
BackupDataResult result = new BackupDataResult();
result.setData(data);
@ -127,6 +167,30 @@ class ResourcePackageServiceTest {
return item;
}
private static DiscoveredResourceItem databaseResource(String code, String path) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DB");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRequired(true);
return item;
}
private static DatabaseBackupCollector mockDatabaseBackupCollector() {
DatabaseBackupCollector collector = Mockito.mock(DatabaseBackupCollector.class);
DatabaseBackupCollector.CollectedDatabaseDump tmsDump = new DatabaseBackupCollector.CollectedDatabaseDump();
tmsDump.setDatabaseName("TMS");
tmsDump.setFileName("TMS.sql");
tmsDump.setContent("CREATE TABLE t_demo(id bigint);\nINSERT INTO t_demo VALUES (1);\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
DatabaseBackupCollector.CollectedDatabaseDump cmepDump = new DatabaseBackupCollector.CollectedDatabaseDump();
cmepDump.setDatabaseName("CMEP");
cmepDump.setFileName("CMEP.sql");
cmepDump.setContent("CREATE TABLE c_demo(id bigint);\nINSERT INTO c_demo VALUES (2);\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
Mockito.when(collector.collectTmsDatabaseDump()).thenReturn(tmsDump);
Mockito.when(collector.collectCmepDatabaseDump()).thenReturn(cmepDump);
return collector;
}
private Path writePayloadFile(String relativePath, String content) throws Exception {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());

View File

@ -0,0 +1,122 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ConsistencyVerifyRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldVerifyRequiredStepOutputsAndHealthEndpoint() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setHealthCheckUrl("http://127.0.0.1:8080/actuator/health");
HttpClient httpClient = Mockito.mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> response = Mockito.mock(HttpResponse.class);
Mockito.when(response.statusCode()).thenReturn(200);
Mockito.when(response.body()).thenReturn("{\"status\":\"UP\"}");
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<HttpResponse.BodyHandler<String>>any()))
.thenReturn(response);
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
httpClient
);
Path taskDir = tempDir.resolve("task");
Files.createDirectories(taskDir.resolve("step-output"));
Files.writeString(taskDir.resolve("step-output/02-RESTORE_FILES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/03-RESTORE_DATABASES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/04-START_SERVICES.json"), "{\"result\":\"SUCCESS\"}");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000004",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("health endpoint is UP"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
Mockito.verify(httpClient).send(requestCaptor.capture(), Mockito.<HttpResponse.BodyHandler<String>>any());
Assertions.assertEquals("http://127.0.0.1:8080/actuator/health", requestCaptor.getValue().uri().toString());
}
@Test
void shouldFailWhenRequiredStepOutputIsMissing() {
ResourceBackupProperties properties = new ResourceBackupProperties();
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
Mockito.mock(HttpClient.class)
);
Path taskDir = tempDir.resolve("task-missing");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000005",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("step output"));
}
@Test
void shouldFailWhenHealthEndpointIsNotUp() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setHealthCheckUrl("http://127.0.0.1:8080/actuator/health");
HttpClient httpClient = Mockito.mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> response = Mockito.mock(HttpResponse.class);
Mockito.when(response.statusCode()).thenReturn(503);
Mockito.when(response.body()).thenReturn("{\"status\":\"DOWN\"}");
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<HttpResponse.BodyHandler<String>>any()))
.thenReturn(response);
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
httpClient
);
Path taskDir = tempDir.resolve("task-down");
Files.createDirectories(taskDir.resolve("step-output"));
Files.writeString(taskDir.resolve("step-output/02-RESTORE_FILES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/03-RESTORE_DATABASES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/04-START_SERVICES.json"), "{\"result\":\"SUCCESS\"}");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000006",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("failed to verify health endpoint"));
}
}

View File

@ -0,0 +1,196 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class DatabaseRestoreRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldExecuteRestoreScriptForTmsAndCmepSql() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
properties.setMysqlPath("/usr/bin/mysql");
properties.setTmsDatabaseName("TMS");
properties.setCmepDatabaseName("CMEP");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Path cmepSqlFile = tempDir.resolve("task/staging/payload/db/CMEP.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
Files.writeString(cmepSqlFile, "CREATE TABLE c_demo(id bigint);");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000001",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("TMS"));
Assertions.assertTrue(result.getSummary().contains("CMEP"));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Mockito.verify(invoker, Mockito.times(2)).run(commandCaptor.capture(), Mockito.any(), Mockito.eq(tempDir.resolve("task")));
List<List<String>> commands = commandCaptor.getAllValues();
Assertions.assertEquals(
List.of(
"/bin/bash",
"/tmp/restore-db.sh",
"--mysql-path",
"/usr/bin/mysql",
"--host",
"127.0.0.1",
"--port",
"4000",
"--username",
"root",
"--password",
"secret",
"--db-name",
"TMS",
"--sql-file",
sqlFile.toString()
),
commands.get(0)
);
Assertions.assertEquals(
List.of(
"/bin/bash",
"/tmp/restore-db.sh",
"--mysql-path",
"/usr/bin/mysql",
"--host",
"127.0.0.1",
"--port",
"4000",
"--username",
"root",
"--password",
"secret",
"--db-name",
"CMEP",
"--sql-file",
cmepSqlFile.toString()
),
commands.get(1)
);
}
@Test
void shouldFailWhenSqlFileIsMissing() {
ResourceBackupProperties properties = new ResourceBackupProperties();
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000002",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("sql file missing"));
Mockito.verifyNoInteractions(invoker);
}
@Test
void shouldSkipCmepWhenSqlFileIsAbsent() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
properties.setMysqlPath("/usr/bin/mysql");
properties.setTmsDatabaseName("TMS");
properties.setCmepDatabaseName("CMEP");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000004",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("TMS"));
Assertions.assertFalse(result.getSummary().contains("CMEP"));
Mockito.verify(invoker, Mockito.times(1)).run(Mockito.anyList(), Mockito.any(), Mockito.eq(tempDir.resolve("task")));
}
@Test
void shouldFailWhenRestoreScriptReturnsNonZeroExitCode() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(7);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000003",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("exitCode=7"));
}
}

View File

@ -9,6 +9,7 @@ import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.packagex.DatabaseBackupCollector;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.packagex.ResourcePackageServiceImpl;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
@ -28,14 +29,16 @@ class PayloadArchiveRunnerStepExecutorTest {
Path tempDir;
@Test
void shouldExtractPayloadArchiveIntoStagingDirectory() throws Exception {
void shouldExtractPayloadArchiveIntoStagingDirectoryAndRestoreCriticalFiles() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourcePackageService packageService = new ResourcePackageServiceImpl(
backupProperties(),
new ResourceEnvelopeCryptoServiceImpl(mockCrypto(), backupProperties()),
mockDatabaseBackupCollector(),
objectMapper
);
Path sourceFile = writeFile("source/application.yml", "server.port=8088");
Path sourceFile = writeFile("source-config/application.yml", "server.port=8088");
Path cpConfig = writeFile("runtime/cpconfig.cfg", "cp=old");
Path taskDir = tempDir.resolve("restore-task");
Files.createDirectories(taskDir);
@ -43,10 +46,18 @@ class PayloadArchiveRunnerStepExecutorTest {
"RBKP-TASK-20260414-000101",
"RBKP-20260414-000101",
sampleInitTask(),
List.of(resource("TMS_CONFIG", sourceFile.toString())),
List.of(
resource("TMS_CONFIG", sourceFile.getParent().toString()),
resource("CP_CONFIG", cpConfig.toString()),
databaseResource("TMS_DB_DUMP", "TMS"),
databaseResource("CMEP_DB_DUMP", "CMEP")
),
new CreateResourceBackupRequest()
).getPackagePath();
Files.delete(sourceFile);
Files.writeString(cpConfig, "cp=newer-runtime-content");
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260414-000001");
entity.setBackupId("RBKP-20260414-000101");
@ -73,44 +84,12 @@ class PayloadArchiveRunnerStepExecutorTest {
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("payload extracted"));
Assertions.assertTrue(result.getSummary().contains("restoredFileCount=2"));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/payload-manifest.json")));
Assertions.assertEquals(
"server.port=8088",
Files.readString(taskDir.resolve("staging/payload/resources/TMS_CONFIG/application.yml"))
);
}
@Test
void shouldVerifyExtractedPayloadManifest() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourcePackageService packageService = new ResourcePackageServiceImpl(
backupProperties(),
new ResourceEnvelopeCryptoServiceImpl(mockCrypto(), backupProperties()),
objectMapper
);
Path taskDir = tempDir.resolve("restore-task-verify");
Files.createDirectories(taskDir.resolve("staging/payload"));
Files.writeString(taskDir.resolve("staging/payload/payload-manifest.json"), "{\"resources\":[{\"resourceCode\":\"TMS_CONFIG\"}]}");
Files.writeString(taskDir.resolve("handoff.json"), "{}");
PayloadArchiveRunnerStepExecutor executor = new PayloadArchiveRunnerStepExecutor(
new HandoffFileStore(objectMapper),
packageService,
objectMapper
);
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260414-000002",
"CONSISTENCY_VERIFY",
3,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("resourceCount=1"));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/db/TMS.sql")));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/db/CMEP.sql")));
Assertions.assertEquals("server.port=8088", Files.readString(sourceFile));
Assertions.assertEquals("cp=old", Files.readString(cpConfig));
}
private ResourceBackupProperties backupProperties() {
@ -171,4 +150,28 @@ class PayloadArchiveRunnerStepExecutorTest {
item.setRequired(true);
return item;
}
private static DiscoveredResourceItem databaseResource(String code, String path) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DB");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRequired(true);
return item;
}
private static DatabaseBackupCollector mockDatabaseBackupCollector() {
DatabaseBackupCollector collector = Mockito.mock(DatabaseBackupCollector.class);
DatabaseBackupCollector.CollectedDatabaseDump tmsDump = new DatabaseBackupCollector.CollectedDatabaseDump();
tmsDump.setDatabaseName("TMS");
tmsDump.setFileName("TMS.sql");
tmsDump.setContent("CREATE TABLE t_restore(id bigint);".getBytes(java.nio.charset.StandardCharsets.UTF_8));
DatabaseBackupCollector.CollectedDatabaseDump cmepDump = new DatabaseBackupCollector.CollectedDatabaseDump();
cmepDump.setDatabaseName("CMEP");
cmepDump.setFileName("CMEP.sql");
cmepDump.setContent("CREATE TABLE c_restore(id bigint);".getBytes(java.nio.charset.StandardCharsets.UTF_8));
Mockito.when(collector.collectTmsDatabaseDump()).thenReturn(tmsDump);
Mockito.when(collector.collectCmepDatabaseDump()).thenReturn(cmepDump);
return collector;
}
}

View File

@ -0,0 +1,71 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ProcessResourceRestoreRunnerLauncherTest {
@TempDir
Path tempDir;
@Test
void shouldSkipLaunchWhenAutoLaunchDisabled() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(false);
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
launcher.launch(tempDir.resolve("handoff.json"));
Mockito.verifyNoInteractions(invoker);
}
@Test
void shouldInvokeExternalLaunchScriptWhenAutoLaunchEnabled() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(true);
properties.setRunnerLaunchScriptPath("/tmp/start-runner.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
Path handoffFile = tempDir.resolve("task/handoff.json");
launcher.launch(handoffFile);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<Path> logCaptor = ArgumentCaptor.forClass(Path.class);
ArgumentCaptor<Path> workDirCaptor = ArgumentCaptor.forClass(Path.class);
Mockito.verify(invoker).run(commandCaptor.capture(), logCaptor.capture(), workDirCaptor.capture());
Assertions.assertEquals(
List.of("/bin/bash", "/tmp/start-runner.sh", "--handoff-file", handoffFile.toAbsolutePath().normalize().toString()),
commandCaptor.getValue()
);
Assertions.assertEquals(handoffFile.toAbsolutePath().normalize().getParent(), workDirCaptor.getValue());
Assertions.assertEquals(handoffFile.toAbsolutePath().normalize().getParent().resolve("runner-launch.log"), logCaptor.getValue());
}
@Test
void shouldFailWhenLaunchScriptReturnsNonZeroExitCode() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(true);
properties.setRunnerLaunchScriptPath("/tmp/start-runner.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(9);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> launcher.launch(tempDir.resolve("task/handoff.json"))
);
Assertions.assertTrue(exception.getMessage().contains("exitCode=9"));
}
}

View File

@ -1,111 +1,150 @@
//package com.cisd.tms.modules.backup.service;
//
//import com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService;
//import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
//import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
//import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
//import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
//import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
//import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
//import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
//import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
//import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl;
//import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
//import com.cisd.tms.modules.init.entity.InitTaskEntity;
//import com.cisd.tms.modules.init.repository.InitTaskRepository;
//import java.util.List;
//import java.util.Optional;
//import org.junit.jupiter.api.Assertions;
//import org.junit.jupiter.api.Test;
//import org.mockito.ArgumentCaptor;
//import org.mockito.Mockito;
//
//class ResourceBackupServiceTest {
//
// @Test
// void shouldCreateBackupTaskFromLatestSuccessfulInitSnapshot() {
// InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class);
// ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class);
// ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class);
// ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class);
//
// InitTaskEntity initTask = sampleInitTask();
// List<DiscoveredResourceItem> discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config"));
// ResourcePackageResult packageResult = new ResourcePackageResult();
// packageResult.setPackageName("RBKP-20260413-153000-000001.tmsbak");
// packageResult.setPackagePath("/tmp/resource-backup/RBKP-TASK-20260413-000001/RBKP-20260413-153000-000001.tmsbak");
// packageResult.setPackageSize(2048L);
// packageResult.setPackageHash("SM3-HASH");
// packageResult.setStorageEncKeyFingerprint("LOCAL-DEV-STORAGE-KEY");
// packageResult.setAuthenticityKeyFingerprint("LOCAL-DEV-AUTH-KEY");
// packageResult.setManifestSummaryJson("{\"resourceCount\":1}");
//
// Mockito.when(initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")).thenReturn(Optional.of(initTask));
// Mockito.when(resourceDiscoveryService.discover(initTask)).thenReturn(discoveredItems);
// Mockito.when(resourcePackageService.createPackage(
// Mockito.anyString(),
// Mockito.anyString(),
// Mockito.same(initTask),
// Mockito.eq(discoveredItems),
// Mockito.any(CreateResourceBackupRequest.class)
// )).thenReturn(packageResult);
//
// ResourceBackupService service = new ResourceBackupServiceImpl(
// initTaskRepository,
// resourceDiscoveryService,
// resourcePackageService,
// resourceBackupTaskRepository,
// new ResourceTaskIdGenerator(),
// new ResourceBackupProperties()
// );
//
// CreateResourceBackupRequest request = new CreateResourceBackupRequest();
// request.setIncludeUpgradeHistory(true);
// request.setIncludeAuditBackups(false);
// request.setRemark("before hardware replacement");
//
// CreateResourceBackupResponse response = service.createBackupTask(request);
//
// Assertions.assertTrue(response.getTaskId().startsWith("RBKP-TASK-"));
// Assertions.assertTrue(response.getBackupId().startsWith("RBKP-"));
// Assertions.assertEquals("SUCCESS", response.getStatus());
// Assertions.assertNotNull(response.getCreatedAt());
//
// ArgumentCaptor<ResourceBackupTaskEntity> captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class);
// Mockito.verify(resourceBackupTaskRepository).save(captor.capture());
// ResourceBackupTaskEntity saved = captor.getValue();
// Assertions.assertEquals(response.getTaskId(), saved.getTaskId());
// Assertions.assertEquals(response.getBackupId(), saved.getBackupId());
// Assertions.assertEquals("SUCCESS", saved.getStatus());
// Assertions.assertEquals("ENTERPRISE", saved.getProductType());
// Assertions.assertEquals("RABBITMQ", saved.getMqType());
// Assertions.assertEquals("AAAABBBBXXX", saved.getOrgCode());
// Assertions.assertEquals("before hardware replacement", saved.getRemark());
// Assertions.assertEquals(packageResult.getPackagePath(), saved.getPackagePath());
// Assertions.assertEquals(packageResult.getPackageHash(), saved.getPackageHash());
// Assertions.assertEquals(packageResult.getManifestSummaryJson(), saved.getManifestSummaryJson());
// Assertions.assertEquals(packageResult.getStorageEncKeyFingerprint(), saved.getStorageEncKeyFingerprint());
// Assertions.assertEquals(packageResult.getAuthenticityKeyFingerprint(), saved.getAuthenticityKeyFingerprint());
// }
//
// private static InitTaskEntity sampleInitTask() {
// InitTaskEntity entity = new InitTaskEntity();
// entity.setTaskId("INIT-20260408-000001");
// entity.setTaskType("INIT");
// entity.setStatus("SUCCESS");
// entity.setProductType("ENTERPRISE");
// entity.setMqType("RABBITMQ");
// entity.setOrgCode("AAAABBBBXXX");
// entity.setDeployMode("SINGLE");
// return entity;
// }
//
// private static DiscoveredResourceItem resource(String code, String path) {
// DiscoveredResourceItem item = new DiscoveredResourceItem();
// item.setResourceType("FILE");
// item.setResourceCode(code);
// item.setResolvedPath(path);
// item.setRequired(true);
// return item;
// }
//}
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.packagex.RequiredBackupResourceMissingException;
import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.cisd.tms.modules.init.repository.InitTaskRepository;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ResourceBackupServiceTest {
@Test
void shouldCreateSuccessfulBackupTaskFromLatestSuccessfulInitSnapshot() {
InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class);
ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class);
ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class);
ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class);
InitTaskEntity initTask = sampleInitTask();
List<DiscoveredResourceItem> discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true));
ResourcePackageResult packageResult = new ResourcePackageResult();
packageResult.setPackageName("RBKP-20260413-153000-000001.tmsbak");
packageResult.setPackagePath("/tmp/resource-backup/RBKP-TASK-20260413-000001/RBKP-20260413-153000-000001.tmsbak");
packageResult.setPackageSize(2048L);
packageResult.setPackageHash("SM3-HASH");
packageResult.setStorageEncKeyFingerprint("LOCAL-DEV-STORAGE-KEY");
packageResult.setAuthenticityKeyFingerprint("LOCAL-DEV-AUTH-KEY");
packageResult.setManifestSummaryJson("{\"resourceCount\":1}");
Mockito.when(initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")).thenReturn(Optional.of(initTask));
Mockito.when(resourceDiscoveryService.discover(initTask)).thenReturn(discoveredItems);
Mockito.when(resourcePackageService.createPackage(
Mockito.anyString(),
Mockito.anyString(),
Mockito.same(initTask),
Mockito.eq(discoveredItems),
Mockito.any(CreateResourceBackupRequest.class)
)).thenReturn(packageResult);
ResourceBackupService service = new ResourceBackupServiceImpl(
initTaskRepository,
resourceDiscoveryService,
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties()
);
CreateResourceBackupResponse response = service.createBackupTask(sampleRequest());
Assertions.assertTrue(response.getTaskId().startsWith("RBKP-TASK-"));
Assertions.assertTrue(response.getBackupId().startsWith("RBKP-"));
Assertions.assertEquals("SUCCESS", response.getStatus());
Assertions.assertNotNull(response.getCreatedAt());
ArgumentCaptor<ResourceBackupTaskEntity> captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class);
Mockito.verify(resourceBackupTaskRepository).save(captor.capture());
ResourceBackupTaskEntity saved = captor.getValue();
Assertions.assertEquals("SUCCESS", saved.getStatus());
Assertions.assertEquals(packageResult.getPackagePath(), saved.getPackagePath());
Assertions.assertEquals(packageResult.getManifestSummaryJson(), saved.getManifestSummaryJson());
Assertions.assertNull(saved.getErrorCode());
Assertions.assertNull(saved.getErrorMessage());
}
@Test
void shouldPersistFailedTaskWhenRequiredResourceIsMissing() {
InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class);
ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class);
ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class);
ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class);
InitTaskEntity initTask = sampleInitTask();
List<DiscoveredResourceItem> discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true));
Mockito.when(initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")).thenReturn(Optional.of(initTask));
Mockito.when(resourceDiscoveryService.discover(initTask)).thenReturn(discoveredItems);
Mockito.when(resourcePackageService.createPackage(
Mockito.anyString(),
Mockito.anyString(),
Mockito.same(initTask),
Mockito.eq(discoveredItems),
Mockito.any(CreateResourceBackupRequest.class)
)).thenThrow(new RequiredBackupResourceMissingException("required backup resource is unavailable: code=TMS_CONFIG"));
ResourceBackupService service = new ResourceBackupServiceImpl(
initTaskRepository,
resourceDiscoveryService,
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties()
);
CreateResourceBackupResponse response = service.createBackupTask(sampleRequest());
Assertions.assertEquals("FAILED", response.getStatus());
ArgumentCaptor<ResourceBackupTaskEntity> captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class);
Mockito.verify(resourceBackupTaskRepository).save(captor.capture());
ResourceBackupTaskEntity saved = captor.getValue();
Assertions.assertEquals("FAILED", saved.getStatus());
Assertions.assertEquals("REQUIRED_RESOURCE_MISSING", saved.getErrorCode());
Assertions.assertTrue(saved.getErrorMessage().contains("TMS_CONFIG"));
Assertions.assertNull(saved.getPackagePath());
}
private static InitTaskEntity sampleInitTask() {
InitTaskEntity entity = new InitTaskEntity();
entity.setTaskId("INIT-20260408-000001");
entity.setTaskType("INIT");
entity.setStatus("SUCCESS");
entity.setProductType("ENTERPRISE");
entity.setMqType("RABBITMQ");
entity.setOrgCode("AAAABBBBXXX");
entity.setDeployMode("SINGLE");
return entity;
}
private static DiscoveredResourceItem resource(String code, String path, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("FILE");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRequired(required);
return item;
}
private static CreateResourceBackupRequest sampleRequest() {
CreateResourceBackupRequest request = new CreateResourceBackupRequest();
request.setIncludeUpgradeHistory(true);
request.setIncludeAuditBackups(false);
request.setRemark("before hardware replacement");
return request;
}
}

View File

@ -1,104 +1,103 @@
//package com.cisd.tms.modules.backup.service;
//
//import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
//import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
//import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
//import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
//import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
//import com.cisd.tms.modules.file.entity.FileRecordEntity;
//import com.cisd.tms.modules.file.repository.FileRecordRepository;
//import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
//import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
//import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
//import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
//import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore;
//import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
//import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
//import com.fasterxml.jackson.databind.JsonNode;
//import com.fasterxml.jackson.databind.ObjectMapper;
//import java.nio.file.Files;
//import java.nio.file.Path;
//import org.junit.jupiter.api.Assertions;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.api.io.TempDir;
//import org.mockito.ArgumentCaptor;
//import org.mockito.Mockito;
//
//class ResourceRestoreHandoffServiceTest {
//
// @TempDir
// Path tempDir;
//
// @Test
// void shouldCreateRestoreTaskAndWriteHandoffFiles() throws Exception {
// ResourceBackupProperties properties = new ResourceBackupProperties();
// properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString());
// properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
// FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper());
// ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
// FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
// ObjectMapper objectMapper = new ObjectMapper();
// Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
// Files.createDirectories(packagePath.getParent());
// Files.writeString(packagePath, "package-bytes");
//
// ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse();
// precheckResponse.setPrecheckId("RPRE-20260413-000001");
// precheckResponse.setBackupId("RBKP-20260413-153000-000001");
// precheckResponse.setSourceProductType("ENTERPRISE");
// precheckResponse.setTargetProductType("ENTERPRISE");
// precheckResponse.setKeysetMatched(true);
// precheckResponse.setSignatureValid(true);
// precheckResponse.setCompatible(true);
// precheckResponse.setRestorePlan(java.util.List.of("STOP_SERVICES", "RESTORE_DATABASES", "START_SERVICES"));
// store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
// FileRecordEntity fileRecordEntity = new FileRecordEntity();
// fileRecordEntity.setFileId("FILE-20260413-000001");
// fileRecordEntity.setStoragePath(packagePath.toString());
// Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001"))
// .thenReturn(java.util.Optional.of(fileRecordEntity));
//
// ResourceRestoreService service = new ResourceRestoreServiceImpl(
// repository,
// fileRecordRepository,
// store,
// new ResourceTaskIdGenerator(),
// new HandoffFileStore(objectMapper),
// new ResourceRestoreStateFileStore(objectMapper),
// properties
// );
//
// CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
// request.setPrecheckId("RPRE-20260413-000001");
// request.setConfirmBackupId("RBKP-20260413-153000-000001");
// request.setConfirmRiskAccepted(true);
//
// CreateResourceRestoreResponse response = service.createRestoreTask(request);
//
// Path taskDir = tempDir.resolve("resource-restore").resolve(response.getTaskId());
// Path handoffFile = taskDir.resolve("handoff.json");
// Path stateFile = taskDir.resolve("restore-state.json");
// Assertions.assertTrue(Files.exists(handoffFile));
// Assertions.assertTrue(Files.exists(stateFile));
//
// JsonNode handoff = objectMapper.readTree(Files.readString(handoffFile));
// Assertions.assertEquals(response.getTaskId(), handoff.path("taskId").asText());
// Assertions.assertEquals("RBKP-20260413-153000-000001", handoff.path("backupId").asText());
// Assertions.assertEquals("FILE-20260413-000001", handoff.path("packageFileId").asText());
// Assertions.assertEquals(packagePath.toString(), handoff.path("packagePath").asText());
//
// JsonNode state = objectMapper.readTree(Files.readString(stateFile));
// Assertions.assertEquals("HANDOFF_READY", state.path("controlPhase").asText());
// Assertions.assertEquals("HANDOFF", state.path("status").asText());
// Assertions.assertEquals("RESTORE_LOCK", state.path("currentStepCode").asText());
// Assertions.assertEquals(1, state.path("currentStepNo").asInt());
//
// ArgumentCaptor<ResourceRestoreTaskEntity> captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class);
// Mockito.verify(repository).save(captor.capture());
// ResourceRestoreTaskEntity saved = captor.getValue();
// Assertions.assertEquals(stateFile.toString(), saved.getRunnerStatePath());
// Assertions.assertTrue(saved.getStagingDir().contains(response.getTaskId()));
// Assertions.assertEquals("HANDOFF", saved.getStatus());
// Assertions.assertEquals(packagePath.toString(), saved.getPackagePath());
// }
//}
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerLauncher;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ResourceRestoreHandoffServiceTest {
@TempDir
Path tempDir;
@Test
void shouldCreateRestoreTaskWriteHandoffFilesAndLaunchRunner() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString());
properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper());
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourceRestoreRunnerLauncher runnerLauncher = Mockito.mock(ResourceRestoreRunnerLauncher.class);
ObjectMapper objectMapper = new ObjectMapper();
Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
Files.createDirectories(packagePath.getParent());
Files.writeString(packagePath, "package-bytes");
ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse();
precheckResponse.setPrecheckId("RPRE-20260413-000001");
precheckResponse.setBackupId("RBKP-20260413-153000-000001");
precheckResponse.setSourceProductType("ENTERPRISE");
precheckResponse.setTargetProductType("ENTERPRISE");
precheckResponse.setKeysetMatched(true);
precheckResponse.setSignatureValid(true);
precheckResponse.setCompatible(true);
precheckResponse.setRestorePlan(java.util.List.of("RESTORE_LOCK", "STOP_SERVICES", "RESTORE_DATABASES", "START_SERVICES"));
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
FileRecordEntity fileRecordEntity = new FileRecordEntity();
fileRecordEntity.setFileId("FILE-20260413-000001");
fileRecordEntity.setStoragePath(packagePath.toString());
Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001"))
.thenReturn(java.util.Optional.of(fileRecordEntity));
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
fileRecordRepository,
store,
new ResourceTaskIdGenerator(),
new HandoffFileStore(objectMapper),
new ResourceRestoreStateFileStore(objectMapper),
properties,
runnerLauncher
);
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
request.setPrecheckId("RPRE-20260413-000001");
request.setConfirmBackupId("RBKP-20260413-153000-000001");
request.setConfirmRiskAccepted(true);
CreateResourceRestoreResponse response = service.createRestoreTask(request);
Path taskDir = tempDir.resolve("resource-restore").resolve(response.getTaskId());
Path handoffFile = taskDir.resolve("handoff.json");
Path stateFile = taskDir.resolve("restore-state.json");
Assertions.assertTrue(Files.exists(handoffFile));
Assertions.assertTrue(Files.exists(stateFile));
JsonNode handoff = objectMapper.readTree(Files.readString(handoffFile));
Assertions.assertEquals(response.getTaskId(), handoff.path("taskId").asText());
Assertions.assertEquals(packagePath.toString(), handoff.path("packagePath").asText());
JsonNode state = objectMapper.readTree(Files.readString(stateFile));
Assertions.assertEquals("HANDOFF_READY", state.path("controlPhase").asText());
Assertions.assertEquals("HANDOFF", state.path("status").asText());
ArgumentCaptor<ResourceRestoreTaskEntity> captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class);
Mockito.verify(repository).save(captor.capture());
ResourceRestoreTaskEntity saved = captor.getValue();
Assertions.assertEquals(packagePath.toString(), saved.getPackagePath());
Assertions.assertEquals("HANDOFF", saved.getStatus());
Mockito.verify(runnerLauncher).launch(handoffFile);
}
}