资源备份流程整理

This commit is contained in:
waner
2026-05-09 11:45:12 +08:00
parent 8229b3e44e
commit 524824bf02
62 changed files with 2856 additions and 1178 deletions
@@ -1,7 +1,7 @@
package com.cisd.tms.modules.backup.config;
import com.cisd.tms.modules.backup.dto.support.BackupResourceKind;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -17,12 +17,21 @@ public class ResourceBackupProperties {
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private String mqReplayScriptPath = "/home/tms/bin/resource-restore/replay-mq.sh";
private String tmsDatabaseName = "";
private List<String> tmsDbExcludedTables = List.of(
"tms_role_account",
"tms_role_ukey_binding",
"tms_auth_full_account",
"tms_auth_session",
"tms_auth_challenge",
"tms_auth_audit_log",
"tms_operation_audit_log"
);
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
private int healthCheckTimeoutSeconds = 5;
private int storageEncKeyIndex = 1;
private int authenticitySignKeyIndex = 1;
private String restoreApplyScriptPath = "/home/tms/bin/resource-restore/apply-resource-backup.sh";
private List<ConfiguredRestoreCommand> restoreStopCommands = List.of();
private List<ConfiguredRestoreCommand> restoreStartCommands = List.of();
private List<String> allowedRestoreRoots = List.of(
"/home/tms/config",
"/home/cmep4i",
@@ -103,6 +112,14 @@ public class ResourceBackupProperties {
this.tmsDatabaseName = tmsDatabaseName;
}
public List<String> getTmsDbExcludedTables() {
return tmsDbExcludedTables;
}
public void setTmsDbExcludedTables(List<String> tmsDbExcludedTables) {
this.tmsDbExcludedTables = tmsDbExcludedTables;
}
public String getCmepDatabaseName() {
return cmepDatabaseName;
}
@@ -127,22 +144,6 @@ public class ResourceBackupProperties {
this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds;
}
public int getStorageEncKeyIndex() {
return storageEncKeyIndex;
}
public void setStorageEncKeyIndex(int storageEncKeyIndex) {
this.storageEncKeyIndex = storageEncKeyIndex;
}
public int getAuthenticitySignKeyIndex() {
return authenticitySignKeyIndex;
}
public void setAuthenticitySignKeyIndex(int authenticitySignKeyIndex) {
this.authenticitySignKeyIndex = authenticitySignKeyIndex;
}
public String getRestoreApplyScriptPath() {
return restoreApplyScriptPath;
}
@@ -151,6 +152,22 @@ public class ResourceBackupProperties {
this.restoreApplyScriptPath = restoreApplyScriptPath;
}
public List<ConfiguredRestoreCommand> getRestoreStopCommands() {
return restoreStopCommands;
}
public void setRestoreStopCommands(List<ConfiguredRestoreCommand> restoreStopCommands) {
this.restoreStopCommands = restoreStopCommands;
}
public List<ConfiguredRestoreCommand> getRestoreStartCommands() {
return restoreStartCommands;
}
public void setRestoreStartCommands(List<ConfiguredRestoreCommand> restoreStartCommands) {
this.restoreStartCommands = restoreStartCommands;
}
public List<String> getAllowedRestoreRoots() {
return allowedRestoreRoots;
}
@@ -167,33 +184,4 @@ public class ResourceBackupProperties {
this.fileResources = fileResources;
}
public List<ConfiguredFileBackupResource> getDefaultFileResources() {
ConfiguredFileBackupResource tmsConfig = fileResource(
"TMS_CONFIG",
"DIR",
"/home/tms/config",
"/home/tms/config",
true
);
ConfiguredFileBackupResource cpConfig = fileResource("CP_CONFIG", "FILE", "", "", true);
ConfiguredFileBackupResource receiverLicense = fileResource("RECEIVER_LICENSE", "FILE", "", "", true);
return List.of(tmsConfig, cpConfig, receiverLicense);
}
private static ConfiguredFileBackupResource fileResource(
String code,
String mode,
String path,
String restorePath,
boolean required
) {
ConfiguredFileBackupResource resource = new ConfiguredFileBackupResource();
resource.setCode(code);
resource.setKind(BackupResourceKind.FILE.name());
resource.setMode(mode);
resource.setPath(path);
resource.setRestorePath(restorePath);
resource.setRequired(required);
return resource;
}
}
@@ -49,7 +49,7 @@ public class ResourceBackupController {
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.BACKUP, summary = "创建资源备份任务")
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) {
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody(required = false) CreateResourceBackupRequest request) {
return ApiResponse.success(resourceBackupService.createBackupTask(request));
}
@@ -7,10 +7,8 @@ import com.cisd.tms.modules.auth.security.RequireInternalAuth;
import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.service.ResourcePrecheckService;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
import com.cisd.tms.modules.backup.service.ResourceTaskQueryService;
@@ -22,7 +20,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
@@ -34,7 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/resource-restores")
@Tag(name = "资源恢复", description = "资源恢复预检、任务创建、详情和步骤日志接口")
@Tag(name = "资源恢复", description = "资源恢复预检、任务创建、执行和最近结果接口")
@RequiredArgsConstructor
public class ResourceRestoreController {
@@ -59,35 +56,23 @@ public class ResourceRestoreController {
return ApiResponse.success(resourceRestoreService.createRestoreTask(request));
}
@GetMapping("/{taskId}")
@PostMapping("/{taskId}/apply")
@ReplayProtected
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。")
public ApiResponse<ResourceRestoreTaskDetailResponse> taskDetail(
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "执行资源恢复任务")
@Operation(summary = "执行资源恢复任务", description = "用户确认停机恢复后,启动后台恢复脚本。")
public ApiResponse<CreateResourceRestoreResponse> applyRestoreTask(
@Parameter(description = "恢复任务号")
@PathVariable String taskId
) {
return ApiResponse.success(resourceTaskQueryService.getRestoreTask(taskId));
return ApiResponse.success(resourceRestoreService.applyRestoreTask(taskId));
}
@GetMapping("/{taskId}/steps")
@GetMapping("/latest")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。")
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "恢复任务号")
@PathVariable String taskId
) {
return ApiResponse.success(resourceTaskQueryService.getRestoreTaskSteps(taskId));
@Operation(summary = "查询最近资源恢复结果", description = "TMS 重启后用于恢复页面展示最近一次恢复时间、结果和失败原因。")
public ApiResponse<ResourceRestoreLatestResponse> latestRestore() {
return ApiResponse.success(resourceTaskQueryService.getLatestRestore());
}
@GetMapping("/{taskId}/steps/{stepNo}/log")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。")
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
@Parameter(description = "恢复任务号")
@PathVariable String taskId,
@Parameter(description = "步骤序号")
@PathVariable int stepNo
) {
return ApiResponse.success(resourceTaskQueryService.getRestoreTaskStepLog(taskId, stepNo));
}
}
@@ -0,0 +1,12 @@
package com.cisd.tms.modules.backup.crypto;
public final class ResourceCryptoKeyConstants {
public static final int STORAGE_ENC_KEY_INDEX = 1;
public static final int AUTHENTICITY_SIGN_KEY_INDEX = 1;
public static final String STORAGE_ENC_KEY_FINGERPRINT = "USER_KEY_ENC:1";
public static final String AUTHENTICITY_KEY_FINGERPRINT = "USER_KEY_SIGN:1";
private ResourceCryptoKeyConstants() {
}
}
@@ -1,12 +1,7 @@
package com.cisd.tms.modules.backup.crypto;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.model.*;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.security.Security;
import java.util.Arrays;
import java.util.Base64;
@@ -24,7 +19,6 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
private static final String PROVIDER = "BC";
private final PcieCryptoService pcieCryptoService;
private final ResourceBackupProperties resourceBackupProperties;
static {
if (Security.getProvider(PROVIDER) == null) {
@@ -32,12 +26,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
}
}
public ResourceEnvelopeCryptoServiceImpl(
PcieCryptoService pcieCryptoService,
ResourceBackupProperties resourceBackupProperties
) {
public ResourceEnvelopeCryptoServiceImpl(PcieCryptoService pcieCryptoService) {
this.pcieCryptoService = pcieCryptoService;
this.resourceBackupProperties = resourceBackupProperties;
}
@Override
@@ -55,7 +45,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
// 用户密钥只负责封装随机 DEK;真正的大 payload 仍走对称加密,避免直接把大文件送进 ECC 接口。
EccInternalEncryptRequest encryptRequest = new EccInternalEncryptRequest();
encryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex());
encryptRequest.setKeyIndex(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_INDEX);
encryptRequest.setData(sm4Key);
BackupDataResult wrappedDek = pcieCryptoService.sdfeInternalEncryptEcc(encryptRequest);
@@ -70,8 +60,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
ResourceSealedPayload sealedPayload = new ResourceSealedPayload();
sealedPayload.setCipherBytes(cipher.doFinal(payloadBytes));
sealedPayload.setEnvelope(envelope);
sealedPayload.setStorageEncKeyFingerprint(buildFingerprint("USER_KEY_ENC", resourceBackupProperties.getStorageEncKeyIndex()));
sealedPayload.setAuthenticityKeyFingerprint(buildFingerprint("USER_KEY_SIGN", resourceBackupProperties.getAuthenticitySignKeyIndex()));
sealedPayload.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT);
sealedPayload.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT);
return sealedPayload;
} catch (Exception ex) {
throw new IllegalStateException("资源备份载荷加封失败", ex);
@@ -85,11 +75,11 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
if (dataToSign == null) {
throw new IllegalArgumentException("dataToSign不能为空");
}
EccInternalSignRequest request = new EccInternalSignRequest();
request.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex());
// 内部签名接口这里统一签 SM3 摘要,避免把整段原文直接交给密码卡并固定后续验签输入语义。
request.setData(sm3(dataToSign));
return normalize(pcieCryptoService.eccInternalSign(request));
UserKeySignRequest request = new UserKeySignRequest();
request.setData(dataToSign);
request.setKeyIndex(ResourceCryptoKeyConstants.AUTHENTICITY_SIGN_KEY_INDEX);
return normalize(pcieCryptoService.userKeySignWithSm3(request));
}
@Override
@@ -101,7 +91,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
throw new IllegalArgumentException("signature不能为空");
}
EccInternalVerifyRequest verifyRequest = new EccInternalVerifyRequest();
verifyRequest.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex());
verifyRequest.setKeyIndex(ResourceCryptoKeyConstants.AUTHENTICITY_SIGN_KEY_INDEX);
verifyRequest.setData(sm3(dataToVerify));
verifyRequest.setSignature(signature);
pcieCryptoService.eccInternalVerify(verifyRequest);
@@ -127,7 +117,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
int dekLength = parseInt(envelope.get("dekLength"), 16);
// 先验签,再解数字信封,再解 payload;顺序固定,避免篡改包先进入明文处理路径。
EccInternalDecryptRequest decryptRequest = new EccInternalDecryptRequest();
decryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex());
decryptRequest.setKeyIndex(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_INDEX);
decryptRequest.setCipherBlob(wrappedDek);
decryptRequest.setOutBufferSize(dekLength);
byte[] sm4Key = normalizeAndWipe(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest));
@@ -193,10 +183,6 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
}
}
private static String buildFingerprint(String prefix, int keyIndex) {
return prefix + ":" + keyIndex;
}
private static void wipe(byte[] bytes) {
if (bytes != null) {
Arrays.fill(bytes, (byte) 0);
@@ -1,19 +1,22 @@
package com.cisd.tms.modules.backup.discovery;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.init.config.InitExecutorProperties;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
private final InitExecutorProperties initExecutorProperties;
private final ResourceBackupProperties resourceBackupProperties;
public ResourceDiscoveryServiceImpl(InitExecutorProperties initExecutorProperties) {
this.initExecutorProperties = initExecutorProperties;
@Autowired
public ResourceDiscoveryServiceImpl(ResourceBackupProperties resourceBackupProperties) {
this.resourceBackupProperties = resourceBackupProperties == null ? new ResourceBackupProperties() : resourceBackupProperties;
}
@Override
@@ -30,16 +33,15 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
// 资源发现只决定“应该备份什么”,不在这里触碰文件系统、数据库或密码卡。
// 真实采集放在 ResourcePackageServiceImpl 中完成,便于统一处理必选资源缺失和包内 manifest。
List<DiscoveredResourceItem> items = new ArrayList<>();
// TMS 自身配置目录是恢复后服务能否按原部署参数启动的基础资源,按目录递归打包。
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
// 收发器配置和 license 按产品形态切换路径:标准版走 cmep4i,直连版走 direct 目标路径。
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
appendConfiguredFileResources(items, productType, mqType);
// 数据库资源在包内固定成 db/*.sql,恢复脚本通过 restore-databases.tsv 再决定导入目标库名。
items.add(database("TMS_DB", "TMS", "db/TMS.sql", true));
if (!"DIRECT".equals(productType)) {
// 第一版标准版迁移需要把收发器业务库一起带走,DIRECT 暂时不要求这份库。
items.add(database("CMEP_DB", "CMEP", "db/CMEP.sql", true));
// 标准收发器初始化还会创建一个以机构号命名的业务库;这份库和 CMEP 一样属于不可重建业务数据。
String orgCode = requireDatabaseIdentifier(initTask.getOrgCode(), "orgCode");
items.add(database("ORG_DB", orgCode, "db/" + orgCode + ".sql", true));
}
// 实体证书表记录的 keyIdx 对应密码卡内用户签名密钥;备份时会按这些索引逐个调用 backupUserKey。
items.add(userKey(true));
@@ -48,18 +50,35 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
return items;
}
private String resolveCpConfigPath(String productType) {
if ("DIRECT".equals(productType)) {
return initExecutorProperties.getDirectCpconfigTargetPath();
private void appendConfiguredFileResources(List<DiscoveredResourceItem> items, String productType, String mqType) {
List<ConfiguredFileBackupResource> resources = resourceBackupProperties.getFileResources();
if (resources == null || resources.isEmpty()) {
return;
}
return initExecutorProperties.getStandardCpconfigPath();
}
private String resolveReceiverLicensePath(String productType) {
if ("DIRECT".equals(productType)) {
return initExecutorProperties.getDirectReceiverLicenseTargetPath();
for (ConfiguredFileBackupResource resource : resources) {
if (!matches(resource.getProductTypes(), productType) || !matches(resource.getMqTypes(), mqType)) {
continue;
}
String code = trim(resource.getCode());
String path = trim(resource.getPath());
if (code.isEmpty() || path.isEmpty()) {
continue;
}
String mode = trim(resource.getMode()).isEmpty() ? "FILE" : trim(resource.getMode()).toUpperCase();
String restorePath = trim(resource.getRestorePath());
if (restorePath.isEmpty()) {
restorePath = "GLOB".equals(mode) ? globRestoreDir(path) : path;
}
DiscoveredResourceItem item = file(
code,
path,
restorePath,
mode,
resource.isRequired()
);
removeExistingCode(items, code);
items.add(item);
}
return initExecutorProperties.getStandardReceiverLicenseTargetPath();
}
private static DiscoveredResourceItem file(String code, String path, String restorePath, String mode, boolean required) {
@@ -73,6 +92,29 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
return item;
}
private static String globRestoreDir(String pattern) {
String value = trim(pattern);
int slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
if (slash <= 0) {
return ".";
}
return value.substring(0, slash);
}
private static void removeExistingCode(List<DiscoveredResourceItem> items, String code) {
items.removeIf(item -> code.equals(item.getResourceCode()));
}
private static boolean matches(List<String> configuredValues, String actualValue) {
if (configuredValues == null || configuredValues.isEmpty()) {
return true;
}
String actual = upper(actualValue);
return configuredValues.stream()
.map(ResourceDiscoveryServiceImpl::upper)
.anyMatch(actual::equals);
}
private static DiscoveredResourceItem database(String code, String databaseName, String entryPath, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DATABASE");
@@ -104,4 +146,19 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
private static String upper(String value) {
return value == null ? "" : value.trim().toUpperCase();
}
private static String requireDatabaseIdentifier(String value, String label) {
String identifier = trim(value);
if (identifier.isEmpty()) {
throw new IllegalArgumentException(label + "不能为空");
}
if (!identifier.matches("[A-Za-z0-9_]+")) {
throw new IllegalArgumentException(label + "数据库名无效:" + identifier);
}
return identifier;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -2,34 +2,12 @@ package com.cisd.tms.modules.backup.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "创建资源备份任务请求")
@Schema(description = "创建资源备份任务请求")
public class CreateResourceBackupRequest {
@Schema(description = "是否包含升级历史", example = "true")
private Boolean includeUpgradeHistory;
@Schema(description = "是否包含审计备份", example = "false")
private Boolean includeAuditBackups;
@Schema(description = "备注", example = "before hardware replacement")
private String remark;
public Boolean getIncludeUpgradeHistory() {
return includeUpgradeHistory;
}
public void setIncludeUpgradeHistory(Boolean includeUpgradeHistory) {
this.includeUpgradeHistory = includeUpgradeHistory;
}
public Boolean getIncludeAuditBackups() {
return includeAuditBackups;
}
public void setIncludeAuditBackups(Boolean includeAuditBackups) {
this.includeAuditBackups = includeAuditBackups;
}
public String getRemark() {
return remark;
}
@@ -1,12 +1,17 @@
package com.cisd.tms.modules.backup.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@Schema(description = "创建资源恢复任务请求")
public class CreateResourceRestoreRequest {
private static final String IPV4_REGEX =
"^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$";
@NotBlank
@Schema(description = "预检号", example = "RPRE-20260408-000001", requiredMode = Schema.RequiredMode.REQUIRED)
private String precheckId;
@@ -15,9 +20,15 @@ public class CreateResourceRestoreRequest {
@Schema(description = "确认的备份号", example = "RBKP-20260408-153000-000001", requiredMode = Schema.RequiredMode.REQUIRED)
private String confirmBackupId;
@NotNull
@Schema(description = "是否确认接受恢复风险", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean confirmRiskAccepted;
@NotBlank(message = "deployMode不能为空")
@Pattern(regexp = "SINGLE|DUAL|QUAD", message = "deployMode必须为SINGLE/DUAL/QUAD")
@Schema(description = "目标部署方式", example = "DUAL", requiredMode = Schema.RequiredMode.REQUIRED)
private String deployMode;
@Valid
@NotNull(message = "nodes不能为空")
@Schema(description = "目标节点 IP 配置", requiredMode = Schema.RequiredMode.REQUIRED)
private Nodes nodes;
public String getPrecheckId() {
return precheckId;
@@ -35,11 +46,72 @@ public class CreateResourceRestoreRequest {
this.confirmBackupId = confirmBackupId;
}
public Boolean getConfirmRiskAccepted() {
return confirmRiskAccepted;
public String getDeployMode() {
return deployMode;
}
public void setConfirmRiskAccepted(Boolean confirmRiskAccepted) {
this.confirmRiskAccepted = confirmRiskAccepted;
public void setDeployMode(String deployMode) {
this.deployMode = deployMode;
}
public Nodes getNodes() {
return nodes;
}
public void setNodes(Nodes nodes) {
this.nodes = nodes;
}
@Schema(description = "目标节点 IP 配置")
public static class Nodes {
@NotBlank(message = "nodes.node01Ip不能为空")
@Pattern(regexp = IPV4_REGEX, message = "nodes.node01Ip必须是有效IPv4地址")
@Schema(description = "节点 1 IP", example = "192.168.1.11", requiredMode = Schema.RequiredMode.REQUIRED)
private String node01Ip;
@Pattern(regexp = IPV4_REGEX, message = "nodes.node02Ip必须是有效IPv4地址")
@Schema(description = "节点 2 IP", example = "192.168.1.12")
private String node02Ip;
@Pattern(regexp = IPV4_REGEX, message = "nodes.node03Ip必须是有效IPv4地址")
@Schema(description = "节点 3 IP", example = "192.168.1.13")
private String node03Ip;
@Pattern(regexp = IPV4_REGEX, message = "nodes.node04Ip必须是有效IPv4地址")
@Schema(description = "节点 4 IP", example = "192.168.1.14")
private String node04Ip;
public String getNode01Ip() {
return node01Ip;
}
public void setNode01Ip(String node01Ip) {
this.node01Ip = node01Ip;
}
public String getNode02Ip() {
return node02Ip;
}
public void setNode02Ip(String node02Ip) {
this.node02Ip = node02Ip;
}
public String getNode03Ip() {
return node03Ip;
}
public void setNode03Ip(String node03Ip) {
this.node03Ip = node03Ip;
}
public String getNode04Ip() {
return node04Ip;
}
public void setNode04Ip(String node04Ip) {
this.node04Ip = node04Ip;
}
}
}
@@ -0,0 +1,71 @@
package com.cisd.tms.modules.backup.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "最近一次资源恢复结果响应")
public class ResourceRestoreLatestResponse {
private String taskId;
private String backupId;
private String status;
private String phase;
private String message;
private String createdAt;
private String finishedAt;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getBackupId() {
return backupId;
}
public void setBackupId(String backupId) {
this.backupId = backupId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getPhase() {
return phase;
}
public void setPhase(String phase) {
this.phase = phase;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(String finishedAt) {
this.finishedAt = finishedAt;
}
}
@@ -16,6 +16,7 @@ public class ResourceRestorePrecheckResponse {
private Boolean keysetMatched;
private Boolean signatureValid;
private Boolean compatible;
private Boolean precheckPassed;
private Boolean payloadDecrypted;
private List<String> warnings;
private List<String> blockingIssues;
@@ -103,6 +104,14 @@ public class ResourceRestorePrecheckResponse {
this.compatible = compatible;
}
public Boolean getPrecheckPassed() {
return precheckPassed;
}
public void setPrecheckPassed(Boolean precheckPassed) {
this.precheckPassed = precheckPassed;
}
public Boolean getPayloadDecrypted() {
return payloadDecrypted;
}
@@ -1,261 +0,0 @@
package com.cisd.tms.modules.backup.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "资源恢复任务详情响应")
public class ResourceRestoreTaskDetailResponse {
private String taskId;
private String precheckId;
private String backupId;
private String status;
private String controlPhase;
private String stateSource;
private String sourceDeviceId;
private String sourceProductType;
private String targetProductType;
private String packageFileId;
private String packageName;
private Boolean packagePathVisible;
private String currentStepCode;
private Integer currentStepNo;
private String lastHeartbeatAt;
private String statusFilePath;
private Boolean orphaned;
private List<String> cleanupWarnings;
private List<String> warnings;
private List<String> blockingIssues;
private List<String> restorePlan;
private Object consistencyReport;
private String errorCode;
private String errorMessage;
private String createdBy;
private String createdAt;
private String startedAt;
private String finishedAt;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getPrecheckId() {
return precheckId;
}
public void setPrecheckId(String precheckId) {
this.precheckId = precheckId;
}
public String getBackupId() {
return backupId;
}
public void setBackupId(String backupId) {
this.backupId = backupId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getControlPhase() {
return controlPhase;
}
public void setControlPhase(String controlPhase) {
this.controlPhase = controlPhase;
}
public String getStateSource() {
return stateSource;
}
public void setStateSource(String stateSource) {
this.stateSource = stateSource;
}
public String getSourceDeviceId() {
return sourceDeviceId;
}
public void setSourceDeviceId(String sourceDeviceId) {
this.sourceDeviceId = sourceDeviceId;
}
public String getSourceProductType() {
return sourceProductType;
}
public void setSourceProductType(String sourceProductType) {
this.sourceProductType = sourceProductType;
}
public String getTargetProductType() {
return targetProductType;
}
public void setTargetProductType(String targetProductType) {
this.targetProductType = targetProductType;
}
public String getPackageFileId() {
return packageFileId;
}
public void setPackageFileId(String packageFileId) {
this.packageFileId = packageFileId;
}
public String getPackageName() {
return packageName;
}
public void setPackageName(String packageName) {
this.packageName = packageName;
}
public Boolean getPackagePathVisible() {
return packagePathVisible;
}
public void setPackagePathVisible(Boolean packagePathVisible) {
this.packagePathVisible = packagePathVisible;
}
public String getCurrentStepCode() {
return currentStepCode;
}
public void setCurrentStepCode(String currentStepCode) {
this.currentStepCode = currentStepCode;
}
public Integer getCurrentStepNo() {
return currentStepNo;
}
public void setCurrentStepNo(Integer currentStepNo) {
this.currentStepNo = currentStepNo;
}
public String getLastHeartbeatAt() {
return lastHeartbeatAt;
}
public void setLastHeartbeatAt(String lastHeartbeatAt) {
this.lastHeartbeatAt = lastHeartbeatAt;
}
public String getStatusFilePath() {
return statusFilePath;
}
public void setStatusFilePath(String statusFilePath) {
this.statusFilePath = statusFilePath;
}
public Boolean getOrphaned() {
return orphaned;
}
public void setOrphaned(Boolean orphaned) {
this.orphaned = orphaned;
}
public List<String> getCleanupWarnings() {
return cleanupWarnings;
}
public void setCleanupWarnings(List<String> cleanupWarnings) {
this.cleanupWarnings = cleanupWarnings;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
public List<String> getBlockingIssues() {
return blockingIssues;
}
public void setBlockingIssues(List<String> blockingIssues) {
this.blockingIssues = blockingIssues;
}
public List<String> getRestorePlan() {
return restorePlan;
}
public void setRestorePlan(List<String> restorePlan) {
this.restorePlan = restorePlan;
}
public Object getConsistencyReport() {
return consistencyReport;
}
public void setConsistencyReport(Object consistencyReport) {
this.consistencyReport = consistencyReport;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getStartedAt() {
return startedAt;
}
public void setStartedAt(String startedAt) {
this.startedAt = startedAt;
}
public String getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(String finishedAt) {
this.finishedAt = finishedAt;
}
}
@@ -1,5 +1,7 @@
package com.cisd.tms.modules.backup.dto.support;
import java.util.List;
public class ConfiguredFileBackupResource {
private String code;
@@ -7,6 +9,8 @@ public class ConfiguredFileBackupResource {
private String mode = "FILE";
private String path;
private String restorePath;
private List<String> productTypes = List.of();
private List<String> mqTypes = List.of();
private boolean required = true;
public String getCode() {
@@ -49,6 +53,22 @@ public class ConfiguredFileBackupResource {
this.restorePath = restorePath;
}
public java.util.List<String> getProductTypes() {
return productTypes;
}
public void setProductTypes(java.util.List<String> productTypes) {
this.productTypes = productTypes;
}
public java.util.List<String> getMqTypes() {
return mqTypes;
}
public void setMqTypes(java.util.List<String> mqTypes) {
this.mqTypes = mqTypes;
}
public boolean isRequired() {
return required;
}
@@ -0,0 +1,43 @@
package com.cisd.tms.modules.backup.dto.support;
import java.util.List;
public class ConfiguredRestoreCommand {
private String code;
private String command;
private boolean required = true;
private List<String> productTypes = List.of();
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getCommand() {
return command;
}
public void setCommand(String command) {
this.command = command;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
public List<String> getProductTypes() {
return productTypes;
}
public void setProductTypes(List<String> productTypes) {
this.productTypes = productTypes;
}
}
@@ -1,100 +0,0 @@
package com.cisd.tms.modules.backup.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.infrastructure.persistence.entity.BaseEntity;
import java.time.LocalDateTime;
@TableName("tms_resource_task_step")
public class ResourceTaskStepEntity extends BaseEntity {
private String taskId;
private String taskType;
private Integer stepNo;
private String stepCode;
private String status;
private String message;
private String logPath;
private Integer exitCode;
private LocalDateTime startTime;
private LocalDateTime finishTime;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getTaskType() {
return taskType;
}
public void setTaskType(String taskType) {
this.taskType = taskType;
}
public Integer getStepNo() {
return stepNo;
}
public void setStepNo(Integer stepNo) {
this.stepNo = stepNo;
}
public String getStepCode() {
return stepCode;
}
public void setStepCode(String stepCode) {
this.stepCode = stepCode;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getLogPath() {
return logPath;
}
public void setLogPath(String logPath) {
this.logPath = logPath;
}
public Integer getExitCode() {
return exitCode;
}
public void setExitCode(Integer exitCode) {
this.exitCode = exitCode;
}
public LocalDateTime getStartTime() {
return startTime;
}
public void setStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
public LocalDateTime getFinishTime() {
return finishTime;
}
public void setFinishTime(LocalDateTime finishTime) {
this.finishTime = finishTime;
}
}
@@ -1,11 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 恢复任务控制阶段。
*/
public enum ResourceControlPhase {
TMS_PRECHECK,
JAVA_EXTRACT,
SHELL_APPLY,
DONE
}
@@ -1,13 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源任务步骤状态。
*/
public enum ResourceStepStatus {
PENDING,
RUNNING,
SUCCESS,
SUCCESS_WITH_WARNINGS,
FAILED,
SKIPPED
}
@@ -1,16 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源备份/恢复任务主状态。
*/
public enum ResourceTaskStatus {
PENDING,
PRECHECKING,
PRECHECK_FAILED,
READY,
APPLYING,
VERIFYING,
SUCCESS,
FAILED,
CANCELLED
}
@@ -1,9 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源任务类型。
*/
public enum ResourceTaskType {
RESOURCE_BACKUP,
RESOURCE_RESTORE
}
@@ -1,19 +0,0 @@
package com.cisd.tms.modules.backup.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ResourceTaskStepMapper extends BaseMapperX<ResourceTaskStepEntity> {
@Select(
"SELECT id, task_id, task_type, step_no, step_code, status, message, log_path, exit_code, "
+ "create_time, update_time, start_time, finish_time "
+ "FROM tms_resource_task_step WHERE task_id = #{taskId} ORDER BY step_no ASC"
)
List<ResourceTaskStepEntity> selectByTaskIdOrderByStepNo(@Param("taskId") String taskId);
}
@@ -6,6 +6,8 @@ public interface DatabaseBackupCollector {
CollectedDatabaseDump collectCmepDatabaseDump();
CollectedDatabaseDump collectDatabaseDump(String databaseName, String fileName);
class CollectedDatabaseDump {
private String databaseName;
@@ -34,7 +34,7 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
@Override
public CollectedDatabaseDump collectTmsDatabaseDump() {
MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getTmsDatabaseName(), "TMS");
return collectDatabaseDump(target, "TMS.sql");
return collectDatabaseDump(target, "TMS.sql", resourceBackupProperties.getTmsDbExcludedTables());
}
@Override
@@ -43,7 +43,21 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
return collectDatabaseDump(target, "CMEP.sql");
}
@Override
public CollectedDatabaseDump collectDatabaseDump(String databaseName, String fileName) {
MysqlConnectionTarget target = resolveTarget(databaseName, databaseName);
return collectDatabaseDump(target, fileName);
}
private CollectedDatabaseDump collectDatabaseDump(MysqlConnectionTarget target, String fileName) {
return collectDatabaseDump(target, fileName, List.of());
}
private CollectedDatabaseDump collectDatabaseDump(
MysqlConnectionTarget target,
String fileName,
List<String> excludedTables
) {
List<String> command = new ArrayList<>();
command.add(resourceBackupProperties.getMysqldumpPath());
command.add("--host=" + target.host());
@@ -52,8 +66,14 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
if (!target.password().isEmpty()) {
command.add("--password=" + target.password());
}
command.add("--single-transaction");
// command.add("--single-transaction");
command.add("--skip-lock-tables");
for (String table : excludedTables == null ? List.<String>of() : excludedTables) {
String ignoreTable = normalizeIgnoreTable(target.databaseName(), table);
if (!ignoreTable.isEmpty()) {
command.add("--ignore-table=" + ignoreTable);
}
}
command.add("--databases");
command.add(target.databaseName());
@@ -126,6 +146,17 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
return value == null ? "" : value.trim();
}
private static String normalizeIgnoreTable(String databaseName, String table) {
String value = trim(table);
if (value.isEmpty()) {
return "";
}
if (value.contains(".")) {
return value;
}
return trim(databaseName) + "." + value;
}
private record MysqlConnectionTarget(String host, int port, String username, String password, String databaseName) {
}
}
@@ -14,8 +14,10 @@ import java.io.InputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.LinkedHashMap;
@@ -271,8 +273,6 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
payloadManifest.put("mqType", initTask.getMqType());
payloadManifest.put("orgCode", initTask.getOrgCode());
payloadManifest.put("remark", request == null ? null : request.getRemark());
payloadManifest.put("includeUpgradeHistory", request != null && Boolean.TRUE.equals(request.getIncludeUpgradeHistory()));
payloadManifest.put("includeAuditBackups", request != null && Boolean.TRUE.equals(request.getIncludeAuditBackups()));
List<Map<String, Object>> resourceEntries = new ArrayList<>();
int collectedCount = 0;
@@ -332,6 +332,9 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
resourceEntry.put("mode", trim(item.getMode()).isEmpty() ? "FILE" : trim(item.getMode()));
resourceEntry.put("sourcePath", item.getResolvedPath());
resourceEntry.put("restorePath", trim(item.getRestorePath()).isEmpty() ? item.getResolvedPath() : item.getRestorePath());
if ("GLOB".equalsIgnoreCase(trim(item.getMode()))) {
return collectGlobFileItem(zipOutputStream, item, resourceEntry);
}
// 文件类资源在备份端只允许采集真实存在的文件/目录;恢复端还会再次校验 restorePath 白名单。
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
@@ -376,6 +379,51 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
return archivedFiles.size();
}
private int collectGlobFileItem(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
Path globParent = globParent(item.getResolvedPath());
if (globParent == null || !Files.isDirectory(globParent)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "PATH_NOT_FOUND");
return 0;
}
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + trim(item.getResolvedPath()));
List<Path> matchedFiles;
try (Stream<Path> pathStream = Files.list(globParent)) {
matchedFiles = pathStream
.filter(Files::isRegularFile)
.filter(path -> matcher.matches(path.normalize()))
.sorted(Comparator.naturalOrder())
.toList();
}
if (matchedFiles.isEmpty()) {
assertOptionalResource(item, "GLOB_NOT_MATCHED");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "GLOB_NOT_MATCHED");
return 0;
}
List<Map<String, Object>> archivedFiles = new ArrayList<>();
for (Path matchedFile : matchedFiles) {
Map<String, Object> fileEntry = writeRegularFileEntry(
zipOutputStream,
item,
matchedFile,
matchedFile.getFileName().toString()
);
String restoreDir = trim(item.getRestorePath()).isEmpty() ? globParent.toString() : trim(item.getRestorePath());
fileEntry.put("restorePath", Path.of(restoreDir).resolve(matchedFile.getFileName()).normalize().toString());
archivedFiles.add(fileEntry);
}
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("fileCount", archivedFiles.size());
resourceEntry.put("files", archivedFiles);
return archivedFiles.size();
}
private int collectDatabaseItem(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
@@ -466,6 +514,16 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
if ("CMEP_DB".equalsIgnoreCase(resourceCode) || "CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectCmepDatabaseDump();
}
if ("ORG_DB".equalsIgnoreCase(resourceCode) || "ORG_DB_DUMP".equalsIgnoreCase(resourceCode)) {
String databaseName = trim(item.getDatabaseName()).isEmpty() ? trim(item.getResolvedPath()) : trim(item.getDatabaseName());
if (databaseName.isEmpty()) {
return null;
}
String fileName = trim(item.getEntryPath()).isEmpty()
? databaseName + ".sql"
: Path.of(trim(item.getEntryPath())).getFileName().toString();
return databaseBackupCollector.collectDatabaseDump(databaseName, fileName);
}
return null;
}
@@ -531,6 +589,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
return Path.of(value).normalize();
}
private static Path globParent(String pattern) {
String value = trim(pattern);
if (value.isEmpty()) {
return null;
}
int slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
if (slash < 0) {
return Path.of(".").normalize();
}
return Path.of(value.substring(0, slash)).normalize();
}
private static <T> T writeUnchecked(CheckedSupplier<T> supplier) {
try {
return supplier.get();
@@ -1,11 +0,0 @@
package com.cisd.tms.modules.backup.repository;
import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity;
import java.util.List;
public interface ResourceTaskStepRepository {
void save(ResourceTaskStepEntity entity);
List<ResourceTaskStepEntity> findByTaskIdOrderByStepNo(String taskId);
}
@@ -1,28 +0,0 @@
package com.cisd.tms.modules.backup.repository.impl;
import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity;
import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper;
import com.cisd.tms.modules.backup.repository.ResourceTaskStepRepository;
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository
public class ResourceTaskStepRepositoryImpl implements ResourceTaskStepRepository {
private final ResourceTaskStepMapper resourceTaskStepMapper;
public ResourceTaskStepRepositoryImpl(ResourceTaskStepMapper resourceTaskStepMapper) {
this.resourceTaskStepMapper = resourceTaskStepMapper;
}
@Override
public void save(ResourceTaskStepEntity entity) {
resourceTaskStepMapper.insert(entity);
}
@Override
public List<ResourceTaskStepEntity> findByTaskIdOrderByStepNo(String taskId) {
List<ResourceTaskStepEntity> steps = resourceTaskStepMapper.selectByTaskIdOrderByStepNo(taskId);
return steps == null ? List.of() : steps;
}
}
@@ -0,0 +1,288 @@
package com.cisd.tms.modules.backup.restore;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class ActiveRestoreStateStore {
private static final String ACTIVE_FILE_NAME = "active-restore.json";
private final ResourceBackupProperties properties;
private final ObjectMapper objectMapper;
public ActiveRestoreStateStore(ResourceBackupProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public void write(ResourceRestoreTaskEntity entity, String status) {
ActiveRestoreSnapshot snapshot = fromEntity(entity, status);
Path path = activeFilePath();
try {
Files.createDirectories(path.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), snapshot);
} catch (IOException ex) {
throw new IllegalStateException("写入当前资源恢复状态失败:" + path, ex);
}
}
public Optional<ActiveRestoreSnapshot> read() {
Path path = activeFilePath();
if (!Files.isRegularFile(path)) {
return Optional.empty();
}
try {
return Optional.of(objectMapper.readValue(path.toFile(), ActiveRestoreSnapshot.class));
} catch (IOException ex) {
throw new IllegalStateException("读取当前资源恢复状态失败:" + path, ex);
}
}
private Path activeFilePath() {
return Path.of(properties.getRestoreTaskRootDir()).resolve(ACTIVE_FILE_NAME).normalize();
}
private static ActiveRestoreSnapshot fromEntity(ResourceRestoreTaskEntity entity, String status) {
return new ActiveRestoreSnapshot(
entity.getTaskId(),
entity.getPrecheckId(),
entity.getBackupId(),
status,
entity.getSourceDeviceId(),
entity.getSourceProductType(),
entity.getTargetProductType(),
entity.getPackageFileId(),
entity.getPackagePath(),
entity.getStagingDir(),
entity.getRunnerStatePath(),
entity.getStorageEncKeyFingerprint(),
entity.getAuthenticityKeyFingerprint(),
entity.getRestorePlanJson(),
entity.getCreateBy(),
text(entity.getCreateTime()),
text(entity.getStartTime()),
text(entity.getFinishTime())
);
}
private static String text(LocalDateTime value) {
return value == null ? null : value.toString();
}
public static class ActiveRestoreSnapshot {
private String taskId;
private String precheckId;
private String backupId;
private String status;
private String sourceDeviceId;
private String sourceProductType;
private String targetProductType;
private String packageFileId;
private String packagePath;
private String stagingDir;
private String runnerStatePath;
private String storageEncKeyFingerprint;
private String authenticityKeyFingerprint;
private String restorePlanJson;
private String createBy;
private String createdAt;
private String startedAt;
private String finishedAt;
public ActiveRestoreSnapshot() {
}
public ActiveRestoreSnapshot(
String taskId,
String precheckId,
String backupId,
String status,
String sourceDeviceId,
String sourceProductType,
String targetProductType,
String packageFileId,
String packagePath,
String stagingDir,
String runnerStatePath,
String storageEncKeyFingerprint,
String authenticityKeyFingerprint,
String restorePlanJson,
String createBy,
String createdAt,
String startedAt,
String finishedAt
) {
this.taskId = taskId;
this.precheckId = precheckId;
this.backupId = backupId;
this.status = status;
this.sourceDeviceId = sourceDeviceId;
this.sourceProductType = sourceProductType;
this.targetProductType = targetProductType;
this.packageFileId = packageFileId;
this.packagePath = packagePath;
this.stagingDir = stagingDir;
this.runnerStatePath = runnerStatePath;
this.storageEncKeyFingerprint = storageEncKeyFingerprint;
this.authenticityKeyFingerprint = authenticityKeyFingerprint;
this.restorePlanJson = restorePlanJson;
this.createBy = createBy;
this.createdAt = createdAt;
this.startedAt = startedAt;
this.finishedAt = finishedAt;
}
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getPrecheckId() {
return precheckId;
}
public void setPrecheckId(String precheckId) {
this.precheckId = precheckId;
}
public String getBackupId() {
return backupId;
}
public void setBackupId(String backupId) {
this.backupId = backupId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getSourceDeviceId() {
return sourceDeviceId;
}
public void setSourceDeviceId(String sourceDeviceId) {
this.sourceDeviceId = sourceDeviceId;
}
public String getSourceProductType() {
return sourceProductType;
}
public void setSourceProductType(String sourceProductType) {
this.sourceProductType = sourceProductType;
}
public String getTargetProductType() {
return targetProductType;
}
public void setTargetProductType(String targetProductType) {
this.targetProductType = targetProductType;
}
public String getPackageFileId() {
return packageFileId;
}
public void setPackageFileId(String packageFileId) {
this.packageFileId = packageFileId;
}
public String getPackagePath() {
return packagePath;
}
public void setPackagePath(String packagePath) {
this.packagePath = packagePath;
}
public String getStagingDir() {
return stagingDir;
}
public void setStagingDir(String stagingDir) {
this.stagingDir = stagingDir;
}
public String getRunnerStatePath() {
return runnerStatePath;
}
public void setRunnerStatePath(String runnerStatePath) {
this.runnerStatePath = runnerStatePath;
}
public String getStorageEncKeyFingerprint() {
return storageEncKeyFingerprint;
}
public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) {
this.storageEncKeyFingerprint = storageEncKeyFingerprint;
}
public String getAuthenticityKeyFingerprint() {
return authenticityKeyFingerprint;
}
public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) {
this.authenticityKeyFingerprint = authenticityKeyFingerprint;
}
public String getRestorePlanJson() {
return restorePlanJson;
}
public void setRestorePlanJson(String restorePlanJson) {
this.restorePlanJson = restorePlanJson;
}
public String getCreateBy() {
return createBy;
}
public void setCreateBy(String createBy) {
this.createBy = createBy;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getStartedAt() {
return startedAt;
}
public void setStartedAt(String startedAt) {
this.startedAt = startedAt;
}
public String getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(String finishedAt) {
this.finishedAt = finishedAt;
}
}
}
@@ -1,6 +1,7 @@
package com.cisd.tms.modules.backup.restore;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@@ -35,10 +36,24 @@ public class LightweightRestoreApplierLauncher {
}
public void launch(Path workDir) {
launch(workDir, "");
}
public void launch(Path workDir, String targetProductType) {
Path normalizedWorkDir = workDir.toAbsolutePath().normalize();
// shell 脚本需要数据库连接、恢复脚本路径、健康检查地址等运行时参数。
// 这些信息来自当前 TMS 配置,写入任务目录下的 restore-env.sh,避免在命令行暴露复杂参数。
writeRestoreEnv(normalizedWorkDir);
writeCommandPlan(
normalizedWorkDir.resolve("restore-stop-commands.tsv"),
properties.getRestoreStopCommands(),
targetProductType
);
writeCommandPlan(
normalizedWorkDir.resolve("restore-start-commands.tsv"),
properties.getRestoreStartCommands(),
targetProductType
);
Path logFile = normalizedWorkDir.resolve("restore-launch.log");
// Java 阶段到这里已经完成包验签、解密、payload 展开和用户密钥恢复。
// 后续进入停机恢复脚本,当前 TMS 服务可能被脚本停止。
@@ -54,6 +69,35 @@ public class LightweightRestoreApplierLauncher {
}
}
private void writeCommandPlan(Path planFile, List<ConfiguredRestoreCommand> commands, String targetProductType) {
try {
StringBuilder builder = new StringBuilder();
for (ConfiguredRestoreCommand command : commands == null ? List.<ConfiguredRestoreCommand>of() : commands) {
String shellCommand = trim(command.getCommand());
if (shellCommand.isEmpty() || !matchesProductType(command.getProductTypes(), targetProductType)) {
continue;
}
builder.append(defaultIfBlank(command.getCode(), "COMMAND"))
.append('\t')
.append(command.isRequired())
.append('\t')
.append(shellCommand.replace('\t', ' '))
.append(System.lineSeparator());
}
Files.writeString(planFile, builder.toString());
} catch (IOException ex) {
throw new IllegalStateException("写入恢复停启命令计划失败:" + planFile, ex);
}
}
private static boolean matchesProductType(List<String> productTypes, String targetProductType) {
if (productTypes == null || productTypes.isEmpty()) {
return true;
}
String target = trim(targetProductType);
return productTypes.stream().anyMatch(item -> trim(item).equalsIgnoreCase(target));
}
private void writeRestoreEnv(Path workDir) {
try {
Files.createDirectories(workDir);
@@ -71,6 +115,7 @@ public class LightweightRestoreApplierLauncher {
values.put("DB_PASSWORD", trim(datasourcePassword));
values.put("HEALTH_CHECK_URL", trim(properties.getHealthCheckUrl()));
values.put("HEALTH_CHECK_TIMEOUT_SECONDS", String.valueOf(properties.getHealthCheckTimeoutSeconds()));
values.put("RESTORE_APPLY_DELAY_SECONDS", "3");
StringBuilder builder = new StringBuilder("# generated by TMS resource restore\n");
for (Map.Entry<String, String> entry : values.entrySet()) {
@@ -105,6 +150,10 @@ public class LightweightRestoreApplierLauncher {
return "'" + trim(value).replace("'", "'\\''") + "'";
}
private static String defaultIfBlank(String candidate, String fallback) {
return trim(candidate).isEmpty() ? fallback : candidate;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
@@ -24,13 +24,10 @@ public class ProcessBuilderRestoreCommandInvoker implements RestoreCommandInvoke
if (logFile != null) {
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile()));
}
Process process = pb.start();
return process.waitFor();
pb.start();
return 0;
} catch (IOException ex) {
throw new IllegalStateException("启动恢复命令失败:" + String.join(" ", command), ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("restore command 被中断: " + String.join(" ", command), ex);
}
}
}
@@ -6,4 +6,6 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
public interface ResourceRestoreService {
CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request);
CreateResourceRestoreResponse applyRestoreTask(String taskId);
}
@@ -1,15 +1,8 @@
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import java.util.List;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse;
public interface ResourceTaskQueryService {
ResourceRestoreTaskDetailResponse getRestoreTask(String taskId);
List<ResourceTaskStepItemResponse> getRestoreTaskSteps(String taskId);
ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo);
ResourceRestoreLatestResponse getLatestRestore();
}
@@ -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.crypto.ResourceCryptoKeyConstants;
import com.cisd.tms.modules.backup.packagex.RequiredBackupResourceMissingException;
import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
@@ -39,7 +40,6 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
private final ResourcePackageService resourcePackageService;
private final ResourceBackupTaskRepository resourceBackupTaskRepository;
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties;
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
@Override
@@ -50,9 +50,9 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
response.setIkReady(false);
response.setStorageEncKeyReady(false);
response.setAuthenticityKeyReady(false);
response.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex());
response.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex());
response.setSource("CONFIGURED_KEY_INDEX");
response.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT);
response.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT);
response.setSource("FIXED_KEY_INDEX");
return response;
}
@@ -80,6 +80,8 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
entity.setSourceDeviceId(resolveSourceDeviceId());
entity.setRemark(request == null ? null : request.getRemark());
entity.setCreateBy("SYSTEM");
entity.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT);
entity.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT);
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
@@ -5,7 +5,7 @@ import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.crypto.ResourceCryptoKeyConstants;
import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.service.ResourcePrecheckService;
@@ -30,7 +30,6 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
private final ResourcePrecheckStateStore resourcePrecheckStateStore;
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final CisdPresetProperties cisdPresetProperties;
private final ResourceBackupProperties resourceBackupProperties;
@Override
public ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request) {
@@ -61,6 +60,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
response.setPayloadDecrypted(false);
response.setWarnings(List.of());
response.setBlockingIssues(List.of());
response.setPrecheckPassed(isPrecheckPassed(response));
// 第一版恢复计划固定为两段:Java 负责验签/解密/展开/用户密钥恢复,shell 负责停服后的落盘恢复。
response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY"));
response.setRestoreScopeSummary(buildScopeSummary(inspectResult));
@@ -84,11 +84,11 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
}
private String expectedStorageFingerprint() {
return "USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex();
return ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT;
}
private String expectedAuthenticityFingerprint() {
return "USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex();
return ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT;
}
private static java.util.Map<String, Object> buildScopeSummary(ResourcePackageInspectResult inspectResult) {
@@ -106,4 +106,11 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
}
return targetProductType.equalsIgnoreCase(sourceProductType);
}
private static boolean isPrecheckPassed(ResourceRestorePrecheckResponse response) {
return Boolean.TRUE.equals(response.getKeysetMatched())
&& Boolean.TRUE.equals(response.getSignatureValid())
&& Boolean.TRUE.equals(response.getCompatible())
&& (response.getBlockingIssues() == null || response.getBlockingIssues().isEmpty());
}
}
@@ -10,19 +10,25 @@ import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractionResult;
import com.cisd.tms.modules.backup.packagex.ResourceUserKeyRestoreService;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
import com.cisd.tms.modules.backup.support.ResourcePrecheckStateStore;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.crypto.ResourceCryptoKeyConstants;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -40,6 +46,8 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
private final ResourceUserKeyRestoreService resourceUserKeyRestoreService;
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
private final ObjectMapper objectMapper;
private final ActiveRestoreStateStore activeRestoreStateStore;
@Override
@@ -47,9 +55,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
// 恢复会导入用户密钥、覆盖配置文件、导入数据库并停启服务,
// 必须在入口处和其它高风险任务互斥。
resourceTaskAdmissionGuard.assertCanStartRestore();
if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险");
}
validateTargetNodes(request);
// 恢复创建必须消费预检结果,确保“用户确认的 backupId”与预检看到的是同一个包。
ResourcePrecheckContext context = resourcePrecheckStateStore.find(request.getPrecheckId())
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在"));
@@ -71,6 +77,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
createDirectories(taskDir);
// Java 阶段完成包的完整验签、解密和 payload 展开,并生成 shell 可消费的 TSV 计划文件。
ResourcePayloadExtractionResult extractionResult = resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
writeTargetNodeContext(taskDir, request);
// 用户密钥恢复必须放在 shell 停服/导库之前:数据库和证书数据恢复后会引用这些 keyIdx。
// 如果这里失败,不保存 APPLYING 任务、不启动 shell,避免出现“数据已覆盖但密钥不可用”的半恢复状态。
resourceUserKeyRestoreService.recover(extractionResult.getPayloadDir());
@@ -79,8 +86,8 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
entity.setTaskId(taskId);
entity.setPrecheckId(precheck.getPrecheckId());
entity.setBackupId(precheck.getBackupId());
// 轻量恢复模型下,Java 已完成包验签/解密/展开,后续由 shell apply 脚本接管实际覆盖/导入。
entity.setStatus("APPLYING");
// 创建接口只准备恢复并返回任务号,避免在 HTTP 响应返回前停掉当前 TMS。
entity.setStatus("READY_TO_APPLY");
entity.setSourceDeviceId("UNKNOWN");
entity.setSourceProductType(precheck.getSourceProductType());
entity.setTargetProductType(precheck.getTargetProductType());
@@ -88,17 +95,17 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
entity.setPackagePath(packagePath);
entity.setStagingDir(taskDir.resolve("payload").toString());
entity.setRunnerStatePath(taskDir.resolve("status.json").toString());
entity.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex());
entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex());
entity.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT);
entity.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT);
entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]");
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
// 只有 Java 解包和用户密钥恢复都成功后,任务才进入 APPLYING。
// 后续状态由 shell 写 status.json,查询接口会读取该状态文件展示实时恢复进度。
// 只有 Java 解包和用户密钥恢复都成功后,任务才进入 READY_TO_APPLY。
// 前端一次确认后可连续调用 create 和 apply;shell 只在 apply 阶段启动,
// 避免创建接口返回前停掉当前 TMS。
resourceRestoreTaskRepository.save(entity);
// shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。
lightweightRestoreApplierLauncher.launch(taskDir);
activeRestoreStateStore.write(entity, entity.getStatus());
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
response.setTaskId(taskId);
@@ -107,6 +114,36 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
return response;
}
@Override
public CreateResourceRestoreResponse applyRestoreTask(String taskId) {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId)));
String status = trim(entity.getStatus());
if ("SUCCESS".equalsIgnoreCase(status) || "FAILED".equalsIgnoreCase(status)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务已结束:" + trim(taskId));
}
if (!"READY_TO_APPLY".equalsIgnoreCase(status) && !"APPLYING".equalsIgnoreCase(status)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务状态不允许执行:" + status);
}
LocalDateTime now = LocalDateTime.now();
entity.setStatus("APPLYING");
if (entity.getStartTime() == null) {
entity.setStartTime(now);
}
entity.setUpdateTime(now);
resourceRestoreTaskRepository.update(entity);
activeRestoreStateStore.write(entity, entity.getStatus());
Path taskDir = resolveTaskDir(entity);
// shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。
lightweightRestoreApplierLauncher.launch(taskDir, entity.getTargetProductType());
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
response.setTaskId(entity.getTaskId());
response.setStatus(entity.getStatus());
response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString());
return response;
}
private static void createDirectories(Path taskDir) {
try {
Files.createDirectories(taskDir);
@@ -115,10 +152,71 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
}
}
private void writeTargetNodeContext(Path taskDir, CreateResourceRestoreRequest request) {
Path contextPath = taskDir.resolve("restore-context.json").normalize();
try {
Map<String, Object> context = Files.isRegularFile(contextPath)
? objectMapper.readValue(contextPath.toFile(), new TypeReference<Map<String, Object>>() {
})
: new LinkedHashMap<>();
context.put("targetDeployMode", trim(request.getDeployMode()).toUpperCase());
context.put("targetNodes", targetNodes(request.getNodes()));
objectMapper.writerWithDefaultPrettyPrinter().writeValue(contextPath.toFile(), context);
} catch (IOException ex) {
throw new IllegalStateException("写入恢复目标节点上下文失败:" + contextPath, ex);
}
}
private static Map<String, Object> targetNodes(CreateResourceRestoreRequest.Nodes nodes) {
Map<String, Object> values = new LinkedHashMap<>();
putIfNotBlank(values, "node01Ip", nodes.getNode01Ip());
putIfNotBlank(values, "node02Ip", nodes.getNode02Ip());
putIfNotBlank(values, "node03Ip", nodes.getNode03Ip());
putIfNotBlank(values, "node04Ip", nodes.getNode04Ip());
return values;
}
private static void putIfNotBlank(Map<String, Object> values, String key, String value) {
String text = trim(value);
if (!text.isEmpty()) {
values.put(key, text);
}
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static Path resolveTaskDir(ResourceRestoreTaskEntity entity) {
String runnerStatePath = trim(entity.getRunnerStatePath());
if (runnerStatePath.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务状态文件路径为空");
}
return Path.of(runnerStatePath).normalize().getParent();
}
private static void validateTargetNodes(CreateResourceRestoreRequest request) {
String deployMode = trim(request.getDeployMode()).toUpperCase();
CreateResourceRestoreRequest.Nodes nodes = request.getNodes();
if (nodes == null) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes不能为空");
}
if (trim(nodes.getNode01Ip()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node01Ip不能为空");
}
if (("DUAL".equals(deployMode) || "QUAD".equals(deployMode)) && trim(nodes.getNode02Ip()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node02Ip不能为空");
}
if ("QUAD".equals(deployMode)) {
if (trim(nodes.getNode03Ip()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node03Ip不能为空");
}
if (trim(nodes.getNode04Ip()).isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node04Ip不能为空");
}
}
}
private static void validatePrecheck(ResourceRestorePrecheckResponse precheck) {
if (precheck == null) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在");
@@ -18,7 +18,7 @@ import org.springframework.stereotype.Component;
public class ResourceTaskAdmissionGuard {
private static final Set<String> ACTIVE_BACKUP_STATUSES = Set.of("RUNNING");
private static final Set<String> ACTIVE_RESTORE_STATUSES = Set.of("APPLYING", "RUNNING", "HANDOFF", "PENDING");
private static final Set<String> ACTIVE_RESTORE_STATUSES = Set.of("READY_TO_APPLY", "APPLYING", "RUNNING", "HANDOFF", "PENDING");
private final ResourceBackupTaskRepository resourceBackupTaskRepository;
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
@@ -1,20 +1,17 @@
package com.cisd.tms.modules.backup.service.impl;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader.RestoreStatusSnapshot;
import com.cisd.tms.modules.backup.service.ResourceTaskQueryService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -24,160 +21,144 @@ import org.springframework.stereotype.Service;
public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ObjectMapper objectMapper;
private final RestoreStatusFileReader restoreStatusFileReader;
private final ActiveRestoreStateStore activeRestoreStateStore;
@Override
public ResourceRestoreTaskDetailResponse getRestoreTask(String taskId) {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId)));
ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse();
response.setTaskId(entity.getTaskId());
response.setPrecheckId(entity.getPrecheckId());
response.setBackupId(entity.getBackupId());
response.setStatus(entity.getStatus());
response.setControlPhase("PRECHECKED");
response.setStateSource("DB");
response.setSourceDeviceId(entity.getSourceDeviceId());
response.setSourceProductType(entity.getSourceProductType());
response.setTargetProductType(entity.getTargetProductType());
response.setPackageFileId(entity.getPackageFileId());
response.setPackagePathVisible(Boolean.FALSE);
response.setStatusFilePath(entity.getRunnerStatePath());
response.setRestorePlan(readStringList(entity.getRestorePlanJson()));
response.setConsistencyReport(readObject(entity.getConsistencyReportJson()));
response.setErrorCode(entity.getErrorCode());
response.setErrorMessage(entity.getErrorMessage());
response.setCreatedBy(entity.getCreateBy());
response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString());
response.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString());
response.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString());
mergeLightweightStatus(response, entity);
return response;
}
@Override
public List<ResourceTaskStepItemResponse> getRestoreTaskSteps(String taskId) {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
Path taskDir = resolveTaskDir(entity);
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(taskDir);
List<ResourceTaskStepItemResponse> responses = new ArrayList<>();
for (int i = 0; i < restorePlan.size(); i++) {
String stepCode = restorePlan.get(i);
int stepNo = i + 1;
ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse();
item.setStepNo(stepNo);
item.setStepCode(stepCode);
applyLightweightStepState(item, snapshot, stepCode);
responses.add(item);
public ResourceRestoreLatestResponse getLatestRestore() {
Optional<ActiveRestoreStateStore.ActiveRestoreSnapshot> active = activeRestoreStateStore.read();
if (active.isPresent()) {
return latestFromActive(active.get());
}
return responses;
}
@Override
public ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo) {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
String stepCode = stepNo > 0 && stepNo <= restorePlan.size() ? restorePlan.get(stepNo - 1) : "";
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity));
ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse();
response.setTaskId(trim(taskId));
response.setStepNo(stepNo);
response.setStepCode(stepCode);
response.setContent(snapshot.log());
response.setTruncated(false);
return response;
return latestFromDatabase();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private void mergeLightweightStatus(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) {
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity));
if (trim(snapshot.status()).isEmpty()) {
private ResourceRestoreLatestResponse latestFromActive(ActiveRestoreStateStore.ActiveRestoreSnapshot active) {
RestoreStatusSnapshot status = restoreStatusFileReader.read(resolveTaskDir(active.getRunnerStatePath()));
String latestStatus = defaultIfBlank(status.status(), active.getStatus());
repairMissingTask(active, status, latestStatus);
ResourceRestoreLatestResponse response = new ResourceRestoreLatestResponse();
response.setTaskId(active.getTaskId());
response.setBackupId(active.getBackupId());
response.setStatus(latestStatus);
response.setPhase(status.phase());
response.setMessage(displayMessage(status.message(), latestStatus));
response.setCreatedAt(active.getCreatedAt());
response.setFinishedAt(isTerminal(latestStatus) ? defaultIfBlank(status.updatedAt(), active.getFinishedAt()) : null);
return response;
}
private ResourceRestoreLatestResponse latestFromDatabase() {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findAll().stream()
.filter(item -> !trim(item.getTaskId()).isEmpty())
.max(Comparator.comparing(item -> item.getCreateTime() == null ? LocalDateTime.MIN : item.getCreateTime()))
.orElse(null);
ResourceRestoreLatestResponse response = new ResourceRestoreLatestResponse();
if (entity == null) {
response.setStatus("NONE");
response.setMessage("暂无资源恢复记录");
return response;
}
RestoreStatusSnapshot status = restoreStatusFileReader.read(resolveTaskDir(entity));
String latestStatus = defaultIfBlank(status.status(), entity.getStatus());
response.setTaskId(entity.getTaskId());
response.setBackupId(entity.getBackupId());
response.setStatus(latestStatus);
response.setPhase(status.phase());
response.setMessage(displayMessage(status.message(), latestStatus));
response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString());
response.setFinishedAt(isTerminal(latestStatus)
? defaultIfBlank(status.updatedAt(), entity.getFinishTime() == null ? null : entity.getFinishTime().toString())
: null);
return response;
}
private void repairMissingTask(
ActiveRestoreStateStore.ActiveRestoreSnapshot active,
RestoreStatusSnapshot status,
String latestStatus
) {
if (trim(active.getTaskId()).isEmpty() || resourceRestoreTaskRepository.findByTaskId(active.getTaskId()).isPresent()) {
return;
}
response.setStatus(defaultIfBlank(snapshot.status(), response.getStatus()));
response.setControlPhase(defaultIfBlank(snapshot.phase(), response.getControlPhase()));
response.setStateSource("SHELL_STATUS");
response.setCurrentStepCode(defaultIfBlank(snapshot.phase(), response.getCurrentStepCode()));
response.setCurrentStepNo(currentStepNo(snapshot.phase(), response.getRestorePlan()));
response.setLastHeartbeatAt(defaultIfBlank(snapshot.updatedAt(), response.getLastHeartbeatAt()));
response.setErrorMessage(defaultIfBlank(snapshot.message(), response.getErrorMessage()));
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(active.getTaskId());
entity.setPrecheckId(defaultIfBlank(active.getPrecheckId(), active.getTaskId()));
entity.setBackupId(defaultIfBlank(active.getBackupId(), "UNKNOWN"));
entity.setStatus(defaultIfBlank(latestStatus, "UNKNOWN"));
entity.setSourceDeviceId(active.getSourceDeviceId());
entity.setSourceProductType(defaultIfBlank(active.getSourceProductType(), "UNKNOWN"));
entity.setTargetProductType(defaultIfBlank(active.getTargetProductType(), "UNKNOWN"));
entity.setPackageFileId(defaultIfBlank(active.getPackageFileId(), "UNKNOWN"));
entity.setPackagePath(active.getPackagePath());
entity.setStagingDir(active.getStagingDir());
entity.setRunnerStatePath(active.getRunnerStatePath());
entity.setStorageEncKeyFingerprint(defaultIfBlank(active.getStorageEncKeyFingerprint(), "UNKNOWN"));
entity.setAuthenticityKeyFingerprint(defaultIfBlank(active.getAuthenticityKeyFingerprint(), "UNKNOWN"));
entity.setRestorePlanJson(defaultIfBlank(active.getRestorePlanJson(), "[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]"));
entity.setErrorMessage(status.message());
entity.setCreateBy(defaultIfBlank(active.getCreateBy(), "SYSTEM"));
entity.setCreateTime(parseLocalDateTime(active.getCreatedAt(), LocalDateTime.now()));
entity.setStartTime(parseLocalDateTime(active.getStartedAt(), null));
entity.setFinishTime(isTerminal(latestStatus) ? parseLocalDateTime(status.updatedAt(), null) : null);
entity.setUpdateTime(LocalDateTime.now());
resourceRestoreTaskRepository.save(entity);
}
private Path resolveStatePath(String statusFilePath) {
return trim(statusFilePath).isEmpty() ? null : Path.of(statusFilePath).normalize();
}
private Path resolveTaskDir(String statusFilePath) {
Path statePath = resolveStatePath(statusFilePath);
return statePath == null ? null : statePath.getParent();
}
private Path resolveTaskDir(ResourceRestoreTaskEntity entity) {
Path statePath = resolveStatePath(entity.getRunnerStatePath());
return statePath == null ? null : statePath.getParent();
}
private void applyLightweightStepState(ResourceTaskStepItemResponse item, RestoreStatusSnapshot snapshot, String stepCode) {
item.setLogAvailable(!trim(snapshot.log()).isEmpty());
if (trim(snapshot.status()).isEmpty()) {
item.setStatus("PENDING");
return;
}
if ("JAVA_EXTRACT".equals(stepCode)) {
item.setStatus("SUCCESS");
return;
}
if ("SHELL_APPLY".equals(stepCode)) {
item.setStatus(snapshot.status());
item.setMessage(snapshot.message());
return;
}
if (stepCode.equals(snapshot.phase())) {
item.setStatus(snapshot.status());
item.setMessage(snapshot.message());
return;
}
item.setStatus("PENDING");
}
private List<String> readStringList(String json) {
if (trim(json).isEmpty()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("解析恢复计划JSON失败", ex);
}
}
private Object readObject(String json) {
if (trim(json).isEmpty()) {
return null;
}
try {
return objectMapper.readValue(json, Object.class);
} catch (Exception ex) {
throw new IllegalStateException("解析一致性报告JSON失败", ex);
}
}
private static String defaultIfBlank(String candidate, String fallback) {
return trim(candidate).isEmpty() ? fallback : candidate;
}
private static Integer currentStepNo(String phase, List<String> restorePlan) {
if (restorePlan == null || restorePlan.isEmpty()) {
return null;
}
for (int i = 0; i < restorePlan.size(); i++) {
if (restorePlan.get(i).equals(phase)) {
return i + 1;
}
}
return restorePlan.contains("SHELL_APPLY") ? restorePlan.indexOf("SHELL_APPLY") + 1 : null;
private static boolean isTerminal(String status) {
String value = trim(status);
return "SUCCESS".equalsIgnoreCase(value) || "FAILED".equalsIgnoreCase(value);
}
private static String displayMessage(String message, String status) {
String text = trim(message);
if (!text.isEmpty()) {
return text;
}
if ("SUCCESS".equalsIgnoreCase(trim(status))) {
return "恢复成功";
}
if ("FAILED".equalsIgnoreCase(trim(status))) {
return "恢复失败";
}
if ("NONE".equalsIgnoreCase(trim(status))) {
return "暂无资源恢复记录";
}
return "恢复执行中";
}
private static LocalDateTime parseLocalDateTime(String value, LocalDateTime fallback) {
String text = trim(value);
if (text.isEmpty()) {
return fallback;
}
try {
return LocalDateTime.parse(text);
} catch (DateTimeException ex) {
return fallback;
}
}
}
@@ -1,9 +0,0 @@
package com.cisd.tms.modules.backup.support;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import java.nio.file.Path;
public interface ResourceTaskStepLogService {
ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode);
}
@@ -1,37 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.support.ResourceTaskStepLogService;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Component;
@Component
public class FileSystemResourceTaskStepLogService implements ResourceTaskStepLogService {
@Override
public ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode) {
ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse();
response.setTaskId(taskId);
response.setStepNo(stepNo);
response.setStepCode(stepCode);
if (taskDir == null || stepCode == null || stepCode.isBlank()) {
response.setContent("");
response.setTruncated(false);
return response;
}
Path logFile = taskDir.resolve("steps").resolve(String.format("%02d-%s.log", stepNo, stepCode));
try {
if (!Files.isRegularFile(logFile)) {
response.setContent("");
response.setTruncated(false);
return response;
}
response.setContent(Files.readString(logFile));
response.setTruncated(false);
return response;
} catch (Exception ex) {
throw new IllegalStateException("读取资源任务步骤日志失败", ex);
}
}
}
+99 -2
View File
@@ -194,6 +194,41 @@ tms:
output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages}
# 轻量恢复 apply 脚本启停 TMS 时使用的脚本路径。
tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh}
# 轻量恢复停服务命令。未配置时 apply 脚本会回退到 tms-script-path stop。
# command 支持现场脚本路径或完整 shell 命令;required=false 表示失败只记录 warning。
restore-stop-commands:
- code: STANDARD_NGINX
command: ${TMS_BACKUP_RESTORE_STOP_STANDARD_NGINX_COMMAND:${tms.init.executor.standard-nginx-stop-command}}
required: ${TMS_BACKUP_RESTORE_STOP_STANDARD_NGINX_REQUIRED:false}
product-types:
- ENTERPRISE
- INDIRECT
- code: STANDARD_APPS
command: ${TMS_BACKUP_RESTORE_STOP_STANDARD_APPS_COMMAND:${tms.init.executor.standard-app-stop-command}}
required: ${TMS_BACKUP_RESTORE_STOP_STANDARD_APPS_REQUIRED:false}
product-types:
- ENTERPRISE
- INDIRECT
- code: TMS
command: ${TMS_BACKUP_RESTORE_STOP_TMS_COMMAND:${tms.backup.tms-script-path} stop}
required: ${TMS_BACKUP_RESTORE_STOP_TMS_REQUIRED:false}
# 轻量恢复启动服务命令。标准/间参默认启动 TMS、CMSP/CMTP 和 nginx。
restore-start-commands:
- code: TMS
command: ${TMS_BACKUP_RESTORE_START_TMS_COMMAND:${tms.backup.tms-script-path} start}
required: ${TMS_BACKUP_RESTORE_START_TMS_REQUIRED:true}
- code: STANDARD_APPS
command: ${TMS_BACKUP_RESTORE_START_STANDARD_APPS_COMMAND:${tms.init.executor.standard-app-start-command}}
required: ${TMS_BACKUP_RESTORE_START_STANDARD_APPS_REQUIRED:true}
product-types:
- ENTERPRISE
- INDIRECT
- code: STANDARD_NGINX
command: ${TMS_BACKUP_RESTORE_START_STANDARD_NGINX_COMMAND:${tms.init.executor.standard-nginx-start-command}}
required: ${TMS_BACKUP_RESTORE_START_STANDARD_NGINX_REQUIRED:true}
product-types:
- ENTERPRISE
- INDIRECT
# 轻量恢复 apply 脚本路径;Java 完成验签/解密/展开后调用该脚本执行覆盖、SQL、MQ replay。
restore-apply-script-path: ${TMS_BACKUP_RESTORE_APPLY_SCRIPT_PATH:/home/tms/bin/resource-restore/apply-resource-backup.sh}
# 文件恢复白名单根路径;manifest 中 FILE 恢复路径必须落在这些目录下。
@@ -202,6 +237,61 @@ tms:
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4I:/home/cmep4i}
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4D:/home/cmep4d}
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_WORK:/home/tms/tmp/resource-restore}
# 现场可追加或覆盖直接复制恢复的文件类资源。
# mode=FILE 表示单文件,mode=DIR 表示目录递归,mode=GLOB 表示按通配符匹配同一目录下的一批文件。
# restore-path 对 FILE 是目标文件路径,对 DIR/GLOB 是目标目录;目标仍必须落在 allowed-restore-roots 下。
file-resources:
- code: TMS_CONFIG
mode: DIR
path: ${TMS_BACKUP_TMS_CONFIG_PATH:${tms.init.executor.tms-config-dir}}
restore-path: ${TMS_BACKUP_TMS_CONFIG_RESTORE_PATH:${tms.init.executor.tms-config-dir}}
required: ${TMS_BACKUP_TMS_CONFIG_REQUIRED:true}
- code: CP_CONFIG
mode: FILE
path: ${TMS_BACKUP_STANDARD_CP_CONFIG_PATH:${tms.init.executor.standard-cpconfig-path}}
restore-path: ${TMS_BACKUP_STANDARD_CP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-cpconfig-path}}
product-types:
- ENTERPRISE
- INDIRECT
required: ${TMS_BACKUP_STANDARD_CP_CONFIG_REQUIRED:true}
- code: CP_CONFIG
mode: FILE
path: ${TMS_BACKUP_DIRECT_CP_CONFIG_PATH:${tms.init.executor.direct-cpconfig-target-path}}
restore-path: ${TMS_BACKUP_DIRECT_CP_CONFIG_RESTORE_PATH:${tms.init.executor.direct-cpconfig-target-path}}
product-types:
- DIRECT
required: ${TMS_BACKUP_DIRECT_CP_CONFIG_REQUIRED:true}
- code: RECEIVER_LICENSE
mode: FILE
path: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_PATH:${tms.init.executor.standard-receiver-license-target-path}}
restore-path: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_RESTORE_PATH:${tms.init.executor.standard-receiver-license-target-path}}
product-types:
- ENTERPRISE
- INDIRECT
required: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_REQUIRED:true}
- code: RECEIVER_LICENSE
mode: FILE
path: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_PATH:${tms.init.executor.direct-receiver-license-target-path}}
restore-path: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_RESTORE_PATH:${tms.init.executor.direct-receiver-license-target-path}}
product-types:
- DIRECT
required: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_REQUIRED:true}
- code: STANDARD_CMSP_APP_CONFIG
mode: GLOB
path: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_PATH:${tms.init.executor.standard-home-dir}/cmsp/application-prd*.properties}
restore-path: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-home-dir}/cmsp}
product-types:
- ENTERPRISE
- INDIRECT
required: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_REQUIRED:true}
- code: STANDARD_CMTP_APP_CONFIG
mode: GLOB
path: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_PATH:${tms.init.executor.standard-home-dir}/cmtp/application-prd*.properties}
restore-path: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-home-dir}/cmtp}
product-types:
- ENTERPRISE
- INDIRECT
required: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_REQUIRED:true}
# 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。
mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump}
# 恢复 TMS 库时使用的 mysql 客户端命令。
@@ -212,6 +302,13 @@ tms:
mq-replay-script-path: ${TMS_BACKUP_MQ_REPLAY_SCRIPT_PATH:/home/tms/bin/resource-restore/replay-mq.sh}
# 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。
tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:}
# TMS 库备份排除表。默认不备份角色、授权、会话和审计表,避免恢复时覆盖新机器安全状态。
tms-db-excluded-tables:
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_ACCOUNT:tms_role_account}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_UKEY:tms_role_ukey_binding}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_FULL:tms_auth_full_account}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_SESSION:tms_auth_session}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_CHALLENGE:tms_auth_challenge}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
cmep-database-name: ${TMS_BACKUP_CMEP_DATABASE_NAME:CMEP}
# 恢复完成后的最小健康检查地址。
@@ -229,9 +326,9 @@ tms:
internal-token: ${TMS_INTERNAL_TOKEN:change-me-internal-token}
internal-auth:
# 是否启用 /api/** 的内部登录鉴权;开发联调时可临时关闭。
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:true}
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:false}
# 关闭内部鉴权后,注入请求上下文的调试角色。
debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:KEY_ADMIN}
debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:OPS_ADMIN}
# 关闭内部鉴权后,注入请求上下文的调试认证等级。
debug-auth-level: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_AUTH_LEVEL:FULL}
# 关闭内部鉴权后,注入请求上下文的调试 session token。
@@ -115,26 +115,6 @@ CREATE TABLE IF NOT EXISTS tms_resource_restore_task (
KEY idx_tms_resource_restore_task_status (status),
KEY idx_tms_resource_restore_task_create_time (create_time)
);
CREATE TABLE IF NOT EXISTS tms_resource_task_step (
id BIGINT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE',
step_no INT NOT NULL,
step_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
message VARCHAR(512) NULL,
log_path VARCHAR(1024) NULL,
exit_code INT NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
start_time DATETIME(3) NULL,
finish_time DATETIME(3) NULL,
UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no),
KEY idx_tms_resource_task_step_task_id (task_id),
KEY idx_tms_resource_task_step_task_type (task_type)
);
-- -----------------------------------------------------------------------------
-- 认证与设备基础表
-- -----------------------------------------------------------------------------
@@ -62,22 +62,3 @@ CREATE TABLE IF NOT EXISTS tms_resource_restore_task (
KEY idx_tms_resource_restore_task_status (status),
KEY idx_tms_resource_restore_task_create_time (create_time)
);
CREATE TABLE IF NOT EXISTS tms_resource_task_step (
id BIGINT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE',
step_no INT NOT NULL,
step_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
message VARCHAR(512) NULL,
log_path VARCHAR(1024) NULL,
exit_code INT NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
start_time DATETIME(3) NULL,
finish_time DATETIME(3) NULL,
UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no),
KEY idx_tms_resource_task_step_task_id (task_id),
KEY idx_tms_resource_task_step_task_type (task_type)
);
@@ -2,7 +2,6 @@ package com.cisd.tms.modules.backup;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -12,15 +11,25 @@ class ResourceBackupDtoContractTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void shouldAcceptBackupCreateRequestFields() {
void shouldOnlyExposeOptionalRemarkOnBackupCreateRequest() throws Exception {
CreateResourceBackupRequest request = new CreateResourceBackupRequest();
request.setIncludeUpgradeHistory(Boolean.TRUE);
request.setIncludeAuditBackups(Boolean.FALSE);
request.setRemark("before hardware replacement");
Assertions.assertEquals(Boolean.TRUE, request.getIncludeUpgradeHistory());
Assertions.assertEquals(Boolean.FALSE, request.getIncludeAuditBackups());
Assertions.assertEquals("before hardware replacement", request.getRemark());
Assertions.assertEquals(1, CreateResourceBackupRequest.class.getDeclaredFields().length);
Assertions.assertThrows(
NoSuchMethodException.class,
() -> CreateResourceBackupRequest.class.getDeclaredMethod("getIncludeUpgradeHistory")
);
Assertions.assertThrows(
NoSuchMethodException.class,
() -> CreateResourceBackupRequest.class.getDeclaredMethod("getIncludeAuditBackups")
);
String json = objectMapper.writeValueAsString(new CreateResourceBackupRequest());
Assertions.assertFalse(json.contains("includeUpgradeHistory"));
Assertions.assertFalse(json.contains("includeAuditBackups"));
}
@Test
@@ -32,6 +41,7 @@ class ResourceBackupDtoContractTest {
response.setKeysetMatched(true);
response.setSignatureValid(true);
response.setCompatible(true);
response.setPrecheckPassed(true);
response.setPayloadDecrypted(false);
String json = objectMapper.writeValueAsString(response);
@@ -40,23 +50,35 @@ class ResourceBackupDtoContractTest {
Assertions.assertTrue(json.contains("\"backupId\":\"RBKP-20260408-153000-000001\""));
Assertions.assertTrue(json.contains("\"sourceProductType\":\"ENTERPRISE\""));
Assertions.assertTrue(json.contains("\"keysetMatched\":true"));
Assertions.assertTrue(json.contains("\"precheckPassed\":true"));
Assertions.assertTrue(json.contains("\"payloadDecrypted\":false"));
}
@Test
void shouldSerializeRestoreTaskDetailControlFields() throws Exception {
ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse();
response.setTaskId("RRST-TASK-20260408-000001");
response.setStatus("APPLYING");
response.setControlPhase("SHELL_APPLY");
response.setStateSource("SHELL_STATUS");
response.setCurrentStepCode("SHELL_APPLY");
void shouldSerializeRestoreCreateTargetNodeFields() throws Exception {
com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest request =
new com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest();
request.setPrecheckId("RPRE-20260408-000001");
request.setConfirmBackupId("RBKP-20260408-153000-000001");
request.setDeployMode("DUAL");
com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.Nodes nodes =
new com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.Nodes();
nodes.setNode01Ip("192.168.1.11");
nodes.setNode02Ip("192.168.1.12");
request.setNodes(nodes);
String json = objectMapper.writeValueAsString(response);
String json = objectMapper.writeValueAsString(request);
Assertions.assertTrue(json.contains("\"taskId\":\"RRST-TASK-20260408-000001\""));
Assertions.assertTrue(json.contains("\"controlPhase\":\"SHELL_APPLY\""));
Assertions.assertTrue(json.contains("\"stateSource\":\"SHELL_STATUS\""));
Assertions.assertTrue(json.contains("\"currentStepCode\":\"SHELL_APPLY\""));
Assertions.assertThrows(
NoSuchMethodException.class,
() -> com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class
.getDeclaredMethod("getConfirmRiskAccepted")
);
Assertions.assertFalse(json.contains("confirmRiskAccepted"));
Assertions.assertTrue(json.contains("\"deployMode\":\"DUAL\""));
Assertions.assertTrue(json.contains("\"nodes\""));
Assertions.assertTrue(json.contains("\"node01Ip\":\"192.168.1.11\""));
Assertions.assertTrue(json.contains("\"node02Ip\":\"192.168.1.12\""));
}
}
@@ -15,7 +15,9 @@ class ResourceBackupEndToEndContractTest {
Assertions.assertTrue(backupController.contains("/api/v1/resource-backups"));
Assertions.assertTrue(restoreController.contains("/api/v1/resource-restores"));
Assertions.assertTrue(restoreController.contains("/precheck"));
Assertions.assertTrue(restoreController.contains("/steps/{stepNo}/log"));
Assertions.assertTrue(restoreController.contains("/{taskId}/apply"));
Assertions.assertTrue(restoreController.contains("/latest"));
Assertions.assertFalse(restoreController.contains("/steps/{stepNo}/log"));
}
@Test
@@ -23,6 +25,6 @@ class ResourceBackupEndToEndContractTest {
String readme = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/README.md"));
Assertions.assertTrue(readme.contains("停机恢复"));
Assertions.assertTrue(readme.contains("创建恢复任务后当前TMS服务可能中断"));
Assertions.assertTrue(readme.contains("确认后调用 apply 接口进入停机恢复"));
}
}
@@ -3,6 +3,7 @@ package com.cisd.tms.modules.backup;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.support.BackupResourceKind;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -21,9 +22,12 @@ class ResourceBackupPropertiesTest {
properties.getMqReplayScriptPath()
);
Assertions.assertTrue(properties.getAllowedRestoreRoots().contains("/home/tms/config"));
Assertions.assertTrue(properties.getDefaultFileResources().stream()
.anyMatch(item -> "TMS_CONFIG".equals(item.getCode())
&& BackupResourceKind.FILE.name().equals(item.getKind())));
Assertions.assertTrue(properties.getFileResources().isEmpty());
Assertions.assertTrue(properties.getRestoreStopCommands().isEmpty());
Assertions.assertTrue(properties.getRestoreStartCommands().isEmpty());
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_account"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_ukey_binding"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_auth_session"));
}
@Test
@@ -35,11 +39,37 @@ class ResourceBackupPropertiesTest {
custom.setMode("FILE");
custom.setPath("/home/tms/certs/custom.crt");
custom.setRestorePath("/home/tms/certs/custom.crt");
custom.setProductTypes(java.util.List.of("ENTERPRISE", "INDIRECT"));
custom.setRequired(false);
properties.setFileResources(java.util.List.of(custom));
Assertions.assertEquals("CUSTOM_CERT", properties.getFileResources().get(0).getCode());
Assertions.assertEquals(java.util.List.of("ENTERPRISE", "INDIRECT"), properties.getFileResources().get(0).getProductTypes());
Assertions.assertFalse(properties.getFileResources().get(0).isRequired());
}
@Test
void shouldAcceptConfiguredRestoreStartAndStopCommands() {
ResourceBackupProperties properties = new ResourceBackupProperties();
ConfiguredRestoreCommand stop = restoreCommand("STANDARD_APPS", "/home/tms/bin/stop_standard_apps.sh", false);
ConfiguredRestoreCommand start = restoreCommand("STANDARD_APPS", "/home/tms/bin/start_standard_apps.sh", true);
properties.setRestoreStopCommands(java.util.List.of(stop));
properties.setRestoreStartCommands(java.util.List.of(start));
Assertions.assertEquals("STANDARD_APPS", properties.getRestoreStopCommands().get(0).getCode());
Assertions.assertFalse(properties.getRestoreStopCommands().get(0).isRequired());
Assertions.assertEquals("/home/tms/bin/start_standard_apps.sh", properties.getRestoreStartCommands().get(0).getCommand());
Assertions.assertEquals(java.util.List.of("ENTERPRISE", "INDIRECT"), properties.getRestoreStartCommands().get(0).getProductTypes());
}
private static ConfiguredRestoreCommand restoreCommand(String code, String command, boolean required) {
ConfiguredRestoreCommand item = new ConfiguredRestoreCommand();
item.setCode(code);
item.setCommand(command);
item.setRequired(required);
item.setProductTypes(java.util.List.of("ENTERPRISE", "INDIRECT"));
return item;
}
}
@@ -6,7 +6,6 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.cisd.tms.modules.backup.service.ResourceBackupService;
import com.cisd.tms.modules.backup.service.ResourcePrecheckService;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
@@ -42,7 +41,7 @@ class ResourceBackupControllerTest {
CreateResourceBackupResponse createBackup = new CreateResourceBackupResponse();
createBackup.setTaskId("RBKP-TASK-20260408-000001");
createBackup.setStatus("PENDING");
Mockito.when(backupService.createBackupTask(Mockito.any(CreateResourceBackupRequest.class))).thenReturn(createBackup);
Mockito.when(backupService.createBackupTask(Mockito.nullable(CreateResourceBackupRequest.class))).thenReturn(createBackup);
ResourceBackupTaskDetailResponse backupDetail = new ResourceBackupTaskDetailResponse();
backupDetail.setTaskId("RBKP-TASK-20260408-000001");
@@ -70,13 +69,6 @@ class ResourceBackupControllerTest {
createRestore.setStatus("PENDING");
Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(createRestore);
ResourceRestoreTaskDetailResponse restoreDetail = new ResourceRestoreTaskDetailResponse();
restoreDetail.setTaskId("RRST-TASK-20260408-000001");
restoreDetail.setStatus("APPLYING");
restoreDetail.setControlPhase("SHELL_APPLY");
restoreDetail.setStateSource("SHELL_STATUS");
Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260408-000001")).thenReturn(restoreDetail);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ResourceBackupController(backupService),
new ResourceRestoreController(precheckService, restoreService, taskQueryService)
@@ -84,7 +76,11 @@ class ResourceBackupControllerTest {
mockMvc.perform(post("/api/v1/resource-backups")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"includeUpgradeHistory\":true,\"includeAuditBackups\":false,\"remark\":\"before hardware replacement\"}"))
.content("{\"remark\":\"before hardware replacement\"}"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RBKP-TASK-20260408-000001\"")));
mockMvc.perform(post("/api/v1/resource-backups"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RBKP-TASK-20260408-000001\"")));
@@ -106,13 +102,8 @@ class ResourceBackupControllerTest {
mockMvc.perform(post("/api/v1/resource-restores")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"precheckId\":\"RPRE-20260408-000001\",\"confirmBackupId\":\"RBKP-20260408-153000-000001\",\"confirmRiskAccepted\":true}"))
.content("{\"precheckId\":\"RPRE-20260408-000001\",\"confirmBackupId\":\"RBKP-20260408-153000-000001\",\"deployMode\":\"DUAL\",\"nodes\":{\"node01Ip\":\"192.168.1.11\",\"node02Ip\":\"192.168.1.12\"}}"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260408-000001\"")));
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260408-000001"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stateSource\":\"SHELL_STATUS\"")));
}
}
@@ -3,14 +3,11 @@ package com.cisd.tms.modules.backup.controller;
import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.service.ResourcePrecheckService;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
import com.cisd.tms.modules.backup.service.ResourceTaskQueryService;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
@@ -25,7 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class ResourceRestoreControllerTest {
@Test
void shouldExposeRestoreEndpointsIncludingStepsAndLogs() throws Exception {
void shouldExposeTwoPhaseRestoreEndpointsAndLatestResult() throws Exception {
ResourcePrecheckService precheckService = Mockito.mock(ResourcePrecheckService.class);
ResourceRestoreService restoreService = Mockito.mock(ResourceRestoreService.class);
ResourceTaskQueryService taskQueryService = Mockito.mock(ResourceTaskQueryService.class);
@@ -37,28 +34,20 @@ class ResourceRestoreControllerTest {
CreateResourceRestoreResponse create = new CreateResourceRestoreResponse();
create.setTaskId("RRST-TASK-20260414-000001");
create.setStatus("APPLYING");
create.setStatus("READY_TO_APPLY");
Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(create);
ResourceRestoreTaskDetailResponse detail = new ResourceRestoreTaskDetailResponse();
detail.setTaskId("RRST-TASK-20260414-000001");
detail.setControlPhase("SHELL_APPLY");
detail.setStateSource("SHELL_STATUS");
Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260414-000001")).thenReturn(detail);
ResourceTaskStepItemResponse step = new ResourceTaskStepItemResponse();
step.setStepNo(2);
step.setStepCode("SHELL_APPLY");
step.setStatus("SUCCESS");
Mockito.when(taskQueryService.getRestoreTaskSteps("RRST-TASK-20260414-000001")).thenReturn(List.of(step));
ResourceTaskStepLogResponse log = new ResourceTaskStepLogResponse();
log.setTaskId("RRST-TASK-20260414-000001");
log.setStepNo(2);
log.setStepCode("SHELL_APPLY");
log.setContent("apply ok");
log.setTruncated(false);
Mockito.when(taskQueryService.getRestoreTaskStepLog("RRST-TASK-20260414-000001", 2)).thenReturn(log);
CreateResourceRestoreResponse apply = new CreateResourceRestoreResponse();
apply.setTaskId("RRST-TASK-20260414-000001");
apply.setStatus("APPLYING");
Mockito.when(restoreService.applyRestoreTask("RRST-TASK-20260414-000001")).thenReturn(apply);
ResourceRestoreLatestResponse latest = new ResourceRestoreLatestResponse();
latest.setTaskId("RRST-TASK-20260414-000001");
latest.setBackupId("RBKP-20260414-000001");
latest.setStatus("FAILED");
latest.setPhase("DATABASE");
latest.setMessage("数据库恢复失败");
latest.setFinishedAt("2026-04-14T10:05:00+0800");
Mockito.when(taskQueryService.getLatestRestore()).thenReturn(latest);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ResourceRestoreController(precheckService, restoreService, taskQueryService)
@@ -72,20 +61,23 @@ class ResourceRestoreControllerTest {
mockMvc.perform(post("/api/v1/resource-restores")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"confirmRiskAccepted\":true}"))
.content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"deployMode\":\"DUAL\",\"nodes\":{\"node01Ip\":\"192.168.1.11\",\"node02Ip\":\"192.168.1.12\"}}"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260414-000001\"")));
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001"))
mockMvc.perform(post("/api/v1/resource-restores/RRST-TASK-20260414-000001/apply"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"APPLYING\"")));
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001/steps"))
mockMvc.perform(get("/api/v1/resource-restores/latest"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stepCode\":\"SHELL_APPLY\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"FAILED\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"phase\":\"DATABASE\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"message\":\"数据库恢复失败\"")));
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001/steps/2/log"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"content\":\"apply ok\"")));
mockMvc.perform(post("/api/v1/resource-restores")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"deployMode\":\"DUAL\"}"))
.andExpect(status().isBadRequest());
}
}
@@ -3,10 +3,9 @@ package com.cisd.tms.modules.backup.crypto;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.model.UserKeySignRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Assertions;
@@ -19,9 +18,6 @@ class ResourceEnvelopeCryptoServiceTest {
@Test
void shouldWrapDekWithEncUserKeyAndSignWithAuthUserKey() {
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setStorageEncKeyIndex(7);
properties.setAuthenticitySignKeyIndex(8);
AtomicReference<byte[]> capturedDek = new AtomicReference<>();
AtomicReference<byte[]> decryptableDek = new AtomicReference<>();
@@ -33,7 +29,7 @@ class ResourceEnvelopeCryptoServiceTest {
decryptableDek.set(Arrays.copyOf(request.getData(), request.getData().length));
return backup(new byte[]{0x11, 0x22, 0x33});
});
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any(EccInternalSignRequest.class)))
Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any(UserKeySignRequest.class)))
.thenReturn(backup(new byte[]{0x44, 0x55, 0x66}));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> {
@@ -42,7 +38,7 @@ class ResourceEnvelopeCryptoServiceTest {
return backup(decryptedDek);
});
ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties);
ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService);
byte[] payload = "payload-data".getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] signSource = "manifest|envelope|cipher".getBytes(java.nio.charset.StandardCharsets.UTF_8);
@@ -55,19 +51,19 @@ class ResourceEnvelopeCryptoServiceTest {
ArgumentCaptor<EccInternalEncryptRequest> encryptCaptor = ArgumentCaptor.forClass(EccInternalEncryptRequest.class);
Mockito.verify(pcieCryptoService).sdfeInternalEncryptEcc(encryptCaptor.capture());
Assertions.assertEquals(7, encryptCaptor.getValue().getKeyIndex());
Assertions.assertEquals(1, encryptCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalSignRequest> signCaptor = ArgumentCaptor.forClass(EccInternalSignRequest.class);
Mockito.verify(pcieCryptoService).eccInternalSign(signCaptor.capture());
Assertions.assertEquals(8, signCaptor.getValue().getKeyIndex());
ArgumentCaptor<UserKeySignRequest> signCaptor = ArgumentCaptor.forClass(UserKeySignRequest.class);
Mockito.verify(pcieCryptoService).userKeySignWithSm3(signCaptor.capture());
Assertions.assertEquals(1, signCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalVerifyRequest> verifyCaptor = ArgumentCaptor.forClass(EccInternalVerifyRequest.class);
Mockito.verify(pcieCryptoService).eccInternalVerify(verifyCaptor.capture());
Assertions.assertEquals(8, verifyCaptor.getValue().getKeyIndex());
Assertions.assertEquals(1, verifyCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalDecryptRequest> decryptCaptor = ArgumentCaptor.forClass(EccInternalDecryptRequest.class);
Mockito.verify(pcieCryptoService).sdfeInternalDecryptEcc(decryptCaptor.capture());
Assertions.assertEquals(7, decryptCaptor.getValue().getKeyIndex());
Assertions.assertEquals(1, decryptCaptor.getValue().getKeyIndex());
Assertions.assertTrue(isAllZero(capturedDek.get()), "generated SM4 DEK should be wiped after sealing");
Assertions.assertTrue(isAllZero(decryptResultDek.get()), "decrypted SM4 DEK result buffer should be wiped after unsealing");
}
@@ -1,7 +1,8 @@
package com.cisd.tms.modules.backup.discovery;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.init.config.InitExecutorProperties;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import java.util.List;
import org.junit.jupiter.api.Assertions;
@@ -11,15 +12,20 @@ class ResourceDiscoveryServiceTest {
@Test
void shouldDiscoverStandardRabbitResourcesWithoutExcludedArtifacts() {
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties());
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(defaultBackupProperties());
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertTrue(containsCode(items, "TMS_CONFIG"));
Assertions.assertTrue(containsCode(items, "TMS_DB"));
Assertions.assertTrue(containsCode(items, "CMEP_DB"));
Assertions.assertTrue(containsCode(items, "ORG_DB"));
Assertions.assertTrue(hasDatabaseName(items, "ORG_DB", "AAAABBBBXXX"));
Assertions.assertTrue(hasEntryPath(items, "ORG_DB", "db/AAAABBBBXXX.sql"));
Assertions.assertTrue(containsCode(items, "CP_CONFIG"));
Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE"));
Assertions.assertTrue(containsCode(items, "STANDARD_CMSP_APP_CONFIG"));
Assertions.assertTrue(containsCode(items, "STANDARD_CMTP_APP_CONFIG"));
Assertions.assertTrue(containsCode(items, "USER_KEY"));
Assertions.assertTrue(containsCode(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS"));
@@ -38,7 +44,7 @@ class ResourceDiscoveryServiceTest {
@Test
void shouldUseDirectPathsForDirectTlqResources() {
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties());
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(defaultBackupProperties());
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("DIRECT", "RABBITMQ_TLQ"));
@@ -50,6 +56,70 @@ class ResourceDiscoveryServiceTest {
Assertions.assertTrue(hasRequired(items, "USER_KEY"));
Assertions.assertTrue(hasRequired(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "CMEP_DB"));
Assertions.assertFalse(containsCode(items, "ORG_DB"));
Assertions.assertFalse(containsCode(items, "STANDARD_CMSP_APP_CONFIG"));
Assertions.assertFalse(containsCode(items, "STANDARD_CMTP_APP_CONFIG"));
}
@Test
void shouldNotDiscoverDirectCopyFileResourcesWhenFileResourcesAreEmpty() {
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(new ResourceBackupProperties());
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertFalse(containsCode(items, "TMS_CONFIG"));
Assertions.assertFalse(containsCode(items, "CP_CONFIG"));
Assertions.assertFalse(containsCode(items, "RECEIVER_LICENSE"));
Assertions.assertFalse(containsCode(items, "STANDARD_CMSP_APP_CONFIG"));
Assertions.assertTrue(containsCode(items, "TMS_DB"));
Assertions.assertTrue(containsCode(items, "CMEP_DB"));
Assertions.assertTrue(containsCode(items, "ORG_DB"));
Assertions.assertTrue(containsCode(items, "USER_KEY"));
Assertions.assertTrue(containsCode(items, "MQ_REPLAY"));
}
@Test
void shouldAppendConfiguredFileResources() {
ResourceBackupProperties backupProperties = new ResourceBackupProperties();
ConfiguredFileBackupResource custom = new ConfiguredFileBackupResource();
custom.setCode("CUSTOM_CERT");
custom.setMode("FILE");
custom.setPath("/home/tms/config/custom-cert.pem");
custom.setRestorePath("/home/tms/config/custom-cert.pem");
custom.setRequired(false);
backupProperties.setFileResources(List.of(custom));
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(backupProperties);
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertTrue(hasPath(items, "CUSTOM_CERT", "/home/tms/config/custom-cert.pem"));
Assertions.assertFalse(hasRequired(items, "CUSTOM_CERT"));
}
@Test
void shouldDiscoverStandardAppConfigOnlyWhenConfiguredAsFileResources() {
ResourceBackupProperties backupProperties = new ResourceBackupProperties();
ConfiguredFileBackupResource cmsp = new ConfiguredFileBackupResource();
cmsp.setCode("STANDARD_CMSP_APP_CONFIG");
cmsp.setMode("GLOB");
cmsp.setPath("/home/cmep4i/cmsp/application-prd*.properties");
cmsp.setRestorePath("/home/cmep4i/cmsp");
cmsp.setRequired(true);
ConfiguredFileBackupResource cmtp = new ConfiguredFileBackupResource();
cmtp.setCode("STANDARD_CMTP_APP_CONFIG");
cmtp.setMode("GLOB");
cmtp.setPath("/home/cmep4i/cmtp/application-prd*.properties");
cmtp.setRestorePath("/home/cmep4i/cmtp");
cmtp.setRequired(true);
backupProperties.setFileResources(List.of(cmsp, cmtp));
ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(backupProperties);
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertTrue(hasPath(items, "STANDARD_CMSP_APP_CONFIG", "/home/cmep4i/cmsp/application-prd*.properties"));
Assertions.assertTrue(hasPath(items, "STANDARD_CMTP_APP_CONFIG", "/home/cmep4i/cmtp/application-prd*.properties"));
Assertions.assertTrue(hasMode(items, "STANDARD_CMSP_APP_CONFIG", "GLOB"));
Assertions.assertTrue(hasRestorePath(items, "STANDARD_CMTP_APP_CONFIG", "/home/cmep4i/cmtp"));
}
private static InitTaskEntity sampleInitTask(String productType, String mqType) {
@@ -64,20 +134,35 @@ class ResourceDiscoveryServiceTest {
return entity;
}
private static InitExecutorProperties sampleProperties() {
InitExecutorProperties properties = new InitExecutorProperties();
properties.setTmsConfigDir("/home/tms/config");
properties.setStandardCpconfigPath("/home/cmep4i/cpconfig.cfg");
properties.setDirectCpconfigTargetPath("/opt/cpconfig.cfg");
properties.setStandardReceiverLicenseTargetPath("/home/cmep4i/cmep/license/cmep.license");
properties.setDirectReceiverLicenseTargetPath("/home/cmep4d/cmep/license/cmep.license");
properties.setStandardTlqLicenseTargetPath("/home/tlq/tlqconf.zip");
properties.setDirectTlqLicenseTargetPath("/home/tlq/TLQ8/license.dat");
properties.setCfmqTargetDir("/pmts/cfg");
properties.setRabbitmqAdminCommand("/usr/sbin/rabbitmqadmin");
private static ResourceBackupProperties defaultBackupProperties() {
ResourceBackupProperties properties = new ResourceBackupProperties();
ConfiguredFileBackupResource tmsConfig = configured("TMS_CONFIG", "DIR", "/home/tms/config", "/home/tms/config", true);
ConfiguredFileBackupResource standardCpConfig = configured("CP_CONFIG", "FILE", "/home/cmep4i/cpconfig.cfg", "/home/cmep4i/cpconfig.cfg", true);
standardCpConfig.setProductTypes(List.of("ENTERPRISE", "INDIRECT"));
ConfiguredFileBackupResource directCpConfig = configured("CP_CONFIG", "FILE", "/opt/cpconfig.cfg", "/opt/cpconfig.cfg", true);
directCpConfig.setProductTypes(List.of("DIRECT"));
ConfiguredFileBackupResource standardLicense = configured("RECEIVER_LICENSE", "FILE", "/home/cmep4i/cmep/license/cmep.license", "/home/cmep4i/cmep/license/cmep.license", true);
standardLicense.setProductTypes(List.of("ENTERPRISE", "INDIRECT"));
ConfiguredFileBackupResource directLicense = configured("RECEIVER_LICENSE", "FILE", "/home/cmep4d/cmep/license/cmep.license", "/home/cmep4d/cmep/license/cmep.license", true);
directLicense.setProductTypes(List.of("DIRECT"));
ConfiguredFileBackupResource cmsp = configured("STANDARD_CMSP_APP_CONFIG", "GLOB", "/home/cmep4i/cmsp/application-prd*.properties", "/home/cmep4i/cmsp", true);
cmsp.setProductTypes(List.of("ENTERPRISE", "INDIRECT"));
ConfiguredFileBackupResource cmtp = configured("STANDARD_CMTP_APP_CONFIG", "GLOB", "/home/cmep4i/cmtp/application-prd*.properties", "/home/cmep4i/cmtp", true);
cmtp.setProductTypes(List.of("ENTERPRISE", "INDIRECT"));
properties.setFileResources(List.of(tmsConfig, standardCpConfig, directCpConfig, standardLicense, directLicense, cmsp, cmtp));
return properties;
}
private static ConfiguredFileBackupResource configured(String code, String mode, String path, String restorePath, boolean required) {
ConfiguredFileBackupResource resource = new ConfiguredFileBackupResource();
resource.setCode(code);
resource.setMode(mode);
resource.setPath(path);
resource.setRestorePath(restorePath);
resource.setRequired(required);
return resource;
}
private static boolean containsCode(List<DiscoveredResourceItem> items, String code) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()));
}
@@ -86,6 +171,22 @@ class ResourceDiscoveryServiceTest {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getResolvedPath()));
}
private static boolean hasRestorePath(List<DiscoveredResourceItem> items, String code, String path) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getRestorePath()));
}
private static boolean hasMode(List<DiscoveredResourceItem> items, String code, String mode) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && mode.equals(item.getMode()));
}
private static boolean hasDatabaseName(List<DiscoveredResourceItem> items, String code, String databaseName) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && databaseName.equals(item.getDatabaseName()));
}
private static boolean hasEntryPath(List<DiscoveredResourceItem> items, String code, String entryPath) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && entryPath.equals(item.getEntryPath()));
}
private static boolean hasRequired(List<DiscoveredResourceItem> items, String code) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && item.isRequired());
}
@@ -0,0 +1,57 @@
package com.cisd.tms.modules.backup.packagex;
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;
class MysqlDatabaseBackupCollectorTest {
@TempDir
Path tempDir;
@Test
void shouldExcludeConfiguredTablesOnlyWhenDumpingTmsDatabase() throws Exception {
Path argsLog = tempDir.resolve("mysqldump-args.log");
Path mysqldump = fakeMysqldump(argsLog);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setMysqldumpPath(mysqldump.toString());
properties.setTmsDbExcludedTables(List.of(
"tms_role_account",
"tms_auth_session",
"CustomDb.already_qualified"
));
MysqlDatabaseBackupCollector collector = new MysqlDatabaseBackupCollector(
properties,
"jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false",
"root",
"secret"
);
DatabaseBackupCollector.CollectedDatabaseDump tmsDump = collector.collectTmsDatabaseDump();
collector.collectDatabaseDump("ORG001", "ORG001.sql");
Assertions.assertEquals("TMS", tmsDump.getDatabaseName());
Assertions.assertEquals("TMS.sql", tmsDump.getFileName());
Assertions.assertEquals("-- synthetic dump\n", new String(tmsDump.getContent()));
String[] invocations = Files.readString(argsLog).split(System.lineSeparator());
Assertions.assertTrue(invocations[0].contains("--ignore-table=TMS.tms_role_account"));
Assertions.assertTrue(invocations[0].contains("--ignore-table=TMS.tms_auth_session"));
Assertions.assertTrue(invocations[0].contains("--ignore-table=CustomDb.already_qualified"));
Assertions.assertFalse(invocations[1].contains("--ignore-table="));
}
private Path fakeMysqldump(Path argsLog) throws Exception {
Path script = tempDir.resolve("mysqldump");
Files.writeString(script, """
#!/usr/bin/env bash
printf '%%s\\n' "$*" >> "%s"
printf -- '-- synthetic dump\\n'
""".formatted(argsLog));
script.toFile().setExecutable(true);
return script;
}
}
@@ -50,7 +50,7 @@ class ResourcePackageServiceTest {
EccInternalEncryptRequest request = invocation.getArgument(0);
return backup(request.getData());
});
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any()))
Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any()))
.thenReturn(backup(new byte[]{0x11, 0x22, 0x33}));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> {
@@ -60,7 +60,7 @@ class ResourcePackageServiceTest {
Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any());
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService),
mockDatabaseBackupCollector(),
new ObjectMapper()
);
@@ -73,6 +73,7 @@ class ResourcePackageServiceTest {
resource("TMS_CONFIG", payloadFile.toString()),
databaseResource("TMS_DB", "TMS", "db/TMS.sql"),
databaseResource("CMEP_DB", "CMEP", "db/CMEP.sql"),
databaseResource("ORG_DB", "AAAABBBBXXX", "db/AAAABBBBXXX.sql"),
mqReplayResource()
),
sampleRequest()
@@ -85,7 +86,7 @@ class ResourcePackageServiceTest {
Assertions.assertFalse(result.getPackageHash().isBlank());
Assertions.assertFalse(result.getStorageEncKeyFingerprint().isBlank());
Assertions.assertFalse(result.getAuthenticityKeyFingerprint().isBlank());
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":4"));
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":5"));
try (ZipFile zipFile = new ZipFile(packagePath.toFile())) {
Assertions.assertNotNull(zipFile.getEntry("manifest.json"));
@@ -103,7 +104,7 @@ class ResourcePackageServiceTest {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
Assertions.assertEquals(4, resources.size());
Assertions.assertEquals(5, 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", resources.get(1).get("resourceCode"));
@@ -112,9 +113,13 @@ class ResourcePackageServiceTest {
Assertions.assertEquals("CMEP_DB", resources.get(2).get("resourceCode"));
Assertions.assertEquals("DATABASE", resources.get(2).get("kind"));
Assertions.assertEquals("db/CMEP.sql", resources.get(2).get("entryPath"));
Assertions.assertEquals("MQ_REPLAY", resources.get(3).get("resourceCode"));
Assertions.assertEquals("MQ_REPLAY", resources.get(3).get("kind"));
Assertions.assertEquals("mq/mq-restore-context.json", resources.get(3).get("entryPath"));
Assertions.assertEquals("ORG_DB", resources.get(3).get("resourceCode"));
Assertions.assertEquals("DATABASE", resources.get(3).get("kind"));
Assertions.assertEquals("AAAABBBBXXX", resources.get(3).get("databaseName"));
Assertions.assertEquals("db/AAAABBBBXXX.sql", resources.get(3).get("entryPath"));
Assertions.assertEquals("MQ_REPLAY", resources.get(4).get("resourceCode"));
Assertions.assertEquals("MQ_REPLAY", resources.get(4).get("kind"));
Assertions.assertEquals("mq/mq-restore-context.json", resources.get(4).get("entryPath"));
try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) {
java.util.Map<String, String> entries = new java.util.LinkedHashMap<>();
@@ -128,6 +133,8 @@ class ResourcePackageServiceTest {
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"));
Assertions.assertTrue(entries.containsKey("db/AAAABBBBXXX.sql"));
Assertions.assertTrue(entries.get("db/AAAABBBBXXX.sql").contains("INSERT INTO org_demo"));
Assertions.assertTrue(entries.containsKey("mq/mq-restore-context.json"));
Assertions.assertTrue(entries.get("mq/mq-restore-context.json").contains("\"channelUsername\":\"tms\""));
}
@@ -140,7 +147,7 @@ class ResourcePackageServiceTest {
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService),
mockDatabaseBackupCollector(),
new ObjectMapper()
);
@@ -160,6 +167,58 @@ class ResourcePackageServiceTest {
Assertions.assertTrue(exception.getMessage().contains("PATH_NOT_FOUND"));
}
@Test
void shouldCollectGlobFileResourcesWithRestoreDirectory() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setOutputDir(tempDir.toString());
Path cmspDir = tempDir.resolve("cmsp");
Files.createDirectories(cmspDir);
Files.writeString(cmspDir.resolve("application-prd.properties"), "spring.rabbitmq.username=user1");
Files.writeString(cmspDir.resolve("application-prd-node2.properties"), "spring.rabbitmq.username=user2");
Files.writeString(cmspDir.resolve("application-dev.properties"), "spring.rabbitmq.username=dev");
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalEncryptRequest>getArgument(0).getData()));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalDecryptRequest>getArgument(0).getCipherBlob()));
Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any()))
.thenReturn(backup(new byte[]{0x11, 0x22, 0x33}));
Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any());
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService),
mockDatabaseBackupCollector(),
new ObjectMapper()
);
DiscoveredResourceItem globResource = resource(
"STANDARD_CMSP_APP_CONFIG",
cmspDir.resolve("application-prd*.properties").toString()
);
globResource.setMode("GLOB");
globResource.setRestorePath("/home/cmep4i/cmsp");
ResourcePackageResult result = service.createPackage(
"RBKP-TASK-20260413-000001",
"RBKP-20260413-153000-000001",
sampleInitTask(),
List.of(globResource),
sampleRequest()
);
ResourcePackageInspectResult inspectResult = service.inspectPackage(Path.of(result.getPackagePath()));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> files = (java.util.List<Map<String, Object>>) resources.get(0).get("files");
Assertions.assertEquals(2, files.size());
Assertions.assertTrue(files.stream().anyMatch(file ->
"resources/STANDARD_CMSP_APP_CONFIG/application-prd.properties".equals(file.get("entryPath"))
&& "/home/cmep4i/cmsp/application-prd.properties".equals(file.get("restorePath"))));
Assertions.assertTrue(files.stream().anyMatch(file ->
"resources/STANDARD_CMSP_APP_CONFIG/application-prd-node2.properties".equals(file.get("entryPath"))
&& "/home/cmep4i/cmsp/application-prd-node2.properties".equals(file.get("restorePath"))));
}
@Test
void shouldCollectUserKeysFromEntityCertificateKeyIndexes() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
@@ -167,7 +226,7 @@ class ResourcePackageServiceTest {
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalEncryptRequest>getArgument(0).getData()));
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any()))
Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any()))
.thenReturn(backup(new byte[]{0x11, 0x22, 0x33}));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalDecryptRequest>getArgument(0).getCipherBlob()));
@@ -180,7 +239,7 @@ class ResourcePackageServiceTest {
Mockito.when(keyEntityRepository.findAll()).thenReturn(List.of(keyEntity(3), keyEntity(2), keyEntity(2)));
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService),
mockDatabaseBackupCollector(),
new ResourceUserKeyBackupCollector(keyEntityRepository, pcieCryptoService, new ObjectMapper()),
new MqReplayContextBuilder(new ObjectMapper()),
@@ -297,6 +356,11 @@ class ResourcePackageServiceTest {
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);
DatabaseBackupCollector.CollectedDatabaseDump orgDump = new DatabaseBackupCollector.CollectedDatabaseDump();
orgDump.setDatabaseName("AAAABBBBXXX");
orgDump.setFileName("AAAABBBBXXX.sql");
orgDump.setContent("CREATE TABLE org_demo(id bigint);\nINSERT INTO org_demo VALUES (3);\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
Mockito.when(collector.collectDatabaseDump("AAAABBBBXXX", "AAAABBBBXXX.sql")).thenReturn(orgDump);
return collector;
}
@@ -309,8 +373,6 @@ class ResourcePackageServiceTest {
private static CreateResourceBackupRequest sampleRequest() {
CreateResourceBackupRequest request = new CreateResourceBackupRequest();
request.setIncludeUpgradeHistory(true);
request.setIncludeAuditBackups(false);
request.setRemark("before hardware replacement");
return request;
}
@@ -29,6 +29,7 @@ class ResourcePayloadExtractorTest {
{"code":"TMS_CONFIG","kind":"FILE","restorePath":"%s/config",
"files":[{"entryPath":"files/TMS_CONFIG/application.yml","restorePath":"%s/config/application.yml"}]},
{"code":"TMS_DB","kind":"DATABASE","entryPath":"db/TMS.sql"},
{"code":"ORG_DB","kind":"DATABASE","databaseName":"AAAABBBBXXX","entryPath":"db/AAAABBBBXXX.sql"},
{"code":"MQ_REPLAY","kind":"MQ_REPLAY","entryPath":"mq/mq-restore-context.json"}
]}
""".formatted(tempDir, tempDir),
@@ -53,7 +54,8 @@ class ResourcePayloadExtractorTest {
Files.readString(workDir.resolve("restore-files.tsv"))
);
Assertions.assertEquals(
"db/TMS.sql\tTMS" + System.lineSeparator(),
"db/TMS.sql\tTMS" + System.lineSeparator()
+ "db/AAAABBBBXXX.sql\tAAAABBBBXXX" + System.lineSeparator(),
Files.readString(workDir.resolve("restore-databases.tsv"))
);
Assertions.assertEquals(
@@ -2,15 +2,11 @@ package com.cisd.tms.modules.backup.repository;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity;
import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper;
import com.cisd.tms.modules.backup.mapper.ResourceRestoreTaskMapper;
import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper;
import com.cisd.tms.modules.backup.repository.impl.ResourceBackupTaskRepositoryImpl;
import com.cisd.tms.modules.backup.repository.impl.ResourceRestoreTaskRepositoryImpl;
import com.cisd.tms.modules.backup.repository.impl.ResourceTaskStepRepositoryImpl;
import java.lang.reflect.Method;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
@@ -82,33 +78,6 @@ class ResourceTaskRepositoryTest {
Assertions.assertEquals("NONE", found.getErrorCode());
}
@Test
void shouldSaveAndQueryTaskStepsByTaskId() {
ResourceTaskStepMapper mapper = Mockito.mock(ResourceTaskStepMapper.class);
ResourceTaskStepRepository repository = new ResourceTaskStepRepositoryImpl(mapper);
ResourceTaskStepEntity entity = new ResourceTaskStepEntity();
entity.setTaskId("RRST-001");
entity.setTaskType("RESOURCE_RESTORE");
entity.setStepNo(1);
entity.setStepCode("SHELL_APPLY");
entity.setStatus("SUCCESS");
entity.setLogPath("/home/tms/tmp/resource-restore/RRST-001/restore.log");
entity.setExitCode(0);
repository.save(entity);
Mockito.verify(mapper).insert(entity);
Mockito.when(mapper.selectByTaskIdOrderByStepNo("RRST-001")).thenReturn(List.of(entity));
List<ResourceTaskStepEntity> steps = repository.findByTaskIdOrderByStepNo("RRST-001");
Assertions.assertEquals(1, steps.size());
Assertions.assertEquals("RESOURCE_RESTORE", steps.get(0).getTaskType());
Assertions.assertEquals("SHELL_APPLY", steps.get(0).getStepCode());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/restore.log", steps.get(0).getLogPath());
Assertions.assertEquals(0, steps.get(0).getExitCode());
}
@Test
void shouldDeclareExplicitSelectColumnsInMappers() throws Exception {
assertSelectSql(
@@ -132,16 +101,6 @@ class ResourceTaskRepositoryTest {
"consistency_report_json",
"error_code"
);
assertSelectSql(
ResourceTaskStepMapper.class.getMethod("selectByTaskIdOrderByStepNo", String.class),
"tms_resource_task_step",
"task_id",
"task_type",
"step_no",
"step_code",
"log_path",
"exit_code"
);
}
private static void assertSelectSql(Method method, String tableName, String... requiredColumns) {
@@ -27,12 +27,7 @@ class ResourceTaskSchemaContractTest {
"source_product_type VARCHAR(16) NOT NULL",
"target_product_type VARCHAR(16) NOT NULL",
"consistency_report_json JSON NULL",
"error_code VARCHAR(64) NULL",
"CREATE TABLE IF NOT EXISTS tms_resource_task_step",
"task_type VARCHAR(32) NOT NULL",
"step_code VARCHAR(64) NOT NULL",
"log_path VARCHAR(1024) NULL",
"exit_code INT NULL");
"error_code VARCHAR(64) NULL");
assertContainsAll(v1Sql,
"CREATE TABLE IF NOT EXISTS tms_resource_backup_task",
@@ -49,46 +44,10 @@ class ResourceTaskSchemaContractTest {
"source_product_type VARCHAR(16) NOT NULL",
"target_product_type VARCHAR(16) NOT NULL",
"consistency_report_json JSON NULL",
"error_code VARCHAR(64) NULL",
"CREATE TABLE IF NOT EXISTS tms_resource_task_step",
"task_type VARCHAR(32) NOT NULL",
"step_code VARCHAR(64) NOT NULL",
"log_path VARCHAR(1024) NULL",
"exit_code INT NULL");
"error_code VARCHAR(64) NULL");
assertEnumConstants(
"com.cisd.tms.modules.backup.enums.ResourceTaskStatus",
"PENDING",
"PRECHECKING",
"PRECHECK_FAILED",
"READY",
"APPLYING",
"VERIFYING",
"SUCCESS",
"FAILED",
"CANCELLED"
);
assertEnumConstants(
"com.cisd.tms.modules.backup.enums.ResourceTaskType",
"RESOURCE_BACKUP",
"RESOURCE_RESTORE"
);
assertEnumConstants(
"com.cisd.tms.modules.backup.enums.ResourceControlPhase",
"TMS_PRECHECK",
"JAVA_EXTRACT",
"SHELL_APPLY",
"DONE"
);
assertEnumConstants(
"com.cisd.tms.modules.backup.enums.ResourceStepStatus",
"PENDING",
"RUNNING",
"SUCCESS",
"SUCCESS_WITH_WARNINGS",
"FAILED",
"SKIPPED"
);
Assertions.assertFalse(v6Sql.contains("tms_resource_task_step"));
Assertions.assertFalse(v1Sql.contains("tms_resource_task_step"));
}
private static String readClasspathResource(String path) throws Exception {
@@ -103,13 +62,4 @@ class ResourceTaskSchemaContractTest {
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static void assertEnumConstants(String className, String... expectedNames) throws Exception {
Class<?> enumClass = Class.forName(className);
Assertions.assertTrue(enumClass.isEnum(), className + " should be an enum");
Class<? extends Enum> typedEnumClass = (Class<? extends Enum>) enumClass.asSubclass(Enum.class);
for (String expectedName : expectedNames) {
Enum.valueOf(typedEnumClass, expectedName);
}
}
}
@@ -128,6 +128,29 @@ class ApplyResourceBackupScriptContractTest {
Assertions.assertTrue(log.contains("http://127.0.0.1:8080/actuator/health"));
}
@Test
void shouldRunConfiguredStopAndStartCommandsInOrder() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir);
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Path commandLog = tempDir.resolve("service-commands.log");
Path stopScript = fakeScript("stop-standard-apps.sh", commandLog);
Path startTmsScript = fakeScript("start-tms.sh", commandLog);
Path startStandardScript = fakeScript("start-standard-apps.sh", commandLog);
Files.writeString(workDir.resolve("restore-stop-commands.tsv"),
"STANDARD_APPS\ttrue\t" + stopScript + System.lineSeparator());
Files.writeString(workDir.resolve("restore-start-commands.tsv"),
"TMS\ttrue\t" + startTmsScript + System.lineSeparator()
+ "STANDARD_APPS\ttrue\t" + startStandardScript + System.lineSeparator());
runScript(workDir, Map.of());
String log = Files.readString(commandLog);
Assertions.assertTrue(log.indexOf("stop-standard-apps.sh") < log.indexOf("start-tms.sh"));
Assertions.assertTrue(log.indexOf("start-tms.sh") < log.indexOf("start-standard-apps.sh"));
}
private static void runScript(Path workDir, Map<String, String> environment) throws Exception {
ProcessBuilder builder = new ProcessBuilder(
"bash",
@@ -1,6 +1,7 @@
package com.cisd.tms.modules.backup.restore;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -19,6 +20,22 @@ class LightweightRestoreApplierLauncherTest {
properties.setRestoreApplyScriptPath("/home/tms/bin/resource-restore/apply-resource-backup.sh");
properties.setRestoreDbScriptPath("/home/tms/bin/resource-restore/restore-db.sh");
properties.setMqReplayScriptPath("/home/tms/bin/resource-restore/replay-mq.sh");
ConfiguredRestoreCommand tmsStart = restoreCommand("TMS", "/home/tms/scripts/tms.sh start", true);
ConfiguredRestoreCommand standardStart = restoreCommand(
"STANDARD_APPS",
"/home/tms/bin/start_standard_apps.sh",
true,
"ENTERPRISE",
"INDIRECT"
);
ConfiguredRestoreCommand directOnlyStart = restoreCommand(
"DIRECT_ONLY",
"/home/tms/bin/start_direct_receiver.sh",
false,
"DIRECT"
);
properties.setRestoreStartCommands(List.of(tmsStart, standardStart, directOnlyStart));
properties.setRestoreStopCommands(List.of(restoreCommand("TMS", "/home/tms/scripts/tms.sh stop", false)));
RecordingInvoker invoker = new RecordingInvoker();
LightweightRestoreApplierLauncher launcher = new LightweightRestoreApplierLauncher(
properties,
@@ -29,7 +46,7 @@ class LightweightRestoreApplierLauncherTest {
);
Path workDir = tempDir.resolve("RRST-001");
launcher.launch(workDir);
launcher.launch(workDir, "ENTERPRISE");
Assertions.assertEquals(List.of(
"/bin/bash",
@@ -48,6 +65,28 @@ class LightweightRestoreApplierLauncherTest {
Assertions.assertTrue(env.contains("export DB_PORT='4000'"));
Assertions.assertTrue(env.contains("export DB_USER='root'"));
Assertions.assertTrue(env.contains("export DB_PASSWORD='p'\\''ass'"));
String stopCommands = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-stop-commands.tsv"));
Assertions.assertTrue(stopCommands.contains("TMS\tfalse\t/home/tms/scripts/tms.sh stop"));
String startCommands = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-start-commands.tsv"));
Assertions.assertTrue(startCommands.contains("TMS\ttrue\t/home/tms/scripts/tms.sh start"));
Assertions.assertTrue(startCommands.contains("STANDARD_APPS\ttrue\t/home/tms/bin/start_standard_apps.sh"));
Assertions.assertFalse(startCommands.contains("DIRECT_ONLY"));
}
private static ConfiguredRestoreCommand restoreCommand(
String code,
String command,
boolean required,
String... productTypes
) {
ConfiguredRestoreCommand item = new ConfiguredRestoreCommand();
item.setCode(code);
item.setCommand(command);
item.setRequired(required);
item.setProductTypes(List.of(productTypes));
return item;
}
private static final class RecordingInvoker implements RestoreCommandInvoker {
@@ -24,6 +24,34 @@ class RestoreCommandInvokerTest {
);
Assertions.assertEquals(0, exitCode);
waitUntilLogContains(logFile, "hello-restore");
Assertions.assertEquals("hello-restore", Files.readString(logFile));
}
@Test
void shouldStartCommandWithoutWaitingForCompletion() {
ProcessBuilderRestoreCommandInvoker invoker = new ProcessBuilderRestoreCommandInvoker();
Path logFile = tempDir.resolve("restore-detached.log");
long started = System.nanoTime();
int exitCode = invoker.run(
List.of("/bin/zsh", "-lc", "sleep 2; printf 'done'"),
logFile,
tempDir
);
long elapsedMillis = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
Assertions.assertEquals(0, exitCode);
Assertions.assertTrue(elapsedMillis < 1000, "restore command should be detached from caller");
}
private static void waitUntilLogContains(Path logFile, String expected) throws Exception {
long deadline = System.currentTimeMillis() + 1000;
while (System.currentTimeMillis() < deadline) {
if (Files.isRegularFile(logFile) && Files.readString(logFile).contains(expected)) {
return;
}
Thread.sleep(20);
}
}
}
@@ -1,6 +1,5 @@
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;
@@ -17,6 +16,7 @@ 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 java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -59,7 +59,6 @@ class ResourceBackupServiceTest {
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties(),
admissionGuard
);
@@ -107,7 +106,6 @@ class ResourceBackupServiceTest {
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties(),
admissionGuard
);
@@ -124,6 +122,62 @@ class ResourceBackupServiceTest {
Assertions.assertNull(saved.getPackagePath());
}
@Test
void shouldPersistRunningTaskWithConfiguredKeyFingerprintsBeforePackaging() {
InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class);
ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class);
ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class);
ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
InitTaskEntity initTask = sampleInitTask();
List<DiscoveredResourceItem> discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true));
ResourcePackageResult packageResult = new ResourcePackageResult();
packageResult.setPackageName("RBKP-20260413-153000-000001.tmsbak");
packageResult.setPackagePath("/tmp/resource-backup/RBKP-TASK-20260413-000001/RBKP-20260413-153000-000001.tmsbak");
packageResult.setPackageSize(2048L);
packageResult.setPackageHash("SM3-HASH");
packageResult.setStorageEncKeyFingerprint("PACKAGE-STORAGE-KEY");
packageResult.setAuthenticityKeyFingerprint("PACKAGE-AUTH-KEY");
packageResult.setManifestSummaryJson("{\"resourceCount\":1}");
AtomicReference<String> runningStatus = new AtomicReference<>();
AtomicReference<String> runningStorageFingerprint = new AtomicReference<>();
AtomicReference<String> runningAuthenticityFingerprint = new AtomicReference<>();
Mockito.doAnswer(invocation -> {
ResourceBackupTaskEntity entity = invocation.getArgument(0);
runningStatus.set(entity.getStatus());
runningStorageFingerprint.set(entity.getStorageEncKeyFingerprint());
runningAuthenticityFingerprint.set(entity.getAuthenticityKeyFingerprint());
return null;
}).when(resourceBackupTaskRepository).save(Mockito.any(ResourceBackupTaskEntity.class));
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(),
admissionGuard
);
service.createBackupTask(sampleRequest());
Assertions.assertEquals("RUNNING", runningStatus.get());
Assertions.assertEquals("USER_KEY_ENC:1", runningStorageFingerprint.get());
Assertions.assertEquals("USER_KEY_SIGN:1", runningAuthenticityFingerprint.get());
}
private static InitTaskEntity sampleInitTask() {
InitTaskEntity entity = new InitTaskEntity();
entity.setTaskId("INIT-20260408-000001");
@@ -147,8 +201,6 @@ class ResourceBackupServiceTest {
private static CreateResourceBackupRequest sampleRequest() {
CreateResourceBackupRequest request = new CreateResourceBackupRequest();
request.setIncludeUpgradeHistory(true);
request.setIncludeAuditBackups(false);
request.setRemark("before hardware replacement");
return request;
}
@@ -1,7 +1,6 @@
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.common.config.properties.CisdPresetProperties;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult;
@@ -30,9 +29,6 @@ class ResourcePrecheckLightweightServiceTest {
void shouldExposeLightweightRestorePlanWithoutDecryptingPayload() {
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePackageService packageService = Mockito.mock(ResourcePackageService.class);
ResourceBackupProperties backupProperties = new ResourceBackupProperties();
backupProperties.setStorageEncKeyIndex(7);
backupProperties.setAuthenticitySignKeyIndex(9);
CisdPresetProperties presetProperties = new CisdPresetProperties();
presetProperties.setProductType("ENTERPRISE");
InMemoryResourcePrecheckStateStore store = new InMemoryResourcePrecheckStateStore();
@@ -50,8 +46,7 @@ class ResourcePrecheckLightweightServiceTest {
packageService,
store,
new ResourceTaskIdGenerator(),
presetProperties,
backupProperties
presetProperties
);
ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest();
request.setFileId("FILE-20260429-000001");
@@ -59,6 +54,7 @@ class ResourcePrecheckLightweightServiceTest {
ResourceRestorePrecheckResponse response = service.precheck(request);
Assertions.assertEquals(List.of("JAVA_EXTRACT", "SHELL_APPLY"), response.getRestorePlan());
Assertions.assertTrue(response.getPrecheckPassed());
Assertions.assertFalse(response.getPayloadDecrypted());
Assertions.assertTrue(response.getSignatureValid());
Assertions.assertTrue(store.find(response.getPrecheckId()).isPresent());
@@ -72,8 +68,8 @@ class ResourcePrecheckLightweightServiceTest {
manifest.put("orgCode", "AAAABBBBXXX");
manifest.put("backupTime", "2026-04-29T10:00:00");
manifest.put("resourceCount", 3);
manifest.put("storageEncKeyFingerprint", "USER_KEY_ENC:7");
manifest.put("authenticityKeyFingerprint", "USER_KEY_SIGN:9");
manifest.put("storageEncKeyFingerprint", "USER_KEY_ENC:1");
manifest.put("authenticityKeyFingerprint", "USER_KEY_SIGN:1");
ResourcePackageInspectResult result = new ResourcePackageInspectResult();
result.setManifest(manifest);
return result;
@@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractionResult;
import com.cisd.tms.modules.backup.packagex.ResourceUserKeyRestoreService;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard;
@@ -34,7 +35,7 @@ class ResourceRestoreHandoffServiceTest {
Path tempDir;
@Test
void shouldCreateRestoreTaskExtractPayloadAndLaunchLightweightApplier() throws Exception {
void shouldCreateRestoreTaskExtractPayloadAndWaitForApplyConfirmation() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString());
properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
@@ -70,6 +71,8 @@ class ResourceRestoreHandoffServiceTest {
Mockito.when(payloadExtractor.extract(Mockito.eq(packagePath), Mockito.any(Path.class)))
.thenAnswer(invocation -> {
Path taskDir = invocation.getArgument(1);
Files.createDirectories(taskDir);
Files.writeString(taskDir.resolve("restore-context.json"), "{\"schemaVersion\":\"1.0\"}");
ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult();
result.setPayloadDir(taskDir.resolve("payload"));
return result;
@@ -84,13 +87,12 @@ class ResourceRestoreHandoffServiceTest {
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
admissionGuard,
new ObjectMapper(),
new ActiveRestoreStateStore(properties, new ObjectMapper())
);
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
request.setPrecheckId("RPRE-20260413-000001");
request.setConfirmBackupId("RBKP-20260413-153000-000001");
request.setConfirmRiskAccepted(true);
CreateResourceRestoreRequest request = validCreateRequest();
CreateResourceRestoreResponse response = service.createRestoreTask(request);
@@ -100,14 +102,77 @@ class ResourceRestoreHandoffServiceTest {
Mockito.verify(repository).save(captor.capture());
ResourceRestoreTaskEntity saved = captor.getValue();
Assertions.assertEquals(packagePath.toString(), saved.getPackagePath());
Assertions.assertEquals("APPLYING", saved.getStatus());
Assertions.assertEquals("READY_TO_APPLY", saved.getStatus());
Assertions.assertEquals(taskDir.resolve("payload").toString(), saved.getStagingDir());
Assertions.assertEquals(taskDir.resolve("status.json").toString(), saved.getRunnerStatePath());
Assertions.assertEquals("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]", saved.getRestorePlanJson());
String restoreContext = Files.readString(taskDir.resolve("restore-context.json"));
Assertions.assertTrue(restoreContext.contains("\"targetDeployMode\" : \"DUAL\""));
Assertions.assertTrue(restoreContext.contains("\"node01Ip\" : \"192.168.1.11\""));
Assertions.assertTrue(restoreContext.contains("\"node02Ip\" : \"192.168.1.12\""));
Mockito.verify(payloadExtractor).extract(packagePath, taskDir);
Mockito.verify(userKeyRestoreService).recover(taskDir.resolve("payload"));
Mockito.verify(applierLauncher).launch(taskDir);
Mockito.verifyNoInteractions(applierLauncher);
String activeRestore = Files.readString(tempDir.resolve("resource-restore").resolve("active-restore.json"));
Assertions.assertTrue(activeRestore.contains("\"taskId\" : \"" + response.getTaskId() + "\""));
Assertions.assertTrue(activeRestore.contains("\"status\" : \"READY_TO_APPLY\""));
}
@Test
void shouldApplyPreparedRestoreTaskAndLaunchLightweightApplier() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
Path taskDir = tempDir.resolve("resource-restore").resolve("RRST-TASK-20260413-000001");
Files.createDirectories(taskDir);
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260413-000001");
entity.setPrecheckId("RPRE-20260413-000001");
entity.setBackupId("RBKP-20260413-153000-000001");
entity.setStatus("READY_TO_APPLY");
entity.setSourceProductType("ENTERPRISE");
entity.setTargetProductType("ENTERPRISE");
entity.setPackageFileId("FILE-20260413-000001");
entity.setPackagePath(tempDir.resolve("download").resolve("RBKP.tmsbak").toString());
entity.setStagingDir(taskDir.resolve("payload").toString());
entity.setRunnerStatePath(taskDir.resolve("status.json").toString());
entity.setStorageEncKeyFingerprint("USER_KEY_ENC:1");
entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:1");
entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]");
entity.setCreateBy("SYSTEM");
entity.setCreateTime(LocalDateTime.parse("2026-04-13T15:30:00"));
Mockito.when(repository.findByTaskId("RRST-TASK-20260413-000001")).thenReturn(java.util.Optional.of(entity));
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
fileRecordRepository,
new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper()),
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard,
new ObjectMapper(),
new ActiveRestoreStateStore(properties, new ObjectMapper())
);
CreateResourceRestoreResponse response = service.applyRestoreTask("RRST-TASK-20260413-000001");
Assertions.assertEquals("RRST-TASK-20260413-000001", response.getTaskId());
Assertions.assertEquals("APPLYING", response.getStatus());
Mockito.verify(repository).update(entity);
Mockito.verify(applierLauncher).launch(taskDir, "ENTERPRISE");
Assertions.assertEquals("APPLYING", entity.getStatus());
Assertions.assertNotNull(entity.getStartTime());
String activeRestore = Files.readString(tempDir.resolve("resource-restore").resolve("active-restore.json"));
Assertions.assertTrue(activeRestore.contains("\"status\" : \"APPLYING\""));
}
@Test
@@ -134,7 +199,9 @@ class ResourceRestoreHandoffServiceTest {
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
admissionGuard,
new ObjectMapper(),
new ActiveRestoreStateStore(properties, new ObjectMapper())
);
BizException exception = Assertions.assertThrows(BizException.class,
@@ -168,7 +235,9 @@ class ResourceRestoreHandoffServiceTest {
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
admissionGuard,
new ObjectMapper(),
new ActiveRestoreStateStore(properties, new ObjectMapper())
);
BizException exception = Assertions.assertThrows(BizException.class,
@@ -178,6 +247,42 @@ class ResourceRestoreHandoffServiceTest {
Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher);
}
@Test
void shouldRejectDualRestoreWhenNode02IpIsMissing() {
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);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
store.save(new ResourcePrecheckContext("FILE-20260413-000001", validPrecheckResponse()));
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
fileRecordRepository,
store,
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard,
new ObjectMapper(),
new ActiveRestoreStateStore(properties, new ObjectMapper())
);
CreateResourceRestoreRequest request = validCreateRequest();
request.getNodes().setNode02Ip("");
BizException exception = Assertions.assertThrows(BizException.class,
() -> service.createRestoreTask(request));
Assertions.assertTrue(exception.getMessage().contains("nodes.node02Ip不能为空"));
Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher);
}
private static ResourceRestorePrecheckResponse validPrecheckResponse() {
ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse();
precheckResponse.setPrecheckId("RPRE-20260413-000001");
@@ -196,7 +301,11 @@ class ResourceRestoreHandoffServiceTest {
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
request.setPrecheckId("RPRE-20260413-000001");
request.setConfirmBackupId("RBKP-20260413-153000-000001");
request.setConfirmRiskAccepted(true);
request.setDeployMode("DUAL");
CreateResourceRestoreRequest.Nodes nodes = new CreateResourceRestoreRequest.Nodes();
nodes.setNode01Ip("192.168.1.11");
nodes.setNode02Ip("192.168.1.12");
request.setNodes(nodes);
return request;
}
}
@@ -1,16 +1,16 @@
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader;
import com.cisd.tms.modules.backup.service.impl.ResourceTaskQueryServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -23,74 +23,42 @@ class ResourceTaskQueryServiceTest {
Path tempDir;
@Test
void shouldPreferLightweightStatusFileWhenPresent() throws Exception {
void shouldReturnLatestRestoreFromActiveFilesAndRepairMissingDbRecord() throws Exception {
ResourceBackupProperties properties = resourceBackupProperties();
Path taskDir = tempDir.resolve("RRST-TASK-20260429-000001");
Files.createDirectories(taskDir);
Files.writeString(taskDir.resolve("status.json"), """
{
"status":"RUNNING",
"phase":"MQ_REPLAY",
"message":"replaying mq",
"updatedAt":"2026-04-29T10:00:00+0800"
"status":"FAILED",
"phase":"DATABASE",
"message":"数据库恢复失败",
"updatedAt":"2026-04-29T10:05:00+0800"
}
""");
Files.writeString(taskDir.resolve("restore.log"), "line1\nline2\n");
ActiveRestoreStateStore activeStore = new ActiveRestoreStateStore(properties, new ObjectMapper());
ResourceRestoreTaskEntity entity = restoreTask(taskDir);
activeStore.write(entity, "APPLYING");
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.of(entity));
Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.empty());
ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl(
repository,
new ObjectMapper(),
new RestoreStatusFileReader(new ObjectMapper())
new RestoreStatusFileReader(new ObjectMapper()),
activeStore
);
var detail = service.getRestoreTask("RRST-TASK-20260429-000001");
ResourceRestoreLatestResponse latest = service.getLatestRestore();
Assertions.assertEquals("RUNNING", detail.getStatus());
Assertions.assertEquals("MQ_REPLAY", detail.getControlPhase());
Assertions.assertEquals("SHELL_STATUS", detail.getStateSource());
Assertions.assertEquals("MQ_REPLAY", detail.getCurrentStepCode());
Assertions.assertEquals("2026-04-29T10:00:00+0800", detail.getLastHeartbeatAt());
Assertions.assertEquals("replaying mq", detail.getErrorMessage());
}
@Test
void shouldExposeLightweightStepsAndRestoreLog() throws Exception {
Path taskDir = tempDir.resolve("RRST-TASK-20260429-000001");
Files.createDirectories(taskDir);
Files.writeString(taskDir.resolve("status.json"), """
{
"status":"RUNNING",
"phase":"MQ_REPLAY",
"message":"replaying mq",
"updatedAt":"2026-04-29T10:00:00+0800"
}
""");
Files.writeString(taskDir.resolve("restore.log"), "line1\nline2\n");
ResourceRestoreTaskEntity entity = restoreTask(taskDir);
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.of(entity));
ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl(
repository,
new ObjectMapper(),
new RestoreStatusFileReader(new ObjectMapper())
);
List<ResourceTaskStepItemResponse> steps = service.getRestoreTaskSteps("RRST-TASK-20260429-000001");
Assertions.assertEquals(2, steps.size());
Assertions.assertEquals("SUCCESS", steps.get(0).getStatus());
Assertions.assertEquals("RUNNING", steps.get(1).getStatus());
Assertions.assertEquals("replaying mq", steps.get(1).getMessage());
Assertions.assertEquals(Boolean.TRUE, steps.get(1).getLogAvailable());
ResourceTaskStepLogResponse log = service.getRestoreTaskStepLog("RRST-TASK-20260429-000001", 2);
Assertions.assertEquals("SHELL_APPLY", log.getStepCode());
Assertions.assertEquals("line1\nline2\n", log.getContent());
Assertions.assertFalse(log.getTruncated());
Assertions.assertEquals("RRST-TASK-20260429-000001", latest.getTaskId());
Assertions.assertEquals("RBKP-20260429-153000-000001", latest.getBackupId());
Assertions.assertEquals("FAILED", latest.getStatus());
Assertions.assertEquals("DATABASE", latest.getPhase());
Assertions.assertEquals("数据库恢复失败", latest.getMessage());
Assertions.assertEquals("2026-04-29T10:05:00+0800", latest.getFinishedAt());
Mockito.verify(repository).save(Mockito.argThat(repaired ->
"RRST-TASK-20260429-000001".equals(repaired.getTaskId())
&& "FAILED".equals(repaired.getStatus())
&& "数据库恢复失败".equals(repaired.getErrorMessage())
));
}
private static ResourceRestoreTaskEntity restoreTask(Path taskDir) {
@@ -108,4 +76,10 @@ class ResourceTaskQueryServiceTest {
entity.setCreateTime(LocalDateTime.parse("2026-04-29T09:59:00"));
return entity;
}
private ResourceBackupProperties resourceBackupProperties() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreTaskRootDir(tempDir.toString());
return properties;
}
}