feat:备份恢复功能

This commit is contained in:
waner
2026-04-29 16:45:52 +08:00
parent cd647e8b00
commit ae72769741
86 changed files with 2648 additions and 3250 deletions
@@ -1,5 +1,8 @@
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 java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "tms.backup")
@@ -9,18 +12,24 @@ public class ResourceBackupProperties {
private String precheckStoreDir = "/home/tms/tmp/resource-restore-precheck";
private String restoreTaskRootDir = "/home/tms/tmp/resource-restore";
private String tmsScriptPath = "/home/tms/scripts/tms.sh";
private String runnerLaunchScriptPath = "/home/tms/bin/resource-restore/start-runner.sh";
private String mysqldumpPath = "mysqldump";
private String mysqlPath = "mysql";
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private String mqReplayScriptPath = "/home/tms/bin/resource-restore/replay-mq.sh";
private String tmsDatabaseName = "";
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
private int healthCheckTimeoutSeconds = 5;
private boolean runnerAutoLaunchEnabled = false;
private int runnerHeartbeatTimeoutSeconds = 300;
private int storageEncKeyIndex = 1;
private int authenticitySignKeyIndex = 1;
private String restoreApplyScriptPath = "/home/tms/bin/resource-restore/apply-resource-backup.sh";
private List<String> allowedRestoreRoots = List.of(
"/home/tms/config",
"/home/cmep4i",
"/home/cmep4d",
"/home/tms/tmp/resource-restore"
);
private List<ConfiguredFileBackupResource> fileResources = List.of();
public String getOutputDir() {
return outputDir;
@@ -54,14 +63,6 @@ public class ResourceBackupProperties {
this.tmsScriptPath = tmsScriptPath;
}
public String getRunnerLaunchScriptPath() {
return runnerLaunchScriptPath;
}
public void setRunnerLaunchScriptPath(String runnerLaunchScriptPath) {
this.runnerLaunchScriptPath = runnerLaunchScriptPath;
}
public String getMysqldumpPath() {
return mysqldumpPath;
}
@@ -86,6 +87,14 @@ public class ResourceBackupProperties {
this.restoreDbScriptPath = restoreDbScriptPath;
}
public String getMqReplayScriptPath() {
return mqReplayScriptPath;
}
public void setMqReplayScriptPath(String mqReplayScriptPath) {
this.mqReplayScriptPath = mqReplayScriptPath;
}
public String getTmsDatabaseName() {
return tmsDatabaseName;
}
@@ -118,22 +127,6 @@ public class ResourceBackupProperties {
this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds;
}
public boolean getRunnerAutoLaunchEnabled() {
return runnerAutoLaunchEnabled;
}
public void setRunnerAutoLaunchEnabled(boolean runnerAutoLaunchEnabled) {
this.runnerAutoLaunchEnabled = runnerAutoLaunchEnabled;
}
public int getRunnerHeartbeatTimeoutSeconds() {
return runnerHeartbeatTimeoutSeconds;
}
public void setRunnerHeartbeatTimeoutSeconds(int runnerHeartbeatTimeoutSeconds) {
this.runnerHeartbeatTimeoutSeconds = runnerHeartbeatTimeoutSeconds;
}
public int getStorageEncKeyIndex() {
return storageEncKeyIndex;
}
@@ -149,4 +142,58 @@ public class ResourceBackupProperties {
public void setAuthenticitySignKeyIndex(int authenticitySignKeyIndex) {
this.authenticitySignKeyIndex = authenticitySignKeyIndex;
}
public String getRestoreApplyScriptPath() {
return restoreApplyScriptPath;
}
public void setRestoreApplyScriptPath(String restoreApplyScriptPath) {
this.restoreApplyScriptPath = restoreApplyScriptPath;
}
public List<String> getAllowedRestoreRoots() {
return allowedRestoreRoots;
}
public void setAllowedRestoreRoots(List<String> allowedRestoreRoots) {
this.allowedRestoreRoots = allowedRestoreRoots;
}
public List<ConfiguredFileBackupResource> getFileResources() {
return fileResources;
}
public void setFileResources(List<ConfiguredFileBackupResource> fileResources) {
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;
}
}
@@ -4,7 +4,6 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse;
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.service.ResourceBackupService;
@@ -39,12 +38,6 @@ public class ResourceBackupController {
private final ResourceBackupService resourceBackupService;
@GetMapping("/keyset/status")
@Operation(summary = "查询资源备份密钥状态", description = "返回资源备份恢复前置密钥状态和公钥指纹。")
public ApiResponse<ResourceKeysetStatusResponse> keysetStatus() {
return ApiResponse.success(resourceBackupService.getKeysetStatus());
}
@PostMapping
@ReplayProtected
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
@@ -7,8 +7,8 @@ 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.service.PcieCryptoService;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -75,6 +75,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return sealedPayload;
} catch (Exception ex) {
throw new IllegalStateException("failed to seal resource payload", ex);
} finally {
wipe(sm4Key);
}
}
@@ -128,7 +130,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
decryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex());
decryptRequest.setCipherBlob(wrappedDek);
decryptRequest.setOutBufferSize(dekLength);
byte[] sm4Key = normalize(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest));
byte[] sm4Key = normalizeAndWipe(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest));
byte[] iv = Base64.getDecoder().decode(String.valueOf(envelope.get("ivBase64")));
try {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", PROVIDER);
@@ -136,6 +138,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return cipher.doFinal(cipherBytes);
} catch (Exception ex) {
throw new IllegalStateException("failed to unseal resource payload", ex);
} finally {
wipe(sm4Key);
}
}
@@ -165,6 +169,16 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return normalized;
}
private static byte[] normalizeAndWipe(BackupDataResult result) {
try {
return normalize(result);
} finally {
if (result != null) {
wipe(result.getData());
}
}
}
private static int parseInt(Object value, int defaultValue) {
if (value == null) {
return defaultValue;
@@ -182,4 +196,10 @@ 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);
}
}
}
@@ -28,24 +28,15 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
}
List<DiscoveredResourceItem> items = new ArrayList<>();
items.add(item("FILE", "TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), true));
items.add(item("DB", "TMS_DB_DUMP", "TMS", true));
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
items.add(database("TMS_DB", "TMS", "db/TMS.sql", true));
if (!"DIRECT".equals(productType)) {
// 第一版标准版迁移需要把收发器业务库一起带走,DIRECT 暂时不要求这份库。
items.add(item("DB", "CMEP_DB_DUMP", "CMEP", true));
}
items.add(item("FILE", "CP_CONFIG", resolveCpConfigPath(productType), true));
items.add(item("FILE", "RECEIVER_LICENSE", resolveReceiverLicensePath(productType), true));
if (mqType.startsWith("RABBITMQ")) {
items.add(item("MQ", "RABBITMQ_DEFINITIONS", initExecutorProperties.getRabbitmqAdminCommand(), true));
}
if ("TLQ".equals(mqType) || "RABBITMQ_TLQ".equals(mqType)) {
items.add(item("FILE", "TLQ_LICENSE", resolveTlqLicensePath(productType), true));
}
if ("RABBITMQ_CFMQ".equals(mqType)) {
items.add(item("FILE", "CFMQ_CONFIG", initExecutorProperties.getCfmqTargetDir(), true));
items.add(database("CMEP_DB", "CMEP", "db/CMEP.sql", true));
}
items.add(mqReplay(true));
return items;
}
@@ -63,18 +54,33 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
return initExecutorProperties.getStandardReceiverLicenseTargetPath();
}
private String resolveTlqLicensePath(String productType) {
if ("DIRECT".equals(productType)) {
return initExecutorProperties.getDirectTlqLicenseTargetPath();
}
return initExecutorProperties.getStandardTlqLicenseTargetPath();
}
private static DiscoveredResourceItem item(String type, String code, String path, boolean required) {
private static DiscoveredResourceItem file(String code, String path, String restorePath, String mode, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType(type);
item.setResourceType("FILE");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRestorePath(restorePath);
item.setMode(mode);
item.setRequired(required);
return item;
}
private static DiscoveredResourceItem database(String code, String databaseName, String entryPath, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DATABASE");
item.setResourceCode(code);
item.setDatabaseName(databaseName);
item.setResolvedPath(databaseName);
item.setEntryPath(entryPath);
item.setRequired(required);
return item;
}
private static DiscoveredResourceItem mqReplay(boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("MQ_REPLAY");
item.setResourceCode("MQ_REPLAY");
item.setEntryPath("mq/mq-restore-context.json");
item.setRequired(required);
return item;
}
@@ -21,7 +21,7 @@ public class ResourceRestoreTaskDetailResponse {
private String currentStepCode;
private Integer currentStepNo;
private String lastHeartbeatAt;
private String runnerStatePath;
private String statusFilePath;
private Boolean orphaned;
private List<String> cleanupWarnings;
private List<String> warnings;
@@ -155,12 +155,12 @@ public class ResourceRestoreTaskDetailResponse {
this.lastHeartbeatAt = lastHeartbeatAt;
}
public String getRunnerStatePath() {
return runnerStatePath;
public String getStatusFilePath() {
return statusFilePath;
}
public void setRunnerStatePath(String runnerStatePath) {
this.runnerStatePath = runnerStatePath;
public void setStatusFilePath(String statusFilePath) {
this.statusFilePath = statusFilePath;
}
public Boolean getOrphaned() {
@@ -0,0 +1,7 @@
package com.cisd.tms.modules.backup.dto.support;
public enum BackupResourceKind {
FILE,
DATABASE,
MQ_REPLAY
}
@@ -0,0 +1,59 @@
package com.cisd.tms.modules.backup.dto.support;
public class ConfiguredFileBackupResource {
private String code;
private String kind = BackupResourceKind.FILE.name();
private String mode = "FILE";
private String path;
private String restorePath;
private boolean required = true;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getKind() {
return kind;
}
public void setKind(String kind) {
this.kind = kind;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getRestorePath() {
return restorePath;
}
public void setRestorePath(String restorePath) {
this.restorePath = restorePath;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
}
@@ -5,6 +5,10 @@ public class DiscoveredResourceItem {
private String resourceType;
private String resourceCode;
private String resolvedPath;
private String restorePath;
private String mode;
private String databaseName;
private String entryPath;
private boolean required;
public String getResourceType() {
@@ -31,6 +35,38 @@ public class DiscoveredResourceItem {
this.resolvedPath = resolvedPath;
}
public String getRestorePath() {
return restorePath;
}
public void setRestorePath(String restorePath) {
this.restorePath = restorePath;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getDatabaseName() {
return databaseName;
}
public void setDatabaseName(String databaseName) {
this.databaseName = databaseName;
}
public String getEntryPath() {
return entryPath;
}
public void setEntryPath(String entryPath) {
this.entryPath = entryPath;
}
public boolean isRequired() {
return required;
}
@@ -5,8 +5,7 @@ package com.cisd.tms.modules.backup.enums;
*/
public enum ResourceControlPhase {
TMS_PRECHECK,
HANDOFF_READY,
RUNNER_OWNED,
RECONCILE_PENDING,
JAVA_EXTRACT,
SHELL_APPLY,
DONE
}
@@ -8,8 +8,7 @@ public enum ResourceTaskStatus {
PRECHECKING,
PRECHECK_FAILED,
READY,
HANDOFF,
RUNNING,
APPLYING,
VERIFYING,
SUCCESS,
FAILED,
@@ -0,0 +1,77 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class MqReplayContextBuilder {
private final ObjectMapper objectMapper;
public MqReplayContextBuilder(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public byte[] build(InitTaskEntity task) {
if (task == null || isBlank(task.getInitPlanJson())) {
throw new IllegalArgumentException("initPlanJson is empty");
}
try {
JsonNode root = objectMapper.readTree(task.getInitPlanJson());
JsonNode request = root.path("request");
JsonNode nodes = request.path("nodes");
JsonNode mq = request.path("mq");
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("schemaVersion", "1.0");
payload.put("productType", firstNonBlank(text(root, "productType"), task.getProductType()));
payload.put("mqType", firstNonBlank(text(mq, "mqType"), text(root, "mqType"), task.getMqType()));
payload.put("orgCodeType", text(request, "orgCodeType"));
payload.put("orgCode", firstNonBlank(text(request, "orgCode"), text(root, "orgCode"), task.getOrgCode()));
payload.put("orgNameCn", text(request, "orgNameCn"));
payload.put("orgNameEn", text(request, "orgNameEn"));
payload.put("deployMode", text(request, "deployMode"));
Map<String, Object> nodeValues = new LinkedHashMap<>();
nodeValues.put("node01Ip", text(nodes, "node01Ip"));
nodeValues.put("node02Ip", text(nodes, "node02Ip"));
nodeValues.put("node03Ip", text(nodes, "node03Ip"));
nodeValues.put("node04Ip", text(nodes, "node04Ip"));
payload.put("nodes", nodeValues);
Map<String, Object> mqValues = new LinkedHashMap<>();
mqValues.put("channelUsername", text(mq, "channelUsername"));
mqValues.put("channelPassword", text(mq, "channelPassword"));
mqValues.put("cipsGatewayHost", text(mq, "cipsGatewayHost"));
mqValues.put("cfgZipResourceCode", "");
mqValues.put("cfmqConfigResourceCode", "");
payload.put("mq", mqValues);
return objectMapper.writeValueAsBytes(payload);
} catch (Exception ex) {
throw new IllegalArgumentException("failed to build mq replay context", ex);
}
}
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.path(field);
return value == null || value.isMissingNode() || value.isNull() ? "" : value.asText("");
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (!isBlank(value)) {
return value.trim();
}
}
return "";
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -37,6 +37,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
private final DatabaseBackupCollector databaseBackupCollector;
private final MqReplayContextBuilder mqReplayContextBuilder;
private final ObjectMapper objectMapper;
public ResourcePackageServiceImpl(
@@ -44,10 +45,21 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, resourceEnvelopeCryptoService, databaseBackupCollector, new MqReplayContextBuilder(objectMapper), objectMapper);
}
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
MqReplayContextBuilder mqReplayContextBuilder,
ObjectMapper objectMapper
) {
this.resourceBackupProperties = resourceBackupProperties;
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
this.databaseBackupCollector = databaseBackupCollector;
this.mqReplayContextBuilder = mqReplayContextBuilder;
this.objectMapper = objectMapper;
}
@@ -225,7 +237,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
resourceEntry.put("resolvedPath", item.getResolvedPath());
resourceEntry.put("required", item.isRequired());
int collectedFiles = collectResourceItem(zipOutputStream, item, resourceEntry);
int collectedFiles = collectResourceItem(zipOutputStream, initTask, item, resourceEntry);
if (collectedFiles > 0) {
collectedCount++;
}
@@ -243,18 +255,26 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private int collectResourceItem(
ZipOutputStream zipOutputStream,
InitTaskEntity initTask,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
if ("DB".equalsIgnoreCase(trim(item.getResourceType()))) {
if ("DATABASE".equalsIgnoreCase(trim(item.getResourceType())) || "DB".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
}
if ("MQ_REPLAY".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectMqReplayItem(zipOutputStream, initTask, item, resourceEntry);
}
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
resourceEntry.put("kind", "FILE");
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());
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
@@ -328,8 +348,10 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
fileEntry.put("contentSm3", sm3Hex(content));
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("kind", "DATABASE");
resourceEntry.put("fileCount", 1);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("database", databaseName);
resourceEntry.put("databaseName", databaseName);
resourceEntry.put("files", List.of(fileEntry));
return 1;
@@ -341,12 +363,28 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
}
}
private int collectMqReplayItem(
ZipOutputStream zipOutputStream,
InitTaskEntity initTask,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
byte[] contextBytes = mqReplayContextBuilder.build(initTask);
String entryPath = trim(item.getEntryPath()).isEmpty() ? "mq/mq-restore-context.json" : trim(item.getEntryPath());
addZipEntry(zipOutputStream, entryPath, contextBytes);
resourceEntry.put("kind", "MQ_REPLAY");
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("contentSm3", sm3Hex(contextBytes));
return 1;
}
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
String resourceCode = trim(item.getResourceCode());
if ("TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
if ("TMS_DB".equalsIgnoreCase(resourceCode) || "TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectTmsDatabaseDump();
}
if ("CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
if ("CMEP_DB".equalsIgnoreCase(resourceCode) || "CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectCmepDatabaseDump();
}
return null;
@@ -0,0 +1,43 @@
package com.cisd.tms.modules.backup.packagex;
import java.nio.file.Path;
public class ResourcePayloadExtractionResult {
private Path workDir;
private Path payloadDir;
private Path manifestPath;
private Path restoreContextPath;
public Path getWorkDir() {
return workDir;
}
public void setWorkDir(Path workDir) {
this.workDir = workDir;
}
public Path getPayloadDir() {
return payloadDir;
}
public void setPayloadDir(Path payloadDir) {
this.payloadDir = payloadDir;
}
public Path getManifestPath() {
return manifestPath;
}
public void setManifestPath(Path manifestPath) {
this.manifestPath = manifestPath;
}
public Path getRestoreContextPath() {
return restoreContextPath;
}
public void setRestoreContextPath(Path restoreContextPath) {
this.restoreContextPath = restoreContextPath;
}
}
@@ -0,0 +1,327 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@Component
public class ResourcePayloadExtractor {
private final ResourcePackageService resourcePackageService;
private final ObjectMapper objectMapper;
private final ResourceBackupProperties properties;
public ResourcePayloadExtractor(
ResourcePackageService resourcePackageService,
ObjectMapper objectMapper,
ResourceBackupProperties properties
) {
this.resourcePackageService = resourcePackageService;
this.objectMapper = objectMapper;
this.properties = properties;
}
public ResourcePayloadExtractionResult extract(Path packagePath, Path workDir) {
Path normalizedWorkDir = workDir.normalize();
Path payloadDir = normalizedWorkDir.resolve("payload").normalize();
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
.orElseThrow(() -> new IllegalStateException("payload archive bytes missing"));
extractPayloadArchive(payloadArchiveBytes, payloadDir);
Path manifestPath = payloadDir.resolve("payload-manifest.json").normalize();
if (!Files.isRegularFile(manifestPath)) {
throw new IllegalStateException("payload manifest missing: " + manifestPath);
}
Map<String, Object> manifest = readManifest(manifestPath);
validateRestoreTargets(payloadDir, manifest);
writeShellPlans(normalizedWorkDir, manifest);
Path restoreContextPath = writeRestoreContext(packagePath, normalizedWorkDir, payloadDir, manifestPath, manifest);
ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult();
result.setWorkDir(normalizedWorkDir);
result.setPayloadDir(payloadDir);
result.setManifestPath(manifestPath);
result.setRestoreContextPath(restoreContextPath);
return result;
}
private void extractPayloadArchive(byte[] payloadArchiveBytes, Path payloadDir) {
try {
Files.createDirectories(payloadDir);
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(payloadArchiveBytes))) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
Path target = resolveSafePayloadTarget(payloadDir, entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.write(target, readAllBytes(zipInputStream));
}
zipInputStream.closeEntry();
}
}
} catch (IOException ex) {
throw new IllegalStateException("failed to extract payload archive", ex);
}
}
private Path resolveSafePayloadTarget(Path payloadDir, String entryName) {
Path target = payloadDir.resolve(entryName).normalize();
if (!target.startsWith(payloadDir)) {
throw new IllegalStateException("payload archive entry escapes payload dir: " + entryName);
}
return target;
}
private Map<String, Object> readManifest(Path manifestPath) {
try {
return objectMapper.readValue(manifestPath.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (IOException ex) {
throw new IllegalStateException("failed to read payload manifest: " + manifestPath, ex);
}
}
private void validateRestoreTargets(Path payloadDir, Map<String, Object> manifest) {
List<Path> allowedRoots = allowedRestoreRoots();
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return;
}
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
if (!"FILE".equalsIgnoreCase(kindOf(resource))) {
continue;
}
String resourceRestorePath = firstText(resource, "restorePath", "resolvedPath");
validateAllowedRestorePath(resourceRestorePath, allowedRoots);
validateFileEntries(payloadDir, resource, resourceRestorePath, allowedRoots);
}
}
private void writeShellPlans(Path workDir, Map<String, Object> manifest) {
List<String> fileLines = new ArrayList<>();
List<String> databaseLines = new ArrayList<>();
List<String> mqLines = new ArrayList<>();
Object resourcesValue = manifest.get("resources");
if (resourcesValue instanceof List<?> resources) {
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
String kind = kindOf(resource);
if ("FILE".equalsIgnoreCase(kind)) {
appendFilePlanLines(fileLines, resource);
} else if ("DATABASE".equalsIgnoreCase(kind) || "DB".equalsIgnoreCase(kind)) {
String entryPath = firstText(resource, "entryPath");
String databaseName = normalizeDatabaseName(firstText(resource, "databaseName", "database", "resourceCode", "code"));
if (!entryPath.isEmpty()) {
databaseLines.add(entryPath + "\t" + databaseName);
}
} else if ("MQ_REPLAY".equalsIgnoreCase(kind)) {
String entryPath = firstText(resource, "entryPath");
if (!entryPath.isEmpty()) {
mqLines.add(entryPath);
}
}
}
}
try {
Files.writeString(workDir.resolve("restore-files.tsv"), String.join(System.lineSeparator(), fileLines)
+ (fileLines.isEmpty() ? "" : System.lineSeparator()));
Files.writeString(workDir.resolve("restore-databases.tsv"), String.join(System.lineSeparator(), databaseLines)
+ (databaseLines.isEmpty() ? "" : System.lineSeparator()));
Files.writeString(workDir.resolve("restore-mq.tsv"), String.join(System.lineSeparator(), mqLines)
+ (mqLines.isEmpty() ? "" : System.lineSeparator()));
} catch (IOException ex) {
throw new IllegalStateException("failed to write shell restore plans", ex);
}
}
private void appendFilePlanLines(List<String> fileLines, Map<String, Object> resource) {
String resourceRestorePath = firstText(resource, "restorePath", "resolvedPath");
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> files)) {
return;
}
for (Object fileObject : files) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
String entryPath = firstText(fileEntry, "entryPath");
if (entryPath.isEmpty()) {
continue;
}
String restorePath = firstText(fileEntry, "restorePath");
if (restorePath.isEmpty()) {
restorePath = inferFileRestorePath(resource, resourceRestorePath, fileEntry);
}
if (!restorePath.isEmpty()) {
fileLines.add(entryPath + "\t" + restorePath);
}
}
}
private void validateFileEntries(
Path payloadDir,
Map<String, Object> resource,
String resourceRestorePath,
List<Path> allowedRoots
) {
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> files)) {
return;
}
for (Object fileObject : files) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
validatePayloadEntryPath(payloadDir, firstText(fileEntry, "entryPath"));
String fileRestorePath = firstText(fileEntry, "restorePath");
if (fileRestorePath.isEmpty()) {
fileRestorePath = inferFileRestorePath(resource, resourceRestorePath, fileEntry);
}
validateAllowedRestorePath(fileRestorePath, allowedRoots);
}
}
private void validatePayloadEntryPath(Path payloadDir, String entryPath) {
if (entryPath.isEmpty()) {
return;
}
Path sourceFile = payloadDir.resolve(entryPath).normalize();
if (!sourceFile.startsWith(payloadDir)) {
throw new IllegalStateException("payload manifest entry escapes payload dir: " + entryPath);
}
}
private String inferFileRestorePath(Map<String, Object> resource, String resourceRestorePath, Map<String, Object> fileEntry) {
if (resourceRestorePath.isEmpty()) {
return "";
}
String relativeName = firstText(fileEntry, "relativeName");
String mode = firstText(resource, "mode");
if (!relativeName.isEmpty() && "DIR".equalsIgnoreCase(mode)) {
return Path.of(resourceRestorePath).resolve(relativeName).normalize().toString();
}
return resourceRestorePath;
}
private void validateAllowedRestorePath(String restorePath, List<Path> allowedRoots) {
if (restorePath.isEmpty()) {
return;
}
Path target = Path.of(restorePath).normalize().toAbsolutePath();
boolean allowed = allowedRoots.stream().anyMatch(target::startsWith);
if (!allowed) {
throw new IllegalStateException("restore path is not allowed: " + restorePath);
}
}
private List<Path> allowedRestoreRoots() {
List<String> configuredRoots = properties.getAllowedRestoreRoots();
if (configuredRoots == null || configuredRoots.isEmpty()) {
return List.of();
}
List<Path> roots = new ArrayList<>();
for (String configuredRoot : configuredRoots) {
String root = trim(configuredRoot);
if (!root.isEmpty()) {
roots.add(Path.of(root).normalize().toAbsolutePath());
}
}
return roots;
}
private Path writeRestoreContext(
Path packagePath,
Path workDir,
Path payloadDir,
Path manifestPath,
Map<String, Object> manifest
) {
try {
Files.createDirectories(workDir);
Path restoreContextPath = workDir.resolve("restore-context.json").normalize();
Map<String, Object> context = new LinkedHashMap<>();
context.put("schemaVersion", "1.0");
context.put("createdAt", Instant.now().toString());
context.put("packagePath", packagePath.normalize().toString());
context.put("workDir", workDir.toString());
context.put("payloadDir", payloadDir.toString());
context.put("manifestPath", manifestPath.toString());
context.put("payloadManifest", manifest);
objectMapper.writerWithDefaultPrettyPrinter().writeValue(restoreContextPath.toFile(), context);
return restoreContextPath;
} catch (IOException ex) {
throw new IllegalStateException("failed to write restore context", ex);
}
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static String kindOf(Map<String, Object> resource) {
String kind = firstText(resource, "kind");
if (!kind.isEmpty()) {
return kind;
}
return firstText(resource, "resourceType");
}
private static String firstText(Map<String, Object> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value != null) {
String text = trim(String.valueOf(value));
if (!text.isEmpty() && !"null".equalsIgnoreCase(text)) {
return text;
}
}
}
return "";
}
private static String normalizeDatabaseName(String value) {
if ("TMS_DB".equalsIgnoreCase(value) || "TMS_DB_DUMP".equalsIgnoreCase(value)) {
return "TMS";
}
if ("CMEP_DB".equalsIgnoreCase(value) || "CMEP_DB_DUMP".equalsIgnoreCase(value)) {
return "CMEP";
}
return value;
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
return inputStream.readAllBytes();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,108 @@
package com.cisd.tms.modules.backup.restore;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class LightweightRestoreApplierLauncher {
private final ResourceBackupProperties properties;
private final RestoreCommandInvoker invoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public LightweightRestoreApplierLauncher(
ResourceBackupProperties properties,
RestoreCommandInvoker invoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.properties = properties;
this.invoker = invoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
public void launch(Path workDir) {
Path normalizedWorkDir = workDir.toAbsolutePath().normalize();
writeRestoreEnv(normalizedWorkDir);
Path logFile = normalizedWorkDir.resolve("restore-launch.log");
List<String> command = List.of(
"/bin/bash",
properties.getRestoreApplyScriptPath(),
"--work-dir",
normalizedWorkDir.toString()
);
int exitCode = invoker.run(command, logFile, normalizedWorkDir);
if (exitCode != 0) {
throw new IllegalStateException("failed to launch lightweight restore apply script, exitCode=" + exitCode);
}
}
private void writeRestoreEnv(Path workDir) {
try {
Files.createDirectories(workDir);
Map<String, String> values = new LinkedHashMap<>();
MysqlTarget target = parseMysqlTarget(datasourceUrl);
values.put("TMS_SCRIPT_PATH", trim(properties.getTmsScriptPath()));
values.put("DB_RESTORE_SCRIPT_PATH", trim(properties.getRestoreDbScriptPath()));
values.put("MQ_REPLAY_SCRIPT_PATH", trim(properties.getMqReplayScriptPath()));
values.put("MYSQL_PATH", trim(properties.getMysqlPath()).isEmpty() ? "mysql" : trim(properties.getMysqlPath()));
values.put("DB_HOST", target.host());
values.put("DB_PORT", target.port());
values.put("DB_USER", trim(datasourceUsername));
values.put("DB_PASSWORD", trim(datasourcePassword));
values.put("HEALTH_CHECK_URL", trim(properties.getHealthCheckUrl()));
values.put("HEALTH_CHECK_TIMEOUT_SECONDS", String.valueOf(properties.getHealthCheckTimeoutSeconds()));
StringBuilder builder = new StringBuilder("# generated by TMS resource restore\n");
for (Map.Entry<String, String> entry : values.entrySet()) {
builder.append("export ")
.append(entry.getKey())
.append("=")
.append(quoteShell(entry.getValue()))
.append(System.lineSeparator());
}
Files.writeString(workDir.resolve("restore-env.sh"), builder.toString());
} catch (IOException ex) {
throw new IllegalStateException("failed to write lightweight restore env", ex);
}
}
private static MysqlTarget parseMysqlTarget(String datasourceUrl) {
String url = trim(datasourceUrl);
if (url.startsWith("jdbc:mysql://")) {
try {
URI uri = URI.create(url.substring("jdbc:".length()).split("\\?", 2)[0]);
String host = uri.getHost() == null ? "127.0.0.1" : uri.getHost();
int port = uri.getPort() <= 0 ? 3306 : uri.getPort();
return new MysqlTarget(host, String.valueOf(port));
} catch (IllegalArgumentException ignore) {
return new MysqlTarget("", "");
}
}
return new MysqlTarget("", "");
}
private static String quoteShell(String value) {
return "'" + trim(value).replace("'", "'\\''") + "'";
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlTarget(String host, String port) {
}
}
@@ -1,4 +1,4 @@
package com.cisd.tms.modules.backup.runner;
package com.cisd.tms.modules.backup.restore;
import java.io.IOException;
import java.nio.file.Files;
@@ -7,7 +7,7 @@ import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessBuilderRunnerCommandInvoker implements RunnerCommandInvoker {
public class ProcessBuilderRestoreCommandInvoker implements RestoreCommandInvoker {
@Override
public int run(List<String> command, Path logFile, Path workingDirectory) {
@@ -27,10 +27,10 @@ public class ProcessBuilderRunnerCommandInvoker implements RunnerCommandInvoker
Process process = pb.start();
return process.waitFor();
} catch (IOException ex) {
throw new IllegalStateException("failed to start runner command: " + String.join(" ", command), ex);
throw new IllegalStateException("failed to start restore command: " + String.join(" ", command), ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("runner command interrupted: " + String.join(" ", command), ex);
throw new IllegalStateException("restore command interrupted: " + String.join(" ", command), ex);
}
}
}
@@ -1,9 +1,9 @@
package com.cisd.tms.modules.backup.runner;
package com.cisd.tms.modules.backup.restore;
import java.nio.file.Path;
import java.util.List;
public interface RunnerCommandInvoker {
public interface RestoreCommandInvoker {
int run(List<String> command, Path logFile, Path workingDirectory);
}
@@ -0,0 +1,82 @@
package com.cisd.tms.modules.backup.restore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class RestoreStatusFileReader {
private static final int MAX_LOG_BYTES = 64 * 1024;
private final ObjectMapper objectMapper;
public RestoreStatusFileReader(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public RestoreStatusSnapshot read(Path workDir) {
if (workDir == null) {
return empty();
}
Path statusFile = workDir.resolve("status.json").normalize();
Path logFile = workDir.resolve("restore.log").normalize();
Map<String, Object> status = readStatus(statusFile);
return new RestoreStatusSnapshot(
text(status.get("status")),
text(status.get("phase")),
text(status.get("message")),
text(status.get("updatedAt")),
readTail(logFile)
);
}
private Map<String, Object> readStatus(Path statusFile) {
if (!Files.isRegularFile(statusFile)) {
return Map.of();
}
try {
return objectMapper.readValue(statusFile.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore status file: " + statusFile, ex);
}
}
private String readTail(Path logFile) {
if (!Files.isRegularFile(logFile)) {
return "";
}
try {
byte[] bytes = Files.readAllBytes(logFile);
if (bytes.length <= MAX_LOG_BYTES) {
return new String(bytes, StandardCharsets.UTF_8);
}
return new String(bytes, bytes.length - MAX_LOG_BYTES, MAX_LOG_BYTES, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore log file: " + logFile, ex);
}
}
private static RestoreStatusSnapshot empty() {
return new RestoreStatusSnapshot("", "", "", "", "");
}
private static String text(Object value) {
return value == null ? "" : String.valueOf(value);
}
public record RestoreStatusSnapshot(
String status,
String phase,
String message,
String updatedAt,
String log
) {
}
}
@@ -1,111 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ConsistencyVerifyRunnerStepExecutor implements RunnerStepExecutor {
private static final List<String> REQUIRED_SUCCESS_STEPS = List.of(
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES"
);
private final ResourceBackupProperties resourceBackupProperties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
@Autowired
public ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, objectMapper, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build());
}
ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper,
HttpClient httpClient
) {
this.resourceBackupProperties = resourceBackupProperties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
@Override
public boolean supports(String stepCode) {
return "CONSISTENCY_VERIFY".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
Path stepOutputDir = context.taskDir().resolve("step-output");
for (String requiredStep : REQUIRED_SUCCESS_STEPS) {
assertStepSucceeded(stepOutputDir, requiredStep);
}
verifyHealthEndpoint();
return RunnerStepExecutionResult.success("required steps succeeded and health endpoint is UP");
}
private void assertStepSucceeded(Path stepOutputDir, String stepCode) {
try {
Path stepFile = resolveRequiredStepOutput(stepOutputDir, stepCode);
Map<String, Object> payload = objectMapper.readValue(stepFile.toFile(), new TypeReference<Map<String, Object>>() {
});
String result = String.valueOf(payload.get("result"));
if (!"SUCCESS".equalsIgnoreCase(result)) {
throw new IllegalStateException("required step is not successful: " + stepCode);
}
} catch (IOException ex) {
throw new IllegalStateException("failed to read step output for " + stepCode, ex);
}
}
private Path resolveRequiredStepOutput(Path stepOutputDir, String stepCode) throws IOException {
if (!Files.isDirectory(stepOutputDir)) {
throw new IllegalStateException("step output directory missing: " + stepOutputDir);
}
try (var pathStream = Files.list(stepOutputDir)) {
return pathStream
.filter(path -> path.getFileName().toString().endsWith("-" + stepCode + ".json"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("step output missing for " + stepCode));
}
}
private void verifyHealthEndpoint() {
String healthUrl = resourceBackupProperties.getHealthCheckUrl();
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(healthUrl))
.timeout(Duration.ofSeconds(resourceBackupProperties.getHealthCheckTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 || !response.body().contains("\"status\":\"UP\"")) {
throw new IllegalStateException("health endpoint is not UP: statusCode=" + response.statusCode());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("health verification interrupted", ex);
} catch (Exception ex) {
throw new IllegalStateException("failed to verify health endpoint: " + healthUrl, ex);
}
}
}
@@ -1,135 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class DatabaseRestoreRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public DatabaseRestoreRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_DATABASES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<String> restoredDatabases = new ArrayList<>();
restoreDatabase(context, "TMS.sql", resourceBackupProperties.getTmsDatabaseName(), "TMS", true, restoredDatabases);
// 标准版第一版还需要恢复收发器业务库;DIRECT 当前没有这份库时允许跳过。
restoreDatabase(context, "CMEP.sql", resourceBackupProperties.getCmepDatabaseName(), "CMEP", false, restoredDatabases);
return RunnerStepExecutionResult.success("restored databases " + String.join(",", restoredDatabases));
}
private void restoreDatabase(
RunnerStepContext context,
String sqlFileName,
String configuredDatabaseName,
String fallbackDatabaseName,
boolean required,
List<String> restoredDatabases
) {
Path sqlFile = context.taskDir().resolve("staging").resolve("payload").resolve("db").resolve(sqlFileName).normalize();
if (!Files.isRegularFile(sqlFile)) {
if (required) {
throw new IllegalStateException("restore database sql file missing: " + sqlFile);
}
return;
}
MysqlTarget target = resolveTarget(configuredDatabaseName, fallbackDatabaseName);
List<String> command = new ArrayList<>();
command.add("/bin/bash");
command.add(resourceBackupProperties.getRestoreDbScriptPath());
command.add("--mysql-path");
command.add(resourceBackupProperties.getMysqlPath());
command.add("--host");
command.add(target.host());
command.add("--port");
command.add(String.valueOf(target.port()));
command.add("--username");
command.add(target.username());
command.add("--password");
command.add(target.password());
command.add("--db-name");
command.add(target.databaseName());
command.add("--sql-file");
command.add(sqlFile.toString());
int exitCode = runnerCommandInvoker.run(command, resolveLogFile(context), context.taskDir());
if (exitCode != 0) {
throw new IllegalStateException("runner step RESTORE_DATABASES failed for " + target.databaseName() + ", exitCode=" + exitCode);
}
restoredDatabases.add(target.databaseName());
}
private MysqlTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
throw new IllegalStateException("spring.datasource.url is required for database restore");
}
if (!url.startsWith("jdbc:mysql://")) {
throw new IllegalStateException("unsupported datasource url for restore: " + url);
}
try {
String normalizedUrl = url.substring("jdbc:".length());
String[] segments = normalizedUrl.split("\\?", 2);
URI uri = URI.create(segments[0]);
String databaseName = trim(configuredDatabaseName);
if (databaseName.isEmpty()) {
String path = trim(uri.getPath());
databaseName = path.startsWith("/") ? path.substring(1) : path;
}
if (databaseName.isEmpty()) {
databaseName = fallbackDatabaseName;
}
return new MysqlTarget(
uri.getHost() == null ? "127.0.0.1" : uri.getHost(),
uri.getPort() <= 0 ? 3306 : uri.getPort(),
trim(datasourceUsername),
trim(datasourcePassword),
databaseName
);
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("invalid datasource url for restore: " + url, ex);
}
}
private Path resolveLogFile(RunnerStepContext context) {
return context.taskDir().resolve("steps").resolve(String.format("%02d-%s.log", context.stepNo(), context.stepCode()));
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlTarget(String host, int port, String username, String password, String databaseName) {
}
}
@@ -1,198 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@Component
public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
private static final Set<String> RESTORE_FILE_RESOURCE_CODES =
Set.of("TMS_CONFIG", "CP_CONFIG", "RECEIVER_LICENSE");
private final HandoffFileStore handoffFileStore;
private final ResourcePackageService resourcePackageService;
private final ObjectMapper objectMapper;
public PayloadArchiveRunnerStepExecutor(
HandoffFileStore handoffFileStore,
ResourcePackageService resourcePackageService,
ObjectMapper objectMapper
) {
this.handoffFileStore = handoffFileStore;
this.resourcePackageService = resourcePackageService;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_FILES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if ("RESTORE_FILES".equals(context.stepCode())) {
return restoreFiles(context);
}
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
private RunnerStepExecutionResult restoreFiles(RunnerStepContext context) {
ResourceRestoreHandoffSnapshot handoff = readRequiredHandoff(context.handoffFile());
Path packagePath = resolveRequiredPackagePath(handoff);
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
.orElseThrow(() -> new IllegalStateException("payload archive bytes missing"));
Path payloadDir = context.taskDir().resolve("staging").resolve("payload").normalize();
extractPayloadArchive(payloadArchiveBytes, payloadDir);
Path payloadManifestFile = payloadDir.resolve("payload-manifest.json");
int resourceCount = readResourceCount(payloadManifestFile);
int restoredFileCount = restoreSupportedFiles(payloadDir, payloadManifestFile);
return RunnerStepExecutionResult.success(
"payload extracted to staging, resourceCount=" + resourceCount + ", restoredFileCount=" + restoredFileCount
);
}
private ResourceRestoreHandoffSnapshot readRequiredHandoff(Path handoffFile) {
return handoffFileStore.read(handoffFile)
.orElseThrow(() -> new IllegalStateException("restore handoff file not found: " + handoffFile));
}
private Path resolveRequiredPackagePath(ResourceRestoreHandoffSnapshot handoff) {
String packagePath = trim(handoff.getPackagePath());
if (packagePath.isEmpty()) {
throw new IllegalStateException("restore handoff packagePath is empty");
}
Path resolved = Path.of(packagePath).normalize();
if (!Files.isRegularFile(resolved)) {
throw new IllegalStateException("restore package file not found: " + resolved);
}
return resolved;
}
private void extractPayloadArchive(byte[] payloadArchiveBytes, Path payloadDir) {
try {
Files.createDirectories(payloadDir);
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(payloadArchiveBytes))) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
Path target = resolveSafePayloadTarget(payloadDir, entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
// 备份包来自外部输入,即使已经验签,也要防御 zip-slip,确保只落到 staging/payload 下。
Files.write(target, readAllBytes(zipInputStream));
}
}
} catch (IOException ex) {
throw new IllegalStateException("failed to extract payload archive", ex);
}
}
private Path resolveSafePayloadTarget(Path payloadDir, String entryName) {
Path target = payloadDir.resolve(entryName).normalize();
if (!target.startsWith(payloadDir)) {
throw new IllegalStateException("payload archive entry escapes staging dir: " + entryName);
}
return target;
}
private int readResourceCount(Path manifestFile) {
try {
Map<String, Object> manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference<Map<String, Object>>() {
});
Object resourcesValue = manifest.get("resources");
if (resourcesValue instanceof List<?> resources) {
return resources.size();
}
return 0;
} catch (IOException ex) {
throw new IllegalStateException("failed to read payload manifest: " + manifestFile, ex);
}
}
private int restoreSupportedFiles(Path payloadDir, Path manifestFile) {
try {
Map<String, Object> manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference<Map<String, Object>>() {
});
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return 0;
}
int restoredFileCount = 0;
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
String resourceCode = String.valueOf(resource.get("resourceCode"));
if (!RESTORE_FILE_RESOURCE_CODES.contains(resourceCode)) {
continue;
}
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> rawFiles)) {
continue;
}
for (Object fileObject : rawFiles) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
Path sourceFile = payloadDir.resolve(String.valueOf(fileEntry.get("entryPath"))).normalize();
if (!sourceFile.startsWith(payloadDir) || !Files.isRegularFile(sourceFile)) {
throw new IllegalStateException("restored file source missing: " + sourceFile);
}
Path targetFile = resolveTargetFile(resourceCode, String.valueOf(resource.get("resolvedPath")), String.valueOf(fileEntry.get("relativeName")));
Files.createDirectories(targetFile.getParent());
// 第一版只回写最关键的配置/license 文件,不做目录合并或增量策略。
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
restoredFileCount++;
}
}
return restoredFileCount;
} catch (IOException ex) {
throw new IllegalStateException("failed to restore payload files", ex);
}
}
private Path resolveTargetFile(String resourceCode, String resolvedPath, String relativeName) {
Path targetPath = Path.of(resolvedPath).normalize();
if ("TMS_CONFIG".equals(resourceCode)) {
return targetPath.resolve(relativeName).normalize();
}
return targetPath;
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new java.util.LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
return inputStream.readAllBytes();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -1,41 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ProcessResourceRestoreRunnerLauncher(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Path normalizedHandoffFile = handoffFile.normalize().toAbsolutePath();
Path taskDir = normalizedHandoffFile.getParent();
Path launcherLog = taskDir.resolve("runner-launch.log");
List<String> command = List.of(
"/bin/bash",
resourceBackupProperties.getRunnerLaunchScriptPath(),
"--handoff-file",
normalizedHandoffFile.toString()
);
int exitCode = runnerCommandInvoker.run(command, launcherLog, taskDir);
if (exitCode != 0) {
throw new IllegalStateException("failed to launch external restore runner, exitCode=" + exitCode);
}
}
}
@@ -1,156 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
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.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class ResourceRestoreRunner {
private static final String INITIAL_STEP_CODE = "RESTORE_LOCK";
private static final int INITIAL_STEP_NO = 1;
private final HandoffFileStore handoffFileStore;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ObjectMapper objectMapper;
private final List<RunnerStepExecutor> runnerStepExecutors;
public ResourceRestoreRunner(
HandoffFileStore handoffFileStore,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ObjectMapper objectMapper,
List<RunnerStepExecutor> runnerStepExecutors
) {
this.handoffFileStore = handoffFileStore;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.objectMapper = objectMapper;
this.runnerStepExecutors = runnerStepExecutors;
}
public void run(Path handoffFile) {
ResourceRestoreHandoffSnapshot handoff = handoffFileStore.read(handoffFile)
.orElseThrow(() -> new IllegalStateException("restore handoff file not found: " + handoffFile));
Path stateFile = Path.of(handoff.getRunnerStatePath()).normalize();
String runnerInstanceId = nextRunnerInstanceId();
long runnerPid = ProcessHandle.current().pid();
resourceRestoreStateFileStore.claimOwnership(stateFile, runnerInstanceId, runnerPid, INITIAL_STEP_CODE, INITIAL_STEP_NO);
List<String> restorePlan = readRestorePlan(handoff.getRestorePlanJson());
try {
for (int i = 0; i < restorePlan.size(); i++) {
String stepCode = restorePlan.get(i);
int stepNo = i + 1;
if (INITIAL_STEP_CODE.equals(stepCode)) {
continue;
}
resourceRestoreStateFileStore.markStepRunning(stateFile, stepCode, stepNo, runnerInstanceId);
RunnerStepExecutor executor = resolveExecutor(stepCode);
RunnerStepContext context = new RunnerStepContext(
handoff.getTaskId(),
stepCode,
stepNo,
handoffFile.getParent(),
handoffFile,
stateFile
);
RunnerStepExecutionResult result = executor.execute(context);
writeStepOutput(context, result);
resourceRestoreStateFileStore.touchHeartbeat(stateFile, runnerInstanceId);
}
String lastStepCode = restorePlan.isEmpty() ? INITIAL_STEP_CODE : restorePlan.get(restorePlan.size() - 1);
int lastStepNo = restorePlan.isEmpty() ? INITIAL_STEP_NO : restorePlan.size();
resourceRestoreStateFileStore.markReconcilePending(stateFile, "SUCCESS", null, null, lastStepCode, lastStepNo, runnerInstanceId);
} catch (RuntimeException ex) {
int failedStepNo = detectFailedStepNo(handoff, stateFile);
String failedStepCode = detectFailedStepCode(handoff, stateFile);
writeStepFailureOutput(handoffFile.getParent(), failedStepCode, failedStepNo, ex);
resourceRestoreStateFileStore.markReconcilePending(
stateFile,
"FAILED",
"RUNNER_EXECUTION_FAILED",
ex.getMessage(),
failedStepCode,
failedStepNo,
runnerInstanceId
);
throw ex;
}
}
private String nextRunnerInstanceId() {
return "RRUN-" + UUID.randomUUID();
}
private List<String> readRestorePlan(String restorePlanJson) {
if (restorePlanJson == null || restorePlanJson.isBlank()) {
return List.of(INITIAL_STEP_CODE);
}
try {
return objectMapper.readValue(restorePlanJson, new TypeReference<List<String>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("failed to parse restore plan json", ex);
}
}
private RunnerStepExecutor resolveExecutor(String stepCode) {
return runnerStepExecutors.stream()
.filter(executor -> executor.supports(stepCode))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no runner step executor for step: " + stepCode));
}
private int detectFailedStepNo(ResourceRestoreHandoffSnapshot handoff, Path stateFile) {
return resourceRestoreStateFileStore.read(stateFile)
.map(snapshot -> snapshot.getCurrentStepNo() == null ? INITIAL_STEP_NO : snapshot.getCurrentStepNo())
.orElse(INITIAL_STEP_NO);
}
private String detectFailedStepCode(ResourceRestoreHandoffSnapshot handoff, Path stateFile) {
return resourceRestoreStateFileStore.read(stateFile)
.map(snapshot -> snapshot.getCurrentStepCode() == null || snapshot.getCurrentStepCode().isBlank()
? INITIAL_STEP_CODE
: snapshot.getCurrentStepCode())
.orElse(INITIAL_STEP_CODE);
}
private void writeStepOutput(RunnerStepContext context, RunnerStepExecutionResult result) {
try {
Path outputDir = context.taskDir().resolve("step-output");
Files.createDirectories(outputDir);
Path outputFile = outputDir.resolve(String.format("%02d-%s.json", context.stepNo(), context.stepCode()));
Map<String, Object> json = new LinkedHashMap<>();
json.put("stepCode", context.stepCode());
json.put("result", result.getResult());
json.put("summary", result.getSummary());
objectMapper.writeValue(outputFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to write step output for " + context.stepCode(), ex);
}
}
private void writeStepFailureOutput(Path taskDir, String stepCode, int stepNo, RuntimeException ex) {
try {
Path outputDir = taskDir.resolve("step-output");
Files.createDirectories(outputDir);
Path outputFile = outputDir.resolve(String.format("%02d-%s.json", stepNo, stepCode));
Map<String, Object> json = new LinkedHashMap<>();
json.put("stepCode", stepCode);
json.put("result", "FAILED");
json.put("summary", ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage());
objectMapper.writeValue(outputFile.toFile(), json);
} catch (IOException ioException) {
throw new IllegalStateException("failed to write failed step output for " + stepCode, ioException);
}
}
}
@@ -1,8 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import java.nio.file.Path;
public interface ResourceRestoreRunnerLauncher {
void launch(Path handoffFile);
}
@@ -1,38 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.TmsApplication;
import java.nio.file.Path;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
public final class ResourceRestoreRunnerMain {
private ResourceRestoreRunnerMain() {
}
public static void main(String[] args) {
ConfigurableApplicationContext context = new SpringApplicationBuilder(TmsApplication.class)
.web(WebApplicationType.NONE)
.logStartupInfo(false)
.run(args);
int exitCode = 0;
try {
Environment environment = context.getEnvironment();
String handoffFile = environment.getProperty("tms.backup.runner-handoff-file", "");
if (handoffFile == null || handoffFile.isBlank()) {
throw new IllegalStateException("tms.backup.runner-handoff-file is required");
}
context.getBean(ResourceRestoreRunner.class).run(Path.of(handoffFile));
} catch (RuntimeException ex) {
exitCode = 1;
throw ex;
} finally {
final int finalExitCode = exitCode;
int springExitCode = SpringApplication.exit(context, () -> finalExitCode);
System.exit(springExitCode);
}
}
}
@@ -1,60 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import java.time.LocalDateTime;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class RestoreStateReconciler {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final Supplier<LocalDateTime> nowSupplier;
@Autowired
public RestoreStateReconciler(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore
) {
this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, LocalDateTime::now);
}
RestoreStateReconciler(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
Supplier<LocalDateTime> nowSupplier
) {
this.resourceRestoreTaskRepository = resourceRestoreTaskRepository;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.nowSupplier = nowSupplier;
}
public void reconcileOnce() {
for (ResourceRestoreTaskEntity entity : resourceRestoreTaskRepository.findAll()) {
if (entity.getRunnerStatePath() == null || entity.getRunnerStatePath().isBlank()) {
continue;
}
java.nio.file.Path stateFile = java.nio.file.Path.of(entity.getRunnerStatePath()).normalize();
resourceRestoreStateFileStore.read(stateFile).ifPresent(snapshot -> {
if (!"RECONCILE_PENDING".equals(snapshot.getControlPhase())) {
return;
}
entity.setStatus(snapshot.getStatus());
entity.setErrorCode(blankToNull(snapshot.getErrorCode()));
entity.setErrorMessage(blankToNull(snapshot.getErrorMessage()));
entity.setFinishTime(nowSupplier.get());
entity.setUpdateTime(nowSupplier.get());
resourceRestoreTaskRepository.update(entity);
resourceRestoreStateFileStore.markDone(stateFile);
});
}
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
}
@@ -1,64 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class RunnerStallDetector {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ResourceBackupProperties resourceBackupProperties;
private final Supplier<LocalDateTime> nowSupplier;
@Autowired
public RunnerStallDetector(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ResourceBackupProperties resourceBackupProperties
) {
this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, resourceBackupProperties, LocalDateTime::now);
}
RunnerStallDetector(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ResourceBackupProperties resourceBackupProperties,
Supplier<LocalDateTime> nowSupplier
) {
this.resourceRestoreTaskRepository = resourceRestoreTaskRepository;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.resourceBackupProperties = resourceBackupProperties;
this.nowSupplier = nowSupplier;
}
public void detectOnce() {
for (ResourceRestoreTaskEntity entity : resourceRestoreTaskRepository.findAll()) {
if (entity.getRunnerStatePath() == null || entity.getRunnerStatePath().isBlank()) {
continue;
}
Path stateFile = Path.of(entity.getRunnerStatePath()).normalize();
resourceRestoreStateFileStore.read(stateFile).ifPresent(snapshot -> {
if (!"RUNNER_OWNED".equals(snapshot.getControlPhase())) {
return;
}
if (snapshot.getLastHeartbeatAt() == null || snapshot.getLastHeartbeatAt().isBlank()) {
return;
}
LocalDateTime heartbeat = LocalDateTime.parse(snapshot.getLastHeartbeatAt());
if (Duration.between(heartbeat, nowSupplier.get()).getSeconds() <= resourceBackupProperties.getRunnerHeartbeatTimeoutSeconds()) {
return;
}
resourceRestoreStateFileStore.markStalled(stateFile, "RUNNER_STALLED", "restore runner heartbeat timed out");
});
}
}
}
@@ -1,13 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import java.nio.file.Path;
public record RunnerStepContext(
String taskId,
String stepCode,
int stepNo,
Path taskDir,
Path handoffFile,
Path stateFile
) {
}
@@ -1,37 +0,0 @@
package com.cisd.tms.modules.backup.runner;
public class RunnerStepExecutionResult {
private String result;
private String summary;
public static RunnerStepExecutionResult success(String summary) {
RunnerStepExecutionResult result = new RunnerStepExecutionResult();
result.setResult("SUCCESS");
result.setSummary(summary);
return result;
}
public static RunnerStepExecutionResult failed(String summary) {
RunnerStepExecutionResult result = new RunnerStepExecutionResult();
result.setResult("FAILED");
result.setSummary(summary);
return result;
}
public String getResult() {
return result;
}
public void setResult(String result) {
this.result = result;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
}
@@ -1,8 +0,0 @@
package com.cisd.tms.modules.backup.runner;
public interface RunnerStepExecutor {
boolean supports(String stepCode);
RunnerStepExecutionResult execute(RunnerStepContext context);
}
@@ -1,82 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.init.config.InitExecutorProperties;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ServiceCommandRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final InitExecutorProperties initExecutorProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ServiceCommandRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
InitExecutorProperties initExecutorProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.initExecutorProperties = initExecutorProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public boolean supports(String stepCode) {
return "STOP_SERVICES".equals(stepCode) || "START_SERVICES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<List<String>> commands = resolveCommands(context.stepCode());
int executedCount = 0;
for (List<String> command : commands) {
int exitCode = runnerCommandInvoker.run(command, resolveLogFile(context), context.taskDir());
if (exitCode != 0) {
throw new IllegalStateException("runner step " + context.stepCode() + " failed, exitCode=" + exitCode);
}
executedCount++;
}
return RunnerStepExecutionResult.success("executed " + executedCount + " command(s)");
}
private List<List<String>> resolveCommands(String stepCode) {
return switch (stepCode) {
case "STOP_SERVICES" -> buildStopCommands();
case "START_SERVICES" -> buildStartCommands();
default -> throw new IllegalArgumentException("unsupported runner step: " + stepCode);
};
}
private List<List<String>> buildStopCommands() {
List<List<String>> commands = new ArrayList<>();
commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "stop"));
appendShellCommand(commands, initExecutorProperties.getStandardAppStopCommand());
appendShellCommand(commands, initExecutorProperties.getStandardRabbitStopCommand());
return commands;
}
private List<List<String>> buildStartCommands() {
List<List<String>> commands = new ArrayList<>();
appendShellCommand(commands, initExecutorProperties.getStandardAppStartCommand());
commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "start"));
return commands;
}
private void appendShellCommand(List<List<String>> commands, String shellCommand) {
if (shellCommand == null || shellCommand.isBlank()) {
return;
}
commands.add(List.of("/bin/zsh", "-lc", shellCommand.trim()));
}
private Path resolveLogFile(RunnerStepContext context) {
return context.taskDir().resolve("steps").resolve(String.format("%02d-%s.log", context.stepNo(), context.stepCode()));
}
}
@@ -1,77 +0,0 @@
package com.cisd.tms.modules.backup.runner.model;
public class ResourceRestoreHandoffSnapshot {
private String taskId;
private String precheckId;
private String backupId;
private String packageFileId;
private String packagePath;
private String stagingDir;
private String runnerStatePath;
private String restorePlanJson;
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 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 getRestorePlanJson() {
return restorePlanJson;
}
public void setRestorePlanJson(String restorePlanJson) {
this.restorePlanJson = restorePlanJson;
}
}
@@ -57,14 +57,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
response.setPayloadDecrypted(false);
response.setWarnings(List.of());
response.setBlockingIssues(List.of());
response.setRestorePlan(List.of(
"RESTORE_LOCK",
"STOP_SERVICES",
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES",
"CONSISTENCY_VERIFY"
));
response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY"));
response.setRestoreScopeSummary(buildScopeSummary(inspectResult));
response.setExpiresAt(LocalDateTime.now().plusMinutes(30).toString());
resourcePrecheckStateStore.save(new ResourcePrecheckContext(request.getFileId(), response));
@@ -6,20 +6,21 @@ import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerLauncher;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
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.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -32,10 +33,9 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
private final FileRecordRepository fileRecordRepository;
private final ResourcePrecheckStateStore resourcePrecheckStateStore;
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final HandoffFileStore handoffFileStore;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceRestoreRunnerLauncher resourceRestoreRunnerLauncher;
private final ResourcePayloadExtractor resourcePayloadExtractor;
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
@Override
@@ -47,6 +47,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
ResourcePrecheckContext context = resourcePrecheckStateStore.find(request.getPrecheckId())
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck not found"));
ResourceRestorePrecheckResponse precheck = context.getResponse();
validatePrecheck(precheck);
if (!trim(request.getConfirmBackupId()).equals(trim(precheck.getBackupId()))) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "confirmed backup id does not match precheck result");
}
@@ -59,36 +60,31 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
String taskId = resourceTaskIdGenerator.nextRestoreTaskId();
LocalDateTime now = LocalDateTime.now();
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
createDirectories(taskDir);
resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(taskId);
entity.setPrecheckId(precheck.getPrecheckId());
entity.setBackupId(precheck.getBackupId());
// 恢复任务创建完成后,就应该已经进入“等待 runner 接管”的交接态,
// 前端和运维看到的状态不能还停留在 PRECHECKED。
entity.setStatus("HANDOFF");
// 轻量恢复模型下,Java 已完成包验签/解密/展开,后续由 shell apply 脚本接管实际覆盖/导入。
entity.setStatus("APPLYING");
entity.setSourceDeviceId("UNKNOWN");
entity.setSourceProductType(precheck.getSourceProductType());
entity.setTargetProductType(precheck.getTargetProductType());
entity.setPackageFileId(context.getFileId());
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
// 恢复 runner 的工作目录和状态文件路径在任务创建时就固定下来,
// 这样后续即使 TMS 重启,控制面路径也不会漂移。
// 这里必须把真实备份包路径写进任务和 handoff,后续 runner 才能真正解包,
// 否则它只能拿到 fileId,却不知道恢复时去哪里取浏览器上传/下载回来的备份文件。
entity.setPackagePath(packagePath);
entity.setStagingDir(taskDir.resolve("staging").toString());
entity.setRunnerStatePath(taskDir.resolve("restore-state.json").toString());
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.setRestorePlanJson(joinAsJsonArray(precheck.getRestorePlan()));
entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]");
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
resourceRestoreTaskRepository.save(entity);
handoffFileStore.write(taskDir, entity);
resourceRestoreStateFileStore.initialize(taskDir, entity);
resourceRestoreStateFileStore.markHandoffReady(taskDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
resourceRestoreRunnerLauncher.launch(taskDir.resolve("handoff.json"));
lightweightRestoreApplierLauncher.launch(taskDir);
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
response.setTaskId(taskId);
@@ -97,23 +93,34 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
return response;
}
private static String joinAsJsonArray(List<String> items) {
if (items == null || items.isEmpty()) {
return "[]";
private static void createDirectories(Path taskDir) {
try {
Files.createDirectories(taskDir);
} catch (IOException ex) {
throw new IllegalStateException("failed to create restore task dir: " + taskDir, ex);
}
// 这里先用最轻量的 JSON 数组序列化,避免为了一个占位字段引入额外 DTO。
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < items.size(); i++) {
if (i > 0) {
builder.append(',');
}
builder.append('"').append(items.get(i)).append('"');
}
builder.append(']');
return builder.toString();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static void validatePrecheck(ResourceRestorePrecheckResponse precheck) {
if (precheck == null) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck not found");
}
if (!Boolean.TRUE.equals(precheck.getCompatible())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck is not compatible");
}
String expiresAt = trim(precheck.getExpiresAt());
if (!expiresAt.isEmpty()) {
try {
if (LocalDateTime.parse(expiresAt).isBefore(LocalDateTime.now())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck expired");
}
} catch (DateTimeException ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck expiresAt is invalid");
}
}
}
}
@@ -7,17 +7,13 @@ 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.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
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.cisd.tms.modules.backup.support.ResourceTaskStepLogService;
import com.cisd.tms.modules.backup.support.file.FileSystemResourceTaskStepLogService;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateSnapshot;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -28,9 +24,8 @@ import org.springframework.stereotype.Service;
public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ObjectMapper objectMapper;
private final ResourceTaskStepLogService resourceTaskStepLogService;
private final RestoreStatusFileReader restoreStatusFileReader;
@Override
public ResourceRestoreTaskDetailResponse getRestoreTask(String taskId) {
@@ -48,7 +43,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
response.setTargetProductType(entity.getTargetProductType());
response.setPackageFileId(entity.getPackageFileId());
response.setPackagePathVisible(Boolean.FALSE);
response.setRunnerStatePath(entity.getRunnerStatePath());
response.setStatusFilePath(entity.getRunnerStatePath());
response.setRestorePlan(readStringList(entity.getRestorePlanJson()));
response.setConsistencyReport(readObject(entity.getConsistencyReportJson()));
response.setErrorCode(entity.getErrorCode());
@@ -57,7 +52,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
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());
mergeRunnerState(response, entity);
mergeLightweightStatus(response, entity);
return response;
}
@@ -66,8 +61,8 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
ResourceRestoreStateSnapshot snapshot = resourceRestoreStateFileStore.read(resolveStatePath(entity.getRunnerStatePath())).orElse(null);
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);
@@ -75,7 +70,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse();
item.setStepNo(stepNo);
item.setStepCode(stepCode);
applyStepState(item, taskDir, snapshot, stepCode, stepNo);
applyLightweightStepState(item, snapshot, stepCode);
responses.add(item);
}
return responses;
@@ -87,38 +82,36 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
String stepCode = stepNo > 0 && stepNo <= restorePlan.size() ? restorePlan.get(stepNo - 1) : "";
return resourceTaskStepLogService.read(resolveTaskDir(entity), trim(taskId), stepNo, stepCode);
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;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private void mergeRunnerState(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) {
// 恢复任务详情以数据库记录为基线,再用 runner 状态文件覆盖实时字段。
// 这样即使 runner 尚未接管,前端也能看到稳定的任务基本信息。
resourceRestoreStateFileStore.read(resolveStatePath(entity.getRunnerStatePath()))
.ifPresent(snapshot -> applySnapshot(response, snapshot));
private void mergeLightweightStatus(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) {
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity));
if (trim(snapshot.status()).isEmpty()) {
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()));
}
private void applySnapshot(ResourceRestoreTaskDetailResponse response, ResourceRestoreStateSnapshot snapshot) {
response.setStatus(defaultIfBlank(snapshot.getStatus(), response.getStatus()));
response.setControlPhase(defaultIfBlank(snapshot.getControlPhase(), response.getControlPhase()));
response.setStateSource(defaultIfBlank(snapshot.getStateSource(), response.getStateSource()));
response.setCurrentStepCode(defaultIfBlank(snapshot.getCurrentStepCode(), response.getCurrentStepCode()));
response.setCurrentStepNo(snapshot.getCurrentStepNo() == null ? response.getCurrentStepNo() : snapshot.getCurrentStepNo());
response.setLastHeartbeatAt(defaultIfBlank(snapshot.getLastHeartbeatAt(), response.getLastHeartbeatAt()));
response.setOrphaned(snapshot.getOrphaned() == null ? response.getOrphaned() : snapshot.getOrphaned());
response.setCleanupWarnings(defaultIfNull(snapshot.getCleanupWarnings(), response.getCleanupWarnings()));
response.setWarnings(defaultIfNull(snapshot.getWarnings(), response.getWarnings()));
response.setBlockingIssues(defaultIfNull(snapshot.getBlockingIssues(), response.getBlockingIssues()));
response.setConsistencyReport(snapshot.getConsistencyReport() == null ? response.getConsistencyReport() : snapshot.getConsistencyReport());
response.setErrorCode(defaultIfBlank(snapshot.getErrorCode(), response.getErrorCode()));
response.setErrorMessage(defaultIfBlank(snapshot.getErrorMessage(), response.getErrorMessage()));
}
private Path resolveStatePath(String runnerStatePath) {
return trim(runnerStatePath).isEmpty() ? null : Path.of(runnerStatePath).normalize();
private Path resolveStatePath(String statusFilePath) {
return trim(statusFilePath).isEmpty() ? null : Path.of(statusFilePath).normalize();
}
private Path resolveTaskDir(ResourceRestoreTaskEntity entity) {
@@ -126,58 +119,29 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
return statePath == null ? null : statePath.getParent();
}
private void applyStepState(
ResourceTaskStepItemResponse item,
Path taskDir,
ResourceRestoreStateSnapshot snapshot,
String stepCode,
int stepNo
) {
Map<String, Object> stepOutput = readStepOutput(taskDir, stepNo, stepCode);
item.setLogAvailable(taskDir != null && Files.isRegularFile(taskDir.resolve("steps").resolve(String.format("%02d-%s.log", stepNo, stepCode))));
if (stepOutput != null) {
item.setStatus(stringValue(stepOutput.get("result"), "UNKNOWN"));
item.setMessage(stringValue(stepOutput.get("summary"), ""));
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 (snapshot != null && stepNo == safeInt(snapshot.getCurrentStepNo())) {
if ("RUNNER_OWNED".equals(snapshot.getControlPhase())) {
item.setStatus("RUNNING");
} else if ("RECONCILE_PENDING".equals(snapshot.getControlPhase()) && "FAILED".equals(snapshot.getStatus())) {
item.setStatus("FAILED");
item.setMessage(defaultIfBlank(snapshot.getErrorMessage(), ""));
} else {
item.setStatus("PENDING");
}
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 Map<String, Object> readStepOutput(Path taskDir, int stepNo, String stepCode) {
if (taskDir == null) {
return null;
}
Path outputFile = taskDir.resolve("step-output").resolve(String.format("%02d-%s.json", stepNo, stepCode));
if (!Files.isRegularFile(outputFile)) {
return null;
}
try {
return objectMapper.readValue(outputFile.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("failed to parse resource task step output", ex);
}
}
private static int safeInt(Integer value) {
return value == null ? -1 : value;
}
private static String stringValue(Object value, String defaultValue) {
return value == null ? defaultValue : String.valueOf(value);
}
private List<String> readStringList(String json) {
if (trim(json).isEmpty()) {
return List.of();
@@ -205,7 +169,15 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
return trim(candidate).isEmpty() ? fallback : candidate;
}
private static <T> T defaultIfNull(T candidate, T fallback) {
return candidate == null ? 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;
}
}
@@ -1,52 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class HandoffFileStore {
private final ObjectMapper objectMapper;
public HandoffFileStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public void write(Path taskDir, ResourceRestoreTaskEntity entity) {
try {
Files.createDirectories(taskDir);
Path file = taskDir.resolve("handoff.json");
Map<String, Object> json = new LinkedHashMap<>();
json.put("taskId", entity.getTaskId());
json.put("precheckId", entity.getPrecheckId());
json.put("backupId", entity.getBackupId());
json.put("packageFileId", entity.getPackageFileId());
json.put("packagePath", entity.getPackagePath());
json.put("stagingDir", entity.getStagingDir());
json.put("runnerStatePath", entity.getRunnerStatePath());
json.put("restorePlanJson", entity.getRestorePlanJson());
objectMapper.writeValue(file.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to write restore handoff file", ex);
}
}
public Optional<ResourceRestoreHandoffSnapshot> read(Path handoffFile) {
if (handoffFile == null || !Files.isRegularFile(handoffFile)) {
return Optional.empty();
}
try {
return Optional.ofNullable(objectMapper.readValue(handoffFile.toFile(), ResourceRestoreHandoffSnapshot.class));
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore handoff file", ex);
}
}
}
@@ -1,190 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
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.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class ResourceRestoreStateFileStore {
private final ObjectMapper objectMapper;
public ResourceRestoreStateFileStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public void initialize(Path taskDir, ResourceRestoreTaskEntity entity) {
try {
Files.createDirectories(taskDir);
Map<String, Object> json = new LinkedHashMap<>();
json.put("taskId", entity.getTaskId());
json.put("status", entity.getStatus());
json.put("controlPhase", "PRECHECKED");
json.put("stateSource", "DB");
json.put("runnerInstanceId", "");
json.put("runnerPid", 0);
json.put("ownershipToken", UUID.randomUUID().toString());
json.put("orphaned", false);
objectMapper.writeValue(taskDir.resolve("restore-state.json").toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to initialize restore state file", ex);
}
}
public java.util.Optional<ResourceRestoreStateSnapshot> read(Path stateFile) {
if (stateFile == null || !Files.isRegularFile(stateFile)) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.ofNullable(objectMapper.readValue(stateFile.toFile(), ResourceRestoreStateSnapshot.class));
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore state file", ex);
}
}
public void markHandoffReady(Path stateFile, String currentStepCode, Integer currentStepNo) {
try {
Map<String, Object> json = readJson(stateFile);
json.put("status", "HANDOFF");
json.put("controlPhase", "HANDOFF_READY");
json.put("stateSource", "DB");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("orphaned", false);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark restore state as handoff ready", ex);
}
}
public void claimOwnership(Path stateFile, String runnerInstanceId, long runnerPid, String currentStepCode, Integer currentStepNo) {
try {
Map<String, Object> json = readJson(stateFile);
String controlPhase = String.valueOf(json.getOrDefault("controlPhase", ""));
if (!"HANDOFF_READY".equals(controlPhase)) {
throw new IllegalStateException("restore state is not handoff ready: " + controlPhase);
}
json.put("status", "RUNNING");
json.put("controlPhase", "RUNNER_OWNED");
json.put("stateSource", "RUNNER_FILE");
json.put("runnerInstanceId", runnerInstanceId);
json.put("runnerPid", runnerPid);
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
json.put("orphaned", false);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to claim restore runner ownership", ex);
}
}
public void markStepRunning(Path stateFile, String currentStepCode, Integer currentStepNo, String runnerInstanceId) {
updateOwnedState(stateFile, runnerInstanceId, json -> {
json.put("status", "RUNNING");
json.put("controlPhase", "RUNNER_OWNED");
json.put("stateSource", "RUNNER_FILE");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
}, "failed to mark restore step running");
}
public void touchHeartbeat(Path stateFile, String runnerInstanceId) {
updateOwnedState(stateFile, runnerInstanceId, json ->
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString()), "failed to touch restore heartbeat");
}
public void markReconcilePending(
Path stateFile,
String status,
String errorCode,
String errorMessage,
String currentStepCode,
Integer currentStepNo,
String runnerInstanceId
) {
updateOwnedState(stateFile, runnerInstanceId, json -> {
json.put("status", status);
json.put("controlPhase", "RECONCILE_PENDING");
json.put("stateSource", "RUNNER_FILE");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("errorCode", errorCode == null ? "" : errorCode);
json.put("errorMessage", errorMessage == null ? "" : errorMessage);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
}, "failed to mark restore reconcile pending");
}
private void updateOwnedState(
Path stateFile,
String runnerInstanceId,
JsonMutator mutator,
String errorMessage
) {
try {
Map<String, Object> json = readJson(stateFile);
String currentRunnerInstanceId = String.valueOf(json.getOrDefault("runnerInstanceId", ""));
if (!runnerInstanceId.equals(currentRunnerInstanceId)) {
throw new IllegalStateException("restore runner ownership changed: " + currentRunnerInstanceId);
}
mutator.accept(json);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException(errorMessage, ex);
}
}
public void markDone(Path stateFile) {
try {
Map<String, Object> json = readJson(stateFile);
json.put("controlPhase", "DONE");
json.put("stateSource", "DB");
json.put("lastHeartbeatAt", LocalDateTime.now().toString());
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark restore state done", ex);
}
}
public void markStalled(Path stateFile, String errorCode, String errorMessage) {
try {
Map<String, Object> json = readJson(stateFile);
if (!"RUNNER_OWNED".equals(String.valueOf(json.getOrDefault("controlPhase", "")))) {
return;
}
json.put("status", "FAILED");
json.put("controlPhase", "RECONCILE_PENDING");
json.put("stateSource", "RUNNER_FILE");
json.put("orphaned", true);
json.put("errorCode", errorCode);
json.put("errorMessage", errorMessage);
json.put("lastHeartbeatAt", LocalDateTime.now().toString());
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark stalled restore state", ex);
}
}
@FunctionalInterface
private interface JsonMutator {
void accept(Map<String, Object> json);
}
private Map<String, Object> readJson(Path stateFile) throws IOException {
if (stateFile == null || !Files.isRegularFile(stateFile)) {
throw new IllegalStateException("restore state file not found: " + stateFile);
}
return objectMapper.readValue(
stateFile.toFile(),
objectMapper.getTypeFactory().constructMapType(LinkedHashMap.class, String.class, Object.class)
);
}
}
@@ -1,160 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import java.util.List;
public class ResourceRestoreStateSnapshot {
private String taskId;
private String status;
private String controlPhase;
private String stateSource;
private String runnerInstanceId;
private Long runnerPid;
private String ownershipToken;
private String currentStepCode;
private Integer currentStepNo;
private String lastHeartbeatAt;
private Boolean orphaned;
private List<String> cleanupWarnings;
private List<String> warnings;
private List<String> blockingIssues;
private Object consistencyReport;
private String errorCode;
private String errorMessage;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
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 getRunnerInstanceId() {
return runnerInstanceId;
}
public void setRunnerInstanceId(String runnerInstanceId) {
this.runnerInstanceId = runnerInstanceId;
}
public Long getRunnerPid() {
return runnerPid;
}
public void setRunnerPid(Long runnerPid) {
this.runnerPid = runnerPid;
}
public String getOwnershipToken() {
return ownershipToken;
}
public void setOwnershipToken(String ownershipToken) {
this.ownershipToken = ownershipToken;
}
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 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 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;
}
}
@@ -8,9 +8,10 @@ import java.util.List;
@Data
public class EntityCreateRequest {
@NotBlank
private String entityName;
private String orgCode;
@NotBlank
private String orgName;
@NotBlank
private String algoType;
private List<String> allowIps;
}
@@ -8,8 +8,9 @@ import lombok.Data;
public class EntityGenP10Request {
@NotNull
private Long id;
@NotNull
private String certMode;
@Valid
@NotNull
private Subject subject;
}
@@ -8,12 +8,14 @@ import java.util.List;
@Data
public class EntityItemResponse {
private Long id;
private String entityName;
private String orgCode;
private String orgName;
private String algoType;
private Integer keyLen;
private Integer keyIdx;
private String subject;
private List<String> allowIps;
private String status;
private String statusName;
private LocalDateTime createTime;
}
@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class EntityListRequest extends CommonPageRequest {
private String keyword;
private String orgCode;
private String algoType;
private String status;
}
@@ -9,7 +9,7 @@ import java.util.List;
public class EntityUpdateRequest {
@NotNull
private Long id;
private String entityName;
private String orgCode;
private String orgName;
private List<String> allowIps;
}
@@ -12,7 +12,8 @@ import lombok.experimental.Accessors;
@TableName("t_key_entity")
public class KeyEntity extends BaseEntity {
private String entityName;
private String orgCode;
private String orgName;
private String algoType;
private Integer keyLen;
private Integer keyIdx;
@@ -20,8 +21,9 @@ public class KeyEntity extends BaseEntity {
private String publicKey;
private String p10Data;
private String subjectDn;
private String certMode;
private String status;
private String allowIps;
private String signData;
private String createBy;
}
@@ -0,0 +1,34 @@
package com.cisd.tms.modules.cert.enums;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import java.util.Arrays;
public enum EntityCertMode {
SINGLE("SINGLE", "单证"),
DOUBLE("DOUBLE", "双证");
private final String code;
private final String displayName;
EntityCertMode(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static EntityCertMode requireValid(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "invalid entity cert mode"));
}
}
@@ -0,0 +1,35 @@
package com.cisd.tms.modules.cert.enums;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import java.util.Arrays;
public enum EntityStatus {
CREATED("CREATED", "已创建"),
P10_GENERATED("P10_GENERATED", "已生成P10请求"),
CERT_IMPORTED("CERT_IMPORTED", "已导入证书");
private final String code;
private final String displayName;
EntityStatus(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static EntityStatus requireValid(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "invalid entity status"));
}
}
@@ -13,5 +13,5 @@ public interface KeyEntityRepository {
Optional<KeyEntity> findByKeyIdx(Integer keyIdx);
List<Integer> findUsedKeyIdx();
Page<KeyEntity> page(EntityListRequest request);
void updateStatus(Long id, String status);
}
@@ -1,6 +1,7 @@
package com.cisd.tms.modules.cert.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.cert.dto.EntityListRequest;
import com.cisd.tms.modules.cert.entity.KeyEntity;
@@ -62,12 +63,14 @@ public class KeyEntityRepositoryImpl implements KeyEntityRepository {
@Override
public Page<KeyEntity> page(EntityListRequest request) {
LambdaQueryWrapper<KeyEntity> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(request.getKeyword())) {
wrapper.and(w -> w.like(KeyEntity::getEntityName, request.getKeyword())
.or().like(KeyEntity::getSubjectDn, request.getKeyword()));
if (StringUtils.hasText(request.getOrgCode())) {
wrapper.like(KeyEntity::getOrgCode, request.getOrgCode());
}
if (StringUtils.hasText(request.getAlgoType())) {
wrapper.eq(KeyEntity::getAlgoType, request.getAlgoType());
wrapper.eq(KeyEntity::getAlgoType, request.getAlgoType().toUpperCase());
}
if (StringUtils.hasText(request.getStatus())) {
wrapper.eq(KeyEntity::getStatus, request.getStatus().toUpperCase());
}
wrapper.orderByDesc(KeyEntity::getCreateTime);
long total = mapper.selectCount(wrapper);
@@ -78,5 +81,11 @@ public class KeyEntityRepositoryImpl implements KeyEntityRepository {
page.setRecords(records);
return page;
}
}
@Override
public void updateStatus(Long id, String status) {
mapper.update(null, new LambdaUpdateWrapper<KeyEntity>()
.eq(KeyEntity::getId, id)
.set(KeyEntity::getStatus, status));
}
}
@@ -92,6 +92,9 @@ public class CertificateService {
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());
certificateRepository.save(entity);
if (finalEntityId != null) {
entityService.markCertificateImported(finalEntityId);
}
return new ImportCertificateResponse(entity.getId(), entity.getSubjectDn(), entity.getValidTo());
} catch (BizException ex) {
throw ex;
@@ -16,6 +16,8 @@ import com.cisd.tms.modules.cert.dto.EntityListRequest;
import com.cisd.tms.modules.cert.dto.EntityUpdateRequest;
import com.cisd.tms.modules.cert.dto.Subject;
import com.cisd.tms.modules.cert.entity.KeyEntity;
import com.cisd.tms.modules.cert.enums.EntityCertMode;
import com.cisd.tms.modules.cert.enums.EntityStatus;
import com.cisd.tms.modules.cert.repository.CertificateRepository;
import com.cisd.tms.modules.cert.repository.KeyEntityRepository;
import com.cisd.tms.modules.cert.support.Sm2KeySupport;
@@ -39,11 +41,13 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
@Service
public class EntityService {
private static final String SM2 = "SM2";
private static final String RSA = "RSA";
private static final byte[] DEFAULT_SM2_USER_ID = "1234567812345678".getBytes(StandardCharsets.UTF_8);
private final KeyEntityRepository keyEntityRepository;
@@ -72,26 +76,33 @@ public class EntityService {
@Transactional
public EntityCreateResponse create(EntityCreateRequest request) {
if (!SM2.equalsIgnoreCase(request.getAlgoType())) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "only SM2 is supported");
}
String algoType = normalizeAlgoType(request.getAlgoType());
int keyIdx = resolveNextKeyIdx();
int keyLen = SM2.equals(algoType) ? 256 : 2048;
GenerateKeyPairRequest generateRequest = new GenerateKeyPairRequest();
generateRequest.setKeyBits(256);
generateRequest.setKeyBits(keyLen);
generateRequest.setKeyIndex(keyIdx);
generateRequest.setKeyType(PcieUserKeyType.SIGN.getCode());
pcieCryptoService.generateKeyPairEcc(generateRequest);
if (SM2.equals(algoType)) {
pcieCryptoService.generateKeyPairEcc(generateRequest);
} else {
pcieCryptoService.generateKeyPairRsa(generateRequest);
}
try {
BackupDataResult backup = pcieCryptoService.backupUserKey(keyIdx, PcieUserKeyType.SIGN.getCode(), 4096);
BackupDataResult publicKey = pcieCryptoService.exportSignPublicKeyEcc(keyIdx);
BackupDataResult publicKey = SM2.equals(algoType)
? pcieCryptoService.exportSignPublicKeyEcc(keyIdx)
: pcieCryptoService.exportSignPublicKeyRsa(keyIdx);
KeyEntity entity = new KeyEntity()
.setEntityName(request.getEntityName())
.setAlgoType(SM2)
.setKeyLen(256)
.setOrgCode(request.getOrgCode())
.setOrgName(request.getOrgName())
.setAlgoType(algoType)
.setKeyLen(keyLen)
.setKeyIdx(keyIdx)
.setBackupData(Base64.getEncoder().encodeToString(backup.getData()))
.setPublicKey(Base64.getEncoder().encodeToString(publicKey.getData()))
.setStatus(EntityStatus.CREATED.getCode())
.setAllowIps(writeAllowIps(request.getAllowIps()))
.setCreateBy("system");
entity.setCreateTime(LocalDateTime.now());
@@ -110,8 +121,11 @@ public class EntityService {
@Transactional
public void update(EntityUpdateRequest request) {
KeyEntity entity = getById(request.getId());
if (StringUtils.hasText(request.getEntityName())) {
entity.setEntityName(request.getEntityName());
if (StringUtils.hasText(request.getOrgCode())) {
entity.setOrgCode(request.getOrgCode());
}
if (StringUtils.hasText(request.getOrgName())) {
entity.setOrgName(request.getOrgName());
}
if (request.getAllowIps() != null) {
entity.setAllowIps(writeAllowIps(request.getAllowIps()));
@@ -132,6 +146,10 @@ public class EntityService {
@Transactional
public String generateP10(EntityGenP10Request request) {
KeyEntity entity = getById(request.getId());
if (!SM2.equalsIgnoreCase(entity.getAlgoType())) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "only SM2 P10 is supported");
}
EntityCertMode certMode = EntityCertMode.requireValid(request.getCertMode());
Subject subject = request.getSubject();
X500Name subjectName = subject.toX500Name();
String subjectDn = subjectName.toString();
@@ -142,6 +160,8 @@ public class EntityService {
String pem = encodePkcs10Pem(csr);
entity.setSubjectDn(subjectDn);
entity.setP10Data(pem);
entity.setCertMode(certMode.getCode());
entity.setStatus(EntityStatus.P10_GENERATED.getCode());
keyEntityRepository.save(entity);
return pem;
}
@@ -156,19 +176,40 @@ public class EntityService {
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "entity not found"));
}
@Transactional
public void markCertificateImported(Long id) {
keyEntityRepository.updateStatus(id, EntityStatus.CERT_IMPORTED.getCode());
}
private EntityItemResponse toItem(KeyEntity entity) {
EntityItemResponse response = new EntityItemResponse();
response.setId(entity.getId());
response.setEntityName(entity.getEntityName());
response.setOrgCode(entity.getOrgCode());
response.setOrgName(entity.getOrgName());
response.setAlgoType(entity.getAlgoType());
response.setKeyLen(entity.getKeyLen());
response.setKeyIdx(entity.getKeyIdx());
response.setSubject(entity.getSubjectDn());
response.setAllowIps(readAllowIps(entity.getAllowIps()));
response.setStatus(entity.getStatus());
if (StringUtils.hasText(entity.getStatus())) {
response.setStatusName(EntityStatus.requireValid(entity.getStatus()).getDisplayName());
}
response.setCreateTime(entity.getCreateTime());
return response;
}
private String normalizeAlgoType(String algoType) {
if (!StringUtils.hasText(algoType)) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "algoType is required");
}
String normalized = algoType.toUpperCase(Locale.ROOT);
if (!SM2.equals(normalized) && !RSA.equals(normalized)) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "algoType must be SM2 or RSA");
}
return normalized;
}
private int resolveNextKeyIdx() {
List<Integer> used = keyEntityRepository.findUsedKeyIdx();
for (int idx = 2; idx <= 8; idx++) {
+13 -9
View File
@@ -184,16 +184,24 @@ tms:
backup:
# 资源备份包输出目录;第一版先在本机生成可见的 .tmsbak 文件。
output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages}
# 恢复 runner 启停 TMS 时使用的脚本路径。
# 轻量恢复 apply 脚本启停 TMS 时使用的脚本路径。
tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh}
# 外部恢复 runner 启动脚本路径;创建恢复任务后由主进程调用该脚本拉起独立 runner。
runner-launch-script-path: ${TMS_BACKUP_RUNNER_LAUNCH_SCRIPT_PATH:/home/tms/bin/resource-restore/start-runner.sh}
# 轻量恢复 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 恢复路径必须落在这些目录下。
allowed-restore-roots:
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_TMS_CONFIG:/home/tms/config}
- ${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}
# 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。
mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump}
# 恢复 TMS 库时使用的 mysql 客户端命令。
mysql-path: ${TMS_BACKUP_MYSQL_PATH:mysql}
# 数据库恢复脚本路径;runner 在 RESTORE_DATABASES 阶段调用。
# 数据库恢复脚本路径;apply 脚本在 RESTORE_DATABASES 阶段调用。
restore-db-script-path: ${TMS_BACKUP_RESTORE_DB_SCRIPT_PATH:/home/tms/bin/resource-restore/restore-db.sh}
# MQ replay 脚本路径;apply 脚本在 MQ_REPLAY 阶段调用。
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:}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
@@ -202,10 +210,6 @@ tms:
health-check-url: ${TMS_BACKUP_HEALTH_CHECK_URL:http://127.0.0.1:8080/actuator/health}
# 健康检查请求超时(秒)。
health-check-timeout-seconds: ${TMS_BACKUP_HEALTH_CHECK_TIMEOUT_SECONDS:5}
# 是否在创建恢复任务后自动拉起本机 runner;默认关闭,避免开发环境误触停机恢复链。
runner-auto-launch-enabled: ${TMS_BACKUP_RUNNER_AUTO_LAUNCH_ENABLED:false}
# 恢复 runner 心跳失联超时(秒)。
runner-heartbeat-timeout-seconds: ${TMS_BACKUP_RUNNER_HEARTBEAT_TIMEOUT_SECONDS:300}
mk:
init-identify:
# SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。
@@ -217,7 +221,7 @@ tms:
internal-token: ${TMS_INTERNAL_TOKEN:change-me-internal-token}
internal-auth:
# 是否启用 /api/** 的内部登录鉴权;开发联调时可临时关闭。
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:false}
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:true}
# 关闭内部鉴权后,注入请求上下文的调试角色。
debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:SUPER_ADMIN}
# 关闭内部鉴权后,注入请求上下文的调试认证等级。
@@ -47,16 +47,16 @@ class ResourceBackupDtoContractTest {
void shouldSerializeRestoreTaskDetailControlFields() throws Exception {
ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse();
response.setTaskId("RRST-TASK-20260408-000001");
response.setStatus("RUNNING");
response.setControlPhase("RUNNER_OWNED");
response.setStateSource("RUNNER_FILE");
response.setCurrentStepCode("RESTORE_DATABASES");
response.setStatus("APPLYING");
response.setControlPhase("SHELL_APPLY");
response.setStateSource("SHELL_STATUS");
response.setCurrentStepCode("SHELL_APPLY");
String json = objectMapper.writeValueAsString(response);
Assertions.assertTrue(json.contains("\"taskId\":\"RRST-TASK-20260408-000001\""));
Assertions.assertTrue(json.contains("\"controlPhase\":\"RUNNER_OWNED\""));
Assertions.assertTrue(json.contains("\"stateSource\":\"RUNNER_FILE\""));
Assertions.assertTrue(json.contains("\"currentStepCode\":\"RESTORE_DATABASES\""));
Assertions.assertTrue(json.contains("\"controlPhase\":\"SHELL_APPLY\""));
Assertions.assertTrue(json.contains("\"stateSource\":\"SHELL_STATUS\""));
Assertions.assertTrue(json.contains("\"currentStepCode\":\"SHELL_APPLY\""));
}
}
@@ -0,0 +1,45 @@
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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class ResourceBackupPropertiesTest {
@Test
void shouldExposeDefaultLightweightRestoreSettings() {
ResourceBackupProperties properties = new ResourceBackupProperties();
Assertions.assertEquals(
"/home/tms/bin/resource-restore/apply-resource-backup.sh",
properties.getRestoreApplyScriptPath()
);
Assertions.assertEquals(
"/home/tms/bin/resource-restore/replay-mq.sh",
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())));
}
@Test
void shouldAcceptAdditionalFileResources() {
ResourceBackupProperties properties = new ResourceBackupProperties();
ConfiguredFileBackupResource custom = new ConfiguredFileBackupResource();
custom.setCode("CUSTOM_CERT");
custom.setKind(BackupResourceKind.FILE.name());
custom.setMode("FILE");
custom.setPath("/home/tms/certs/custom.crt");
custom.setRestorePath("/home/tms/certs/custom.crt");
custom.setRequired(false);
properties.setFileResources(java.util.List.of(custom));
Assertions.assertEquals("CUSTOM_CERT", properties.getFileResources().get(0).getCode());
Assertions.assertFalse(properties.getFileResources().get(0).isRequired());
}
}
@@ -5,7 +5,6 @@ import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
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.ResourceKeysetStatusResponse;
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;
@@ -40,12 +39,6 @@ class ResourceBackupControllerTest {
ResourceRestoreService restoreService = Mockito.mock(ResourceRestoreService.class);
ResourceTaskQueryService taskQueryService = Mockito.mock(ResourceTaskQueryService.class);
ResourceKeysetStatusResponse keysetStatus = new ResourceKeysetStatusResponse();
keysetStatus.setLmkReady(true);
keysetStatus.setStorageEncKeyReady(true);
keysetStatus.setStorageEncKeyFingerprint("SM3(KEYSET)");
Mockito.when(backupService.getKeysetStatus()).thenReturn(keysetStatus);
CreateResourceBackupResponse createBackup = new CreateResourceBackupResponse();
createBackup.setTaskId("RBKP-TASK-20260408-000001");
createBackup.setStatus("PENDING");
@@ -79,9 +72,9 @@ class ResourceBackupControllerTest {
ResourceRestoreTaskDetailResponse restoreDetail = new ResourceRestoreTaskDetailResponse();
restoreDetail.setTaskId("RRST-TASK-20260408-000001");
restoreDetail.setStatus("RUNNING");
restoreDetail.setControlPhase("RUNNER_OWNED");
restoreDetail.setStateSource("RUNNER_FILE");
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(
@@ -89,11 +82,6 @@ class ResourceBackupControllerTest {
new ResourceRestoreController(precheckService, restoreService, taskQueryService)
).build();
mockMvc.perform(get("/api/v1/resource-backups/keyset/status"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"lmkReady\":true")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"storageEncKeyFingerprint\":\"SM3(KEYSET)\"")));
mockMvc.perform(post("/api/v1/resource-backups")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"includeUpgradeHistory\":true,\"includeAuditBackups\":false,\"remark\":\"before hardware replacement\"}"))
@@ -124,7 +112,7 @@ class ResourceBackupControllerTest {
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260408-000001"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"RUNNER_OWNED\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stateSource\":\"RUNNER_FILE\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stateSource\":\"SHELL_STATUS\"")));
}
}
@@ -37,26 +37,26 @@ class ResourceRestoreControllerTest {
CreateResourceRestoreResponse create = new CreateResourceRestoreResponse();
create.setTaskId("RRST-TASK-20260414-000001");
create.setStatus("HANDOFF");
create.setStatus("APPLYING");
Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(create);
ResourceRestoreTaskDetailResponse detail = new ResourceRestoreTaskDetailResponse();
detail.setTaskId("RRST-TASK-20260414-000001");
detail.setControlPhase("RECONCILE_PENDING");
detail.setStateSource("RUNNER_FILE");
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("STOP_SERVICES");
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("STOP_SERVICES");
log.setContent("stop ok");
log.setStepCode("SHELL_APPLY");
log.setContent("apply ok");
log.setTruncated(false);
Mockito.when(taskQueryService.getRestoreTaskStepLog("RRST-TASK-20260414-000001", 2)).thenReturn(log);
@@ -78,14 +78,14 @@ class ResourceRestoreControllerTest {
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"RECONCILE_PENDING\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\"")));
mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001/steps"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stepCode\":\"STOP_SERVICES\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"stepCode\":\"SHELL_APPLY\"")));
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\":\"stop ok\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"content\":\"apply ok\"")));
}
}
@@ -7,6 +7,7 @@ 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.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;
import org.junit.jupiter.api.Test;
@@ -23,16 +24,23 @@ class ResourceEnvelopeCryptoServiceTest {
properties.setAuthenticitySignKeyIndex(8);
AtomicReference<byte[]> capturedDek = new AtomicReference<>();
AtomicReference<byte[]> decryptableDek = new AtomicReference<>();
AtomicReference<byte[]> decryptResultDek = new AtomicReference<>();
Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
.thenAnswer(invocation -> {
EccInternalEncryptRequest request = invocation.getArgument(0);
capturedDek.set(request.getData());
decryptableDek.set(Arrays.copyOf(request.getData(), request.getData().length));
return backup(new byte[]{0x11, 0x22, 0x33});
});
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any(EccInternalSignRequest.class)))
.thenReturn(backup(new byte[]{0x44, 0x55, 0x66}));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> backup(capturedDek.get()));
.thenAnswer(invocation -> {
byte[] decryptedDek = Arrays.copyOf(decryptableDek.get(), decryptableDek.get().length);
decryptResultDek.set(decryptedDek);
return backup(decryptedDek);
});
ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties);
byte[] payload = "payload-data".getBytes(java.nio.charset.StandardCharsets.UTF_8);
@@ -60,6 +68,8 @@ class ResourceEnvelopeCryptoServiceTest {
ArgumentCaptor<EccInternalDecryptRequest> decryptCaptor = ArgumentCaptor.forClass(EccInternalDecryptRequest.class);
Mockito.verify(pcieCryptoService).sdfeInternalDecryptEcc(decryptCaptor.capture());
Assertions.assertEquals(7, 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");
}
private static BackupDataResult backup(byte[] data) {
@@ -68,4 +78,16 @@ class ResourceEnvelopeCryptoServiceTest {
result.setLength(data.length);
return result;
}
private static boolean isAllZero(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return false;
}
for (byte value : bytes) {
if (value != 0) {
return false;
}
}
return true;
}
}
@@ -16,11 +16,18 @@ class ResourceDiscoveryServiceTest {
List<DiscoveredResourceItem> items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ"));
Assertions.assertTrue(containsCode(items, "TMS_CONFIG"));
Assertions.assertTrue(containsCode(items, "TMS_DB_DUMP"));
Assertions.assertTrue(containsCode(items, "CMEP_DB_DUMP"));
Assertions.assertTrue(containsCode(items, "TMS_DB"));
Assertions.assertTrue(containsCode(items, "CMEP_DB"));
Assertions.assertTrue(containsCode(items, "CP_CONFIG"));
Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE"));
Assertions.assertTrue(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertTrue(containsCode(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertFalse(containsCode(items, "TLQ_LICENSE"));
Assertions.assertFalse(containsCode(items, "CFMQ_CONFIG"));
Assertions.assertTrue(items.stream().allMatch(item ->
"FILE".equals(item.getResourceType())
|| "DATABASE".equals(item.getResourceType())
|| "MQ_REPLAY".equals(item.getResourceType())));
Assertions.assertFalse(containsCode(items, "NGINX_CONFIG"));
Assertions.assertFalse(containsCode(items, "WEB_STATIC"));
Assertions.assertFalse(containsCode(items, "ORGANIZATION_JSON"));
@@ -35,10 +42,11 @@ class ResourceDiscoveryServiceTest {
Assertions.assertTrue(hasPath(items, "CP_CONFIG", "/opt/cpconfig.cfg"));
Assertions.assertTrue(hasPath(items, "RECEIVER_LICENSE", "/home/cmep4d/cmep/license/cmep.license"));
Assertions.assertTrue(hasPath(items, "TLQ_LICENSE", "/home/tlq/TLQ8/license.dat"));
Assertions.assertTrue(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertTrue(hasRequired(items, "TMS_DB_DUMP"));
Assertions.assertFalse(containsCode(items, "CMEP_DB_DUMP"));
Assertions.assertFalse(containsCode(items, "TLQ_LICENSE"));
Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertTrue(hasRequired(items, "TMS_DB"));
Assertions.assertTrue(hasRequired(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "CMEP_DB"));
}
private static InitTaskEntity sampleInitTask(String productType, String mqType) {
@@ -0,0 +1,46 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class MqReplayContextBuilderTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final MqReplayContextBuilder builder = new MqReplayContextBuilder(objectMapper);
@Test
void shouldBuildMqReplayContextFromInitPlanJson() throws Exception {
InitTaskEntity task = new InitTaskEntity();
task.setProductType("INDIRECT");
task.setMqType("RABBITMQ_TLQ");
task.setOrgCode("BKCHCNBJXXX");
task.setInitPlanJson("""
{"productType":"INDIRECT","orgCode":"BKCHCNBJXXX","mqType":"RABBITMQ_TLQ",
"request":{"orgCodeType":"BIC","orgCode":"BKCHCNBJXXX","orgNameCn":"中国银行",
"orgNameEn":"Bank of China","deployMode":"DUAL",
"nodes":{"node01Ip":"192.168.1.11","node02Ip":"192.168.1.12"},
"mq":{"mqType":"RABBITMQ_TLQ","channelUsername":"tms","channelPassword":"Secret123!"}}}
""");
byte[] json = builder.build(task);
JsonNode root = objectMapper.readTree(json);
Assertions.assertEquals("INDIRECT", root.path("productType").asText());
Assertions.assertEquals("RABBITMQ_TLQ", root.path("mqType").asText());
Assertions.assertEquals("tms", root.path("mq").path("channelUsername").asText());
Assertions.assertEquals("Secret123!", root.path("mq").path("channelPassword").asText());
}
@Test
void shouldRejectMissingInitPlanJson() {
InitTaskEntity task = new InitTaskEntity();
task.setProductType("INDIRECT");
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, () -> builder.build(task));
Assertions.assertTrue(ex.getMessage().contains("initPlanJson is empty"));
}
}
@@ -58,8 +58,9 @@ class ResourcePackageServiceTest {
sampleInitTask(),
List.of(
resource("TMS_CONFIG", payloadFile.toString()),
databaseResource("TMS_DB_DUMP", "TMS"),
databaseResource("CMEP_DB_DUMP", "CMEP")
databaseResource("TMS_DB", "TMS", "db/TMS.sql"),
databaseResource("CMEP_DB", "CMEP", "db/CMEP.sql"),
mqReplayResource()
),
sampleRequest()
);
@@ -71,7 +72,7 @@ class ResourcePackageServiceTest {
Assertions.assertFalse(result.getPackageHash().isBlank());
Assertions.assertFalse(result.getStorageEncKeyFingerprint().isBlank());
Assertions.assertFalse(result.getAuthenticityKeyFingerprint().isBlank());
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":3"));
Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":4"));
try (ZipFile zipFile = new ZipFile(packagePath.toFile())) {
Assertions.assertNotNull(zipFile.getEntry("manifest.json"));
@@ -89,13 +90,18 @@ class ResourcePackageServiceTest {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
Assertions.assertEquals(3, resources.size());
Assertions.assertEquals(4, resources.size());
Assertions.assertEquals(Boolean.TRUE, resources.get(0).get("collected"));
Assertions.assertTrue(String.valueOf(resources.get(0).get("entryPath")).contains("application.yml"));
Assertions.assertEquals("TMS_DB_DUMP", resources.get(1).get("resourceCode"));
Assertions.assertEquals("TMS_DB", resources.get(1).get("resourceCode"));
Assertions.assertEquals("DATABASE", resources.get(1).get("kind"));
Assertions.assertEquals("db/TMS.sql", resources.get(1).get("entryPath"));
Assertions.assertEquals("CMEP_DB_DUMP", resources.get(2).get("resourceCode"));
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"));
try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) {
java.util.Map<String, String> entries = new java.util.LinkedHashMap<>();
@@ -109,6 +115,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("mq/mq-restore-context.json"));
Assertions.assertTrue(entries.get("mq/mq-restore-context.json").contains("\"channelUsername\":\"tms\""));
}
}
@@ -155,6 +163,13 @@ class ResourcePackageServiceTest {
entity.setMqType("RABBITMQ");
entity.setOrgCode("AAAABBBBXXX");
entity.setDeployMode("SINGLE");
entity.setInitPlanJson("""
{"productType":"ENTERPRISE","orgCode":"AAAABBBBXXX","mqType":"RABBITMQ",
"request":{"orgCodeType":"BIC","orgCode":"AAAABBBBXXX","orgNameCn":"测试机构",
"orgNameEn":"Test Bank","deployMode":"SINGLE",
"nodes":{"node01Ip":"127.0.0.1"},
"mq":{"mqType":"RABBITMQ","channelUsername":"tms","channelPassword":"Secret123!"}}}
""");
return entity;
}
@@ -167,11 +182,22 @@ class ResourcePackageServiceTest {
return item;
}
private static DiscoveredResourceItem databaseResource(String code, String path) {
private static DiscoveredResourceItem databaseResource(String code, String databaseName, String entryPath) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DB");
item.setResourceType("DATABASE");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setResolvedPath(databaseName);
item.setDatabaseName(databaseName);
item.setEntryPath(entryPath);
item.setRequired(true);
return item;
}
private static DiscoveredResourceItem mqReplayResource() {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("MQ_REPLAY");
item.setResourceCode("MQ_REPLAY");
item.setEntryPath("mq/mq-restore-context.json");
item.setRequired(true);
return item;
}
@@ -0,0 +1,124 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
class ResourcePayloadExtractorTest {
@TempDir
Path tempDir;
@Test
void shouldExtractPayloadAndWriteRestoreContext() throws Exception {
ResourcePackageService packageService = Mockito.mock(ResourcePackageService.class);
ResourcePackageInspectResult inspectResult = new ResourcePackageInspectResult();
inspectResult.setPayloadArchiveBytes(payloadArchive(
"""
{"schemaVersion":"2.0","resources":[
{"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":"MQ_REPLAY","kind":"MQ_REPLAY","entryPath":"mq/mq-restore-context.json"}
]}
""".formatted(tempDir, tempDir),
"files/TMS_CONFIG/application.yml",
"server.port=8080"
));
Mockito.when(packageService.inspectPackage(Mockito.any(Path.class))).thenReturn(inspectResult);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setAllowedRestoreRoots(List.of(tempDir.toString()));
ResourcePayloadExtractor extractor = new ResourcePayloadExtractor(packageService, new ObjectMapper(), properties);
Path workDir = tempDir.resolve("restore");
ResourcePayloadExtractionResult result = extractor.extract(tempDir.resolve("backup.tmsbak"), workDir);
Assertions.assertEquals(workDir.resolve("payload"), result.getPayloadDir());
Assertions.assertTrue(Files.exists(result.getPayloadDir().resolve("payload-manifest.json")));
Assertions.assertTrue(Files.exists(result.getPayloadDir().resolve("files/TMS_CONFIG/application.yml")));
Assertions.assertTrue(Files.exists(workDir.resolve("restore-context.json")));
Assertions.assertEquals(
"files/TMS_CONFIG/application.yml\t" + tempDir.resolve("config/application.yml") + System.lineSeparator(),
Files.readString(workDir.resolve("restore-files.tsv"))
);
Assertions.assertEquals(
"db/TMS.sql\tTMS" + System.lineSeparator(),
Files.readString(workDir.resolve("restore-databases.tsv"))
);
Assertions.assertEquals(
"mq/mq-restore-context.json" + System.lineSeparator(),
Files.readString(workDir.resolve("restore-mq.tsv"))
);
}
@Test
void shouldRejectPayloadEntryEscapingTargetDirectory() throws Exception {
ResourcePackageService packageService = Mockito.mock(ResourcePackageService.class);
ResourcePackageInspectResult inspectResult = new ResourcePackageInspectResult();
inspectResult.setPayloadArchiveBytes(payloadArchive("{}", "../escaped.txt", "bad"));
Mockito.when(packageService.inspectPackage(Mockito.any(Path.class))).thenReturn(inspectResult);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setAllowedRestoreRoots(List.of(tempDir.toString()));
ResourcePayloadExtractor extractor = new ResourcePayloadExtractor(packageService, new ObjectMapper(), properties);
IllegalStateException ex = Assertions.assertThrows(
IllegalStateException.class,
() -> extractor.extract(tempDir.resolve("backup.tmsbak"), tempDir.resolve("restore"))
);
Assertions.assertTrue(ex.getMessage().contains("escapes payload dir"));
}
@Test
void shouldRejectFileRestorePathOutsideAllowedRoots() throws Exception {
ResourcePackageService packageService = Mockito.mock(ResourcePackageService.class);
ResourcePackageInspectResult inspectResult = new ResourcePackageInspectResult();
inspectResult.setPayloadArchiveBytes(payloadArchive(
"""
{"schemaVersion":"2.0","resources":[
{"code":"TMS_CONFIG","kind":"FILE","restorePath":"/etc",
"files":[{"entryPath":"files/TMS_CONFIG/application.yml","restorePath":"/etc/application.yml"}]}
]}
""",
"files/TMS_CONFIG/application.yml",
"server.port=8080"
));
Mockito.when(packageService.inspectPackage(Mockito.any(Path.class))).thenReturn(inspectResult);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setAllowedRestoreRoots(List.of(tempDir.toString()));
ResourcePayloadExtractor extractor = new ResourcePayloadExtractor(packageService, new ObjectMapper(), properties);
IllegalStateException ex = Assertions.assertThrows(
IllegalStateException.class,
() -> extractor.extract(tempDir.resolve("backup.tmsbak"), tempDir.resolve("restore"))
);
Assertions.assertTrue(ex.getMessage().contains("restore path is not allowed"));
}
private static byte[] payloadArchive(String manifestJson, String extraEntry, String extraContent) throws Exception {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
zipOutputStream.putNextEntry(new ZipEntry("payload-manifest.json"));
zipOutputStream.write(manifestJson.getBytes(StandardCharsets.UTF_8));
zipOutputStream.closeEntry();
zipOutputStream.putNextEntry(new ZipEntry(extraEntry));
zipOutputStream.write(extraContent.getBytes(StandardCharsets.UTF_8));
zipOutputStream.closeEntry();
}
return outputStream.toByteArray();
}
}
@@ -57,8 +57,8 @@ class ResourceTaskRepositoryTest {
entity.setBackupId("RBACKUP-001");
entity.setStatus("READY");
entity.setPackageFileId("FILE-001");
entity.setStagingDir("/home/tms/tmp/resource-restore/RRST-001/staging");
entity.setRunnerStatePath("/home/tms/tmp/resource-restore/RRST-001/restore-state.json");
entity.setStagingDir("/home/tms/tmp/resource-restore/RRST-001/payload");
entity.setRunnerStatePath("/home/tms/tmp/resource-restore/RRST-001/status.json");
entity.setSourceProductType("DIRECT");
entity.setTargetProductType("DIRECT");
entity.setConsistencyReportJson("{\"ok\":true}");
@@ -74,8 +74,8 @@ class ResourceTaskRepositoryTest {
Assertions.assertEquals("RRST-001", found.getTaskId());
Assertions.assertEquals("RPRE-001", found.getPrecheckId());
Assertions.assertEquals("FILE-001", found.getPackageFileId());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/staging", found.getStagingDir());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/restore-state.json", found.getRunnerStatePath());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/payload", found.getStagingDir());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/status.json", found.getRunnerStatePath());
Assertions.assertEquals("DIRECT", found.getSourceProductType());
Assertions.assertEquals("DIRECT", found.getTargetProductType());
Assertions.assertEquals("{\"ok\":true}", found.getConsistencyReportJson());
@@ -90,9 +90,9 @@ class ResourceTaskRepositoryTest {
entity.setTaskId("RRST-001");
entity.setTaskType("RESOURCE_RESTORE");
entity.setStepNo(1);
entity.setStepCode("STOP_SERVICES");
entity.setStepCode("SHELL_APPLY");
entity.setStatus("SUCCESS");
entity.setLogPath("/home/tms/tmp/resource-restore/RRST-001/steps/01-STOP_SERVICES.log");
entity.setLogPath("/home/tms/tmp/resource-restore/RRST-001/restore.log");
entity.setExitCode(0);
repository.save(entity);
@@ -104,8 +104,8 @@ class ResourceTaskRepositoryTest {
Assertions.assertEquals(1, steps.size());
Assertions.assertEquals("RESOURCE_RESTORE", steps.get(0).getTaskType());
Assertions.assertEquals("STOP_SERVICES", steps.get(0).getStepCode());
Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/steps/01-STOP_SERVICES.log", steps.get(0).getLogPath());
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());
}
@@ -62,8 +62,7 @@ class ResourceTaskSchemaContractTest {
"PRECHECKING",
"PRECHECK_FAILED",
"READY",
"HANDOFF",
"RUNNING",
"APPLYING",
"VERIFYING",
"SUCCESS",
"FAILED",
@@ -77,9 +76,8 @@ class ResourceTaskSchemaContractTest {
assertEnumConstants(
"com.cisd.tms.modules.backup.enums.ResourceControlPhase",
"TMS_PRECHECK",
"HANDOFF_READY",
"RUNNER_OWNED",
"RECONCILE_PENDING",
"JAVA_EXTRACT",
"SHELL_APPLY",
"DONE"
);
assertEnumConstants(
@@ -0,0 +1,154 @@
package com.cisd.tms.modules.backup.restore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ApplyResourceBackupScriptContractTest {
@TempDir
Path tempDir;
@Test
void scriptShouldContainRequiredPhasesAndNoSecretsEcho() throws Exception {
String script = Files.readString(Path.of("scripts/resource-restore/apply-resource-backup.sh"));
Assertions.assertTrue(script.contains("FILE"));
Assertions.assertTrue(script.contains("DATABASE"));
Assertions.assertTrue(script.contains("MQ_REPLAY"));
Assertions.assertTrue(script.contains("HEALTH_CHECK"));
Assertions.assertFalse(script.contains("channelPassword"));
}
@Test
void shouldRestoreFilesFromGeneratedPlan() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Path sourceFile = payloadDir.resolve("resources/TMS_CONFIG/application.yml");
Path targetFile = tempDir.resolve("target/application.yml");
Files.createDirectories(sourceFile.getParent());
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(sourceFile, "server.port=8080");
Files.writeString(workDir.resolve("restore-files.tsv"),
"resources/TMS_CONFIG/application.yml\t" + targetFile + System.lineSeparator());
runScript(workDir, Map.of());
Assertions.assertEquals("server.port=8080", Files.readString(targetFile));
Assertions.assertTrue(Files.readString(workDir.resolve("status.json")).contains("\"status\":\"SUCCESS\""));
}
@Test
void shouldRunDatabaseAndMqReplayScriptsFromGeneratedPlans() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir.resolve("db"));
Files.createDirectories(payloadDir.resolve("mq"));
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(payloadDir.resolve("db/TMS.sql"), "select 1;");
Files.writeString(payloadDir.resolve("mq/mq-restore-context.json"), "{}");
Files.writeString(workDir.resolve("restore-databases.tsv"), "db/TMS.sql\tTMS" + System.lineSeparator());
Files.writeString(workDir.resolve("restore-mq.tsv"), "mq/mq-restore-context.json" + System.lineSeparator());
Path commandLog = tempDir.resolve("commands.log");
Path dbScript = fakeScript("db-restore.sh", commandLog);
Path mqScript = fakeScript("mq-replay.sh", commandLog);
runScript(workDir, Map.of(
"DB_RESTORE_SCRIPT_PATH", dbScript.toString(),
"MQ_REPLAY_SCRIPT_PATH", mqScript.toString(),
"MYSQL_PATH", "mysql-test",
"DB_HOST", "127.0.0.1",
"DB_PORT", "4000",
"DB_USER", "root",
"DB_PASSWORD", "secret"
));
String log = Files.readString(commandLog);
Assertions.assertTrue(log.contains("--db-name TMS"));
Assertions.assertTrue(log.contains("--sql-file " + payloadDir.resolve("db/TMS.sql")));
Assertions.assertTrue(log.contains("--context-file " + payloadDir.resolve("mq/mq-restore-context.json")));
}
@Test
void shouldLoadRestoreEnvFileFromWorkDir() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir.resolve("db"));
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(payloadDir.resolve("db/TMS.sql"), "select 1;");
Files.writeString(workDir.resolve("restore-databases.tsv"), "db/TMS.sql\tTMS" + System.lineSeparator());
Path commandLog = tempDir.resolve("commands.log");
Path dbScript = fakeScript("db-restore.sh", commandLog);
Files.writeString(workDir.resolve("restore-env.sh"), """
export DB_RESTORE_SCRIPT_PATH='%s'
export MYSQL_PATH='mysql-from-env'
export DB_HOST='10.0.0.1'
export DB_PORT='4000'
export DB_USER='root'
export DB_PASSWORD='secret'
""".formatted(dbScript));
runScript(workDir, Map.of());
String log = Files.readString(commandLog);
Assertions.assertTrue(log.contains("--mysql-path mysql-from-env"));
Assertions.assertTrue(log.contains("--host 10.0.0.1"));
}
@Test
void shouldRunHealthCheckWhenConfigured() 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("curl.log");
Path binDir = tempDir.resolve("bin");
Files.createDirectories(binDir);
Path curl = binDir.resolve("curl");
Files.writeString(curl, """
#!/usr/bin/env bash
printf '%%s\\n' "$*" >> "%s"
printf '{"status":"UP"}'
""".formatted(commandLog));
curl.toFile().setExecutable(true);
runScript(workDir, Map.of(
"HEALTH_CHECK_URL", "http://127.0.0.1:8080/actuator/health",
"HEALTH_CHECK_TIMEOUT_SECONDS", "3",
"PATH", binDir + ":" + System.getenv("PATH")
));
String log = Files.readString(commandLog);
Assertions.assertTrue(log.contains("--max-time 3"));
Assertions.assertTrue(log.contains("http://127.0.0.1:8080/actuator/health"));
}
private static void runScript(Path workDir, Map<String, String> environment) throws Exception {
ProcessBuilder builder = new ProcessBuilder(
"bash",
Path.of("scripts/resource-restore/apply-resource-backup.sh").toAbsolutePath().normalize().toString(),
"--work-dir",
workDir.toString()
);
builder.directory(Path.of(".").toAbsolutePath().normalize().toFile());
builder.environment().putAll(environment);
Process process = builder.start();
int exitCode = process.waitFor();
Assertions.assertEquals(0, exitCode, new String(process.getErrorStream().readAllBytes()));
}
private Path fakeScript(String name, Path commandLog) throws Exception {
Path script = tempDir.resolve(name);
Files.writeString(script, """
#!/usr/bin/env bash
printf '%%s %%s\\n' "$0" "$*" >> "%s"
""".formatted(commandLog));
script.toFile().setExecutable(true);
return script;
}
}
@@ -0,0 +1,67 @@
package com.cisd.tms.modules.backup.restore;
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 LightweightRestoreApplierLauncherTest {
@TempDir
Path tempDir;
@Test
void shouldLaunchApplyScriptWithWorkDir() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
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");
RecordingInvoker invoker = new RecordingInvoker();
LightweightRestoreApplierLauncher launcher = new LightweightRestoreApplierLauncher(
properties,
invoker,
"jdbc:mysql://db-host:4000/tms?useUnicode=true",
"root",
"p'ass"
);
Path workDir = tempDir.resolve("RRST-001");
launcher.launch(workDir);
Assertions.assertEquals(List.of(
"/bin/bash",
"/home/tms/bin/resource-restore/apply-resource-backup.sh",
"--work-dir",
workDir.toAbsolutePath().normalize().toString()
), invoker.command);
Assertions.assertEquals(workDir.toAbsolutePath().normalize().resolve("restore-launch.log"), invoker.logFile);
Assertions.assertEquals(workDir.toAbsolutePath().normalize(), invoker.workingDirectory);
String env = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-env.sh"));
Assertions.assertTrue(env.contains("export DB_RESTORE_SCRIPT_PATH='/home/tms/bin/resource-restore/restore-db.sh'"));
Assertions.assertTrue(env.contains("export MQ_REPLAY_SCRIPT_PATH='/home/tms/bin/resource-restore/replay-mq.sh'"));
Assertions.assertTrue(env.contains("export MYSQL_PATH='mysql'"));
Assertions.assertTrue(env.contains("export DB_HOST='db-host'"));
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'"));
}
private static final class RecordingInvoker implements RestoreCommandInvoker {
private List<String> command;
private Path logFile;
private Path workingDirectory;
@Override
public int run(List<String> command, Path logFile, Path workingDirectory) {
this.command = command;
this.logFile = logFile;
this.workingDirectory = workingDirectory;
return 0;
}
}
}
@@ -0,0 +1,74 @@
package com.cisd.tms.modules.backup.restore;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class MqReplayScriptContractTest {
@TempDir
Path tempDir;
@Test
void shouldReplayRabbitMqUserAndPermissionsFromContext() throws Exception {
Path context = tempDir.resolve("mq-restore-context.json");
Files.writeString(context, """
{"schemaVersion":"1.0","productType":"INDIRECT","mqType":"RABBITMQ_TLQ","orgCode":"BKCHCNBJXXX",
"mq":{"channelUsername":"tms","channelPassword":"Secret123!"}}
""");
Path commandLog = tempDir.resolve("commands.log");
Path rabbitmqctl = fakeRabbitmqctl(commandLog);
ProcessBuilder builder = new ProcessBuilder(
"bash",
Path.of("scripts/resource-restore/replay-mq.sh").toAbsolutePath().normalize().toString(),
"--context-file",
context.toString(),
"--work-dir",
tempDir.resolve("work").toString()
);
builder.environment().put("RABBITMQ_CTL_COMMAND", rabbitmqctl.toString());
Process process = builder.start();
Assertions.assertEquals(0, process.waitFor(), new String(process.getErrorStream().readAllBytes()));
String log = Files.readString(commandLog);
Assertions.assertTrue(log.contains("add_vhost /RQ"));
Assertions.assertTrue(log.contains("add_user tms Secret123!"));
Assertions.assertTrue(log.contains("set_permissions -p /RQ tms .* .* .*"));
}
@Test
void shouldDelegateToCustomReplayCommandWhenConfigured() throws Exception {
Path context = tempDir.resolve("mq-restore-context.json");
Files.writeString(context, "{}");
Path commandLog = tempDir.resolve("commands.log");
Path custom = fakeRabbitmqctl(commandLog);
ProcessBuilder builder = new ProcessBuilder(
"bash",
Path.of("scripts/resource-restore/replay-mq.sh").toAbsolutePath().normalize().toString(),
"--context-file",
context.toString(),
"--work-dir",
tempDir.resolve("work").toString()
);
builder.environment().put("MQ_REPLAY_COMMAND", custom.toString());
Process process = builder.start();
Assertions.assertEquals(0, process.waitFor(), new String(process.getErrorStream().readAllBytes()));
Assertions.assertTrue(Files.readString(commandLog).contains("--context-file " + context));
}
private Path fakeRabbitmqctl(Path commandLog) throws Exception {
Path script = tempDir.resolve("rabbitmqctl");
Files.writeString(script, """
#!/usr/bin/env bash
printf '%%s\\n' "$*" >> "%s"
exit 0
""".formatted(commandLog));
script.toFile().setExecutable(true);
return script;
}
}
@@ -1,4 +1,4 @@
package com.cisd.tms.modules.backup.runner;
package com.cisd.tms.modules.backup.restore;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -7,23 +7,23 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RunnerCommandInvokerTest {
class RestoreCommandInvokerTest {
@TempDir
Path tempDir;
@Test
void shouldExecuteCommandAndAppendLog() throws Exception {
ProcessBuilderRunnerCommandInvoker invoker = new ProcessBuilderRunnerCommandInvoker();
Path logFile = tempDir.resolve("steps/01-STOP_SERVICES.log");
ProcessBuilderRestoreCommandInvoker invoker = new ProcessBuilderRestoreCommandInvoker();
Path logFile = tempDir.resolve("restore.log");
int exitCode = invoker.run(
List.of("/bin/zsh", "-lc", "printf 'hello-runner'"),
List.of("/bin/zsh", "-lc", "printf 'hello-restore'"),
logFile,
tempDir
);
Assertions.assertEquals(0, exitCode);
Assertions.assertEquals("hello-runner", Files.readString(logFile));
Assertions.assertEquals("hello-restore", Files.readString(logFile));
}
}
@@ -0,0 +1,33 @@
package com.cisd.tms.modules.backup.restore;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RestoreStatusFileReaderTest {
@TempDir
Path tempDir;
@Test
void shouldReadStatusJsonAndLog() throws Exception {
Path workDir = tempDir.resolve("RRST-001");
Files.createDirectories(workDir);
Files.writeString(workDir.resolve("status.json"),
"{\"status\":\"RUNNING\",\"phase\":\"MQ_REPLAY\",\"message\":\"replaying mq\",\"updatedAt\":\"2026-04-29T10:00:00+08:00\"}");
Files.writeString(workDir.resolve("restore.log"), "line1\nline2\n");
RestoreStatusFileReader reader = new RestoreStatusFileReader(new ObjectMapper());
RestoreStatusFileReader.RestoreStatusSnapshot snapshot = reader.read(workDir);
Assertions.assertEquals("RUNNING", snapshot.status());
Assertions.assertEquals("MQ_REPLAY", snapshot.phase());
Assertions.assertEquals("replaying mq", snapshot.message());
Assertions.assertEquals("2026-04-29T10:00:00+08:00", snapshot.updatedAt());
Assertions.assertTrue(snapshot.log().contains("line1"));
}
}
@@ -1,122 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ConsistencyVerifyRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldVerifyRequiredStepOutputsAndHealthEndpoint() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setHealthCheckUrl("http://127.0.0.1:8080/actuator/health");
HttpClient httpClient = Mockito.mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> response = Mockito.mock(HttpResponse.class);
Mockito.when(response.statusCode()).thenReturn(200);
Mockito.when(response.body()).thenReturn("{\"status\":\"UP\"}");
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<HttpResponse.BodyHandler<String>>any()))
.thenReturn(response);
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
httpClient
);
Path taskDir = tempDir.resolve("task");
Files.createDirectories(taskDir.resolve("step-output"));
Files.writeString(taskDir.resolve("step-output/02-RESTORE_FILES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/03-RESTORE_DATABASES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/04-START_SERVICES.json"), "{\"result\":\"SUCCESS\"}");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000004",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("health endpoint is UP"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
Mockito.verify(httpClient).send(requestCaptor.capture(), Mockito.<HttpResponse.BodyHandler<String>>any());
Assertions.assertEquals("http://127.0.0.1:8080/actuator/health", requestCaptor.getValue().uri().toString());
}
@Test
void shouldFailWhenRequiredStepOutputIsMissing() {
ResourceBackupProperties properties = new ResourceBackupProperties();
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
Mockito.mock(HttpClient.class)
);
Path taskDir = tempDir.resolve("task-missing");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000005",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("step output"));
}
@Test
void shouldFailWhenHealthEndpointIsNotUp() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setHealthCheckUrl("http://127.0.0.1:8080/actuator/health");
HttpClient httpClient = Mockito.mock(HttpClient.class);
@SuppressWarnings("unchecked")
HttpResponse<String> response = Mockito.mock(HttpResponse.class);
Mockito.when(response.statusCode()).thenReturn(503);
Mockito.when(response.body()).thenReturn("{\"status\":\"DOWN\"}");
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<HttpResponse.BodyHandler<String>>any()))
.thenReturn(response);
ConsistencyVerifyRunnerStepExecutor executor = new ConsistencyVerifyRunnerStepExecutor(
properties,
new ObjectMapper(),
httpClient
);
Path taskDir = tempDir.resolve("task-down");
Files.createDirectories(taskDir.resolve("step-output"));
Files.writeString(taskDir.resolve("step-output/02-RESTORE_FILES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/03-RESTORE_DATABASES.json"), "{\"result\":\"SUCCESS\"}");
Files.writeString(taskDir.resolve("step-output/04-START_SERVICES.json"), "{\"result\":\"SUCCESS\"}");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000006",
"CONSISTENCY_VERIFY",
5,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("failed to verify health endpoint"));
}
}
@@ -1,196 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class DatabaseRestoreRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldExecuteRestoreScriptForTmsAndCmepSql() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
properties.setMysqlPath("/usr/bin/mysql");
properties.setTmsDatabaseName("TMS");
properties.setCmepDatabaseName("CMEP");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Path cmepSqlFile = tempDir.resolve("task/staging/payload/db/CMEP.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
Files.writeString(cmepSqlFile, "CREATE TABLE c_demo(id bigint);");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000001",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("TMS"));
Assertions.assertTrue(result.getSummary().contains("CMEP"));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Mockito.verify(invoker, Mockito.times(2)).run(commandCaptor.capture(), Mockito.any(), Mockito.eq(tempDir.resolve("task")));
List<List<String>> commands = commandCaptor.getAllValues();
Assertions.assertEquals(
List.of(
"/bin/bash",
"/tmp/restore-db.sh",
"--mysql-path",
"/usr/bin/mysql",
"--host",
"127.0.0.1",
"--port",
"4000",
"--username",
"root",
"--password",
"secret",
"--db-name",
"TMS",
"--sql-file",
sqlFile.toString()
),
commands.get(0)
);
Assertions.assertEquals(
List.of(
"/bin/bash",
"/tmp/restore-db.sh",
"--mysql-path",
"/usr/bin/mysql",
"--host",
"127.0.0.1",
"--port",
"4000",
"--username",
"root",
"--password",
"secret",
"--db-name",
"CMEP",
"--sql-file",
cmepSqlFile.toString()
),
commands.get(1)
);
}
@Test
void shouldFailWhenSqlFileIsMissing() {
ResourceBackupProperties properties = new ResourceBackupProperties();
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000002",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("sql file missing"));
Mockito.verifyNoInteractions(invoker);
}
@Test
void shouldSkipCmepWhenSqlFileIsAbsent() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
properties.setMysqlPath("/usr/bin/mysql");
properties.setTmsDatabaseName("TMS");
properties.setCmepDatabaseName("CMEP");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000004",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("TMS"));
Assertions.assertFalse(result.getSummary().contains("CMEP"));
Mockito.verify(invoker, Mockito.times(1)).run(Mockito.anyList(), Mockito.any(), Mockito.eq(tempDir.resolve("task")));
}
@Test
void shouldFailWhenRestoreScriptReturnsNonZeroExitCode() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRestoreDbScriptPath("/tmp/restore-db.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(7);
DatabaseRestoreRunnerStepExecutor executor = new DatabaseRestoreRunnerStepExecutor(
properties,
invoker,
"jdbc:mysql://127.0.0.1:4000/TMS",
"root",
"secret"
);
Path sqlFile = tempDir.resolve("task/staging/payload/db/TMS.sql");
Files.createDirectories(sqlFile.getParent());
Files.writeString(sqlFile, "CREATE TABLE t_demo(id bigint);");
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260418-000003",
"RESTORE_DATABASES",
4,
tempDir.resolve("task"),
tempDir.resolve("task/handoff.json"),
tempDir.resolve("task/restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("exitCode=7"));
}
}
@@ -1,177 +0,0 @@
package com.cisd.tms.modules.backup.runner;
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.service.PcieCryptoService;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.packagex.DatabaseBackupCollector;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.packagex.ResourcePackageServiceImpl;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
class PayloadArchiveRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldExtractPayloadArchiveIntoStagingDirectoryAndRestoreCriticalFiles() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourcePackageService packageService = new ResourcePackageServiceImpl(
backupProperties(),
new ResourceEnvelopeCryptoServiceImpl(mockCrypto(), backupProperties()),
mockDatabaseBackupCollector(),
objectMapper
);
Path sourceFile = writeFile("source-config/application.yml", "server.port=8088");
Path cpConfig = writeFile("runtime/cpconfig.cfg", "cp=old");
Path taskDir = tempDir.resolve("restore-task");
Files.createDirectories(taskDir);
String packagePath = packageService.createPackage(
"RBKP-TASK-20260414-000101",
"RBKP-20260414-000101",
sampleInitTask(),
List.of(
resource("TMS_CONFIG", sourceFile.getParent().toString()),
resource("CP_CONFIG", cpConfig.toString()),
databaseResource("TMS_DB_DUMP", "TMS"),
databaseResource("CMEP_DB_DUMP", "CMEP")
),
new CreateResourceBackupRequest()
).getPackagePath();
Files.delete(sourceFile);
Files.writeString(cpConfig, "cp=newer-runtime-content");
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260414-000001");
entity.setBackupId("RBKP-20260414-000101");
entity.setPackageFileId("FILE-20260414-000001");
entity.setPackagePath(packagePath);
entity.setStagingDir(taskDir.resolve("staging").toString());
entity.setRunnerStatePath(taskDir.resolve("restore-state.json").toString());
entity.setRestorePlanJson("[\"RESTORE_LOCK\",\"RESTORE_FILES\",\"CONSISTENCY_VERIFY\"]");
new HandoffFileStore(objectMapper).write(taskDir, entity);
PayloadArchiveRunnerStepExecutor executor = new PayloadArchiveRunnerStepExecutor(
new HandoffFileStore(objectMapper),
packageService,
objectMapper
);
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
entity.getTaskId(),
"RESTORE_FILES",
2,
taskDir,
taskDir.resolve("handoff.json"),
taskDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertTrue(result.getSummary().contains("restoredFileCount=2"));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/payload-manifest.json")));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/db/TMS.sql")));
Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/db/CMEP.sql")));
Assertions.assertEquals("server.port=8088", Files.readString(sourceFile));
Assertions.assertEquals("cp=old", Files.readString(cpConfig));
}
private ResourceBackupProperties backupProperties() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setOutputDir(tempDir.resolve("backup-output").toString());
return properties;
}
private Path writeFile(String relativePath, String content) throws Exception {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, content);
return file;
}
private static PcieCryptoService mockCrypto() {
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
.thenAnswer(invocation -> {
EccInternalEncryptRequest request = invocation.getArgument(0);
return backup(request.getData());
});
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> {
EccInternalDecryptRequest request = invocation.getArgument(0);
return backup(request.getCipherBlob());
});
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any()))
.thenReturn(backup(new byte[]{0x21, 0x22, 0x23}));
Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any());
return pcieCryptoService;
}
private static BackupDataResult backup(byte[] data) {
BackupDataResult result = new BackupDataResult();
result.setData(data);
result.setLength(data.length);
return result;
}
private static InitTaskEntity sampleInitTask() {
InitTaskEntity entity = new InitTaskEntity();
entity.setTaskId("INIT-20260408-000001");
entity.setTaskType("INIT");
entity.setStatus("SUCCESS");
entity.setProductType("ENTERPRISE");
entity.setMqType("RABBITMQ");
entity.setOrgCode("AAAABBBBXXX");
entity.setDeployMode("SINGLE");
return entity;
}
private static DiscoveredResourceItem resource(String code, String path) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("FILE");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRequired(true);
return item;
}
private static DiscoveredResourceItem databaseResource(String code, String path) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DB");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRequired(true);
return item;
}
private static DatabaseBackupCollector mockDatabaseBackupCollector() {
DatabaseBackupCollector collector = Mockito.mock(DatabaseBackupCollector.class);
DatabaseBackupCollector.CollectedDatabaseDump tmsDump = new DatabaseBackupCollector.CollectedDatabaseDump();
tmsDump.setDatabaseName("TMS");
tmsDump.setFileName("TMS.sql");
tmsDump.setContent("CREATE TABLE t_restore(id bigint);".getBytes(java.nio.charset.StandardCharsets.UTF_8));
DatabaseBackupCollector.CollectedDatabaseDump cmepDump = new DatabaseBackupCollector.CollectedDatabaseDump();
cmepDump.setDatabaseName("CMEP");
cmepDump.setFileName("CMEP.sql");
cmepDump.setContent("CREATE TABLE c_restore(id bigint);".getBytes(java.nio.charset.StandardCharsets.UTF_8));
Mockito.when(collector.collectTmsDatabaseDump()).thenReturn(tmsDump);
Mockito.when(collector.collectCmepDatabaseDump()).thenReturn(cmepDump);
return collector;
}
}
@@ -1,71 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ProcessResourceRestoreRunnerLauncherTest {
@TempDir
Path tempDir;
@Test
void shouldSkipLaunchWhenAutoLaunchDisabled() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(false);
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
launcher.launch(tempDir.resolve("handoff.json"));
Mockito.verifyNoInteractions(invoker);
}
@Test
void shouldInvokeExternalLaunchScriptWhenAutoLaunchEnabled() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(true);
properties.setRunnerLaunchScriptPath("/tmp/start-runner.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(0);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
Path handoffFile = tempDir.resolve("task/handoff.json");
launcher.launch(handoffFile);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<Path> logCaptor = ArgumentCaptor.forClass(Path.class);
ArgumentCaptor<Path> workDirCaptor = ArgumentCaptor.forClass(Path.class);
Mockito.verify(invoker).run(commandCaptor.capture(), logCaptor.capture(), workDirCaptor.capture());
Assertions.assertEquals(
List.of("/bin/bash", "/tmp/start-runner.sh", "--handoff-file", handoffFile.toAbsolutePath().normalize().toString()),
commandCaptor.getValue()
);
Assertions.assertEquals(handoffFile.toAbsolutePath().normalize().getParent(), workDirCaptor.getValue());
Assertions.assertEquals(handoffFile.toAbsolutePath().normalize().getParent().resolve("runner-launch.log"), logCaptor.getValue());
}
@Test
void shouldFailWhenLaunchScriptReturnsNonZeroExitCode() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerAutoLaunchEnabled(true);
properties.setRunnerLaunchScriptPath("/tmp/start-runner.sh");
RunnerCommandInvoker invoker = Mockito.mock(RunnerCommandInvoker.class);
Mockito.when(invoker.run(Mockito.anyList(), Mockito.any(), Mockito.any())).thenReturn(9);
ProcessResourceRestoreRunnerLauncher launcher = new ProcessResourceRestoreRunnerLauncher(properties, invoker);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> launcher.launch(tempDir.resolve("task/handoff.json"))
);
Assertions.assertTrue(exception.getMessage().contains("exitCode=9"));
}
}
@@ -1,148 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ResourceRestoreRunnerTest {
@TempDir
Path tempDir;
@Test
void shouldTakeOwnershipFromHandoffReadyState() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
HandoffFileStore handoffFileStore = new HandoffFileStore(objectMapper);
ResourceRestoreStateFileStore stateFileStore = new ResourceRestoreStateFileStore(objectMapper);
RecordingRunnerStepExecutor stepExecutor = new RecordingRunnerStepExecutor();
ResourceRestoreRunner runner = new ResourceRestoreRunner(
handoffFileStore,
stateFileStore,
objectMapper,
List.of(stepExecutor)
);
ResourceRestoreTaskEntity entity = sampleTask();
handoffFileStore.write(tempDir, entity);
stateFileStore.initialize(tempDir, entity);
stateFileStore.markHandoffReady(tempDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
runner.run(tempDir.resolve("handoff.json"));
JsonNode state = objectMapper.readTree(Files.readString(tempDir.resolve("restore-state.json")));
Assertions.assertEquals("SUCCESS", state.path("status").asText());
Assertions.assertEquals("RECONCILE_PENDING", state.path("controlPhase").asText());
Assertions.assertEquals("RUNNER_FILE", state.path("stateSource").asText());
Assertions.assertEquals("START_SERVICES", state.path("currentStepCode").asText());
Assertions.assertEquals(3, state.path("currentStepNo").asInt());
Assertions.assertFalse(state.path("runnerInstanceId").asText().isBlank());
Assertions.assertTrue(state.path("runnerPid").asLong() > 0);
Assertions.assertFalse(state.path("lastHeartbeatAt").asText().isBlank());
Assertions.assertEquals(List.of("STOP_SERVICES", "START_SERVICES"), stepExecutor.executedSteps);
JsonNode stepOutput = objectMapper.readTree(Files.readString(tempDir.resolve("step-output/02-STOP_SERVICES.json")));
Assertions.assertEquals("STOP_SERVICES", stepOutput.path("stepCode").asText());
Assertions.assertEquals("SUCCESS", stepOutput.path("result").asText());
Assertions.assertEquals("executed STOP_SERVICES", stepOutput.path("summary").asText());
}
@Test
void shouldRejectDuplicateOwnershipWhenStateAlreadyTaken() {
ObjectMapper objectMapper = new ObjectMapper();
HandoffFileStore handoffFileStore = new HandoffFileStore(objectMapper);
ResourceRestoreStateFileStore stateFileStore = new ResourceRestoreStateFileStore(objectMapper);
RecordingRunnerStepExecutor stepExecutor = new RecordingRunnerStepExecutor();
ResourceRestoreRunner runner = new ResourceRestoreRunner(
handoffFileStore,
stateFileStore,
objectMapper,
List.of(stepExecutor)
);
ResourceRestoreTaskEntity entity = sampleTask();
handoffFileStore.write(tempDir, entity);
stateFileStore.initialize(tempDir, entity);
stateFileStore.markHandoffReady(tempDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
runner.run(tempDir.resolve("handoff.json"));
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> runner.run(tempDir.resolve("handoff.json"))
);
Assertions.assertTrue(exception.getMessage().contains("handoff ready"));
}
@Test
void shouldPersistFailureWhenStepExecutorMissing() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
HandoffFileStore handoffFileStore = new HandoffFileStore(objectMapper);
ResourceRestoreStateFileStore stateFileStore = new ResourceRestoreStateFileStore(objectMapper);
ResourceRestoreRunner runner = new ResourceRestoreRunner(
handoffFileStore,
stateFileStore,
objectMapper,
List.of()
);
ResourceRestoreTaskEntity entity = sampleTask();
handoffFileStore.write(tempDir, entity);
stateFileStore.initialize(tempDir, entity);
stateFileStore.markHandoffReady(tempDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> runner.run(tempDir.resolve("handoff.json"))
);
Assertions.assertTrue(exception.getMessage().contains("no runner step executor"));
JsonNode state = objectMapper.readTree(Files.readString(tempDir.resolve("restore-state.json")));
Assertions.assertEquals("FAILED", state.path("status").asText());
Assertions.assertEquals("RECONCILE_PENDING", state.path("controlPhase").asText());
Assertions.assertEquals("STOP_SERVICES", state.path("currentStepCode").asText());
Assertions.assertEquals(2, state.path("currentStepNo").asInt());
Assertions.assertEquals("RUNNER_EXECUTION_FAILED", state.path("errorCode").asText());
Assertions.assertTrue(state.path("errorMessage").asText().contains("no runner step executor"));
JsonNode stepOutput = objectMapper.readTree(Files.readString(tempDir.resolve("step-output/02-STOP_SERVICES.json")));
Assertions.assertEquals("FAILED", stepOutput.path("result").asText());
Assertions.assertTrue(stepOutput.path("summary").asText().contains("no runner step executor"));
}
private ResourceRestoreTaskEntity sampleTask() {
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260413-000001");
entity.setPrecheckId("RPRE-20260413-000001");
entity.setBackupId("RBKP-20260413-153000-000001");
entity.setPackageFileId("FILE-20260413-000001");
entity.setPackagePath("/tmp/backup.tmsbak");
entity.setStagingDir(tempDir.resolve("staging").toString());
entity.setRunnerStatePath(tempDir.resolve("restore-state.json").toString());
entity.setRestorePlanJson("[\"RESTORE_LOCK\",\"STOP_SERVICES\",\"START_SERVICES\"]");
entity.setStatus("HANDOFF");
return entity;
}
private static class RecordingRunnerStepExecutor implements RunnerStepExecutor {
private final List<String> executedSteps = new ArrayList<>();
@Override
public boolean supports(String stepCode) {
return "STOP_SERVICES".equals(stepCode) || "START_SERVICES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
executedSteps.add(context.stepCode());
return RunnerStepExecutionResult.success("executed " + context.stepCode());
}
}
}
@@ -1,62 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class RestoreStateReconcilerTest {
@TempDir
Path tempDir;
@Test
void shouldOnlyReconcileTasksInReconcilePendingPhase() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourceRestoreStateFileStore stateFileStore = new ResourceRestoreStateFileStore(objectMapper);
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
ResourceRestoreTaskEntity running = task("RRST-TASK-001", tempDir.resolve("running/restore-state.json"));
Files.createDirectories(runningStateDir(running));
Files.writeString(Path.of(running.getRunnerStatePath()), """
{"taskId":"RRST-TASK-001","status":"RUNNING","controlPhase":"RUNNER_OWNED","stateSource":"RUNNER_FILE"}
""");
ResourceRestoreTaskEntity pending = task("RRST-TASK-002", tempDir.resolve("pending/restore-state.json"));
Files.createDirectories(runningStateDir(pending));
Files.writeString(Path.of(pending.getRunnerStatePath()), """
{"taskId":"RRST-TASK-002","status":"SUCCESS","controlPhase":"RECONCILE_PENDING","stateSource":"RUNNER_FILE","errorCode":"","errorMessage":""}
""");
Mockito.when(repository.findAll()).thenReturn(List.of(running, pending));
RestoreStateReconciler reconciler = new RestoreStateReconciler(repository, stateFileStore, LocalDateTime::now);
reconciler.reconcileOnce();
ArgumentCaptor<ResourceRestoreTaskEntity> captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class);
Mockito.verify(repository, Mockito.times(1)).update(captor.capture());
Assertions.assertEquals("RRST-TASK-002", captor.getValue().getTaskId());
String stateContent = Files.readString(Path.of(pending.getRunnerStatePath()));
Assertions.assertTrue(stateContent.contains("\"controlPhase\":\"DONE\""));
}
private ResourceRestoreTaskEntity task(String taskId, Path statePath) {
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(taskId);
entity.setStatus("HANDOFF");
entity.setRunnerStatePath(statePath.toString());
return entity;
}
private Path runningStateDir(ResourceRestoreTaskEntity entity) {
return Path.of(entity.getRunnerStatePath()).getParent();
}
}
@@ -1,53 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
class RunnerStallDetectorTest {
@TempDir
Path tempDir;
@Test
void shouldMarkRunnerOwnedTaskAsFailedWhenHeartbeatExpired() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourceRestoreStateFileStore stateFileStore = new ResourceRestoreStateFileStore(objectMapper);
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setRunnerHeartbeatTimeoutSeconds(60);
ResourceRestoreTaskEntity stale = task("RRST-TASK-003", tempDir.resolve("stale/restore-state.json"));
Files.createDirectories(Path.of(stale.getRunnerStatePath()).getParent());
Files.writeString(Path.of(stale.getRunnerStatePath()), """
{"taskId":"RRST-TASK-003","status":"RUNNING","controlPhase":"RUNNER_OWNED","stateSource":"RUNNER_FILE","runnerInstanceId":"RRUN-001","lastHeartbeatAt":"2026-04-14T08:00:00","currentStepCode":"STOP_SERVICES","currentStepNo":2,"orphaned":false}
""");
Mockito.when(repository.findAll()).thenReturn(List.of(stale));
RunnerStallDetector detector = new RunnerStallDetector(repository, stateFileStore, properties, () -> LocalDateTime.parse("2026-04-14T08:05:01"));
detector.detectOnce();
String content = Files.readString(Path.of(stale.getRunnerStatePath()));
Assertions.assertTrue(content.contains("\"status\":\"FAILED\""));
Assertions.assertTrue(content.contains("\"controlPhase\":\"RECONCILE_PENDING\""));
Assertions.assertTrue(content.contains("\"orphaned\":true"));
Assertions.assertTrue(content.contains("RUNNER_STALLED"));
}
private ResourceRestoreTaskEntity task(String taskId, Path statePath) {
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(taskId);
entity.setRunnerStatePath(statePath.toString());
return entity;
}
}
@@ -1,145 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.init.config.InitExecutorProperties;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ServiceCommandRunnerStepExecutorTest {
@TempDir
Path tempDir;
@Test
void shouldInvokeStopCommandsInExpectedOrder() {
RecordingRunnerCommandInvoker invoker = new RecordingRunnerCommandInvoker();
ServiceCommandRunnerStepExecutor executor = new ServiceCommandRunnerStepExecutor(
backupProperties(),
initProperties(),
invoker
);
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260414-000001",
"STOP_SERVICES",
2,
tempDir,
tempDir.resolve("handoff.json"),
tempDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertEquals("executed 3 command(s)", result.getSummary());
Assertions.assertEquals(List.of(
List.of("bash", "/mock/scripts/tms.sh", "stop"),
List.of("/bin/zsh", "-lc", "/mock/bin/stop_standard_apps.sh"),
List.of("/bin/zsh", "-lc", "/mock/bin/stop_standard_rabbitmq.sh")
), invoker.commands);
}
@Test
void shouldInvokeStartCommandsInExpectedOrder() {
RecordingRunnerCommandInvoker invoker = new RecordingRunnerCommandInvoker();
ServiceCommandRunnerStepExecutor executor = new ServiceCommandRunnerStepExecutor(
backupProperties(),
initProperties(),
invoker
);
RunnerStepExecutionResult result = executor.execute(new RunnerStepContext(
"RRST-TASK-20260414-000001",
"START_SERVICES",
3,
tempDir,
tempDir.resolve("handoff.json"),
tempDir.resolve("restore-state.json")
));
Assertions.assertEquals("SUCCESS", result.getResult());
Assertions.assertEquals("executed 2 command(s)", result.getSummary());
Assertions.assertEquals(List.of(
List.of("/bin/zsh", "-lc", "/mock/bin/start_standard_apps.sh"),
List.of("bash", "/mock/scripts/tms.sh", "start")
), invoker.commands);
}
@Test
void shouldRejectUnsupportedStep() {
RecordingRunnerCommandInvoker invoker = new RecordingRunnerCommandInvoker();
ServiceCommandRunnerStepExecutor executor = new ServiceCommandRunnerStepExecutor(
backupProperties(),
initProperties(),
invoker
);
IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260414-000001",
"RESTORE_DATABASES",
4,
tempDir,
tempDir.resolve("handoff.json"),
tempDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("unsupported runner step"));
}
@Test
void shouldSurfaceCommandFailure() {
RecordingRunnerCommandInvoker invoker = new RecordingRunnerCommandInvoker();
invoker.exitCodes.add(1);
ServiceCommandRunnerStepExecutor executor = new ServiceCommandRunnerStepExecutor(
backupProperties(),
initProperties(),
invoker
);
IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> executor.execute(new RunnerStepContext(
"RRST-TASK-20260414-000001",
"STOP_SERVICES",
2,
tempDir,
tempDir.resolve("handoff.json"),
tempDir.resolve("restore-state.json")
))
);
Assertions.assertTrue(exception.getMessage().contains("exitCode=1"));
}
private ResourceBackupProperties backupProperties() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setTmsScriptPath("/mock/scripts/tms.sh");
return properties;
}
private InitExecutorProperties initProperties() {
InitExecutorProperties properties = new InitExecutorProperties();
properties.setStandardAppStartCommand("/mock/bin/start_standard_apps.sh");
properties.setStandardAppStopCommand("/mock/bin/stop_standard_apps.sh");
properties.setStandardRabbitStopCommand("/mock/bin/stop_standard_rabbitmq.sh");
return properties;
}
private static class RecordingRunnerCommandInvoker implements RunnerCommandInvoker {
private final List<List<String>> commands = new ArrayList<>();
private final List<Integer> exitCodes = new ArrayList<>();
@Override
public int run(List<String> command, Path logFile, Path workingDirectory) {
commands.add(List.copyOf(command));
if (exitCodes.isEmpty()) {
return 0;
}
return exitCodes.remove(0);
}
}
}
@@ -0,0 +1,81 @@
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;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.service.impl.ResourcePrecheckServiceImpl;
import com.cisd.tms.modules.backup.support.InMemoryResourcePrecheckStateStore;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
class ResourcePrecheckLightweightServiceTest {
@TempDir
Path tempDir;
@Test
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();
Path packagePath = tempDir.resolve("resource.tmsbak");
FileRecordEntity fileRecord = new FileRecordEntity();
fileRecord.setFileId("FILE-20260429-000001");
fileRecord.setStoragePath(packagePath.toString());
Mockito.when(fileRecordRepository.findByFileId("FILE-20260429-000001"))
.thenReturn(Optional.of(fileRecord));
Mockito.when(packageService.inspectPackageHeader(packagePath)).thenReturn(inspectResult());
ResourcePrecheckService service = new ResourcePrecheckServiceImpl(
fileRecordRepository,
packageService,
store,
new ResourceTaskIdGenerator(),
presetProperties,
backupProperties
);
ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest();
request.setFileId("FILE-20260429-000001");
ResourceRestorePrecheckResponse response = service.precheck(request);
Assertions.assertEquals(List.of("JAVA_EXTRACT", "SHELL_APPLY"), response.getRestorePlan());
Assertions.assertFalse(response.getPayloadDecrypted());
Assertions.assertTrue(response.getSignatureValid());
Assertions.assertTrue(store.find(response.getPrecheckId()).isPresent());
}
private static ResourcePackageInspectResult inspectResult() {
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("backupId", "RBKP-20260429-000001");
manifest.put("productType", "ENTERPRISE");
manifest.put("mqType", "RABBITMQ");
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");
ResourcePackageInspectResult result = new ResourcePackageInspectResult();
result.setManifest(manifest);
return result;
}
}
@@ -1,24 +1,24 @@
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerLauncher;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -31,15 +31,15 @@ class ResourceRestoreHandoffServiceTest {
Path tempDir;
@Test
void shouldCreateRestoreTaskWriteHandoffFilesAndLaunchRunner() throws Exception {
void shouldCreateRestoreTaskExtractPayloadAndLaunchLightweightApplier() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString());
properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper());
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourceRestoreRunnerLauncher runnerLauncher = Mockito.mock(ResourceRestoreRunnerLauncher.class);
ObjectMapper objectMapper = new ObjectMapper();
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
Files.createDirectories(packagePath.getParent());
Files.writeString(packagePath, "package-bytes");
@@ -52,7 +52,8 @@ class ResourceRestoreHandoffServiceTest {
precheckResponse.setKeysetMatched(true);
precheckResponse.setSignatureValid(true);
precheckResponse.setCompatible(true);
precheckResponse.setRestorePlan(java.util.List.of("RESTORE_LOCK", "STOP_SERVICES", "RESTORE_DATABASES", "START_SERVICES"));
precheckResponse.setExpiresAt(LocalDateTime.now().plusMinutes(10).toString());
precheckResponse.setRestorePlan(java.util.List.of("JAVA_EXTRACT", "SHELL_APPLY"));
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
FileRecordEntity fileRecordEntity = new FileRecordEntity();
fileRecordEntity.setFileId("FILE-20260413-000001");
@@ -65,10 +66,9 @@ class ResourceRestoreHandoffServiceTest {
fileRecordRepository,
store,
new ResourceTaskIdGenerator(),
new HandoffFileStore(objectMapper),
new ResourceRestoreStateFileStore(objectMapper),
properties,
runnerLauncher
payloadExtractor,
applierLauncher
);
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
@@ -79,25 +79,99 @@ class ResourceRestoreHandoffServiceTest {
CreateResourceRestoreResponse response = service.createRestoreTask(request);
Path taskDir = tempDir.resolve("resource-restore").resolve(response.getTaskId());
Path handoffFile = taskDir.resolve("handoff.json");
Path stateFile = taskDir.resolve("restore-state.json");
Assertions.assertTrue(Files.exists(handoffFile));
Assertions.assertTrue(Files.exists(stateFile));
JsonNode handoff = objectMapper.readTree(Files.readString(handoffFile));
Assertions.assertEquals(response.getTaskId(), handoff.path("taskId").asText());
Assertions.assertEquals(packagePath.toString(), handoff.path("packagePath").asText());
JsonNode state = objectMapper.readTree(Files.readString(stateFile));
Assertions.assertEquals("HANDOFF_READY", state.path("controlPhase").asText());
Assertions.assertEquals("HANDOFF", state.path("status").asText());
ArgumentCaptor<ResourceRestoreTaskEntity> captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class);
Mockito.verify(repository).save(captor.capture());
ResourceRestoreTaskEntity saved = captor.getValue();
Assertions.assertEquals(packagePath.toString(), saved.getPackagePath());
Assertions.assertEquals("HANDOFF", saved.getStatus());
Assertions.assertEquals("APPLYING", 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());
Mockito.verify(runnerLauncher).launch(handoffFile);
Mockito.verify(payloadExtractor).extract(packagePath, taskDir);
Mockito.verify(applierLauncher).launch(taskDir);
}
@Test
void shouldRejectIncompatiblePrecheckWhenCreatingRestoreTask() {
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);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
precheckResponse.setCompatible(false);
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
fileRecordRepository,
store,
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
applierLauncher
);
BizException exception = Assertions.assertThrows(BizException.class,
() -> service.createRestoreTask(validCreateRequest()));
Assertions.assertTrue(exception.getMessage().contains("not compatible"));
Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher);
}
@Test
void shouldRejectExpiredPrecheckWhenCreatingRestoreTask() {
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);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
precheckResponse.setExpiresAt(LocalDateTime.now().minusMinutes(1).toString());
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
fileRecordRepository,
store,
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
applierLauncher
);
BizException exception = Assertions.assertThrows(BizException.class,
() -> service.createRestoreTask(validCreateRequest()));
Assertions.assertTrue(exception.getMessage().contains("expired"));
Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher);
}
private static ResourceRestorePrecheckResponse validPrecheckResponse() {
ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse();
precheckResponse.setPrecheckId("RPRE-20260413-000001");
precheckResponse.setBackupId("RBKP-20260413-153000-000001");
precheckResponse.setSourceProductType("ENTERPRISE");
precheckResponse.setTargetProductType("ENTERPRISE");
precheckResponse.setKeysetMatched(true);
precheckResponse.setSignatureValid(true);
precheckResponse.setCompatible(true);
precheckResponse.setExpiresAt(LocalDateTime.now().plusMinutes(10).toString());
precheckResponse.setRestorePlan(java.util.List.of("JAVA_EXTRACT", "SHELL_APPLY"));
return precheckResponse;
}
private static CreateResourceRestoreRequest validCreateRequest() {
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
request.setPrecheckId("RPRE-20260413-000001");
request.setConfirmBackupId("RBKP-20260413-153000-000001");
request.setConfirmRiskAccepted(true);
return request;
}
}
@@ -1,241 +0,0 @@
//package com.cisd.tms.modules.backup.service;
//
//import com.cisd.tms.common.config.properties.CisdPresetProperties;
//import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
//import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest;
//import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
//import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
//import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl;
//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.ResourceRestorePrecheckResponse;
//import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
//import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse;
//import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
//import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
//import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
//import com.cisd.tms.modules.backup.packagex.ResourcePackageServiceImpl;
//import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
//import com.cisd.tms.modules.backup.service.impl.ResourcePrecheckServiceImpl;
//import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
//import com.cisd.tms.modules.backup.service.impl.ResourceTaskQueryServiceImpl;
//import com.cisd.tms.modules.backup.support.InMemoryResourcePrecheckStateStore;
//import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
//import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
//import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
//import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
//import com.cisd.tms.modules.file.entity.FileRecordEntity;
//import com.cisd.tms.modules.file.repository.FileRecordRepository;
//import com.cisd.tms.modules.init.entity.InitTaskEntity;
//import com.fasterxml.jackson.databind.ObjectMapper;
//import java.nio.file.Files;
//import java.nio.file.Path;
//import java.util.Optional;
//import org.junit.jupiter.api.Assertions;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.api.io.TempDir;
//import org.mockito.ArgumentCaptor;
//import org.mockito.Mockito;
//
//class ResourceRestoreSupportServiceTest {
//
// @TempDir
// Path tempDir;
//
// @Test
// void shouldParseRealBackupPackageByFileIdAndCachePrecheckContext() {
// InMemoryResourcePrecheckStateStore store = new InMemoryResourcePrecheckStateStore();
// FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
// CisdPresetProperties presetProperties = new CisdPresetProperties();
// presetProperties.setProductType("ENTERPRISE");
// ResourceBackupProperties backupProperties = new ResourceBackupProperties();
// backupProperties.setOutputDir(tempDir.toString());
// Path payloadFile = writePayloadFile("runtime/application.yml", "server.port=9090");
// PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
// Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
// .thenAnswer(invocation -> {
// EccInternalEncryptRequest request = invocation.getArgument(0);
// return backup(request.getData());
// });
// Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any()))
// .thenReturn(backup(new byte[]{0x21, 0x22, 0x23}));
// Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any()))
// .thenAnswer(invocation -> {
// com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest request = invocation.getArgument(0);
// return backup(request.getCipherBlob());
// });
//
// ResourcePackageService packageService = new ResourcePackageServiceImpl(
// backupProperties,
// new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, backupProperties),
// new ObjectMapper()
// );
// ResourcePackageResult packageResult = packageService.createPackage(
// "RBKP-TASK-20260413-000001",
// "RBKP-20260413-153000-000001",
// sampleInitTask(),
// java.util.List.of(resource("TMS_CONFIG", payloadFile.toString())),
// new com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest()
// );
//
// FileRecordEntity fileRecordEntity = new FileRecordEntity();
// fileRecordEntity.setFileId("FILE-20260413-000001");
// fileRecordEntity.setStoragePath(packageResult.getPackagePath());
// Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001")).thenReturn(Optional.of(fileRecordEntity));
//
// ResourcePrecheckService service = new ResourcePrecheckServiceImpl(
// fileRecordRepository,
// packageService,
// store,
// new ResourceTaskIdGenerator(),
// presetProperties,
// backupProperties
// );
//
// ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest();
// request.setFileId("FILE-20260413-000001");
//
// ResourceRestorePrecheckResponse response = service.precheck(request);
//
// Assertions.assertTrue(response.getPrecheckId().startsWith("RPRE-"));
// Assertions.assertEquals("RBKP-20260413-153000-000001", response.getBackupId());
// Assertions.assertEquals("ENTERPRISE", response.getSourceProductType());
// Assertions.assertEquals("RABBITMQ", response.getSourceMqType());
// Assertions.assertEquals("AAAABBBBXXX", response.getSourceOrgCode());
// Assertions.assertEquals("ENTERPRISE", response.getTargetProductType());
// Assertions.assertTrue(response.getKeysetMatched());
// Assertions.assertTrue(response.getSignatureValid());
// Assertions.assertTrue(response.getCompatible());
// Assertions.assertFalse(response.getPayloadDecrypted());
// Assertions.assertEquals("RESTORE_LOCK", response.getRestorePlan().get(0));
//
// ResourcePrecheckContext context = store.find(response.getPrecheckId()).orElseThrow();
// Assertions.assertEquals("FILE-20260413-000001", context.getFileId());
// Assertions.assertEquals(response.getBackupId(), context.getResponse().getBackupId());
// }
//
// @Test
// void shouldCreateAndQueryRestoreTaskFromCachedPrecheck() {
// InMemoryResourcePrecheckStateStore store = new InMemoryResourcePrecheckStateStore();
// ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
// FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
// Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
// try {
// Files.createDirectories(packagePath.getParent());
// Files.writeString(packagePath, "backup-package");
// } catch (Exception ex) {
// throw new IllegalStateException(ex);
// }
//
// ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse();
// precheckResponse.setPrecheckId("RPRE-20260413-000001");
// precheckResponse.setBackupId("RBKP-20260413-153000-000001");
// precheckResponse.setSourceProductType("ENTERPRISE");
// precheckResponse.setTargetProductType("ENTERPRISE");
// precheckResponse.setKeysetMatched(true);
// precheckResponse.setSignatureValid(true);
// precheckResponse.setCompatible(true);
// store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
// FileRecordEntity fileRecordEntity = new FileRecordEntity();
// fileRecordEntity.setFileId("FILE-20260413-000001");
// fileRecordEntity.setStoragePath(packagePath.toString());
// Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001"))
// .thenReturn(Optional.of(fileRecordEntity));
//
// ResourceRestoreService restoreService = new ResourceRestoreServiceImpl(
// repository,
// fileRecordRepository,
// store,
// new ResourceTaskIdGenerator(),
// new HandoffFileStore(new ObjectMapper()),
// new ResourceRestoreStateFileStore(new ObjectMapper()),
// restoreProperties()
// );
//
// CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
// request.setPrecheckId("RPRE-20260413-000001");
// request.setConfirmBackupId("RBKP-20260413-153000-000001");
// request.setConfirmRiskAccepted(true);
//
// CreateResourceRestoreResponse response = restoreService.createRestoreTask(request);
//
// Assertions.assertTrue(response.getTaskId().startsWith("RRST-TASK-"));
// Assertions.assertEquals("HANDOFF", response.getStatus());
//
// ArgumentCaptor<ResourceRestoreTaskEntity> captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class);
// Mockito.verify(repository).save(captor.capture());
// ResourceRestoreTaskEntity saved = captor.getValue();
// Assertions.assertEquals(response.getTaskId(), saved.getTaskId());
// Assertions.assertEquals("RPRE-20260413-000001", saved.getPrecheckId());
// Assertions.assertEquals("RBKP-20260413-153000-000001", saved.getBackupId());
// Assertions.assertEquals("FILE-20260413-000001", saved.getPackageFileId());
// Assertions.assertEquals(packagePath.toString(), saved.getPackagePath());
// Assertions.assertEquals("ENTERPRISE", saved.getSourceProductType());
// Assertions.assertEquals("ENTERPRISE", saved.getTargetProductType());
// Assertions.assertTrue(saved.getStagingDir().contains(response.getTaskId()));
// Assertions.assertTrue(saved.getRunnerStatePath().contains(response.getTaskId()));
//
// Mockito.when(repository.findByTaskId(response.getTaskId())).thenReturn(Optional.of(saved));
// ResourceTaskQueryService queryService = new ResourceTaskQueryServiceImpl(
// repository,
// new ResourceRestoreStateFileStore(new ObjectMapper()),
// new ObjectMapper()
// );
//
// ResourceRestoreTaskDetailResponse detail = queryService.getRestoreTask(response.getTaskId());
//
// Assertions.assertEquals(response.getTaskId(), detail.getTaskId());
// Assertions.assertEquals("HANDOFF", detail.getStatus());
// Assertions.assertEquals("DB", detail.getStateSource());
// Assertions.assertEquals("HANDOFF_READY", detail.getControlPhase());
// Assertions.assertEquals("FILE-20260413-000001", detail.getPackageFileId());
// Assertions.assertEquals("RESTORE_LOCK", detail.getCurrentStepCode());
// Assertions.assertEquals(1, detail.getCurrentStepNo());
// }
//
// private static InitTaskEntity sampleInitTask() {
// InitTaskEntity entity = new InitTaskEntity();
// entity.setTaskId("INIT-20260408-000001");
// entity.setTaskType("INIT");
// entity.setStatus("SUCCESS");
// entity.setProductType("ENTERPRISE");
// entity.setMqType("RABBITMQ");
// entity.setOrgCode("AAAABBBBXXX");
// entity.setDeployMode("SINGLE");
// return entity;
// }
//
// private static DiscoveredResourceItem resource(String code, String path) {
// DiscoveredResourceItem item = new DiscoveredResourceItem();
// item.setResourceType("FILE");
// item.setResourceCode(code);
// item.setResolvedPath(path);
// item.setRequired(true);
// return item;
// }
//
// private Path writePayloadFile(String relativePath, String content) {
// try {
// Path file = tempDir.resolve(relativePath);
// Files.createDirectories(file.getParent());
// Files.writeString(file, content);
// return file;
// } catch (Exception ex) {
// throw new IllegalStateException(ex);
// }
// }
//
// private static BackupDataResult backup(byte[] data) {
// BackupDataResult result = new BackupDataResult();
// result.setData(data);
// result.setLength(data.length);
// return result;
// }
//
// private ResourceBackupProperties restoreProperties() {
// ResourceBackupProperties properties = new ResourceBackupProperties();
// properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString());
// return properties;
// }
//}
@@ -1,158 +1,111 @@
//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.entity.ResourceRestoreTaskEntity;
//import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
//import com.cisd.tms.modules.backup.service.impl.ResourceTaskQueryServiceImpl;
//import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
//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;
//import org.junit.jupiter.api.io.TempDir;
//import org.mockito.Mockito;
//
//class ResourceTaskQueryServiceTest {
//
// @TempDir
// Path tempDir;
//
// @Test
// void shouldPreferRunnerStateFileWhenPresent() throws Exception {
// Path taskDir = tempDir.resolve("RRST-TASK-20260413-000001");
// Files.createDirectories(taskDir);
// Path stateFile = taskDir.resolve("restore-state.json");
// Files.writeString(stateFile, """
// {
// "taskId":"RRST-TASK-20260413-000001",
// "status":"RUNNING",
// "controlPhase":"RUNNER_OWNED",
// "stateSource":"RUNNER_FILE",
// "runnerInstanceId":"RRUN-20260413-000001",
// "runnerPid":1234,
// "ownershipToken":"TOKEN-001",
// "lastHeartbeatAt":"2026-04-13T17:10:00",
// "orphaned":false,
// "cleanupWarnings":["cleanup skipped"]
// }
// """);
//
// ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
// entity.setTaskId("RRST-TASK-20260413-000001");
// entity.setPrecheckId("RPRE-20260413-000001");
// entity.setBackupId("RBKP-20260413-153000-000001");
// entity.setStatus("PENDING");
// entity.setSourceProductType("ENTERPRISE");
// entity.setTargetProductType("ENTERPRISE");
// entity.setPackageFileId("FILE-20260413-000001");
// entity.setRunnerStatePath(stateFile.toString());
// entity.setCreateBy("SYSTEM");
// entity.setCreateTime(LocalDateTime.parse("2026-04-13T17:00:00"));
//
// ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
// Mockito.when(repository.findByTaskId("RRST-TASK-20260413-000001")).thenReturn(Optional.of(entity));
//
// ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl(
// repository,
// new ResourceRestoreStateFileStore(new ObjectMapper()),
// new ObjectMapper()
// );
//
// var detail = service.getRestoreTask("RRST-TASK-20260413-000001");
//
// Assertions.assertEquals("RUNNING", detail.getStatus());
// Assertions.assertEquals("RUNNER_OWNED", detail.getControlPhase());
// Assertions.assertEquals("RUNNER_FILE", detail.getStateSource());
// Assertions.assertEquals("2026-04-13T17:10:00", detail.getLastHeartbeatAt());
// Assertions.assertEquals(Boolean.FALSE, detail.getOrphaned());
// Assertions.assertEquals(1, detail.getCleanupWarnings().size());
// Assertions.assertEquals("cleanup skipped", detail.getCleanupWarnings().get(0));
// }
//
// @Test
// void shouldFallbackToDatabaseStateWhenRunnerStateFileMissing() {
// ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
// entity.setTaskId("RRST-TASK-20260413-000002");
// entity.setPrecheckId("RPRE-20260413-000002");
// entity.setBackupId("RBKP-20260413-153500-000002");
// entity.setStatus("PENDING");
// entity.setSourceProductType("ENTERPRISE");
// entity.setTargetProductType("ENTERPRISE");
// entity.setPackageFileId("FILE-20260413-000002");
// entity.setRunnerStatePath(tempDir.resolve("missing.json").toString());
//
// ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
// Mockito.when(repository.findByTaskId("RRST-TASK-20260413-000002")).thenReturn(Optional.of(entity));
//
// ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl(
// repository,
// new ResourceRestoreStateFileStore(new ObjectMapper()),
// new ObjectMapper()
// );
//
// var detail = service.getRestoreTask("RRST-TASK-20260413-000002");
//
// Assertions.assertEquals("PENDING", detail.getStatus());
// Assertions.assertEquals("PRECHECKED", detail.getControlPhase());
// Assertions.assertEquals("DB", detail.getStateSource());
// }
//
// @Test
// void shouldExposeRestoreStepsAndLogsFromRunnerArtifacts() throws Exception {
// Path taskDir = tempDir.resolve("RRST-TASK-20260413-000003");
// Files.createDirectories(taskDir.resolve("step-output"));
// Files.createDirectories(taskDir.resolve("steps"));
// Path stateFile = taskDir.resolve("restore-state.json");
// Files.writeString(stateFile, """
// {
// "taskId":"RRST-TASK-20260413-000003",
// "status":"RUNNING",
// "controlPhase":"RUNNER_OWNED",
// "stateSource":"RUNNER_FILE",
// "currentStepCode":"START_SERVICES",
// "currentStepNo":3
// }
// """);
// Files.writeString(taskDir.resolve("step-output/02-STOP_SERVICES.json"), """
// {
// "stepCode":"STOP_SERVICES",
// "result":"SUCCESS",
// "summary":"stopped services"
// }
// """);
// Files.writeString(taskDir.resolve("steps/02-STOP_SERVICES.log"), "stop log line");
//
// ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
// entity.setTaskId("RRST-TASK-20260413-000003");
// entity.setStatus("HANDOFF");
// entity.setRestorePlanJson("[\"RESTORE_LOCK\",\"STOP_SERVICES\",\"START_SERVICES\"]");
// entity.setRunnerStatePath(stateFile.toString());
//
// ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
// Mockito.when(repository.findByTaskId("RRST-TASK-20260413-000003")).thenReturn(Optional.of(entity));
//
// ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl(
// repository,
// new ResourceRestoreStateFileStore(new ObjectMapper()),
// new ObjectMapper()
// );
//
// List<ResourceTaskStepItemResponse> steps = service.getRestoreTaskSteps("RRST-TASK-20260413-000003");
// Assertions.assertEquals(3, steps.size());
// Assertions.assertEquals("PENDING", steps.get(0).getStatus());
// Assertions.assertEquals("SUCCESS", steps.get(1).getStatus());
// Assertions.assertEquals("stopped services", steps.get(1).getMessage());
// Assertions.assertEquals(Boolean.TRUE, steps.get(1).getLogAvailable());
// Assertions.assertEquals("RUNNING", steps.get(2).getStatus());
//
// ResourceTaskStepLogResponse log = service.getRestoreTaskStepLog("RRST-TASK-20260413-000003", 2);
// Assertions.assertEquals("STOP_SERVICES", log.getStepCode());
// Assertions.assertEquals("stop log line", log.getContent());
// Assertions.assertFalse(log.getTruncated());
// }
//}
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.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
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;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
class ResourceTaskQueryServiceTest {
@TempDir
Path tempDir;
@Test
void shouldPreferLightweightStatusFileWhenPresent() 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())
);
var detail = service.getRestoreTask("RRST-TASK-20260429-000001");
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());
}
private static ResourceRestoreTaskEntity restoreTask(Path taskDir) {
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260429-000001");
entity.setPrecheckId("RPRE-20260429-000001");
entity.setBackupId("RBKP-20260429-153000-000001");
entity.setStatus("APPLYING");
entity.setSourceProductType("ENTERPRISE");
entity.setTargetProductType("ENTERPRISE");
entity.setPackageFileId("FILE-20260429-000001");
entity.setRunnerStatePath(taskDir.resolve("status.json").toString());
entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]");
entity.setCreateBy("SYSTEM");
entity.setCreateTime(LocalDateTime.parse("2026-04-29T09:59:00"));
return entity;
}
}
@@ -1,35 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ResourceRestoreStateFileStoreTest {
@TempDir
Path tempDir;
@Test
void shouldPromoteStateToHandoffReady() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ResourceRestoreStateFileStore store = new ResourceRestoreStateFileStore(objectMapper);
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId("RRST-TASK-20260413-000001");
entity.setStatus("PENDING");
store.initialize(tempDir, entity);
store.markHandoffReady(tempDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
JsonNode state = objectMapper.readTree(Files.readString(tempDir.resolve("restore-state.json")));
Assertions.assertEquals("HANDOFF", state.path("status").asText());
Assertions.assertEquals("HANDOFF_READY", state.path("controlPhase").asText());
Assertions.assertEquals("DB", state.path("stateSource").asText());
Assertions.assertEquals("RESTORE_LOCK", state.path("currentStepCode").asText());
Assertions.assertEquals(1, state.path("currentStepNo").asInt());
}
}
@@ -1,12 +1,18 @@
package com.cisd.tms.modules.cert.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.integration.crypto.pcie.jna.EccRefPublicKey;
import com.cisd.tms.integration.crypto.pcie.jna.EccSignature;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.model.GenerateKeyPairRequest;
import com.cisd.tms.integration.crypto.pcie.model.UserKeySignRequest;
import com.cisd.tms.integration.crypto.pcie.model.UserKeySm2SignRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.cert.dto.EntityCreateRequest;
import com.cisd.tms.modules.cert.dto.EntityGenP10Request;
import com.cisd.tms.modules.cert.dto.EntityItemResponse;
import com.cisd.tms.modules.cert.dto.EntityListRequest;
import com.cisd.tms.modules.cert.dto.EntityUpdateRequest;
import com.cisd.tms.modules.cert.dto.Subject;
import com.cisd.tms.modules.cert.entity.KeyEntity;
import com.cisd.tms.modules.cert.repository.CertificateRepository;
@@ -26,14 +32,177 @@ import java.security.Security;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
class EntityServiceTest {
@Test
void shouldListEntityWithUiFieldsAndStatusName() {
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
CertificateRepository certificateRepository = Mockito.mock(CertificateRepository.class);
EntityService service = new EntityService(
keyEntityRepository,
certificateRepository,
Mockito.mock(PcieCryptoService.class),
new ObjectMapper()
);
KeyEntity entity = new KeyEntity()
.setOrgCode("00000001")
.setOrgName("中国工商银行杭州分行")
.setAlgoType("SM2")
.setKeyLen(256)
.setKeyIdx(2)
.setStatus("CERT_IMPORTED")
.setAllowIps("[]");
entity.setId(10L);
Page<KeyEntity> page = new Page<>(1, 10, 1);
page.setRecords(List.of(entity));
EntityListRequest request = new EntityListRequest();
request.setOrgCode("00000001");
request.setAlgoType("SM2");
request.setStatus("CERT_IMPORTED");
Mockito.when(keyEntityRepository.page(request)).thenReturn(page);
Page<EntityItemResponse> result = service.list(request);
assertEquals(1, result.getRecords().size());
EntityItemResponse item = result.getRecords().get(0);
assertEquals("00000001", item.getOrgCode());
assertEquals("中国工商银行杭州分行", item.getOrgName());
assertEquals("SM2", item.getAlgoType());
assertEquals("CERT_IMPORTED", item.getStatus());
assertEquals("已导入证书", item.getStatusName());
}
@Test
void shouldCreateEntityWithOrgFieldsAndCreatedStatus() {
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
CertificateRepository certificateRepository = Mockito.mock(CertificateRepository.class);
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
EntityService service = new EntityService(keyEntityRepository, certificateRepository, pcieCryptoService, new ObjectMapper());
Mockito.when(keyEntityRepository.findUsedKeyIdx()).thenReturn(List.of());
Mockito.when(keyEntityRepository.save(any())).thenAnswer(invocation -> {
KeyEntity saved = invocation.getArgument(0);
saved.setId(100L);
return saved;
});
Mockito.when(pcieCryptoService.backupUserKey(Mockito.eq(2), Mockito.anyInt(), Mockito.anyInt()))
.thenReturn(binaryResult(new byte[] {0x01, 0x02}));
Mockito.when(pcieCryptoService.exportSignPublicKeyEcc(2))
.thenReturn(binaryResult(new byte[] {0x03, 0x04}));
EntityCreateRequest request = new EntityCreateRequest();
request.setOrgCode("00000001");
request.setOrgName("中国工商银行杭州分行");
request.setAlgoType("SM2");
request.setAllowIps(List.of("127.0.0.1"));
service.create(request);
ArgumentCaptor<GenerateKeyPairRequest> generateCaptor = ArgumentCaptor.forClass(GenerateKeyPairRequest.class);
Mockito.verify(pcieCryptoService).generateKeyPairEcc(generateCaptor.capture());
assertEquals(256, generateCaptor.getValue().getKeyBits());
assertEquals(2, generateCaptor.getValue().getKeyIndex());
ArgumentCaptor<KeyEntity> entityCaptor = ArgumentCaptor.forClass(KeyEntity.class);
Mockito.verify(keyEntityRepository).save(entityCaptor.capture());
KeyEntity saved = entityCaptor.getValue();
assertEquals("00000001", saved.getOrgCode());
assertEquals("中国工商银行杭州分行", saved.getOrgName());
assertEquals("SM2", saved.getAlgoType());
assertEquals("CREATED", saved.getStatus());
}
@Test
void shouldRejectUnsupportedEntityAlgoType() {
EntityService service = new EntityService(
Mockito.mock(KeyEntityRepository.class),
Mockito.mock(CertificateRepository.class),
Mockito.mock(PcieCryptoService.class),
new ObjectMapper()
);
EntityCreateRequest request = new EntityCreateRequest();
request.setOrgCode("00000001");
request.setOrgName("中国工商银行杭州分行");
request.setAlgoType("DSA");
assertThrows(RuntimeException.class, () -> service.create(request));
}
@Test
void shouldCreateRsaEntityWhenAlgoTypeIsRsa() {
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
CertificateRepository certificateRepository = Mockito.mock(CertificateRepository.class);
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
EntityService service = new EntityService(keyEntityRepository, certificateRepository, pcieCryptoService, new ObjectMapper());
Mockito.when(keyEntityRepository.findUsedKeyIdx()).thenReturn(List.of());
Mockito.when(keyEntityRepository.save(any())).thenAnswer(invocation -> {
KeyEntity saved = invocation.getArgument(0);
saved.setId(101L);
return saved;
});
Mockito.when(pcieCryptoService.backupUserKey(Mockito.eq(2), Mockito.anyInt(), Mockito.anyInt()))
.thenReturn(binaryResult(new byte[] {0x05, 0x06}));
Mockito.when(pcieCryptoService.exportSignPublicKeyRsa(2))
.thenReturn(binaryResult(new byte[] {0x07, 0x08}));
EntityCreateRequest request = new EntityCreateRequest();
request.setOrgCode("00000002");
request.setOrgName("中国工商银行上海分行");
request.setAlgoType("RSA");
service.create(request);
ArgumentCaptor<GenerateKeyPairRequest> generateCaptor = ArgumentCaptor.forClass(GenerateKeyPairRequest.class);
Mockito.verify(pcieCryptoService).generateKeyPairRsa(generateCaptor.capture());
assertEquals(2048, generateCaptor.getValue().getKeyBits());
assertEquals(2, generateCaptor.getValue().getKeyIndex());
ArgumentCaptor<KeyEntity> entityCaptor = ArgumentCaptor.forClass(KeyEntity.class);
Mockito.verify(keyEntityRepository).save(entityCaptor.capture());
assertEquals("RSA", entityCaptor.getValue().getAlgoType());
assertEquals(2048, entityCaptor.getValue().getKeyLen());
assertEquals("CREATED", entityCaptor.getValue().getStatus());
}
@Test
void shouldUpdateEntityOrgFields() {
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
CertificateRepository certificateRepository = Mockito.mock(CertificateRepository.class);
EntityService service = new EntityService(
keyEntityRepository,
certificateRepository,
Mockito.mock(PcieCryptoService.class),
new ObjectMapper()
);
KeyEntity entity = new KeyEntity();
entity.setId(10L);
entity.setOrgCode("00000001");
entity.setOrgName("旧机构");
Mockito.when(keyEntityRepository.findById(10L)).thenReturn(Optional.of(entity));
Mockito.when(keyEntityRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
EntityUpdateRequest request = new EntityUpdateRequest();
request.setId(10L);
request.setOrgCode("00000002");
request.setOrgName("中国工商银行上海分行");
request.setAllowIps(List.of("10.0.0.1"));
service.update(request);
ArgumentCaptor<KeyEntity> entityCaptor = ArgumentCaptor.forClass(KeyEntity.class);
Mockito.verify(keyEntityRepository).save(entityCaptor.capture());
assertEquals("00000002", entityCaptor.getValue().getOrgCode());
assertEquals("中国工商银行上海分行", entityCaptor.getValue().getOrgName());
assertEquals("[\"10.0.0.1\"]", entityCaptor.getValue().getAllowIps());
}
@Test
void shouldGenerateP10WithStandardSm2Sm3Signer() throws Exception {
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
@@ -52,6 +221,11 @@ class EntityServiceTest {
Mockito.verify(pcieCryptoService, Mockito.never()).userKeySignWithSm3(any(UserKeySignRequest.class));
assertEquals(5, sm2Captor.getValue().getKeyIndex());
assertEquals("1234567812345678", new String(sm2Captor.getValue().getUserId()));
ArgumentCaptor<KeyEntity> entityCaptor = ArgumentCaptor.forClass(KeyEntity.class);
Mockito.verify(keyEntityRepository).save(entityCaptor.capture());
assertEquals("SINGLE", entityCaptor.getValue().getCertMode());
assertEquals("P10_GENERATED", entityCaptor.getValue().getStatus());
}
@Test
@@ -82,6 +256,7 @@ class EntityServiceTest {
subject.setCountry("CN");
EntityGenP10Request request = new EntityGenP10Request();
request.setId(10L);
request.setCertMode("SINGLE");
request.setSubject(subject);
return request;
}
@@ -90,10 +265,19 @@ class EntityServiceTest {
KeyEntity entity = new KeyEntity();
entity.setId(id);
entity.setKeyIdx(keyIdx);
entity.setAlgoType("SM2");
entity.setStatus("CREATED");
entity.setPublicKey(Base64.getEncoder().encodeToString(gm0018EccRefPublicKey(generateSm2PublicKey())));
return entity;
}
private BackupDataResult binaryResult(byte[] data) {
BackupDataResult result = new BackupDataResult();
result.setLength(data.length);
result.setData(data);
return result;
}
private BackupDataResult signatureResult() {
EccSignature signature = new EccSignature();
signature.r[EccRefPublicKey.MAX_LEN - 1] = 0x01;