From 2efdc047e39e737d6924e7a622fb3c0f85322463 Mon Sep 17 00:00:00 2001 From: waner Date: Mon, 20 Apr 2026 10:15:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=A4=87=E4=BB=BD=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 +++- scripts/resource-restore/restore-db.sh | 64 +++++ scripts/resource-restore/start-runner.sh | 48 ++++ .../config/ResourceBackupProperties.java | 72 +++++ .../ResourceDiscoveryServiceImpl.java | 5 + .../packagex/DatabaseBackupCollector.java | 39 +++ .../MysqlDatabaseBackupCollector.java | 131 +++++++++ ...equiredBackupResourceMissingException.java | 8 + .../packagex/ResourcePackageServiceImpl.java | 76 +++++ .../AsyncResourceRestoreRunnerLauncher.java | 30 -- .../ConsistencyVerifyRunnerStepExecutor.java | 109 ++++++++ .../DatabaseRestoreRunnerStepExecutor.java | 135 +++++++++ .../PayloadArchiveRunnerStepExecutor.java | 85 +++++- .../ProcessResourceRestoreRunnerLauncher.java | 41 +++ .../runner/ResourceRestoreRunnerMain.java | 38 +++ .../impl/ResourceBackupServiceImpl.java | 52 ++-- src/main/resources/application.yml | 16 ++ .../ResourceDiscoveryServiceTest.java | 8 + ...redBackupResourceMissingExceptionTest.java | 15 + .../packagex/ResourcePackageServiceTest.java | 80 +++++- ...nsistencyVerifyRunnerStepExecutorTest.java | 122 ++++++++ ...DatabaseRestoreRunnerStepExecutorTest.java | 196 +++++++++++++ .../PayloadArchiveRunnerStepExecutorTest.java | 83 +++--- ...cessResourceRestoreRunnerLauncherTest.java | 71 +++++ .../service/ResourceBackupServiceTest.java | 261 ++++++++++-------- .../ResourceRestoreHandoffServiceTest.java | 207 +++++++------- 26 files changed, 1712 insertions(+), 329 deletions(-) create mode 100755 scripts/resource-restore/restore-db.sh create mode 100755 scripts/resource-restore/start-runner.sh create mode 100644 src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingException.java delete mode 100644 src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutor.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutor.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncher.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerMain.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingExceptionTest.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutorTest.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutorTest.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncherTest.java diff --git a/README.md b/README.md index fc30b73..e6fa9d7 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/scripts/resource-restore/restore-db.sh b/scripts/resource-restore/restore-db.sh new file mode 100755 index 0000000..4c7a9ed --- /dev/null +++ b/scripts/resource-restore/restore-db.sh @@ -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}" diff --git a/scripts/resource-restore/start-runner.sh b/scripts/resource-restore/start-runner.sh new file mode 100755 index 0000000..b3aaa34 --- /dev/null +++ b/scripts/resource-restore/start-runner.sh @@ -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 $! diff --git a/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java index 4eafd6d..1304e1b 100644 --- a/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java +++ b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java @@ -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; } diff --git a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java index 0571004..fb30062 100644 --- a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java @@ -29,6 +29,11 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { List 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)); diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java b/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java new file mode 100644 index 0000000..c91383e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java @@ -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; + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java b/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java new file mode 100644 index 0000000..8e845c2 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java @@ -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 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) { + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingException.java b/src/main/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingException.java new file mode 100644 index 0000000..d5cc525 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingException.java @@ -0,0 +1,8 @@ +package com.cisd.tms.modules.backup.packagex; + +public class RequiredBackupResourceMissingException extends IllegalStateException { + + public RequiredBackupResourceMissingException(String message) { + super(message); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java index 64df206..ce67a24 100644 --- a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java @@ -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 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 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 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 writeRegularFileEntry( ZipOutputStream zipOutputStream, DiscoveredResourceItem item, diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java b/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java deleted file mode 100644 index 2a9872f..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutor.java new file mode 100644 index 0000000..ad504e9 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutor.java @@ -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 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 payload = objectMapper.readValue(stepFile.toFile(), new TypeReference>() { + }); + 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 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); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutor.java new file mode 100644 index 0000000..6ac43d4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutor.java @@ -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 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 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 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) { + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java index 3a0a16d..b9f8159 100644 --- a/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java +++ b/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java @@ -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 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 manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference>() { + }); + 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 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 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 castMap(Map rawMap) { + Map 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(); } diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncher.java b/src/main/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncher.java new file mode 100644 index 0000000..1e597c4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncher.java @@ -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 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); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerMain.java b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerMain.java new file mode 100644 index 0000000..ad8e759 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerMain.java @@ -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); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java index 76a3f93..3a9cf67 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java @@ -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,45 +63,56 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { String taskId = resourceTaskIdGenerator.nextBackupTaskId(); String backupId = resourceTaskIdGenerator.nextBackupId(); - // 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。 - ResourcePackageResult packageResult = resourcePackageService.createPackage( - taskId, - backupId, - initTask, - resourceDiscoveryService.discover(initTask), - request - ); - LocalDateTime now = LocalDateTime.now(); ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity(); - // 第一版先按同步成功模型落库,后续切换为真正异步任务时继续复用这张任务表。 entity.setTaskId(taskId); entity.setBackupId(backupId); - entity.setStatus("SUCCESS"); + entity.setStatus("RUNNING"); 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()); - entity.setPackageHash(packageResult.getPackageHash()); - 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); + + try { + // 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。 + ResourcePackageResult packageResult = resourcePackageService.createPackage( + taskId, + backupId, + initTask, + resourceDiscoveryService.discover(initTask), + request + ); + // 第一版先按同步模型直接落最终结果,后续切换异步任务时仍复用同一张任务表。 + entity.setStatus("SUCCESS"); + entity.setPackageName(packageResult.getPackageName()); + entity.setPackagePath(packageResult.getPackagePath()); + entity.setPackageSize(packageResult.getPackageSize()); + entity.setPackageHash(packageResult.getPackageHash()); + entity.setStorageEncKeyFingerprint(packageResult.getStorageEncKeyFingerprint()); + entity.setAuthenticityKeyFingerprint(packageResult.getAuthenticityKeyFingerprint()); + entity.setManifestSummaryJson(packageResult.getManifestSummaryJson()); + } 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; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9758e6f..456c0bc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 心跳失联超时(秒)。 diff --git a/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java index 7da6d72..a609df3 100644 --- a/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java @@ -16,6 +16,8 @@ class ResourceDiscoveryServiceTest { List 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 items, String code, String path) { return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getResolvedPath())); } + + private static boolean hasRequired(List items, String code) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && item.isRequired()); + } } diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingExceptionTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingExceptionTest.java new file mode 100644 index 0000000..a44151b --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/RequiredBackupResourceMissingExceptionTest.java @@ -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()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java index 69a54ab..1b03364 100644 --- a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java @@ -48,16 +48,21 @@ class ResourcePackageServiceTest { ResourcePackageService service = new ResourcePackageServiceImpl( properties, new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + mockDatabaseBackupCollector(), new ObjectMapper() ); ResourcePackageResult result = service.createPackage( - "RBKP-TASK-20260413-000001", - "RBKP-20260413-153000-000001", - sampleInitTask(), - List.of(resource("TMS_CONFIG", payloadFile.toString())), - sampleRequest() - ); + "RBKP-TASK-20260413-000001", + "RBKP-20260413-153000-000001", + sampleInitTask(), + List.of( + resource("TMS_CONFIG", payloadFile.toString()), + databaseResource("TMS_DB_DUMP", "TMS"), + databaseResource("CMEP_DB_DUMP", "CMEP") + ), + sampleRequest() + ); Path packagePath = Path.of(result.getPackagePath()); Assertions.assertTrue(Files.exists(packagePath)); @@ -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> resources = (java.util.List>) 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 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()); diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutorTest.java new file mode 100644 index 0000000..f6d38e5 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/ConsistencyVerifyRunnerStepExecutorTest.java @@ -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 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.>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 requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + Mockito.verify(httpClient).send(requestCaptor.capture(), Mockito.>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 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.>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")); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutorTest.java new file mode 100644 index 0000000..fbdcfdb --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/DatabaseRestoreRunnerStepExecutorTest.java @@ -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> commandCaptor = ArgumentCaptor.forClass(List.class); + Mockito.verify(invoker, Mockito.times(2)).run(commandCaptor.capture(), Mockito.any(), Mockito.eq(tempDir.resolve("task"))); + List> 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")); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java index bdd88d9..9527cca 100644 --- a/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java @@ -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; + } } diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncherTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncherTest.java new file mode 100644 index 0000000..73d095c --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/ProcessResourceRestoreRunnerLauncherTest.java @@ -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> commandCaptor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(Path.class); + ArgumentCaptor 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")); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java index c238a13..408dced 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java @@ -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 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 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 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 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 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 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; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java index 2839dff..7bfeb2d 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java @@ -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 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 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); + } +}