diff --git a/README.md b/README.md index 92c4e3b..fc30b73 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,38 @@ Open: - `FIRMWARE` 不增加额外后端流程,具体固件刷写、重启、恢复提示由包内脚本负责 - 回滚不自动触发,需要调用回滚接口 - `TMS`、`RECEIVER` 升级成功后会更新 `tms_device_software_version`;`FIRMWARE` 暂不维护版本表 + +## Resource Backup / Restore + +最小调用顺序: +- `GET /api/v1/resource-backups/keyset/status` +- `POST /api/v1/resource-backups` +- `GET /api/v1/resource-backups/{taskId}` +- `GET /api/v1/resource-backups/{taskId}/download` +- `POST /api/v1/resource-restores/precheck` +- `POST /api/v1/resource-restores` +- `GET /api/v1/resource-restores/{taskId}` +- `GET /api/v1/resource-restores/{taskId}/steps` +- `GET /api/v1/resource-restores/{taskId}/steps/{stepNo}/log` + +当前实现边界: +- 资源备份包输出为 `.tmsbak`,包内包含 `manifest.json`、`envelope.json`、`payload.enc`、`signature.sig` +- 预检阶段只做包头解析、指纹匹配和验签,不提前解密完整 payload +- 恢复任务创建后会生成 `handoff.json`、`restore-state.json`,并进入 `HANDOFF_READY` +- `ResourceRestoreRunner` 当前已具备接管、步骤循环、`step-output/*.json`、心跳刷新、`RECONCILE_PENDING` 推进能力 +- `STOP_SERVICES` / `START_SERVICES` 已对接现有 `tms.sh` 与标准版启停脚本配置 + +关键配置: +- `tms.backup.output-dir` +- `tms.backup.precheck-store-dir` +- `tms.backup.restore-task-root-dir` +- `tms.backup.tms-script-path` +- `tms.backup.runner-auto-launch-enabled` +- `tms.backup.runner-heartbeat-timeout-seconds` + +恢复前置条件: +- LMK、IK、数据存储加密密钥对、数据真实性保护密钥对需先通过 UKey 恢复 +- 默认不自动拉起本机 runner;如需在现场机器自动执行,显式开启 `tms.backup.runner-auto-launch-enabled=true` - 升级执行日志统一写入 `tms.upgrade.log-dir` 当前配置项: diff --git a/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java new file mode 100644 index 0000000..4eafd6d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java @@ -0,0 +1,80 @@ +package com.cisd.tms.modules.backup.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "tms.backup") +public class ResourceBackupProperties { + + private String outputDir = "/home/tms/tmp/resource-backup-packages"; + 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 boolean runnerAutoLaunchEnabled = false; + private int runnerHeartbeatTimeoutSeconds = 300; + private int storageEncKeyIndex = 1; + private int authenticitySignKeyIndex = 1; + + public String getOutputDir() { + return outputDir; + } + + public void setOutputDir(String outputDir) { + this.outputDir = outputDir; + } + + public String getPrecheckStoreDir() { + return precheckStoreDir; + } + + public void setPrecheckStoreDir(String precheckStoreDir) { + this.precheckStoreDir = precheckStoreDir; + } + + public String getRestoreTaskRootDir() { + return restoreTaskRootDir; + } + + public void setRestoreTaskRootDir(String restoreTaskRootDir) { + this.restoreTaskRootDir = restoreTaskRootDir; + } + + public String getTmsScriptPath() { + return tmsScriptPath; + } + + public void setTmsScriptPath(String tmsScriptPath) { + this.tmsScriptPath = tmsScriptPath; + } + + 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; + } + + public void setStorageEncKeyIndex(int storageEncKeyIndex) { + this.storageEncKeyIndex = storageEncKeyIndex; + } + + public int getAuthenticitySignKeyIndex() { + return authenticitySignKeyIndex; + } + + public void setAuthenticitySignKeyIndex(int authenticitySignKeyIndex) { + this.authenticitySignKeyIndex = authenticitySignKeyIndex; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java new file mode 100644 index 0000000..7a944a5 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java @@ -0,0 +1,98 @@ +package com.cisd.tms.modules.backup.controller; + +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; +import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult; +import com.cisd.tms.security.internal.ReplayProtected; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/resource-backups") +@Tag(name = "资源备份", description = "资源备份创建、状态查询和步骤日志接口") +public class ResourceBackupController { + + private final ResourceBackupService resourceBackupService; + + public ResourceBackupController(ResourceBackupService resourceBackupService) { + this.resourceBackupService = resourceBackupService; + } + + @GetMapping("/keyset/status") + @Operation(summary = "查询资源备份密钥状态", description = "返回资源备份恢复前置密钥状态和公钥指纹。") + public ApiResponse keysetStatus() { + return ApiResponse.success(resourceBackupService.getKeysetStatus()); + } + + @PostMapping + @ReplayProtected + @Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。") + public ApiResponse createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) { + return ApiResponse.success(resourceBackupService.createBackupTask(request)); + } + + @GetMapping("/{taskId}") + @Operation(summary = "查询资源备份任务详情", description = "返回资源备份任务详情。") + public ApiResponse taskDetail( + @Parameter(description = "备份任务号") + @PathVariable String taskId + ) { + return ApiResponse.success(resourceBackupService.getBackupTask(taskId)); + } + + @GetMapping("/{taskId}/download") + @Operation(summary = "下载资源备份包", description = "以浏览器附件方式下载资源备份包。") + public ResponseEntity downloadPackage( + @Parameter(description = "备份任务号") + @PathVariable String taskId + ) { + ResourceBackupDownloadResult result = resourceBackupService.downloadBackupPackage(taskId); + FileSystemResource resource = new FileSystemResource(result.getPackagePath()); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(result.getContentLength()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(result.getFilename()).build().toString()) + .body(resource); + } + + @GetMapping("/{taskId}/steps") + @Operation(summary = "查询资源备份任务步骤", description = "返回资源备份任务步骤列表。") + public ApiResponse> taskSteps( + @Parameter(description = "备份任务号") + @PathVariable String taskId + ) { + return ApiResponse.success(resourceBackupService.getBackupTaskSteps(taskId)); + } + + @GetMapping("/{taskId}/steps/{stepNo}/log") + @Operation(summary = "查询资源备份步骤日志", description = "返回资源备份任务步骤日志。") + public ApiResponse taskStepLog( + @Parameter(description = "备份任务号") + @PathVariable String taskId, + @Parameter(description = "步骤序号") + @PathVariable int stepNo + ) { + return ApiResponse.success(resourceBackupService.getBackupTaskStepLog(taskId, stepNo)); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java new file mode 100644 index 0000000..49269ed --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java @@ -0,0 +1,87 @@ +package com.cisd.tms.modules.backup.controller; + +import com.cisd.tms.common.api.ApiResponse; +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.response.ResourceRestoreTaskDetailResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.service.ResourcePrecheckService; +import com.cisd.tms.modules.backup.service.ResourceRestoreService; +import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; +import com.cisd.tms.security.internal.ReplayProtected; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/resource-restores") +@Tag(name = "资源恢复", description = "资源恢复预检、任务创建、详情和步骤日志接口") +public class ResourceRestoreController { + + private final ResourcePrecheckService resourcePrecheckService; + private final ResourceRestoreService resourceRestoreService; + private final ResourceTaskQueryService resourceTaskQueryService; + + public ResourceRestoreController( + ResourcePrecheckService resourcePrecheckService, + ResourceRestoreService resourceRestoreService, + ResourceTaskQueryService resourceTaskQueryService + ) { + this.resourcePrecheckService = resourcePrecheckService; + this.resourceRestoreService = resourceRestoreService; + this.resourceTaskQueryService = resourceTaskQueryService; + } + + @PostMapping("/precheck") + @Operation(summary = "预检资源恢复请求", description = "根据 fileId 校验备份包头、签名和恢复范围摘要。") + public ApiResponse precheck(@Valid @RequestBody ResourceRestorePrecheckRequest request) { + return ApiResponse.success(resourcePrecheckService.precheck(request)); + } + + @PostMapping + @ReplayProtected + @Operation(summary = "创建资源恢复任务", description = "根据预检号创建资源恢复任务。") + public ApiResponse createRestoreTask(@Valid @RequestBody CreateResourceRestoreRequest request) { + return ApiResponse.success(resourceRestoreService.createRestoreTask(request)); + } + + @GetMapping("/{taskId}") + @Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。") + public ApiResponse taskDetail( + @Parameter(description = "恢复任务号") + @PathVariable String taskId + ) { + return ApiResponse.success(resourceTaskQueryService.getRestoreTask(taskId)); + } + + @GetMapping("/{taskId}/steps") + @Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。") + public ApiResponse> taskSteps( + @Parameter(description = "恢复任务号") + @PathVariable String taskId + ) { + return ApiResponse.success(resourceTaskQueryService.getRestoreTaskSteps(taskId)); + } + + @GetMapping("/{taskId}/steps/{stepNo}/log") + @Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。") + public ApiResponse taskStepLog( + @Parameter(description = "恢复任务号") + @PathVariable String taskId, + @Parameter(description = "步骤序号") + @PathVariable int stepNo + ) { + return ApiResponse.success(resourceTaskQueryService.getRestoreTaskStepLog(taskId, stepNo)); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoService.java b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoService.java new file mode 100644 index 0000000..3b2d0ad --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoService.java @@ -0,0 +1,17 @@ +package com.cisd.tms.modules.backup.crypto; + +public interface ResourceEnvelopeCryptoService { + + ResourceSealedPayload seal(byte[] payloadBytes); + + byte[] sign(byte[] dataToSign); + + void verify(byte[] dataToVerify, byte[] signature); + + byte[] unsealAndVerify( + java.util.Map envelope, + byte[] cipherBytes, + byte[] dataToVerify, + byte[] signature + ); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java new file mode 100644 index 0000000..acedbc2 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java @@ -0,0 +1,185 @@ +package com.cisd.tms.modules.backup.crypto; + +import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest; +import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import java.nio.charset.StandardCharsets; +import java.security.Security; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.bouncycastle.crypto.digests.SM3Digest; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.springframework.stereotype.Service; + +@Service +public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCryptoService { + + private static final String PROVIDER = "BC"; + private final PcieCryptoService pcieCryptoService; + private final ResourceBackupProperties resourceBackupProperties; + + static { + if (Security.getProvider(PROVIDER) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + public ResourceEnvelopeCryptoServiceImpl( + PcieCryptoService pcieCryptoService, + ResourceBackupProperties resourceBackupProperties + ) { + this.pcieCryptoService = pcieCryptoService; + this.resourceBackupProperties = resourceBackupProperties; + } + + @Override + public ResourceSealedPayload seal(byte[] payloadBytes) { + if (payloadBytes == null) { + throw new IllegalArgumentException("payloadBytes is required"); + } + // 资源包正文使用随机 SM4 DEK 加密,再由用户密钥把 DEK 封装成 envelope。 + // 这样大 payload 仍走对称加密,ECC 只负责保护一次性数据密钥。 + byte[] sm4Key = randomBytes(16); + byte[] iv = randomBytes(16); + try { + Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", PROVIDER); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sm4Key, "SM4"), new IvParameterSpec(iv)); + + // 用户密钥只负责封装随机 DEK;真正的大 payload 仍走对称加密,避免直接把大文件送进 ECC 接口。 + EccInternalEncryptRequest encryptRequest = new EccInternalEncryptRequest(); + encryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex()); + encryptRequest.setData(sm4Key); + BackupDataResult wrappedDek = pcieCryptoService.sdfeInternalEncryptEcc(encryptRequest); + + Map envelope = new LinkedHashMap<>(); + envelope.put("version", 1); + envelope.put("payloadAlg", "SM4-CBC"); + envelope.put("keyWrapAlg", "SDFE_InternalEncrypt_ECC"); + envelope.put("ivBase64", Base64.getEncoder().encodeToString(iv)); + envelope.put("wrappedDekBase64", Base64.getEncoder().encodeToString(normalize(wrappedDek))); + envelope.put("dekLength", sm4Key.length); + + ResourceSealedPayload sealedPayload = new ResourceSealedPayload(); + sealedPayload.setCipherBytes(cipher.doFinal(payloadBytes)); + sealedPayload.setEnvelope(envelope); + sealedPayload.setStorageEncKeyFingerprint(buildFingerprint("USER_KEY_ENC", resourceBackupProperties.getStorageEncKeyIndex())); + sealedPayload.setAuthenticityKeyFingerprint(buildFingerprint("USER_KEY_SIGN", resourceBackupProperties.getAuthenticitySignKeyIndex())); + return sealedPayload; + } catch (Exception ex) { + throw new IllegalStateException("failed to seal resource payload", ex); + } + } + + @Override + public byte[] sign(byte[] dataToSign) { + if (dataToSign == null) { + throw new IllegalArgumentException("dataToSign is required"); + } + EccInternalSignRequest request = new EccInternalSignRequest(); + request.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex()); + // 内部签名接口这里统一签 SM3 摘要,避免把整段原文直接交给密码卡并固定后续验签输入语义。 + request.setData(sm3(dataToSign)); + return normalize(pcieCryptoService.eccInternalSign(request)); + } + + @Override + public void verify(byte[] dataToVerify, byte[] signature) { + if (dataToVerify == null) { + throw new IllegalArgumentException("dataToVerify is required"); + } + if (signature == null || signature.length == 0) { + throw new IllegalArgumentException("signature is required"); + } + EccInternalVerifyRequest verifyRequest = new EccInternalVerifyRequest(); + verifyRequest.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex()); + verifyRequest.setData(sm3(dataToVerify)); + verifyRequest.setSignature(signature); + pcieCryptoService.eccInternalVerify(verifyRequest); + } + + @Override + public byte[] unsealAndVerify(Map envelope, byte[] cipherBytes, byte[] dataToVerify, byte[] signature) { + if (envelope == null) { + throw new IllegalArgumentException("envelope is required"); + } + if (cipherBytes == null) { + throw new IllegalArgumentException("cipherBytes is required"); + } + if (dataToVerify == null) { + throw new IllegalArgumentException("dataToVerify is required"); + } + if (signature == null || signature.length == 0) { + throw new IllegalArgumentException("signature is required"); + } + verify(dataToVerify, signature); + + byte[] wrappedDek = Base64.getDecoder().decode(String.valueOf(envelope.get("wrappedDekBase64"))); + int dekLength = parseInt(envelope.get("dekLength"), 16); + // 先验签,再解数字信封,再解 payload;顺序固定,避免篡改包先进入明文处理路径。 + EccInternalDecryptRequest decryptRequest = new EccInternalDecryptRequest(); + decryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex()); + decryptRequest.setCipherBlob(wrappedDek); + decryptRequest.setOutBufferSize(dekLength); + byte[] sm4Key = normalize(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest)); + byte[] iv = Base64.getDecoder().decode(String.valueOf(envelope.get("ivBase64"))); + try { + Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", PROVIDER); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(sm4Key, "SM4"), new IvParameterSpec(iv)); + return cipher.doFinal(cipherBytes); + } catch (Exception ex) { + throw new IllegalStateException("failed to unseal resource payload", ex); + } + } + + private static byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + new java.security.SecureRandom().nextBytes(bytes); + return bytes; + } + + private static byte[] sm3(byte[] data) { + SM3Digest digest = new SM3Digest(); + digest.update(data, 0, data.length); + byte[] out = new byte[digest.getDigestSize()]; + digest.doFinal(out, 0); + return out; + } + + private static byte[] normalize(BackupDataResult result) { + if (result == null || result.getData() == null) { + return new byte[0]; + } + int safeLength = result.getLength() > 0 && result.getLength() <= result.getData().length + ? result.getLength() + : result.getData().length; + byte[] normalized = new byte[safeLength]; + System.arraycopy(result.getData(), 0, normalized, 0, safeLength); + return normalized; + } + + private static int parseInt(Object value, int defaultValue) { + if (value == null) { + return defaultValue; + } + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + private static String buildFingerprint(String prefix, int keyIndex) { + return prefix + ":" + keyIndex; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceSealedPayload.java b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceSealedPayload.java new file mode 100644 index 0000000..a593ec4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceSealedPayload.java @@ -0,0 +1,43 @@ +package com.cisd.tms.modules.backup.crypto; + +import java.util.Map; + +public class ResourceSealedPayload { + + private byte[] cipherBytes; + private Map envelope; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + + public byte[] getCipherBytes() { + return cipherBytes; + } + + public void setCipherBytes(byte[] cipherBytes) { + this.cipherBytes = cipherBytes; + } + + public Map getEnvelope() { + return envelope; + } + + public void setEnvelope(Map envelope) { + this.envelope = envelope; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryService.java b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryService.java new file mode 100644 index 0000000..da2c04f --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryService.java @@ -0,0 +1,10 @@ +package com.cisd.tms.modules.backup.discovery; + +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import java.util.List; + +public interface ResourceDiscoveryService { + + List discover(InitTaskEntity initTask); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java new file mode 100644 index 0000000..0571004 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java @@ -0,0 +1,80 @@ +package com.cisd.tms.modules.backup.discovery; + +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.init.config.InitExecutorProperties; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { + + private final InitExecutorProperties initExecutorProperties; + + public ResourceDiscoveryServiceImpl(InitExecutorProperties initExecutorProperties) { + this.initExecutorProperties = initExecutorProperties; + } + + @Override + public List discover(InitTaskEntity initTask) { + if (initTask == null) { + throw new IllegalArgumentException("initTask is required"); + } + String productType = upper(initTask.getProductType()); + String mqType = upper(initTask.getMqType()); + if (productType.isEmpty() || mqType.isEmpty()) { + throw new IllegalArgumentException("productType and mqType are required"); + } + + List items = new ArrayList<>(); + items.add(item("FILE", "TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), 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)); + } + return items; + } + + private String resolveCpConfigPath(String productType) { + if ("DIRECT".equals(productType)) { + return initExecutorProperties.getDirectCpconfigTargetPath(); + } + return initExecutorProperties.getStandardCpconfigPath(); + } + + private String resolveReceiverLicensePath(String productType) { + if ("DIRECT".equals(productType)) { + return initExecutorProperties.getDirectReceiverLicenseTargetPath(); + } + 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) { + DiscoveredResourceItem item = new DiscoveredResourceItem(); + item.setResourceType(type); + item.setResourceCode(code); + item.setResolvedPath(path); + item.setRequired(required); + return item; + } + + private static String upper(String value) { + return value == null ? "" : value.trim().toUpperCase(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java new file mode 100644 index 0000000..f742542 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java @@ -0,0 +1,40 @@ +package com.cisd.tms.modules.backup.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "创建资源备份任务请求") +public class CreateResourceBackupRequest { + + @Schema(description = "是否包含升级历史", example = "true") + private Boolean includeUpgradeHistory; + + @Schema(description = "是否包含审计备份", example = "false") + private Boolean includeAuditBackups; + + @Schema(description = "备注", example = "before hardware replacement") + private String remark; + + public Boolean getIncludeUpgradeHistory() { + return includeUpgradeHistory; + } + + public void setIncludeUpgradeHistory(Boolean includeUpgradeHistory) { + this.includeUpgradeHistory = includeUpgradeHistory; + } + + public Boolean getIncludeAuditBackups() { + return includeAuditBackups; + } + + public void setIncludeAuditBackups(Boolean includeAuditBackups) { + this.includeAuditBackups = includeAuditBackups; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java new file mode 100644 index 0000000..c4ae9cb --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java @@ -0,0 +1,45 @@ +package com.cisd.tms.modules.backup.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "创建资源恢复任务请求") +public class CreateResourceRestoreRequest { + + @NotBlank + @Schema(description = "预检号", example = "RPRE-20260408-000001", requiredMode = Schema.RequiredMode.REQUIRED) + private String precheckId; + + @NotBlank + @Schema(description = "确认的备份号", example = "RBKP-20260408-153000-000001", requiredMode = Schema.RequiredMode.REQUIRED) + private String confirmBackupId; + + @NotNull + @Schema(description = "是否确认接受恢复风险", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean confirmRiskAccepted; + + public String getPrecheckId() { + return precheckId; + } + + public void setPrecheckId(String precheckId) { + this.precheckId = precheckId; + } + + public String getConfirmBackupId() { + return confirmBackupId; + } + + public void setConfirmBackupId(String confirmBackupId) { + this.confirmBackupId = confirmBackupId; + } + + public Boolean getConfirmRiskAccepted() { + return confirmRiskAccepted; + } + + public void setConfirmRiskAccepted(Boolean confirmRiskAccepted) { + this.confirmRiskAccepted = confirmRiskAccepted; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/request/ResourceRestorePrecheckRequest.java b/src/main/java/com/cisd/tms/modules/backup/dto/request/ResourceRestorePrecheckRequest.java new file mode 100644 index 0000000..eba135f --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/request/ResourceRestorePrecheckRequest.java @@ -0,0 +1,20 @@ +package com.cisd.tms.modules.backup.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "资源恢复预检请求") +public class ResourceRestorePrecheckRequest { + + @NotBlank + @Schema(description = "备份包 fileId", example = "FILE-20260408-000001", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileId; + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceBackupResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceBackupResponse.java new file mode 100644 index 0000000..3734b45 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceBackupResponse.java @@ -0,0 +1,48 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "创建资源备份任务响应") +public class CreateResourceBackupResponse { + + @Schema(description = "备份任务号", example = "RBKP-TASK-20260408-000001") + private String taskId; + @Schema(description = "备份号", example = "RBKP-20260408-153000-000001") + private String backupId; + @Schema(description = "任务状态", example = "PENDING") + private String status; + @Schema(description = "创建时间", example = "2026-04-08T15:30:00+08:00") + private String createdAt; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceRestoreResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceRestoreResponse.java new file mode 100644 index 0000000..071df92 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/CreateResourceRestoreResponse.java @@ -0,0 +1,35 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "创建资源恢复任务响应") +public class CreateResourceRestoreResponse { + + private String taskId; + private String status; + private String createdAt; + + 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 getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceBackupTaskDetailResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceBackupTaskDetailResponse.java new file mode 100644 index 0000000..785b4a4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceBackupTaskDetailResponse.java @@ -0,0 +1,206 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "资源备份任务详情响应") +public class ResourceBackupTaskDetailResponse { + + private String taskId; + private String backupId; + private String status; + private String productType; + private String mqType; + private String orgCode; + private String sourceDeviceId; + private String packageName; + private Long packageSize; + private String packageHash; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + private Object manifestSummary; + private String currentStepCode; + private Integer currentStepNo; + private String remark; + private String errorCode; + private String errorMessage; + private String createdBy; + private String createdAt; + private String startedAt; + private String finishedAt; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public String getMqType() { + return mqType; + } + + public void setMqType(String mqType) { + this.mqType = mqType; + } + + public String getOrgCode() { + return orgCode; + } + + public void setOrgCode(String orgCode) { + this.orgCode = orgCode; + } + + public String getSourceDeviceId() { + return sourceDeviceId; + } + + public void setSourceDeviceId(String sourceDeviceId) { + this.sourceDeviceId = sourceDeviceId; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public Long getPackageSize() { + return packageSize; + } + + public void setPackageSize(Long packageSize) { + this.packageSize = packageSize; + } + + public String getPackageHash() { + return packageHash; + } + + public void setPackageHash(String packageHash) { + this.packageHash = packageHash; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public Object getManifestSummary() { + return manifestSummary; + } + + public void setManifestSummary(Object manifestSummary) { + this.manifestSummary = manifestSummary; + } + + 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 getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceKeysetStatusResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceKeysetStatusResponse.java new file mode 100644 index 0000000..fd39b75 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceKeysetStatusResponse.java @@ -0,0 +1,88 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "资源备份密钥状态响应") +public class ResourceKeysetStatusResponse { + + @Schema(description = "LMK 是否就绪") + private Boolean lmkReady; + @Schema(description = "IK 是否就绪") + private Boolean ikReady; + @Schema(description = "数据存储加密密钥对是否就绪") + private Boolean storageEncKeyReady; + @Schema(description = "数据真实性保护密钥对是否就绪") + private Boolean authenticityKeyReady; + @Schema(description = "数据存储加密公钥指纹") + private String storageEncKeyFingerprint; + @Schema(description = "真实性保护公钥指纹") + private String authenticityKeyFingerprint; + @Schema(description = "设备产品类型") + private String productType; + @Schema(description = "来源") + private String source; + + public Boolean getLmkReady() { + return lmkReady; + } + + public void setLmkReady(Boolean lmkReady) { + this.lmkReady = lmkReady; + } + + public Boolean getIkReady() { + return ikReady; + } + + public void setIkReady(Boolean ikReady) { + this.ikReady = ikReady; + } + + public Boolean getStorageEncKeyReady() { + return storageEncKeyReady; + } + + public void setStorageEncKeyReady(Boolean storageEncKeyReady) { + this.storageEncKeyReady = storageEncKeyReady; + } + + public Boolean getAuthenticityKeyReady() { + return authenticityKeyReady; + } + + public void setAuthenticityKeyReady(Boolean authenticityKeyReady) { + this.authenticityKeyReady = authenticityKeyReady; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java new file mode 100644 index 0000000..53b7878 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java @@ -0,0 +1,153 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "资源恢复预检响应") +public class ResourceRestorePrecheckResponse { + + private String precheckId; + private String backupId; + private String sourceProductType; + private String sourceMqType; + private String sourceOrgCode; + private String sourceBackupTime; + private String targetProductType; + private Boolean keysetMatched; + private Boolean signatureValid; + private Boolean compatible; + private Boolean payloadDecrypted; + private List warnings; + private List blockingIssues; + private List restorePlan; + private Object restoreScopeSummary; + private String expiresAt; + + 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 getSourceProductType() { + return sourceProductType; + } + + public void setSourceProductType(String sourceProductType) { + this.sourceProductType = sourceProductType; + } + + public String getSourceMqType() { + return sourceMqType; + } + + public void setSourceMqType(String sourceMqType) { + this.sourceMqType = sourceMqType; + } + + public String getSourceOrgCode() { + return sourceOrgCode; + } + + public void setSourceOrgCode(String sourceOrgCode) { + this.sourceOrgCode = sourceOrgCode; + } + + public String getSourceBackupTime() { + return sourceBackupTime; + } + + public void setSourceBackupTime(String sourceBackupTime) { + this.sourceBackupTime = sourceBackupTime; + } + + public String getTargetProductType() { + return targetProductType; + } + + public void setTargetProductType(String targetProductType) { + this.targetProductType = targetProductType; + } + + public Boolean getKeysetMatched() { + return keysetMatched; + } + + public void setKeysetMatched(Boolean keysetMatched) { + this.keysetMatched = keysetMatched; + } + + public Boolean getSignatureValid() { + return signatureValid; + } + + public void setSignatureValid(Boolean signatureValid) { + this.signatureValid = signatureValid; + } + + public Boolean getCompatible() { + return compatible; + } + + public void setCompatible(Boolean compatible) { + this.compatible = compatible; + } + + public Boolean getPayloadDecrypted() { + return payloadDecrypted; + } + + public void setPayloadDecrypted(Boolean payloadDecrypted) { + this.payloadDecrypted = payloadDecrypted; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } + + public List getBlockingIssues() { + return blockingIssues; + } + + public void setBlockingIssues(List blockingIssues) { + this.blockingIssues = blockingIssues; + } + + public List getRestorePlan() { + return restorePlan; + } + + public void setRestorePlan(List restorePlan) { + this.restorePlan = restorePlan; + } + + public Object getRestoreScopeSummary() { + return restoreScopeSummary; + } + + public void setRestoreScopeSummary(Object restoreScopeSummary) { + this.restoreScopeSummary = restoreScopeSummary; + } + + public String getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(String expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java new file mode 100644 index 0000000..8116fb1 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java @@ -0,0 +1,261 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "资源恢复任务详情响应") +public class ResourceRestoreTaskDetailResponse { + + private String taskId; + private String precheckId; + private String backupId; + private String status; + private String controlPhase; + private String stateSource; + private String sourceDeviceId; + private String sourceProductType; + private String targetProductType; + private String packageFileId; + private String packageName; + private Boolean packagePathVisible; + private String currentStepCode; + private Integer currentStepNo; + private String lastHeartbeatAt; + private String runnerStatePath; + private Boolean orphaned; + private List cleanupWarnings; + private List warnings; + private List blockingIssues; + private List restorePlan; + private Object consistencyReport; + private String errorCode; + private String errorMessage; + private String createdBy; + private String createdAt; + private String startedAt; + private String finishedAt; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getPrecheckId() { + return precheckId; + } + + public void setPrecheckId(String precheckId) { + this.precheckId = precheckId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getControlPhase() { + return controlPhase; + } + + public void setControlPhase(String controlPhase) { + this.controlPhase = controlPhase; + } + + public String getStateSource() { + return stateSource; + } + + public void setStateSource(String stateSource) { + this.stateSource = stateSource; + } + + public String getSourceDeviceId() { + return sourceDeviceId; + } + + public void setSourceDeviceId(String sourceDeviceId) { + this.sourceDeviceId = sourceDeviceId; + } + + public String getSourceProductType() { + return sourceProductType; + } + + public void setSourceProductType(String sourceProductType) { + this.sourceProductType = sourceProductType; + } + + public String getTargetProductType() { + return targetProductType; + } + + public void setTargetProductType(String targetProductType) { + this.targetProductType = targetProductType; + } + + public String getPackageFileId() { + return packageFileId; + } + + public void setPackageFileId(String packageFileId) { + this.packageFileId = packageFileId; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public Boolean getPackagePathVisible() { + return packagePathVisible; + } + + public void setPackagePathVisible(Boolean packagePathVisible) { + this.packagePathVisible = packagePathVisible; + } + + public String getCurrentStepCode() { + return currentStepCode; + } + + public void setCurrentStepCode(String currentStepCode) { + this.currentStepCode = currentStepCode; + } + + public Integer getCurrentStepNo() { + return currentStepNo; + } + + public void setCurrentStepNo(Integer currentStepNo) { + this.currentStepNo = currentStepNo; + } + + public String getLastHeartbeatAt() { + return lastHeartbeatAt; + } + + public void setLastHeartbeatAt(String lastHeartbeatAt) { + this.lastHeartbeatAt = lastHeartbeatAt; + } + + public String getRunnerStatePath() { + return runnerStatePath; + } + + public void setRunnerStatePath(String runnerStatePath) { + this.runnerStatePath = runnerStatePath; + } + + public Boolean getOrphaned() { + return orphaned; + } + + public void setOrphaned(Boolean orphaned) { + this.orphaned = orphaned; + } + + public List getCleanupWarnings() { + return cleanupWarnings; + } + + public void setCleanupWarnings(List cleanupWarnings) { + this.cleanupWarnings = cleanupWarnings; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } + + public List getBlockingIssues() { + return blockingIssues; + } + + public void setBlockingIssues(List blockingIssues) { + this.blockingIssues = blockingIssues; + } + + public List getRestorePlan() { + return restorePlan; + } + + public void setRestorePlan(List restorePlan) { + this.restorePlan = restorePlan; + } + + public Object getConsistencyReport() { + return consistencyReport; + } + + public void setConsistencyReport(Object consistencyReport) { + this.consistencyReport = consistencyReport; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepItemResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepItemResponse.java new file mode 100644 index 0000000..d3031d1 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepItemResponse.java @@ -0,0 +1,80 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "资源任务步骤项响应") +public class ResourceTaskStepItemResponse { + + private Integer stepNo; + private String stepCode; + private String status; + private String message; + private Integer exitCode; + private String startedAt; + private String finishedAt; + private Boolean logAvailable; + + public Integer getStepNo() { + return stepNo; + } + + public void setStepNo(Integer stepNo) { + this.stepNo = stepNo; + } + + public String getStepCode() { + return stepCode; + } + + public void setStepCode(String stepCode) { + this.stepCode = stepCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Integer getExitCode() { + return exitCode; + } + + public void setExitCode(Integer exitCode) { + this.exitCode = exitCode; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + + public Boolean getLogAvailable() { + return logAvailable; + } + + public void setLogAvailable(Boolean logAvailable) { + this.logAvailable = logAvailable; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepLogResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepLogResponse.java new file mode 100644 index 0000000..9406f79 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceTaskStepLogResponse.java @@ -0,0 +1,53 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "资源任务步骤日志响应") +public class ResourceTaskStepLogResponse { + + private String taskId; + private Integer stepNo; + private String stepCode; + private String content; + private Boolean truncated; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public Integer getStepNo() { + return stepNo; + } + + public void setStepNo(Integer stepNo) { + this.stepNo = stepNo; + } + + public String getStepCode() { + return stepCode; + } + + public void setStepCode(String stepCode) { + this.stepCode = stepCode; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Boolean getTruncated() { + return truncated; + } + + public void setTruncated(Boolean truncated) { + this.truncated = truncated; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/support/DiscoveredResourceItem.java b/src/main/java/com/cisd/tms/modules/backup/dto/support/DiscoveredResourceItem.java new file mode 100644 index 0000000..5baa990 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/support/DiscoveredResourceItem.java @@ -0,0 +1,41 @@ +package com.cisd.tms.modules.backup.dto.support; + +public class DiscoveredResourceItem { + + private String resourceType; + private String resourceCode; + private String resolvedPath; + private boolean required; + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public String getResourceCode() { + return resourceCode; + } + + public void setResourceCode(String resourceCode) { + this.resourceCode = resourceCode; + } + + public String getResolvedPath() { + return resolvedPath; + } + + public void setResolvedPath(String resolvedPath) { + this.resolvedPath = resolvedPath; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/entity/ResourceBackupTaskEntity.java b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceBackupTaskEntity.java new file mode 100644 index 0000000..a31d82c --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceBackupTaskEntity.java @@ -0,0 +1,190 @@ +package com.cisd.tms.modules.backup.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; +import java.time.LocalDateTime; + +@TableName("tms_resource_backup_task") +public class ResourceBackupTaskEntity extends BaseEntity { + + private String taskId; + private String backupId; + private String status; + private String productType; + private String mqType; + private String orgCode; + private String sourceDeviceId; + private String packageName; + private String packagePath; + private Long packageSize; + private String packageHash; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + private String manifestSummaryJson; + private String errorCode; + private String errorMessage; + private String remark; + private String createBy; + private LocalDateTime startTime; + private LocalDateTime finishTime; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public String getMqType() { + return mqType; + } + + public void setMqType(String mqType) { + this.mqType = mqType; + } + + public String getOrgCode() { + return orgCode; + } + + public void setOrgCode(String orgCode) { + this.orgCode = orgCode; + } + + public String getSourceDeviceId() { + return sourceDeviceId; + } + + public void setSourceDeviceId(String sourceDeviceId) { + this.sourceDeviceId = sourceDeviceId; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPackagePath() { + return packagePath; + } + + public void setPackagePath(String packagePath) { + this.packagePath = packagePath; + } + + public Long getPackageSize() { + return packageSize; + } + + public void setPackageSize(Long packageSize) { + this.packageSize = packageSize; + } + + public String getPackageHash() { + return packageHash; + } + + public void setPackageHash(String packageHash) { + this.packageHash = packageHash; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public String getManifestSummaryJson() { + return manifestSummaryJson; + } + + public void setManifestSummaryJson(String manifestSummaryJson) { + this.manifestSummaryJson = manifestSummaryJson; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getCreateBy() { + return createBy; + } + + public void setCreateBy(String createBy) { + this.createBy = createBy; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getFinishTime() { + return finishTime; + } + + public void setFinishTime(LocalDateTime finishTime) { + this.finishTime = finishTime; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/entity/ResourceRestoreTaskEntity.java b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceRestoreTaskEntity.java new file mode 100644 index 0000000..cdc2a05 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceRestoreTaskEntity.java @@ -0,0 +1,199 @@ +package com.cisd.tms.modules.backup.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; +import java.time.LocalDateTime; + +@TableName("tms_resource_restore_task") +public class ResourceRestoreTaskEntity extends BaseEntity { + + private String taskId; + private String precheckId; + private String backupId; + private String status; + private String sourceDeviceId; + private String sourceProductType; + private String targetProductType; + private String packageFileId; + private String packagePath; + private String stagingDir; + private String runnerStatePath; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + private String restorePlanJson; + private String consistencyReportJson; + private String errorCode; + private String errorMessage; + private String remark; + private String createBy; + private LocalDateTime startTime; + private LocalDateTime finishTime; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getPrecheckId() { + return precheckId; + } + + public void setPrecheckId(String precheckId) { + this.precheckId = precheckId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getSourceDeviceId() { + return sourceDeviceId; + } + + public void setSourceDeviceId(String sourceDeviceId) { + this.sourceDeviceId = sourceDeviceId; + } + + public String getSourceProductType() { + return sourceProductType; + } + + public void setSourceProductType(String sourceProductType) { + this.sourceProductType = sourceProductType; + } + + public String getTargetProductType() { + return targetProductType; + } + + public void setTargetProductType(String targetProductType) { + this.targetProductType = targetProductType; + } + + public String getPackageFileId() { + return packageFileId; + } + + public void setPackageFileId(String packageFileId) { + this.packageFileId = packageFileId; + } + + public String getPackagePath() { + return packagePath; + } + + public void setPackagePath(String packagePath) { + this.packagePath = packagePath; + } + + public String getStagingDir() { + return stagingDir; + } + + public void setStagingDir(String stagingDir) { + this.stagingDir = stagingDir; + } + + public String getRunnerStatePath() { + return runnerStatePath; + } + + public void setRunnerStatePath(String runnerStatePath) { + this.runnerStatePath = runnerStatePath; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public String getRestorePlanJson() { + return restorePlanJson; + } + + public void setRestorePlanJson(String restorePlanJson) { + this.restorePlanJson = restorePlanJson; + } + + public String getConsistencyReportJson() { + return consistencyReportJson; + } + + public void setConsistencyReportJson(String consistencyReportJson) { + this.consistencyReportJson = consistencyReportJson; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getCreateBy() { + return createBy; + } + + public void setCreateBy(String createBy) { + this.createBy = createBy; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getFinishTime() { + return finishTime; + } + + public void setFinishTime(LocalDateTime finishTime) { + this.finishTime = finishTime; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java new file mode 100644 index 0000000..9fea7b3 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java @@ -0,0 +1,100 @@ +package com.cisd.tms.modules.backup.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; +import java.time.LocalDateTime; + +@TableName("tms_resource_task_step") +public class ResourceTaskStepEntity extends BaseEntity { + + private String taskId; + private String taskType; + private Integer stepNo; + private String stepCode; + private String status; + private String message; + private String logPath; + private Integer exitCode; + private LocalDateTime startTime; + private LocalDateTime finishTime; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getTaskType() { + return taskType; + } + + public void setTaskType(String taskType) { + this.taskType = taskType; + } + + public Integer getStepNo() { + return stepNo; + } + + public void setStepNo(Integer stepNo) { + this.stepNo = stepNo; + } + + public String getStepCode() { + return stepCode; + } + + public void setStepCode(String stepCode) { + this.stepCode = stepCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getLogPath() { + return logPath; + } + + public void setLogPath(String logPath) { + this.logPath = logPath; + } + + public Integer getExitCode() { + return exitCode; + } + + public void setExitCode(Integer exitCode) { + this.exitCode = exitCode; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getFinishTime() { + return finishTime; + } + + public void setFinishTime(LocalDateTime finishTime) { + this.finishTime = finishTime; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java new file mode 100644 index 0000000..87d3c5c --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java @@ -0,0 +1,12 @@ +package com.cisd.tms.modules.backup.enums; + +/** + * 恢复任务控制阶段。 + */ +public enum ResourceControlPhase { + TMS_PRECHECK, + HANDOFF_READY, + RUNNER_OWNED, + RECONCILE_PENDING, + DONE +} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java new file mode 100644 index 0000000..71fdd20 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java @@ -0,0 +1,13 @@ +package com.cisd.tms.modules.backup.enums; + +/** + * 资源任务步骤状态。 + */ +public enum ResourceStepStatus { + PENDING, + RUNNING, + SUCCESS, + SUCCESS_WITH_WARNINGS, + FAILED, + SKIPPED +} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java new file mode 100644 index 0000000..781bafb --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java @@ -0,0 +1,17 @@ +package com.cisd.tms.modules.backup.enums; + +/** + * 资源备份/恢复任务主状态。 + */ +public enum ResourceTaskStatus { + PENDING, + PRECHECKING, + PRECHECK_FAILED, + READY, + HANDOFF, + RUNNING, + VERIFYING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java new file mode 100644 index 0000000..008b198 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.backup.enums; + +/** + * 资源任务类型。 + */ +public enum ResourceTaskType { + RESOURCE_BACKUP, + RESOURCE_RESTORE +} diff --git a/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceBackupTaskMapper.java b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceBackupTaskMapper.java new file mode 100644 index 0000000..7775694 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceBackupTaskMapper.java @@ -0,0 +1,20 @@ +package com.cisd.tms.modules.backup.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ResourceBackupTaskMapper extends BaseMapperX { + + @Select( + "SELECT id, task_id, backup_id, status, product_type, mq_type, org_code, source_device_id, " + + "package_name, package_path, package_size, package_hash, storage_enc_key_fingerprint, " + + "authenticity_key_fingerprint, manifest_summary_json, error_code, error_message, remark, " + + "create_by, create_time, update_time, start_time, finish_time " + + "FROM tms_resource_backup_task WHERE task_id = #{taskId}" + ) + ResourceBackupTaskEntity selectByTaskId(@Param("taskId") String taskId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceRestoreTaskMapper.java b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceRestoreTaskMapper.java new file mode 100644 index 0000000..0fc763f --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceRestoreTaskMapper.java @@ -0,0 +1,21 @@ +package com.cisd.tms.modules.backup.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ResourceRestoreTaskMapper extends BaseMapperX { + + @Select( + "SELECT id, task_id, precheck_id, backup_id, status, source_device_id, source_product_type, " + + "target_product_type, package_file_id, package_path, staging_dir, runner_state_path, " + + "storage_enc_key_fingerprint, authenticity_key_fingerprint, restore_plan_json, " + + "consistency_report_json, error_code, error_message, remark, create_by, create_time, " + + "update_time, start_time, finish_time " + + "FROM tms_resource_restore_task WHERE task_id = #{taskId}" + ) + ResourceRestoreTaskEntity selectByTaskId(@Param("taskId") String taskId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java new file mode 100644 index 0000000..1e78738 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java @@ -0,0 +1,19 @@ +package com.cisd.tms.modules.backup.mapper; + +import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; +import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ResourceTaskStepMapper extends BaseMapperX { + + @Select( + "SELECT id, task_id, task_type, step_no, step_code, status, message, log_path, exit_code, " + + "create_time, update_time, start_time, finish_time " + + "FROM tms_resource_task_step WHERE task_id = #{taskId} ORDER BY step_no ASC" + ) + List selectByTaskIdOrderByStepNo(@Param("taskId") String taskId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageFormatSupport.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageFormatSupport.java new file mode 100644 index 0000000..d492523 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageFormatSupport.java @@ -0,0 +1,29 @@ +package com.cisd.tms.modules.backup.packagex; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +final class ResourcePackageFormatSupport { + + private ResourcePackageFormatSupport() { + } + + static byte[] buildSignatureSource(byte[] manifestBytes, byte[] envelopeBytes, byte[] cipherBytes) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + writePart(outputStream, manifestBytes); + writePart(outputStream, envelopeBytes); + writePart(outputStream, cipherBytes); + return outputStream.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("failed to build package signature source", ex); + } + } + + private static void writePart(ByteArrayOutputStream outputStream, byte[] data) throws IOException { + byte[] safeData = data == null ? new byte[0] : data; + outputStream.write(ByteBuffer.allocate(4).putInt(safeData.length).array()); + outputStream.write(safeData); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageInspectResult.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageInspectResult.java new file mode 100644 index 0000000..d5065d4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageInspectResult.java @@ -0,0 +1,52 @@ +package com.cisd.tms.modules.backup.packagex; + +import java.util.Map; + +public class ResourcePackageInspectResult { + + private Map manifest; + private Map envelope; + private Map payload; + private byte[] payloadArchiveBytes; + private byte[] signature; + + public Map getManifest() { + return manifest; + } + + public void setManifest(Map manifest) { + this.manifest = manifest; + } + + public Map getEnvelope() { + return envelope; + } + + public void setEnvelope(Map envelope) { + this.envelope = envelope; + } + + public Map getPayload() { + return payload; + } + + public void setPayload(Map payload) { + this.payload = payload; + } + + public byte[] getPayloadArchiveBytes() { + return payloadArchiveBytes; + } + + public void setPayloadArchiveBytes(byte[] payloadArchiveBytes) { + this.payloadArchiveBytes = payloadArchiveBytes; + } + + public byte[] getSignature() { + return signature; + } + + public void setSignature(byte[] signature) { + this.signature = signature; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageResult.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageResult.java new file mode 100644 index 0000000..54d133c --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageResult.java @@ -0,0 +1,68 @@ +package com.cisd.tms.modules.backup.packagex; + +public class ResourcePackageResult { + + private String packageName; + private String packagePath; + private Long packageSize; + private String packageHash; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + private String manifestSummaryJson; + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPackagePath() { + return packagePath; + } + + public void setPackagePath(String packagePath) { + this.packagePath = packagePath; + } + + public Long getPackageSize() { + return packageSize; + } + + public void setPackageSize(Long packageSize) { + this.packageSize = packageSize; + } + + public String getPackageHash() { + return packageHash; + } + + public void setPackageHash(String packageHash) { + this.packageHash = packageHash; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public String getManifestSummaryJson() { + return manifestSummaryJson; + } + + public void setManifestSummaryJson(String manifestSummaryJson) { + this.manifestSummaryJson = manifestSummaryJson; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageService.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageService.java new file mode 100644 index 0000000..c228265 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageService.java @@ -0,0 +1,22 @@ +package com.cisd.tms.modules.backup.packagex; + +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import java.nio.file.Path; +import java.util.List; + +public interface ResourcePackageService { + + ResourcePackageResult createPackage( + String taskId, + String backupId, + InitTaskEntity initTask, + List discoveredItems, + CreateResourceBackupRequest request + ); + + ResourcePackageInspectResult inspectPackageHeader(Path packagePath); + + ResourcePackageInspectResult inspectPackage(Path packagePath); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java new file mode 100644 index 0000000..64df206 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java @@ -0,0 +1,392 @@ +package com.cisd.tms.modules.backup.packagex; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoService; +import com.cisd.tms.modules.backup.crypto.ResourceSealedPayload; +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; +import org.bouncycastle.crypto.digests.SM3Digest; +import org.springframework.stereotype.Service; + +@Service +public class ResourcePackageServiceImpl implements ResourcePackageService { + + private final ResourceBackupProperties resourceBackupProperties; + private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService; + private final ObjectMapper objectMapper; + + public ResourcePackageServiceImpl( + ResourceBackupProperties resourceBackupProperties, + ResourceEnvelopeCryptoService resourceEnvelopeCryptoService, + ObjectMapper objectMapper + ) { + this.resourceBackupProperties = resourceBackupProperties; + this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService; + this.objectMapper = objectMapper; + } + + @Override + public ResourcePackageResult createPackage( + String taskId, + String backupId, + InitTaskEntity initTask, + List discoveredItems, + CreateResourceBackupRequest request + ) { + if (isBlank(taskId) || isBlank(backupId)) { + throw new IllegalArgumentException("taskId and backupId are required"); + } + if (initTask == null) { + throw new IllegalArgumentException("initTask is required"); + } + if (discoveredItems == null) { + throw new IllegalArgumentException("discoveredItems is required"); + } + try { + Path taskDir = Path.of(resourceBackupProperties.getOutputDir()).resolve(taskId).normalize(); + Files.createDirectories(taskDir); + + // 资源恢复真正消费的是“受保护的内部归档”,而不是一段摘要 JSON。 + // 这里先把可采集的文件资源打成 payload.zip,再统一交给数字信封加密, + // 后续即使补数据库导出/MQ definitions,也仍然沿着这个边界扩展。 + PayloadArchiveBuildResult payloadArchive = buildPayloadArchive( + taskId, + backupId, + initTask, + discoveredItems, + request + ); + ResourceSealedPayload sealedPayload = resourceEnvelopeCryptoService.seal(payloadArchive.archiveBytes()); + + // manifest 只保留恢复前需要快速识别的元数据,不直接展开敏感资源内容。 + Map manifest = new LinkedHashMap<>(); + manifest.put("backupId", backupId); + manifest.put("taskId", taskId); + manifest.put("backupTime", LocalDateTime.now().toString()); + manifest.put("productType", initTask.getProductType()); + manifest.put("mqType", initTask.getMqType()); + manifest.put("orgCode", initTask.getOrgCode()); + manifest.put("resourceCount", discoveredItems.size()); + manifest.put("collectedResourceCount", payloadArchive.collectedResourceCount()); + manifest.put("storageEncKeyFingerprint", sealedPayload.getStorageEncKeyFingerprint()); + manifest.put("authenticityKeyFingerprint", sealedPayload.getAuthenticityKeyFingerprint()); + byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest); + byte[] envelopeBytes = objectMapper.writeValueAsBytes(sealedPayload.getEnvelope()); + // 签名覆盖 manifest + envelope + ciphertext 三段,恢复端按同一字节拼装规则重建验签输入。 + byte[] signatureBytes = resourceEnvelopeCryptoService.sign( + ResourcePackageFormatSupport.buildSignatureSource(manifestBytes, envelopeBytes, sealedPayload.getCipherBytes()) + ); + + String packageName = backupId + ".tmsbak"; + Path packagePath = taskDir.resolve(packageName); + try (OutputStream outputStream = Files.newOutputStream(packagePath); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + // 当前包结构已经固定成 manifest + envelope + ciphertext,后续即使替换加密实现也不必重做外层格式。 + addZipEntry(zipOutputStream, "manifest.json", manifestBytes); + addZipEntry(zipOutputStream, "envelope.json", envelopeBytes); + addZipEntry(zipOutputStream, "payload.enc", sealedPayload.getCipherBytes()); + addZipEntry(zipOutputStream, "signature.sig", signatureBytes); + } + + Map manifestSummary = new LinkedHashMap<>(); + manifestSummary.put("resourceCount", discoveredItems.size()); + manifestSummary.put("collectedResourceCount", payloadArchive.collectedResourceCount()); + manifestSummary.put("productType", initTask.getProductType()); + manifestSummary.put("mqType", initTask.getMqType()); + manifestSummary.put("orgCode", initTask.getOrgCode()); + manifestSummary.put("packageName", packageName); + + ResourcePackageResult result = new ResourcePackageResult(); + result.setPackageName(packageName); + result.setPackagePath(packagePath.toString()); + result.setPackageSize(Files.size(packagePath)); + // 这里记录的是整个备份包文件的 SM3,方便后续下载校验和审计追踪。 + result.setPackageHash(sm3Hex(Files.readAllBytes(packagePath))); + result.setStorageEncKeyFingerprint(sealedPayload.getStorageEncKeyFingerprint()); + result.setAuthenticityKeyFingerprint(sealedPayload.getAuthenticityKeyFingerprint()); + result.setManifestSummaryJson(objectMapper.writeValueAsString(manifestSummary)); + return result; + } catch (IOException ex) { + throw new IllegalStateException("failed to create backup package", ex); + } + } + + @Override + public ResourcePackageInspectResult inspectPackageHeader(Path packagePath) { + if (packagePath == null) { + throw new IllegalArgumentException("packagePath is required"); + } + try (ZipFile zipFile = new ZipFile(packagePath.toFile())) { + byte[] manifestBytes = readRequiredEntry(zipFile, "manifest.json"); + byte[] envelopeBytes = readRequiredEntry(zipFile, "envelope.json"); + byte[] cipherBytes = readRequiredEntry(zipFile, "payload.enc"); + byte[] signatureBytes = readRequiredEntry(zipFile, "signature.sig"); + resourceEnvelopeCryptoService.verify( + ResourcePackageFormatSupport.buildSignatureSource(manifestBytes, envelopeBytes, cipherBytes), + signatureBytes + ); + ResourcePackageInspectResult result = new ResourcePackageInspectResult(); + result.setManifest(objectMapper.readValue(manifestBytes, new TypeReference>() { + })); + result.setEnvelope(objectMapper.readValue(envelopeBytes, new TypeReference>() { + })); + result.setSignature(signatureBytes); + return result; + } catch (IOException ex) { + throw new IllegalStateException("failed to inspect backup package header", ex); + } + } + + @Override + public ResourcePackageInspectResult inspectPackage(Path packagePath) { + if (packagePath == null) { + throw new IllegalArgumentException("packagePath is required"); + } + try (ZipFile zipFile = new ZipFile(packagePath.toFile())) { + byte[] manifestBytes = readRequiredEntry(zipFile, "manifest.json"); + byte[] envelopeBytes = readRequiredEntry(zipFile, "envelope.json"); + byte[] cipherBytes = readRequiredEntry(zipFile, "payload.enc"); + byte[] signatureBytes = readRequiredEntry(zipFile, "signature.sig"); + byte[] payloadBytes = resourceEnvelopeCryptoService.unsealAndVerify( + objectMapper.readValue(envelopeBytes, new TypeReference>() { + }), + cipherBytes, + ResourcePackageFormatSupport.buildSignatureSource(manifestBytes, envelopeBytes, cipherBytes), + signatureBytes + ); + Map payloadManifest = readPayloadManifest(payloadBytes); + + ResourcePackageInspectResult result = new ResourcePackageInspectResult(); + result.setManifest(objectMapper.readValue(manifestBytes, new TypeReference>() { + })); + result.setEnvelope(objectMapper.readValue(envelopeBytes, new TypeReference>() { + })); + result.setPayload(payloadManifest); + result.setPayloadArchiveBytes(payloadBytes); + result.setSignature(signatureBytes); + return result; + } catch (IOException ex) { + throw new IllegalStateException("failed to inspect backup package", ex); + } + } + + private PayloadArchiveBuildResult buildPayloadArchive( + String taskId, + String backupId, + InitTaskEntity initTask, + List discoveredItems, + CreateResourceBackupRequest request + ) throws IOException { + Map payloadManifest = new LinkedHashMap<>(); + payloadManifest.put("archiveFormat", "ZIP"); + payloadManifest.put("backupId", backupId); + payloadManifest.put("taskId", taskId); + payloadManifest.put("productType", initTask.getProductType()); + payloadManifest.put("mqType", initTask.getMqType()); + payloadManifest.put("orgCode", initTask.getOrgCode()); + payloadManifest.put("remark", request == null ? null : request.getRemark()); + payloadManifest.put("includeUpgradeHistory", request != null && Boolean.TRUE.equals(request.getIncludeUpgradeHistory())); + payloadManifest.put("includeAuditBackups", request != null && Boolean.TRUE.equals(request.getIncludeAuditBackups())); + + List> resourceEntries = new ArrayList<>(); + int collectedCount = 0; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (DiscoveredResourceItem item : discoveredItems) { + Map resourceEntry = new LinkedHashMap<>(); + resourceEntry.put("resourceType", item.getResourceType()); + resourceEntry.put("resourceCode", item.getResourceCode()); + resourceEntry.put("resolvedPath", item.getResolvedPath()); + resourceEntry.put("required", item.isRequired()); + + int collectedFiles = collectResourceItem(zipOutputStream, item, resourceEntry); + if (collectedFiles > 0) { + collectedCount++; + } + resourceEntries.add(resourceEntry); + } + payloadManifest.put("resources", resourceEntries); + addZipEntry( + zipOutputStream, + "payload-manifest.json", + objectMapper.writeValueAsBytes(payloadManifest) + ); + } + return new PayloadArchiveBuildResult(outputStream.toByteArray(), collectedCount); + } + + private int collectResourceItem( + ZipOutputStream zipOutputStream, + DiscoveredResourceItem item, + Map resourceEntry + ) throws IOException { + if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) { + resourceEntry.put("collected", Boolean.FALSE); + resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET"); + return 0; + } + Path resolvedPath = toResolvedPath(item.getResolvedPath()); + if (resolvedPath == null || !Files.exists(resolvedPath)) { + resourceEntry.put("collected", Boolean.FALSE); + resourceEntry.put("skipReason", "PATH_NOT_FOUND"); + return 0; + } + + List> archivedFiles = new ArrayList<>(); + if (Files.isRegularFile(resolvedPath)) { + archivedFiles.add(writeRegularFileEntry(zipOutputStream, item, resolvedPath, resolvedPath.getFileName().toString())); + } else if (Files.isDirectory(resolvedPath)) { + // 目录资源恢复时真正需要的是内部文件集合,而不是目录本身的绝对路径。 + // 这里把目录拍平成 zip entry,并把相对路径写进 payload-manifest,后续恢复端按 manifest 还原。 + try (Stream pathStream = Files.walk(resolvedPath)) { + pathStream + .filter(Files::isRegularFile) + .sorted(Comparator.naturalOrder()) + .forEach(path -> archivedFiles.add(writeUnchecked( + () -> writeRegularFileEntry( + zipOutputStream, + item, + path, + resolvedPath.relativize(path).toString() + ) + ))); + } + } else { + resourceEntry.put("collected", Boolean.FALSE); + resourceEntry.put("skipReason", "UNSUPPORTED_FILE_TYPE"); + return 0; + } + + resourceEntry.put("collected", !archivedFiles.isEmpty()); + resourceEntry.put("fileCount", archivedFiles.size()); + resourceEntry.put("files", archivedFiles); + if (archivedFiles.size() == 1) { + resourceEntry.put("entryPath", archivedFiles.get(0).get("entryPath")); + } + return archivedFiles.size(); + } + + private Map writeRegularFileEntry( + ZipOutputStream zipOutputStream, + DiscoveredResourceItem item, + Path file, + String relativeName + ) throws IOException { + byte[] content = Files.readAllBytes(file); + String entryPath = buildArchiveEntryPath(item, relativeName); + addZipEntry(zipOutputStream, entryPath, content); + + Map fileEntry = new LinkedHashMap<>(); + fileEntry.put("entryPath", entryPath); + fileEntry.put("sourcePath", file.toString()); + fileEntry.put("relativeName", relativeName.replace('\\', '/')); + fileEntry.put("size", content.length); + fileEntry.put("contentSm3", sm3Hex(content)); + return fileEntry; + } + + private Map readPayloadManifest(byte[] payloadArchiveBytes) throws IOException { + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(payloadArchiveBytes))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if ("payload-manifest.json".equals(entry.getName())) { + return objectMapper.readValue(zipInputStream.readAllBytes(), new TypeReference>() { + }); + } + } + } + throw new IllegalStateException("payload archive entry missing: payload-manifest.json"); + } + + private static String buildArchiveEntryPath(DiscoveredResourceItem item, String relativeName) { + String code = trim(item.getResourceCode()).isEmpty() ? "UNKNOWN" : trim(item.getResourceCode()); + String normalizedRelativeName = trim(relativeName).replace('\\', '/'); + if (normalizedRelativeName.isEmpty()) { + normalizedRelativeName = "content.bin"; + } + return "resources/" + code + "/" + normalizedRelativeName; + } + + private static Path toResolvedPath(String path) { + String value = trim(path); + if (value.isEmpty()) { + return null; + } + return Path.of(value).normalize(); + } + + private static T writeUnchecked(CheckedSupplier supplier) { + try { + return supplier.get(); + } catch (IOException ex) { + throw new IllegalStateException("failed to write payload archive entry", ex); + } + } + + private static void addZipEntry(ZipOutputStream zipOutputStream, String entryName, byte[] content) throws IOException { + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + zipOutputStream.write(content); + zipOutputStream.closeEntry(); + } + + private static byte[] readRequiredEntry(ZipFile zipFile, String entryName) throws IOException { + ZipEntry entry = zipFile.getEntry(entryName); + if (entry == null) { + throw new IllegalStateException("backup package entry missing: " + entryName); + } + try (InputStream inputStream = zipFile.getInputStream(entry)) { + return inputStream.readAllBytes(); + } + } + + private static String sm3Hex(byte[] data) { + SM3Digest digest = new SM3Digest(); + digest.update(data, 0, data.length); + byte[] out = new byte[digest.getDigestSize()]; + digest.doFinal(out, 0); + StringBuilder builder = new StringBuilder(out.length * 2); + for (byte item : out) { + builder.append(String.format("%02x", item)); + } + return builder.toString(); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + @FunctionalInterface + private interface CheckedSupplier { + T get() throws IOException; + } + + private record PayloadArchiveBuildResult(byte[] archiveBytes, int collectedResourceCount) { + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java new file mode 100644 index 0000000..4e6aefc --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java @@ -0,0 +1,11 @@ +package com.cisd.tms.modules.backup.repository; + +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import java.util.Optional; + +public interface ResourceBackupTaskRepository { + + void save(ResourceBackupTaskEntity entity); + + Optional findByTaskId(String taskId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceRestoreTaskRepository.java b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceRestoreTaskRepository.java new file mode 100644 index 0000000..b9645ef --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceRestoreTaskRepository.java @@ -0,0 +1,16 @@ +package com.cisd.tms.modules.backup.repository; + +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import java.util.List; +import java.util.Optional; + +public interface ResourceRestoreTaskRepository { + + void save(ResourceRestoreTaskEntity entity); + + void update(ResourceRestoreTaskEntity entity); + + Optional findByTaskId(String taskId); + + List findAll(); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java new file mode 100644 index 0000000..10b93bf --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java @@ -0,0 +1,11 @@ +package com.cisd.tms.modules.backup.repository; + +import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; +import java.util.List; + +public interface ResourceTaskStepRepository { + + void save(ResourceTaskStepEntity entity); + + List findByTaskIdOrderByStepNo(String taskId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java new file mode 100644 index 0000000..ba69faa --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.cisd.tms.modules.backup.repository.impl; + +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class ResourceBackupTaskRepositoryImpl implements ResourceBackupTaskRepository { + + private final ResourceBackupTaskMapper resourceBackupTaskMapper; + + public ResourceBackupTaskRepositoryImpl(ResourceBackupTaskMapper resourceBackupTaskMapper) { + this.resourceBackupTaskMapper = resourceBackupTaskMapper; + } + + @Override + public void save(ResourceBackupTaskEntity entity) { + resourceBackupTaskMapper.insert(entity); + } + + @Override + public Optional findByTaskId(String taskId) { + return Optional.ofNullable(resourceBackupTaskMapper.selectByTaskId(taskId)); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceRestoreTaskRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceRestoreTaskRepositoryImpl.java new file mode 100644 index 0000000..4ddf850 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceRestoreTaskRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.cisd.tms.modules.backup.repository.impl; + +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.backup.mapper.ResourceRestoreTaskMapper; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class ResourceRestoreTaskRepositoryImpl implements ResourceRestoreTaskRepository { + + private final ResourceRestoreTaskMapper resourceRestoreTaskMapper; + + public ResourceRestoreTaskRepositoryImpl(ResourceRestoreTaskMapper resourceRestoreTaskMapper) { + this.resourceRestoreTaskMapper = resourceRestoreTaskMapper; + } + + @Override + public void save(ResourceRestoreTaskEntity entity) { + resourceRestoreTaskMapper.insert(entity); + } + + @Override + public void update(ResourceRestoreTaskEntity entity) { + resourceRestoreTaskMapper.updateById(entity); + } + + @Override + public Optional findByTaskId(String taskId) { + return Optional.ofNullable(resourceRestoreTaskMapper.selectByTaskId(taskId)); + } + + @Override + public List findAll() { + List tasks = resourceRestoreTaskMapper.selectList(null); + return tasks == null ? List.of() : tasks; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java new file mode 100644 index 0000000..c368067 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.cisd.tms.modules.backup.repository.impl; + +import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; +import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper; +import com.cisd.tms.modules.backup.repository.ResourceTaskStepRepository; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class ResourceTaskStepRepositoryImpl implements ResourceTaskStepRepository { + + private final ResourceTaskStepMapper resourceTaskStepMapper; + + public ResourceTaskStepRepositoryImpl(ResourceTaskStepMapper resourceTaskStepMapper) { + this.resourceTaskStepMapper = resourceTaskStepMapper; + } + + @Override + public void save(ResourceTaskStepEntity entity) { + resourceTaskStepMapper.insert(entity); + } + + @Override + public List findByTaskIdOrderByStepNo(String taskId) { + List steps = resourceTaskStepMapper.selectByTaskIdOrderByStepNo(taskId); + return steps == null ? List.of() : steps; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java b/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java new file mode 100644 index 0000000..2a9872f --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/AsyncResourceRestoreRunnerLauncher.java @@ -0,0 +1,30 @@ +package com.cisd.tms.modules.backup.runner; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import java.nio.file.Path; +import org.springframework.stereotype.Component; + +@Component +public class AsyncResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher { + + private final ResourceRestoreRunner resourceRestoreRunner; + private final ResourceBackupProperties resourceBackupProperties; + + public AsyncResourceRestoreRunnerLauncher( + ResourceRestoreRunner resourceRestoreRunner, + ResourceBackupProperties resourceBackupProperties + ) { + this.resourceRestoreRunner = resourceRestoreRunner; + this.resourceBackupProperties = resourceBackupProperties; + } + + @Override + public void launch(Path handoffFile) { + if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) { + return; + } + Thread thread = new Thread(() -> resourceRestoreRunner.run(handoffFile), "resource-restore-runner"); + thread.setDaemon(true); + thread.start(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java new file mode 100644 index 0000000..3a0a16d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutor.java @@ -0,0 +1,143 @@ +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.Path; +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 PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor { + + 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) || "CONSISTENCY_VERIFY".equals(stepCode); + } + + @Override + public RunnerStepExecutionResult execute(RunnerStepContext context) { + if ("RESTORE_FILES".equals(context.stepCode())) { + return restoreFiles(context); + } + if ("CONSISTENCY_VERIFY".equals(context.stepCode())) { + return verifyExtractedPayload(context); + } + throw new IllegalArgumentException("unsupported runner step: " + context.stepCode()); + } + + 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); + + int resourceCount = readResourceCount(payloadDir.resolve("payload-manifest.json")); + return RunnerStepExecutionResult.success("payload extracted to staging, resourceCount=" + resourceCount); + } + + private RunnerStepExecutionResult verifyExtractedPayload(RunnerStepContext context) { + Path manifestFile = context.taskDir().resolve("staging").resolve("payload").resolve("payload-manifest.json").normalize(); + if (!Files.isRegularFile(manifestFile)) { + throw new IllegalStateException("payload manifest missing: " + manifestFile); + } + int resourceCount = readResourceCount(manifestFile); + return RunnerStepExecutionResult.success("payload manifest verified, resourceCount=" + resourceCount); + } + + 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 manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference>() { + }); + 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 static byte[] readAllBytes(InputStream inputStream) throws IOException { + return inputStream.readAllBytes(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ProcessBuilderRunnerCommandInvoker.java b/src/main/java/com/cisd/tms/modules/backup/runner/ProcessBuilderRunnerCommandInvoker.java new file mode 100644 index 0000000..4eb831b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ProcessBuilderRunnerCommandInvoker.java @@ -0,0 +1,36 @@ +package com.cisd.tms.modules.backup.runner; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ProcessBuilderRunnerCommandInvoker implements RunnerCommandInvoker { + + @Override + public int run(List command, Path logFile, Path workingDirectory) { + try { + if (logFile != null && logFile.getParent() != null) { + Files.createDirectories(logFile.getParent()); + } + ProcessBuilder pb = new ProcessBuilder(command); + if (workingDirectory != null) { + Files.createDirectories(workingDirectory); + pb.directory(workingDirectory.toFile()); + } + pb.redirectErrorStream(true); + if (logFile != null) { + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile())); + } + Process process = pb.start(); + return process.waitFor(); + } catch (IOException ex) { + throw new IllegalStateException("failed to start runner command: " + String.join(" ", command), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("runner command interrupted: " + String.join(" ", command), ex); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunner.java b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunner.java new file mode 100644 index 0000000..d6500e8 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunner.java @@ -0,0 +1,156 @@ +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 runnerStepExecutors; + + public ResourceRestoreRunner( + HandoffFileStore handoffFileStore, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ObjectMapper objectMapper, + List 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 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 readRestorePlan(String restorePlanJson) { + if (restorePlanJson == null || restorePlanJson.isBlank()) { + return List.of(INITIAL_STEP_CODE); + } + try { + return objectMapper.readValue(restorePlanJson, new TypeReference>() { + }); + } 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 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 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); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerLauncher.java b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerLauncher.java new file mode 100644 index 0000000..d7604c2 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerLauncher.java @@ -0,0 +1,8 @@ +package com.cisd.tms.modules.backup.runner; + +import java.nio.file.Path; + +public interface ResourceRestoreRunnerLauncher { + + void launch(Path handoffFile); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RestoreStateReconciler.java b/src/main/java/com/cisd/tms/modules/backup/runner/RestoreStateReconciler.java new file mode 100644 index 0000000..5ed689c --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RestoreStateReconciler.java @@ -0,0 +1,58 @@ +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.stereotype.Component; + +@Component +public class RestoreStateReconciler { + + private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; + private final ResourceRestoreStateFileStore resourceRestoreStateFileStore; + private final Supplier nowSupplier; + + public RestoreStateReconciler( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore + ) { + this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, LocalDateTime::now); + } + + RestoreStateReconciler( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + Supplier 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; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvoker.java b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvoker.java new file mode 100644 index 0000000..42b6f95 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvoker.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.backup.runner; + +import java.nio.file.Path; +import java.util.List; + +public interface RunnerCommandInvoker { + + int run(List command, Path logFile, Path workingDirectory); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStallDetector.java b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStallDetector.java new file mode 100644 index 0000000..d74eba5 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStallDetector.java @@ -0,0 +1,62 @@ +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.stereotype.Component; + +@Component +public class RunnerStallDetector { + + private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; + private final ResourceRestoreStateFileStore resourceRestoreStateFileStore; + private final ResourceBackupProperties resourceBackupProperties; + private final Supplier nowSupplier; + + public RunnerStallDetector( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ResourceBackupProperties resourceBackupProperties + ) { + this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, resourceBackupProperties, LocalDateTime::now); + } + + RunnerStallDetector( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ResourceBackupProperties resourceBackupProperties, + Supplier 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"); + }); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepContext.java b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepContext.java new file mode 100644 index 0000000..8df9e77 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepContext.java @@ -0,0 +1,13 @@ +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 +) { +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutionResult.java b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutionResult.java new file mode 100644 index 0000000..d507261 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutionResult.java @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutor.java new file mode 100644 index 0000000..cf5d762 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/RunnerStepExecutor.java @@ -0,0 +1,8 @@ +package com.cisd.tms.modules.backup.runner; + +public interface RunnerStepExecutor { + + boolean supports(String stepCode); + + RunnerStepExecutionResult execute(RunnerStepContext context); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutor.java b/src/main/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutor.java new file mode 100644 index 0000000..3348cec --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutor.java @@ -0,0 +1,82 @@ +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> commands = resolveCommands(context.stepCode()); + int executedCount = 0; + for (List 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> resolveCommands(String stepCode) { + return switch (stepCode) { + case "STOP_SERVICES" -> buildStopCommands(); + case "START_SERVICES" -> buildStartCommands(); + default -> throw new IllegalArgumentException("unsupported runner step: " + stepCode); + }; + } + + private List> buildStopCommands() { + List> commands = new ArrayList<>(); + commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "stop")); + appendShellCommand(commands, initExecutorProperties.getStandardAppStopCommand()); + appendShellCommand(commands, initExecutorProperties.getStandardRabbitStopCommand()); + return commands; + } + + private List> buildStartCommands() { + List> commands = new ArrayList<>(); + appendShellCommand(commands, initExecutorProperties.getStandardAppStartCommand()); + commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "start")); + return commands; + } + + private void appendShellCommand(List> 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())); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/runner/model/ResourceRestoreHandoffSnapshot.java b/src/main/java/com/cisd/tms/modules/backup/runner/model/ResourceRestoreHandoffSnapshot.java new file mode 100644 index 0000000..e55c4c0 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/runner/model/ResourceRestoreHandoffSnapshot.java @@ -0,0 +1,77 @@ +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; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourceBackupService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourceBackupService.java new file mode 100644 index 0000000..c93c0a8 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourceBackupService.java @@ -0,0 +1,25 @@ +package com.cisd.tms.modules.backup.service; + +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.support.ResourceBackupDownloadResult; +import java.util.List; + +public interface ResourceBackupService { + + ResourceKeysetStatusResponse getKeysetStatus(); + + CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request); + + ResourceBackupTaskDetailResponse getBackupTask(String taskId); + + ResourceBackupDownloadResult downloadBackupPackage(String taskId); + + List getBackupTaskSteps(String taskId); + + ResourceTaskStepLogResponse getBackupTaskStepLog(String taskId, int stepNo); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourcePrecheckService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourcePrecheckService.java new file mode 100644 index 0000000..145a415 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourcePrecheckService.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; + +public interface ResourcePrecheckService { + + ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java new file mode 100644 index 0000000..18ceddf --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; + +public interface ResourceRestoreService { + + CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java new file mode 100644 index 0000000..8302b82 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java @@ -0,0 +1,15 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import java.util.List; + +public interface ResourceTaskQueryService { + + ResourceRestoreTaskDetailResponse getRestoreTask(String taskId); + + List getRestoreTaskSteps(String taskId); + + ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java new file mode 100644 index 0000000..be22f38 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java @@ -0,0 +1,218 @@ +package com.cisd.tms.modules.backup.service.impl; + +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService; +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse; +import com.cisd.tms.modules.backup.dto.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.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.packagex.ResourcePackageResult; +import com.cisd.tms.modules.backup.packagex.ResourcePackageService; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import com.cisd.tms.modules.backup.service.ResourceBackupService; +import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult; +import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import com.cisd.tms.modules.init.repository.InitTaskRepository; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class ResourceBackupServiceImpl implements ResourceBackupService { + + private final InitTaskRepository initTaskRepository; + private final ResourceDiscoveryService resourceDiscoveryService; + private final ResourcePackageService resourcePackageService; + private final ResourceBackupTaskRepository resourceBackupTaskRepository; + private final ResourceTaskIdGenerator resourceTaskIdGenerator; + private final com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties; + + public ResourceBackupServiceImpl( + InitTaskRepository initTaskRepository, + ResourceDiscoveryService resourceDiscoveryService, + ResourcePackageService resourcePackageService, + ResourceBackupTaskRepository resourceBackupTaskRepository, + ResourceTaskIdGenerator resourceTaskIdGenerator, + com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties + ) { + this.initTaskRepository = initTaskRepository; + this.resourceDiscoveryService = resourceDiscoveryService; + this.resourcePackageService = resourcePackageService; + this.resourceBackupTaskRepository = resourceBackupTaskRepository; + this.resourceTaskIdGenerator = resourceTaskIdGenerator; + this.resourceBackupProperties = resourceBackupProperties; + } + + @Override + public ResourceKeysetStatusResponse getKeysetStatus() { + ResourceKeysetStatusResponse response = new ResourceKeysetStatusResponse(); + // 第一版先把接口骨架和本地备份主链打通;真实密钥状态后续接入密码卡/UKey恢复结果。 + response.setLmkReady(false); + response.setIkReady(false); + response.setStorageEncKeyReady(false); + response.setAuthenticityKeyReady(false); + response.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex()); + response.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex()); + response.setSource("CONFIGURED_KEY_INDEX"); + return response; + } + + @Override + public CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request) { + // 资源备份必须基于“最近一次成功初始化”快照收集上下文,避免前端重新传一套可能失真的参数。 + InitTaskEntity initTask = initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS") + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "successful init task is missing")); + + String taskId = resourceTaskIdGenerator.nextBackupTaskId(); + String backupId = resourceTaskIdGenerator.nextBackupId(); + // 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。 + ResourcePackageResult packageResult = resourcePackageService.createPackage( + taskId, + backupId, + initTask, + resourceDiscoveryService.discover(initTask), + request + ); + + LocalDateTime now = LocalDateTime.now(); + ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity(); + // 第一版先按同步成功模型落库,后续切换为真正异步任务时继续复用这张任务表。 + entity.setTaskId(taskId); + entity.setBackupId(backupId); + entity.setStatus("SUCCESS"); + entity.setProductType(initTask.getProductType()); + entity.setMqType(initTask.getMqType()); + entity.setOrgCode(initTask.getOrgCode()); + entity.setSourceDeviceId(resolveSourceDeviceId()); + entity.setPackageName(packageResult.getPackageName()); + entity.setPackagePath(packageResult.getPackagePath()); + entity.setPackageSize(packageResult.getPackageSize()); + entity.setPackageHash(packageResult.getPackageHash()); + entity.setStorageEncKeyFingerprint(packageResult.getStorageEncKeyFingerprint()); + entity.setAuthenticityKeyFingerprint(packageResult.getAuthenticityKeyFingerprint()); + entity.setManifestSummaryJson(packageResult.getManifestSummaryJson()); + entity.setRemark(request == null ? null : request.getRemark()); + entity.setCreateBy("SYSTEM"); + entity.setCreateTime(now); + entity.setUpdateTime(now); + entity.setStartTime(now); + entity.setFinishTime(now); + resourceBackupTaskRepository.save(entity); + + CreateResourceBackupResponse response = new CreateResourceBackupResponse(); + response.setTaskId(taskId); + response.setBackupId(backupId); + response.setStatus(entity.getStatus()); + response.setCreatedAt(now.toString()); + return response; + } + + @Override + public ResourceBackupTaskDetailResponse getBackupTask(String taskId) { + ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup task not found: " + trim(taskId))); + ResourceBackupTaskDetailResponse response = new ResourceBackupTaskDetailResponse(); + response.setTaskId(entity.getTaskId()); + response.setBackupId(entity.getBackupId()); + response.setStatus(entity.getStatus()); + response.setProductType(entity.getProductType()); + response.setMqType(entity.getMqType()); + response.setOrgCode(entity.getOrgCode()); + response.setSourceDeviceId(entity.getSourceDeviceId()); + response.setPackageName(entity.getPackageName()); + response.setPackageSize(entity.getPackageSize()); + response.setPackageHash(entity.getPackageHash()); + response.setStorageEncKeyFingerprint(entity.getStorageEncKeyFingerprint()); + response.setAuthenticityKeyFingerprint(entity.getAuthenticityKeyFingerprint()); + response.setManifestSummary(entity.getManifestSummaryJson()); + response.setRemark(entity.getRemark()); + response.setErrorCode(entity.getErrorCode()); + response.setErrorMessage(entity.getErrorMessage()); + response.setCreatedBy(entity.getCreateBy()); + response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString()); + response.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString()); + response.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString()); + return response; + } + + @Override + public ResourceBackupDownloadResult downloadBackupPackage(String taskId) { + ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup task not found: " + trim(taskId))); + Path packagePath = Path.of(trim(entity.getPackagePath())).normalize().toAbsolutePath(); + if (trim(entity.getPackagePath()).isEmpty() || !Files.exists(packagePath) || !Files.isRegularFile(packagePath)) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup package file not found"); + } + try { + ResourceBackupDownloadResult result = new ResourceBackupDownloadResult(); + result.setFilename(trim(entity.getPackageName()).isEmpty() ? packagePath.getFileName().toString() : entity.getPackageName()); + result.setPackagePath(packagePath); + result.setContentLength(Files.size(packagePath)); + return result; + } catch (Exception ex) { + throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), "failed to read resource backup package"); + } + } + + @Override + public List getBackupTaskSteps(String taskId) { + ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup task not found: " + trim(taskId))); + ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse(); + item.setStepNo(1); + item.setStepCode("PACKAGE_CREATE"); + item.setStatus(entity.getStatus()); + item.setMessage("generated backup package " + trim(entity.getPackageName())); + item.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString()); + item.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString()); + item.setLogAvailable(Boolean.FALSE); + return List.of(item); + } + + @Override + public ResourceTaskStepLogResponse getBackupTaskStepLog(String taskId, int stepNo) { + ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup task not found: " + trim(taskId))); + ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse(); + response.setTaskId(trim(taskId)); + response.setStepNo(stepNo); + response.setStepCode("PACKAGE_CREATE"); + response.setContent(stepNo == 1 ? objectToJsonLine(Map.of( + "packageName", trim(entity.getPackageName()), + "packagePath", trim(entity.getPackagePath()), + "packageHash", trim(entity.getPackageHash()) + )) : ""); + response.setTruncated(false); + return response; + } + + private static String resolveSourceDeviceId() { + try { + // 先用主机名兜底,后续如有更稳定的设备唯一标识,可在这里统一替换。 + return InetAddress.getLocalHost().getHostName(); + } catch (Exception ex) { + return "UNKNOWN"; + } + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String objectToJsonLine(Map value) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value); + } catch (Exception ex) { + return "{}"; + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java new file mode 100644 index 0000000..b7ae1fe --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java @@ -0,0 +1,124 @@ +package com.cisd.tms.modules.backup.service.impl; + +import com.cisd.tms.common.config.properties.CisdPresetProperties; +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult; +import com.cisd.tms.modules.backup.packagex.ResourcePackageService; +import com.cisd.tms.modules.backup.service.ResourcePrecheckService; +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.file.entity.FileRecordEntity; +import com.cisd.tms.modules.file.repository.FileRecordRepository; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class ResourcePrecheckServiceImpl implements ResourcePrecheckService { + + private final FileRecordRepository fileRecordRepository; + private final ResourcePackageService resourcePackageService; + private final ResourcePrecheckStateStore resourcePrecheckStateStore; + private final ResourceTaskIdGenerator resourceTaskIdGenerator; + private final CisdPresetProperties cisdPresetProperties; + private final ResourceBackupProperties resourceBackupProperties; + + public ResourcePrecheckServiceImpl( + FileRecordRepository fileRecordRepository, + ResourcePackageService resourcePackageService, + ResourcePrecheckStateStore resourcePrecheckStateStore, + ResourceTaskIdGenerator resourceTaskIdGenerator, + CisdPresetProperties cisdPresetProperties, + ResourceBackupProperties resourceBackupProperties + ) { + this.fileRecordRepository = fileRecordRepository; + this.resourcePackageService = resourcePackageService; + this.resourcePrecheckStateStore = resourcePrecheckStateStore; + this.resourceTaskIdGenerator = resourceTaskIdGenerator; + this.cisdPresetProperties = cisdPresetProperties; + this.resourceBackupProperties = resourceBackupProperties; + } + + @Override + public ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request) { + FileRecordEntity fileRecord = fileRecordRepository.findByFileId(trim(request.getFileId())) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "backup package file not found: " + trim(request.getFileId()))); + // 预检阶段只解析包头、验签和比对指纹,不提前解密完整 payload。 + ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackageHeader(Path.of(fileRecord.getStoragePath())); + String storageFingerprint = stringValue(inspectResult.getManifest().get("storageEncKeyFingerprint")); + String authenticityFingerprint = stringValue(inspectResult.getManifest().get("authenticityKeyFingerprint")); + if (!expectedStorageFingerprint().equals(storageFingerprint) || !expectedAuthenticityFingerprint().equals(authenticityFingerprint)) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份包密钥指纹不匹配"); + } + ResourceRestorePrecheckResponse response = new ResourceRestorePrecheckResponse(); + response.setPrecheckId(resourceTaskIdGenerator.nextPrecheckId()); + response.setBackupId(stringValue(inspectResult.getManifest().get("backupId"))); + response.setSourceProductType(stringValue(inspectResult.getManifest().get("productType"))); + response.setSourceMqType(stringValue(inspectResult.getManifest().get("mqType"))); + response.setSourceOrgCode(stringValue(inspectResult.getManifest().get("orgCode"))); + response.setSourceBackupTime(stringValue(inspectResult.getManifest().get("backupTime"))); + response.setTargetProductType(defaultProductType()); + response.setKeysetMatched(true); + response.setSignatureValid(true); + response.setCompatible(isCompatible(response.getSourceProductType(), response.getTargetProductType())); + 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.setRestoreScopeSummary(buildScopeSummary(inspectResult)); + response.setExpiresAt(LocalDateTime.now().plusMinutes(30).toString()); + resourcePrecheckStateStore.save(new ResourcePrecheckContext(request.getFileId(), response)); + return response; + } + + private String defaultProductType() { + return cisdPresetProperties.getProductType() == null || cisdPresetProperties.getProductType().isBlank() + ? "UNKNOWN" + : cisdPresetProperties.getProductType().trim(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private String expectedStorageFingerprint() { + return "USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex(); + } + + private String expectedAuthenticityFingerprint() { + return "USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex(); + } + + private static java.util.Map buildScopeSummary(ResourcePackageInspectResult inspectResult) { + java.util.LinkedHashMap summary = new java.util.LinkedHashMap<>(); + summary.put("resourceCount", inspectResult.getManifest().get("resourceCount")); + summary.put("productType", inspectResult.getManifest().get("productType")); + summary.put("mqType", inspectResult.getManifest().get("mqType")); + summary.put("orgCode", inspectResult.getManifest().get("orgCode")); + return summary; + } + + private static boolean isCompatible(String sourceProductType, String targetProductType) { + if (targetProductType == null || targetProductType.isBlank() || "UNKNOWN".equalsIgnoreCase(targetProductType)) { + return true; + } + return targetProductType.equalsIgnoreCase(sourceProductType); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java new file mode 100644 index 0000000..6e4d54e --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java @@ -0,0 +1,157 @@ +package com.cisd.tms.modules.backup.service.impl; + +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerLauncher; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.service.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.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class ResourceRestoreServiceImpl implements ResourceRestoreService { + + private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; + 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; + + public ResourceRestoreServiceImpl( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + FileRecordRepository fileRecordRepository, + ResourcePrecheckStateStore resourcePrecheckStateStore, + ResourceTaskIdGenerator resourceTaskIdGenerator, + HandoffFileStore handoffFileStore, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ResourceBackupProperties resourceBackupProperties, + ResourceRestoreRunnerLauncher resourceRestoreRunnerLauncher + ) { + this.resourceRestoreTaskRepository = resourceRestoreTaskRepository; + this.fileRecordRepository = fileRecordRepository; + this.resourcePrecheckStateStore = resourcePrecheckStateStore; + this.resourceTaskIdGenerator = resourceTaskIdGenerator; + this.handoffFileStore = handoffFileStore; + this.resourceRestoreStateFileStore = resourceRestoreStateFileStore; + this.resourceBackupProperties = resourceBackupProperties; + this.resourceRestoreRunnerLauncher = resourceRestoreRunnerLauncher; + } + + public ResourceRestoreServiceImpl( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + FileRecordRepository fileRecordRepository, + ResourcePrecheckStateStore resourcePrecheckStateStore, + ResourceTaskIdGenerator resourceTaskIdGenerator, + HandoffFileStore handoffFileStore, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ResourceBackupProperties resourceBackupProperties + ) { + this( + resourceRestoreTaskRepository, + fileRecordRepository, + resourcePrecheckStateStore, + resourceTaskIdGenerator, + handoffFileStore, + resourceRestoreStateFileStore, + resourceBackupProperties, + handoffFile -> { + } + ); + } + + @Override + public CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request) { + if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "restore risk must be accepted"); + } + // 恢复创建必须消费预检结果,确保“用户确认的 backupId”与预检看到的是同一个包。 + ResourcePrecheckContext context = resourcePrecheckStateStore.find(request.getPrecheckId()) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck not found")); + ResourceRestorePrecheckResponse precheck = context.getResponse(); + if (!trim(request.getConfirmBackupId()).equals(trim(precheck.getBackupId()))) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "confirmed backup id does not match precheck result"); + } + FileRecordEntity packageFile = fileRecordRepository.findByFileId(context.getFileId()) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup package file not found: " + trim(context.getFileId()))); + String packagePath = trim(packageFile.getStoragePath()); + if (packagePath.isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource backup package path is empty"); + } + + String taskId = resourceTaskIdGenerator.nextRestoreTaskId(); + LocalDateTime now = LocalDateTime.now(); + ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity(); + entity.setTaskId(taskId); + entity.setPrecheckId(precheck.getPrecheckId()); + entity.setBackupId(precheck.getBackupId()); + // 恢复任务创建完成后,就应该已经进入“等待 runner 接管”的交接态, + // 前端和运维看到的状态不能还停留在 PRECHECKED。 + entity.setStatus("HANDOFF"); + 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.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex()); + entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex()); + entity.setRestorePlanJson(joinAsJsonArray(precheck.getRestorePlan())); + 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")); + + CreateResourceRestoreResponse response = new CreateResourceRestoreResponse(); + response.setTaskId(taskId); + response.setStatus(entity.getStatus()); + response.setCreatedAt(now.toString()); + return response; + } + + private static String joinAsJsonArray(List items) { + if (items == null || items.isEmpty()) { + return "[]"; + } + // 这里先用最轻量的 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(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java new file mode 100644 index 0000000..7537ab1 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java @@ -0,0 +1,228 @@ +package com.cisd.tms.modules.backup.service.impl; + +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +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 org.springframework.stereotype.Service; + +@Service +public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService { + + private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; + private final ResourceRestoreStateFileStore resourceRestoreStateFileStore; + private final ObjectMapper objectMapper; + private final ResourceTaskStepLogService resourceTaskStepLogService; + + public ResourceTaskQueryServiceImpl( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ObjectMapper objectMapper + ) { + this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, objectMapper, new FileSystemResourceTaskStepLogService()); + } + + public ResourceTaskQueryServiceImpl( + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + ResourceRestoreStateFileStore resourceRestoreStateFileStore, + ObjectMapper objectMapper, + ResourceTaskStepLogService resourceTaskStepLogService + ) { + this.resourceRestoreTaskRepository = resourceRestoreTaskRepository; + this.resourceRestoreStateFileStore = resourceRestoreStateFileStore; + this.objectMapper = objectMapper; + this.resourceTaskStepLogService = resourceTaskStepLogService; + } + + @Override + public ResourceRestoreTaskDetailResponse getRestoreTask(String taskId) { + ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId))); + ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse(); + response.setTaskId(entity.getTaskId()); + response.setPrecheckId(entity.getPrecheckId()); + response.setBackupId(entity.getBackupId()); + response.setStatus(entity.getStatus()); + response.setControlPhase("PRECHECKED"); + response.setStateSource("DB"); + response.setSourceDeviceId(entity.getSourceDeviceId()); + response.setSourceProductType(entity.getSourceProductType()); + response.setTargetProductType(entity.getTargetProductType()); + response.setPackageFileId(entity.getPackageFileId()); + response.setPackagePathVisible(Boolean.FALSE); + response.setRunnerStatePath(entity.getRunnerStatePath()); + response.setRestorePlan(readStringList(entity.getRestorePlanJson())); + response.setConsistencyReport(readObject(entity.getConsistencyReportJson())); + response.setErrorCode(entity.getErrorCode()); + response.setErrorMessage(entity.getErrorMessage()); + response.setCreatedBy(entity.getCreateBy()); + response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString()); + response.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString()); + response.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString()); + mergeRunnerState(response, entity); + return response; + } + + @Override + public List getRestoreTaskSteps(String taskId) { + ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId))); + List restorePlan = readStringList(entity.getRestorePlanJson()); + ResourceRestoreStateSnapshot snapshot = resourceRestoreStateFileStore.read(resolveStatePath(entity.getRunnerStatePath())).orElse(null); + Path taskDir = resolveTaskDir(entity); + List responses = new ArrayList<>(); + for (int i = 0; i < restorePlan.size(); i++) { + String stepCode = restorePlan.get(i); + int stepNo = i + 1; + ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse(); + item.setStepNo(stepNo); + item.setStepCode(stepCode); + applyStepState(item, taskDir, snapshot, stepCode, stepNo); + responses.add(item); + } + return responses; + } + + @Override + public ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo) { + ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId))); + List restorePlan = readStringList(entity.getRestorePlanJson()); + String stepCode = stepNo > 0 && stepNo <= restorePlan.size() ? restorePlan.get(stepNo - 1) : ""; + return resourceTaskStepLogService.read(resolveTaskDir(entity), trim(taskId), stepNo, stepCode); + } + + 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 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 resolveTaskDir(ResourceRestoreTaskEntity entity) { + Path statePath = resolveStatePath(entity.getRunnerStatePath()); + return statePath == null ? null : statePath.getParent(); + } + + private void applyStepState( + ResourceTaskStepItemResponse item, + Path taskDir, + ResourceRestoreStateSnapshot snapshot, + String stepCode, + int stepNo + ) { + Map 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"), "")); + 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"); + } + return; + } + item.setStatus("PENDING"); + } + + private Map 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>() { + }); + } 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 readStringList(String json) { + if (trim(json).isEmpty()) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (Exception ex) { + throw new IllegalStateException("failed to parse restore plan json", ex); + } + } + + private Object readObject(String json) { + if (trim(json).isEmpty()) { + return null; + } + try { + return objectMapper.readValue(json, Object.class); + } catch (Exception ex) { + throw new IllegalStateException("failed to parse consistency report json", ex); + } + } + + private static String defaultIfBlank(String candidate, String fallback) { + return trim(candidate).isEmpty() ? fallback : candidate; + } + + private static T defaultIfNull(T candidate, T fallback) { + return candidate == null ? fallback : candidate; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/InMemoryResourcePrecheckStateStore.java b/src/main/java/com/cisd/tms/modules/backup/support/InMemoryResourcePrecheckStateStore.java new file mode 100644 index 0000000..25ac38b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/InMemoryResourcePrecheckStateStore.java @@ -0,0 +1,25 @@ +package com.cisd.tms.modules.backup.support; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.stereotype.Component; + +@Component +public class InMemoryResourcePrecheckStateStore implements ResourcePrecheckStateStore { + + private final ConcurrentMap contexts = new ConcurrentHashMap<>(); + + @Override + public void save(ResourcePrecheckContext context) { + if (context == null || context.getResponse() == null || context.getResponse().getPrecheckId() == null) { + throw new IllegalArgumentException("context with precheckId is required"); + } + contexts.put(context.getResponse().getPrecheckId(), context); + } + + @Override + public Optional find(String precheckId) { + return Optional.ofNullable(contexts.get(precheckId)); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourceBackupDownloadResult.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourceBackupDownloadResult.java new file mode 100644 index 0000000..b18139b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/ResourceBackupDownloadResult.java @@ -0,0 +1,34 @@ +package com.cisd.tms.modules.backup.support; + +import java.nio.file.Path; + +public class ResourceBackupDownloadResult { + + private String filename; + private Path packagePath; + private long contentLength; + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public Path getPackagePath() { + return packagePath; + } + + public void setPackagePath(Path packagePath) { + this.packagePath = packagePath; + } + + public long getContentLength() { + return contentLength; + } + + public void setContentLength(long contentLength) { + this.contentLength = contentLength; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckContext.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckContext.java new file mode 100644 index 0000000..2f8dfb6 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckContext.java @@ -0,0 +1,22 @@ +package com.cisd.tms.modules.backup.support; + +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; + +public class ResourcePrecheckContext { + + private final String fileId; + private final ResourceRestorePrecheckResponse response; + + public ResourcePrecheckContext(String fileId, ResourceRestorePrecheckResponse response) { + this.fileId = fileId; + this.response = response; + } + + public String getFileId() { + return fileId; + } + + public ResourceRestorePrecheckResponse getResponse() { + return response; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckStateStore.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckStateStore.java new file mode 100644 index 0000000..7e7aa80 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/ResourcePrecheckStateStore.java @@ -0,0 +1,10 @@ +package com.cisd.tms.modules.backup.support; + +import java.util.Optional; + +public interface ResourcePrecheckStateStore { + + void save(ResourcePrecheckContext context); + + Optional find(String precheckId); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskIdGenerator.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskIdGenerator.java new file mode 100644 index 0000000..8aa7332 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskIdGenerator.java @@ -0,0 +1,34 @@ +package com.cisd.tms.modules.backup.support; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class ResourceTaskIdGenerator { + + private static final DateTimeFormatter TASK_ID_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final DateTimeFormatter BACKUP_ID_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + public String nextBackupTaskId() { + return "RBKP-TASK-" + LocalDateTime.now().format(TASK_ID_FORMATTER) + "-" + randomSuffix(); + } + + public String nextBackupId() { + return "RBKP-" + LocalDateTime.now().format(BACKUP_ID_FORMATTER) + "-" + randomSuffix(); + } + + public String nextPrecheckId() { + return "RPRE-" + LocalDateTime.now().format(TASK_ID_FORMATTER) + "-" + randomSuffix(); + } + + public String nextRestoreTaskId() { + return "RRST-TASK-" + LocalDateTime.now().format(TASK_ID_FORMATTER) + "-" + randomSuffix(); + } + + private static String randomSuffix() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java new file mode 100644 index 0000000..886e62d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java @@ -0,0 +1,9 @@ +package com.cisd.tms.modules.backup.support; + +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import java.nio.file.Path; + +public interface ResourceTaskStepLogService { + + ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode); +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStore.java b/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStore.java new file mode 100644 index 0000000..44d9803 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStore.java @@ -0,0 +1,67 @@ +package com.cisd.tms.modules.backup.support.file; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.support.ResourcePrecheckContext; +import com.cisd.tms.modules.backup.support.ResourcePrecheckStateStore; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Component +@Primary +public class FileSystemResourcePrecheckStateStore implements ResourcePrecheckStateStore { + + private final ResourceBackupProperties resourceBackupProperties; + private final ObjectMapper objectMapper; + + public FileSystemResourcePrecheckStateStore( + ResourceBackupProperties resourceBackupProperties, + ObjectMapper objectMapper + ) { + this.resourceBackupProperties = resourceBackupProperties; + this.objectMapper = objectMapper; + } + + @Override + public void save(ResourcePrecheckContext context) { + if (context == null || context.getResponse() == null || context.getResponse().getPrecheckId() == null) { + throw new IllegalArgumentException("context with precheckId is required"); + } + try { + Path file = resolvePath(context.getResponse().getPrecheckId()); + Files.createDirectories(file.getParent()); + objectMapper.writeValue(file.toFile(), new StoredResourcePrecheckContext(context.getFileId(), context.getResponse())); + } catch (IOException ex) { + throw new IllegalStateException("failed to persist resource precheck context", ex); + } + } + + @Override + public Optional find(String precheckId) { + Path file = resolvePath(precheckId); + if (!Files.exists(file)) { + return Optional.empty(); + } + try { + JsonNode root = objectMapper.readTree(file.toFile()); + String fileId = root.path("fileId").asText(); + ResourceRestorePrecheckResponse response = objectMapper.treeToValue(root.path("response"), ResourceRestorePrecheckResponse.class); + return Optional.of(new ResourcePrecheckContext(fileId, response)); + } catch (IOException ex) { + throw new IllegalStateException("failed to load resource precheck context", ex); + } + } + + private Path resolvePath(String precheckId) { + return Path.of(resourceBackupProperties.getPrecheckStoreDir()).resolve(precheckId + ".json").normalize(); + } + + private record StoredResourcePrecheckContext(String fileId, ResourceRestorePrecheckResponse response) { + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java b/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java new file mode 100644 index 0000000..912d47a --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java @@ -0,0 +1,37 @@ +package com.cisd.tms.modules.backup.support.file; + +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.support.ResourceTaskStepLogService; +import java.nio.file.Files; +import java.nio.file.Path; +import org.springframework.stereotype.Component; + +@Component +public class FileSystemResourceTaskStepLogService implements ResourceTaskStepLogService { + + @Override + public ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode) { + ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse(); + response.setTaskId(taskId); + response.setStepNo(stepNo); + response.setStepCode(stepCode); + if (taskDir == null || stepCode == null || stepCode.isBlank()) { + response.setContent(""); + response.setTruncated(false); + return response; + } + Path logFile = taskDir.resolve("steps").resolve(String.format("%02d-%s.log", stepNo, stepCode)); + try { + if (!Files.isRegularFile(logFile)) { + response.setContent(""); + response.setTruncated(false); + return response; + } + response.setContent(Files.readString(logFile)); + response.setTruncated(false); + return response; + } catch (Exception ex) { + throw new IllegalStateException("failed to read resource task step log", ex); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/HandoffFileStore.java b/src/main/java/com/cisd/tms/modules/backup/support/file/HandoffFileStore.java new file mode 100644 index 0000000..fd147e5 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/file/HandoffFileStore.java @@ -0,0 +1,52 @@ +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 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 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); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStore.java b/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStore.java new file mode 100644 index 0000000..eb513e3 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStore.java @@ -0,0 +1,190 @@ +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 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 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 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 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 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 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 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 json); + } + + private Map 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) + ); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateSnapshot.java b/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateSnapshot.java new file mode 100644 index 0000000..bf6c570 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateSnapshot.java @@ -0,0 +1,160 @@ +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 cleanupWarnings; + private List warnings; + private List 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 getCleanupWarnings() { + return cleanupWarnings; + } + + public void setCleanupWarnings(List cleanupWarnings) { + this.cleanupWarnings = cleanupWarnings; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } + + public List getBlockingIssues() { + return blockingIssues; + } + + public void setBlockingIssues(List 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; + } +} diff --git a/src/main/java/com/cisd/tms/modules/init/config/InitExecutorProperties.java b/src/main/java/com/cisd/tms/modules/init/config/InitExecutorProperties.java index d32e623..63667fc 100644 --- a/src/main/java/com/cisd/tms/modules/init/config/InitExecutorProperties.java +++ b/src/main/java/com/cisd/tms/modules/init/config/InitExecutorProperties.java @@ -18,6 +18,8 @@ public class InitExecutorProperties { private int timeoutSec = 600; // 所有步骤日志会写入该目录,读取日志接口会做同目录约束校验 private String logDir = "/tmp/tms-init-logs"; + // TMS 外置配置目录(资源备份恢复时使用) + private String tmsConfigDir = "/home/tms/config"; // 标准版(企业/间参)安装根目录(统一入口) private String standardHomeDir = DEFAULT_STANDARD_HOME_DIR; // 标准版(企业/间参)默认 cpconfig.cfg 路径 @@ -133,6 +135,14 @@ public class InitExecutorProperties { this.logDir = logDir; } + public String getTmsConfigDir() { + return tmsConfigDir; + } + + public void setTmsConfigDir(String tmsConfigDir) { + this.tmsConfigDir = tmsConfigDir; + } + public String getStandardHomeDir() { return isBlank(standardHomeDir) ? DEFAULT_STANDARD_HOME_DIR : standardHomeDir.trim(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b834c8d..9758e6f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,6 +57,8 @@ tms: timeout-sec: ${TMS_INIT_EXECUTOR_TIMEOUT_SEC:600} # 初始化步骤日志目录;按 taskId/stepNo 生成日志文件。 log-dir: ${TMS_INIT_EXECUTOR_LOG_DIR:/home/tms/tmp/tms-init-logs} + # TMS 外置配置目录;资源备份恢复会按此目录收集配置。 + tms-config-dir: ${TMS_INIT_EXECUTOR_TMS_CONFIG_DIR:/home/tms/config} # 标准版(企业/间参)安装根目录统一变量。 standard-home-dir: ${TMS_INIT_STANDARD_HOME_DIR:/home/cmep4i} # 标准版 cpconfig.cfg 路径;默认取 standard-home-dir 下文件。 @@ -179,6 +181,15 @@ tms: log-dir: ${TMS_UPGRADE_LOG_DIR:/home/tms/tmp/tms-upgrade-logs} # 升级包验签使用的公钥 PEM 文件路径;为空时升级预检会拒绝通过。 signature-public-key-pem-path: ${TMS_UPGRADE_SIGNATURE_PUBLIC_KEY_PEM_PATH:/home/tms/cert/public_key.pem} + backup: + # 资源备份包输出目录;第一版先在本机生成可见的 .tmsbak 文件。 + output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages} + # 恢复 runner 启停 TMS 时使用的脚本路径。 + tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh} + # 是否在创建恢复任务后自动拉起本机 runner;默认关闭,避免开发环境误触停机恢复链。 + runner-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 编码);主密钥初始化接口会读取该值。 diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 97ff413..1e295ff 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -51,6 +51,90 @@ CREATE TABLE IF NOT EXISTS tms_init_task_step ( KEY idx_tms_init_task_step_task_id (task_id) ); +-- ----------------------------------------------------------------------------- +-- 资源备份与恢复 +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_resource_backup_task ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + backup_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + product_type VARCHAR(16) NOT NULL, + mq_type VARCHAR(32) NULL, + org_code VARCHAR(32) NULL, + source_device_id VARCHAR(128) NULL, + package_name VARCHAR(255) NULL, + package_path VARCHAR(1024) NULL, + package_size BIGINT NULL, + package_hash VARCHAR(128) NULL, + storage_enc_key_fingerprint VARCHAR(128) NOT NULL, + authenticity_key_fingerprint VARCHAR(128) NOT NULL, + manifest_summary_json JSON NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + remark VARCHAR(1024) NULL, + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_backup_task_task_id (task_id), + UNIQUE KEY uk_tms_resource_backup_task_backup_id (backup_id), + KEY idx_tms_resource_backup_task_status (status), + KEY idx_tms_resource_backup_task_create_time (create_time) +); + +CREATE TABLE IF NOT EXISTS tms_resource_restore_task ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + precheck_id VARCHAR(64) NOT NULL, + backup_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + source_device_id VARCHAR(128) NULL, + source_product_type VARCHAR(16) NOT NULL, + target_product_type VARCHAR(16) NOT NULL, + package_file_id VARCHAR(64) NOT NULL, + package_path VARCHAR(1024) NULL, + staging_dir VARCHAR(1024) NULL, + runner_state_path VARCHAR(1024) NULL, + storage_enc_key_fingerprint VARCHAR(128) NOT NULL, + authenticity_key_fingerprint VARCHAR(128) NOT NULL, + restore_plan_json JSON NULL, + consistency_report_json JSON NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + remark VARCHAR(1024) NULL, + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_restore_task_task_id (task_id), + UNIQUE KEY uk_tms_resource_restore_task_precheck_id (precheck_id), + KEY idx_tms_resource_restore_task_status (status), + KEY idx_tms_resource_restore_task_create_time (create_time) +); + +CREATE TABLE IF NOT EXISTS tms_resource_task_step ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE', + step_no INT NOT NULL, + step_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + message VARCHAR(512) NULL, + log_path VARCHAR(1024) NULL, + exit_code INT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no), + KEY idx_tms_resource_task_step_task_id (task_id), + KEY idx_tms_resource_task_step_task_type (task_type) +); + -- ----------------------------------------------------------------------------- -- 认证与设备基础表 -- ----------------------------------------------------------------------------- @@ -365,4 +449,4 @@ CREATE TABLE IF NOT EXISTS tms_access_whitelist ( mask VARCHAR(10) NOT NULL, create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) - ); \ No newline at end of file + ); diff --git a/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql b/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql new file mode 100644 index 0000000..464e299 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql @@ -0,0 +1,83 @@ +-- ----------------------------------------------------------------------------- +-- Resource backup and restore tables +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tms_resource_backup_task ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + backup_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + product_type VARCHAR(16) NOT NULL, + mq_type VARCHAR(32) NULL, + org_code VARCHAR(32) NULL, + source_device_id VARCHAR(128) NULL, + package_name VARCHAR(255) NULL, + package_path VARCHAR(1024) NULL, + package_size BIGINT NULL, + package_hash VARCHAR(128) NULL, + storage_enc_key_fingerprint VARCHAR(128) NOT NULL, + authenticity_key_fingerprint VARCHAR(128) NOT NULL, + manifest_summary_json JSON NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + remark VARCHAR(1024) NULL, + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_backup_task_task_id (task_id), + UNIQUE KEY uk_tms_resource_backup_task_backup_id (backup_id), + KEY idx_tms_resource_backup_task_status (status), + KEY idx_tms_resource_backup_task_create_time (create_time) +); + +CREATE TABLE IF NOT EXISTS tms_resource_restore_task ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + precheck_id VARCHAR(64) NOT NULL, + backup_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + source_device_id VARCHAR(128) NULL, + source_product_type VARCHAR(16) NOT NULL, + target_product_type VARCHAR(16) NOT NULL, + package_file_id VARCHAR(64) NOT NULL, + package_path VARCHAR(1024) NULL, + staging_dir VARCHAR(1024) NULL, + runner_state_path VARCHAR(1024) NULL, + storage_enc_key_fingerprint VARCHAR(128) NOT NULL, + authenticity_key_fingerprint VARCHAR(128) NOT NULL, + restore_plan_json JSON NULL, + consistency_report_json JSON NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + remark VARCHAR(1024) NULL, + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_restore_task_task_id (task_id), + UNIQUE KEY uk_tms_resource_restore_task_precheck_id (precheck_id), + KEY idx_tms_resource_restore_task_status (status), + KEY idx_tms_resource_restore_task_create_time (create_time) +); + +CREATE TABLE IF NOT EXISTS tms_resource_task_step ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE', + step_no INT NOT NULL, + step_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + message VARCHAR(512) NULL, + log_path VARCHAR(1024) NULL, + exit_code INT NULL, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + start_time DATETIME(3) NULL, + finish_time DATETIME(3) NULL, + UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no), + KEY idx_tms_resource_task_step_task_id (task_id), + KEY idx_tms_resource_task_step_task_type (task_type) +); diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java index a7f49fd..793a981 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java @@ -26,6 +26,7 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; class AuthAdminServiceTest { @@ -168,12 +169,12 @@ class AuthAdminServiceTest { @Test void shouldIssueBindingSignWithBackupPacketForSuperAdmin() { - LmkService lmkService = org.mockito.Mockito.mock(LmkService.class); - org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); - org.mockito.Mockito.when(lmkService.signIk(org.mockito.ArgumentMatchers.anyString())).thenReturn("ISSUE-SIGN-001"); + LmkService lmkService = Mockito.mock(LmkService.class); + Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); + Mockito.when(lmkService.signIk(org.mockito.ArgumentMatchers.anyString())).thenReturn("ISSUE-SIGN-001"); com.cisd.tms.modules.mk.dto.MasterKeyBackupPacket packet = new com.cisd.tms.modules.mk.dto.MasterKeyBackupPacket(); - packet.setComponentIndex(1); - org.mockito.Mockito.when(lmkService.buildBackupPacket(1)).thenReturn(packet); + packet.setPacketIndex(1); + Mockito.when(lmkService.buildBackupPacket(1)).thenReturn(packet); AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), @@ -189,24 +190,23 @@ class AuthAdminServiceTest { dto.setRole("ignored"); dto.setUid("1"); dto.setRid("RID-001"); -// dto.setExtra("EXTRA-001"); UKeySignResult result = service.issueUkeyBindingSign(RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.SUPER_ADMIN.getCode(), dto); Assertions.assertEquals("ISSUE-SIGN-001", result.getSign()); Assertions.assertNotNull(result.getBackupPacket()); - Assertions.assertEquals(1, result.getBackupPacket().getComponentIndex()); - org.mockito.Mockito.verify(lmkService).signIk( + Assertions.assertEquals(1, result.getBackupPacket().getPacketIndex()); + Mockito.verify(lmkService).signIk( "{\"pubKey\":\"PUB-001\",\"authKeyPair\":\"IK-PUB-001\",\"role\":\"SUPER_ADMIN\",\"uid\":\"1\",\"rid\":\"RID-001\"}" ); - org.mockito.Mockito.verify(lmkService).buildBackupPacket(1); + Mockito.verify(lmkService).buildBackupPacket(1); } @Test void shouldIssueBindingSignAndPreserveExtraForNonSuperAdmin() { - LmkService lmkService = org.mockito.Mockito.mock(LmkService.class); - org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); - org.mockito.Mockito.when(lmkService.signIk(org.mockito.ArgumentMatchers.anyString())).thenReturn("ISSUE-SIGN-002"); + LmkService lmkService = Mockito.mock(LmkService.class); + Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); + Mockito.when(lmkService.signIk(org.mockito.ArgumentMatchers.anyString())).thenReturn("ISSUE-SIGN-002"); AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java new file mode 100644 index 0000000..9f065d4 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java @@ -0,0 +1,62 @@ +package com.cisd.tms.modules.backup; + +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ResourceBackupDtoContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldAcceptBackupCreateRequestFields() { + CreateResourceBackupRequest request = new CreateResourceBackupRequest(); + request.setIncludeUpgradeHistory(Boolean.TRUE); + request.setIncludeAuditBackups(Boolean.FALSE); + request.setRemark("before hardware replacement"); + + Assertions.assertEquals(Boolean.TRUE, request.getIncludeUpgradeHistory()); + Assertions.assertEquals(Boolean.FALSE, request.getIncludeAuditBackups()); + Assertions.assertEquals("before hardware replacement", request.getRemark()); + } + + @Test + void shouldSerializeRestorePrecheckResponseFields() throws Exception { + ResourceRestorePrecheckResponse response = new ResourceRestorePrecheckResponse(); + response.setPrecheckId("RPRE-20260408-000001"); + response.setBackupId("RBKP-20260408-153000-000001"); + response.setSourceProductType("ENTERPRISE"); + response.setKeysetMatched(true); + response.setSignatureValid(true); + response.setCompatible(true); + response.setPayloadDecrypted(false); + + String json = objectMapper.writeValueAsString(response); + + Assertions.assertTrue(json.contains("\"precheckId\":\"RPRE-20260408-000001\"")); + Assertions.assertTrue(json.contains("\"backupId\":\"RBKP-20260408-153000-000001\"")); + Assertions.assertTrue(json.contains("\"sourceProductType\":\"ENTERPRISE\"")); + Assertions.assertTrue(json.contains("\"keysetMatched\":true")); + Assertions.assertTrue(json.contains("\"payloadDecrypted\":false")); + } + + @Test + 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"); + + 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\"")); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java new file mode 100644 index 0000000..fe5720e --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java @@ -0,0 +1,20 @@ +package com.cisd.tms.modules.backup; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ResourceBackupEndToEndContractTest { + + @Test + void shouldExposeColdRestoreEndpointsInControllerContracts() throws Exception { + String backupController = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java")); + String restoreController = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java")); + + Assertions.assertTrue(backupController.contains("/api/v1/resource-backups")); + Assertions.assertTrue(restoreController.contains("/api/v1/resource-restores")); + Assertions.assertTrue(restoreController.contains("/precheck")); + Assertions.assertTrue(restoreController.contains("/steps/{stepNo}/log")); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java new file mode 100644 index 0000000..a4a3024 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java @@ -0,0 +1,130 @@ +package com.cisd.tms.modules.backup.controller; + +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +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; +import com.cisd.tms.modules.backup.service.ResourcePrecheckService; +import com.cisd.tms.modules.backup.service.ResourceRestoreService; +import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; +import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ResourceBackupControllerTest { + + @TempDir + Path tempDir; + + @Test + void shouldHandleBackupAndRestoreEndpoints() throws Exception { + ResourceBackupService backupService = Mockito.mock(ResourceBackupService.class); + ResourcePrecheckService precheckService = Mockito.mock(ResourcePrecheckService.class); + 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"); + Mockito.when(backupService.createBackupTask(Mockito.any(CreateResourceBackupRequest.class))).thenReturn(createBackup); + + ResourceBackupTaskDetailResponse backupDetail = new ResourceBackupTaskDetailResponse(); + backupDetail.setTaskId("RBKP-TASK-20260408-000001"); + backupDetail.setStatus("RUNNING"); + backupDetail.setProductType("ENTERPRISE"); + Mockito.when(backupService.getBackupTask("RBKP-TASK-20260408-000001")).thenReturn(backupDetail); + + Path packageFile = tempDir.resolve("RBKP-20260408-153000-000001.tmsbak"); + Files.writeString(packageFile, "download-content", StandardCharsets.UTF_8); + ResourceBackupDownloadResult downloadResult = new ResourceBackupDownloadResult(); + downloadResult.setFilename("RBKP-20260408-153000-000001.tmsbak"); + downloadResult.setPackagePath(packageFile); + downloadResult.setContentLength(Files.size(packageFile)); + Mockito.when(backupService.downloadBackupPackage("RBKP-TASK-20260408-000001")).thenReturn(downloadResult); + + ResourceRestorePrecheckResponse precheck = new ResourceRestorePrecheckResponse(); + precheck.setPrecheckId("RPRE-20260408-000001"); + precheck.setBackupId("RBKP-20260408-153000-000001"); + precheck.setKeysetMatched(true); + precheck.setSignatureValid(true); + Mockito.when(precheckService.precheck(Mockito.any())).thenReturn(precheck); + + CreateResourceRestoreResponse createRestore = new CreateResourceRestoreResponse(); + createRestore.setTaskId("RRST-TASK-20260408-000001"); + createRestore.setStatus("PENDING"); + Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(createRestore); + + ResourceRestoreTaskDetailResponse restoreDetail = new ResourceRestoreTaskDetailResponse(); + restoreDetail.setTaskId("RRST-TASK-20260408-000001"); + restoreDetail.setStatus("RUNNING"); + restoreDetail.setControlPhase("RUNNER_OWNED"); + restoreDetail.setStateSource("RUNNER_FILE"); + Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260408-000001")).thenReturn(restoreDetail); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup( + new ResourceBackupController(backupService), + 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\"}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RBKP-TASK-20260408-000001\""))); + + mockMvc.perform(get("/api/v1/resource-backups/RBKP-TASK-20260408-000001")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"RUNNING\""))); + + mockMvc.perform(get("/api/v1/resource-backups/RBKP-TASK-20260408-000001/download")) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Content-Disposition", org.hamcrest.Matchers.containsString("attachment"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Content-Disposition", org.hamcrest.Matchers.containsString("RBKP-20260408-153000-000001.tmsbak"))) + .andExpect(content().string("download-content")); + + mockMvc.perform(post("/api/v1/resource-restores/precheck") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileId\":\"FILE-20260408-000001\"}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"precheckId\":\"RPRE-20260408-000001\""))); + + mockMvc.perform(post("/api/v1/resource-restores") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"precheckId\":\"RPRE-20260408-000001\",\"confirmBackupId\":\"RBKP-20260408-153000-000001\",\"confirmRiskAccepted\":true}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260408-000001\""))); + + mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260408-000001")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"RUNNER_OWNED\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"stateSource\":\"RUNNER_FILE\""))); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java new file mode 100644 index 0000000..0c683a6 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java @@ -0,0 +1,91 @@ +package com.cisd.tms.modules.backup.controller; + +import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; +import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.service.ResourcePrecheckService; +import com.cisd.tms.modules.backup.service.ResourceRestoreService; +import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ResourceRestoreControllerTest { + + @Test + void shouldExposeRestoreEndpointsIncludingStepsAndLogs() throws Exception { + ResourcePrecheckService precheckService = Mockito.mock(ResourcePrecheckService.class); + ResourceRestoreService restoreService = Mockito.mock(ResourceRestoreService.class); + ResourceTaskQueryService taskQueryService = Mockito.mock(ResourceTaskQueryService.class); + + ResourceRestorePrecheckResponse precheck = new ResourceRestorePrecheckResponse(); + precheck.setPrecheckId("RPRE-20260414-000001"); + precheck.setPayloadDecrypted(false); + Mockito.when(precheckService.precheck(Mockito.any(ResourceRestorePrecheckRequest.class))).thenReturn(precheck); + + CreateResourceRestoreResponse create = new CreateResourceRestoreResponse(); + create.setTaskId("RRST-TASK-20260414-000001"); + create.setStatus("HANDOFF"); + 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"); + Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260414-000001")).thenReturn(detail); + + ResourceTaskStepItemResponse step = new ResourceTaskStepItemResponse(); + step.setStepNo(2); + step.setStepCode("STOP_SERVICES"); + 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.setTruncated(false); + Mockito.when(taskQueryService.getRestoreTaskStepLog("RRST-TASK-20260414-000001", 2)).thenReturn(log); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup( + new ResourceRestoreController(precheckService, restoreService, taskQueryService) + ).build(); + + mockMvc.perform(post("/api/v1/resource-restores/precheck") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileId\":\"FILE-20260414-000001\"}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"payloadDecrypted\":false"))); + + mockMvc.perform(post("/api/v1/resource-restores") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"confirmRiskAccepted\":true}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260414-000001\""))); + + mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"RECONCILE_PENDING\""))); + + 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\""))); + + 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\""))); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java new file mode 100644 index 0000000..913e6a8 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java @@ -0,0 +1,71 @@ +package com.cisd.tms.modules.backup.crypto; + +import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest; +import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest; +import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ResourceEnvelopeCryptoServiceTest { + + @Test + void shouldWrapDekWithEncUserKeyAndSignWithAuthUserKey() { + PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setStorageEncKeyIndex(7); + properties.setAuthenticitySignKeyIndex(8); + + AtomicReference capturedDek = new AtomicReference<>(); + Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class))) + .thenAnswer(invocation -> { + EccInternalEncryptRequest request = invocation.getArgument(0); + capturedDek.set(request.getData()); + 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())); + + ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties); + byte[] payload = "payload-data".getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] signSource = "manifest|envelope|cipher".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + ResourceSealedPayload sealed = service.seal(payload); + byte[] signature = service.sign(signSource); + byte[] opened = service.unsealAndVerify(sealed.getEnvelope(), sealed.getCipherBytes(), signSource, signature); + + Assertions.assertArrayEquals(payload, opened); + Assertions.assertArrayEquals(new byte[]{0x44, 0x55, 0x66}, signature); + + ArgumentCaptor encryptCaptor = ArgumentCaptor.forClass(EccInternalEncryptRequest.class); + Mockito.verify(pcieCryptoService).sdfeInternalEncryptEcc(encryptCaptor.capture()); + Assertions.assertEquals(7, encryptCaptor.getValue().getKeyIndex()); + + ArgumentCaptor signCaptor = ArgumentCaptor.forClass(EccInternalSignRequest.class); + Mockito.verify(pcieCryptoService).eccInternalSign(signCaptor.capture()); + Assertions.assertEquals(8, signCaptor.getValue().getKeyIndex()); + + ArgumentCaptor verifyCaptor = ArgumentCaptor.forClass(EccInternalVerifyRequest.class); + Mockito.verify(pcieCryptoService).eccInternalVerify(verifyCaptor.capture()); + Assertions.assertEquals(8, verifyCaptor.getValue().getKeyIndex()); + + ArgumentCaptor decryptCaptor = ArgumentCaptor.forClass(EccInternalDecryptRequest.class); + Mockito.verify(pcieCryptoService).sdfeInternalDecryptEcc(decryptCaptor.capture()); + Assertions.assertEquals(7, decryptCaptor.getValue().getKeyIndex()); + } + + private static BackupDataResult backup(byte[] data) { + BackupDataResult result = new BackupDataResult(); + result.setData(data); + result.setLength(data.length); + return result; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java new file mode 100644 index 0000000..7da6d72 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java @@ -0,0 +1,73 @@ +package com.cisd.tms.modules.backup.discovery; + +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.init.config.InitExecutorProperties; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ResourceDiscoveryServiceTest { + + @Test + void shouldDiscoverStandardRabbitResourcesWithoutExcludedArtifacts() { + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties()); + + List items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ")); + + Assertions.assertTrue(containsCode(items, "TMS_CONFIG")); + Assertions.assertTrue(containsCode(items, "CP_CONFIG")); + Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE")); + Assertions.assertTrue(containsCode(items, "RABBITMQ_DEFINITIONS")); + Assertions.assertFalse(containsCode(items, "NGINX_CONFIG")); + Assertions.assertFalse(containsCode(items, "WEB_STATIC")); + Assertions.assertFalse(containsCode(items, "ORGANIZATION_JSON")); + Assertions.assertFalse(containsCode(items, "TMS_UPLOADS")); + } + + @Test + void shouldUseDirectPathsForDirectTlqResources() { + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties()); + + List items = service.discover(sampleInitTask("DIRECT", "RABBITMQ_TLQ")); + + 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")); + } + + private static InitTaskEntity sampleInitTask(String productType, String mqType) { + InitTaskEntity entity = new InitTaskEntity(); + entity.setTaskId("INIT-20260408-000001"); + entity.setTaskType("INIT"); + entity.setStatus("SUCCESS"); + entity.setProductType(productType); + entity.setMqType(mqType); + entity.setOrgCode("AAAABBBBXXX"); + entity.setDeployMode("SINGLE"); + return entity; + } + + private static InitExecutorProperties sampleProperties() { + InitExecutorProperties properties = new InitExecutorProperties(); + properties.setTmsConfigDir("/home/tms/config"); + properties.setStandardCpconfigPath("/home/cmep4i/cpconfig.cfg"); + properties.setDirectCpconfigTargetPath("/opt/cpconfig.cfg"); + properties.setStandardReceiverLicenseTargetPath("/home/cmep4i/cmep/license/cmep.license"); + properties.setDirectReceiverLicenseTargetPath("/home/cmep4d/cmep/license/cmep.license"); + properties.setStandardTlqLicenseTargetPath("/home/tlq/tlqconf.zip"); + properties.setDirectTlqLicenseTargetPath("/home/tlq/TLQ8/license.dat"); + properties.setCfmqTargetDir("/pmts/cfg"); + properties.setRabbitmqAdminCommand("/usr/sbin/rabbitmqadmin"); + return properties; + } + + private static boolean containsCode(List items, String code) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode())); + } + + private static boolean hasPath(List items, String code, String path) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getResolvedPath())); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java new file mode 100644 index 0000000..69a54ab --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java @@ -0,0 +1,144 @@ +package com.cisd.tms.modules.backup.packagex; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl; +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.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +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.Map; +import java.util.List; +import org.mockito.Mockito; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ResourcePackageServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldWriteManifestEnvelopeAndCiphertextIntoBackupPackage() throws Exception { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setOutputDir(tempDir.toString()); + Path payloadFile = writePayloadFile("resource/application.yml", "server.port=8080"); + 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[]{0x11, 0x22, 0x33})); + Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) + .thenAnswer(invocation -> { + EccInternalDecryptRequest request = invocation.getArgument(0); + return backup(request.getCipherBlob()); + }); + Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any()); + ResourcePackageService service = new ResourcePackageServiceImpl( + properties, + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + new ObjectMapper() + ); + + ResourcePackageResult result = service.createPackage( + "RBKP-TASK-20260413-000001", + "RBKP-20260413-153000-000001", + sampleInitTask(), + List.of(resource("TMS_CONFIG", payloadFile.toString())), + sampleRequest() + ); + + Path packagePath = Path.of(result.getPackagePath()); + Assertions.assertTrue(Files.exists(packagePath)); + Assertions.assertTrue(result.getPackageName().endsWith(".tmsbak")); + Assertions.assertTrue(result.getPackageSize() > 0); + Assertions.assertFalse(result.getPackageHash().isBlank()); + Assertions.assertFalse(result.getStorageEncKeyFingerprint().isBlank()); + Assertions.assertFalse(result.getAuthenticityKeyFingerprint().isBlank()); + Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":1")); + + try (ZipFile zipFile = new ZipFile(packagePath.toFile())) { + Assertions.assertNotNull(zipFile.getEntry("manifest.json")); + Assertions.assertNotNull(zipFile.getEntry("envelope.json")); + Assertions.assertNotNull(zipFile.getEntry("payload.enc")); + Assertions.assertNotNull(zipFile.getEntry("signature.sig")); + } + + ResourcePackageInspectResult inspectResult = service.inspectPackage(packagePath); + Assertions.assertEquals("RBKP-20260413-153000-000001", inspectResult.getManifest().get("backupId")); + Assertions.assertNotNull(inspectResult.getPayloadArchiveBytes()); + Assertions.assertTrue(inspectResult.getPayloadArchiveBytes().length > 0); + Assertions.assertEquals("ZIP", inspectResult.getPayload().get("archiveFormat")); + Assertions.assertTrue(inspectResult.getPayload().containsKey("resources")); + + @SuppressWarnings("unchecked") + java.util.List> resources = (java.util.List>) inspectResult.getPayload().get("resources"); + Assertions.assertEquals(1, resources.size()); + Assertions.assertEquals(Boolean.TRUE, resources.get(0).get("collected")); + Assertions.assertTrue(String.valueOf(resources.get(0).get("entryPath")).contains("application.yml")); + + try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) { + java.util.Map entries = new java.util.LinkedHashMap<>(); + java.util.zip.ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + entries.put(entry.getName(), new String(zipInputStream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } + Assertions.assertTrue(entries.containsKey("payload-manifest.json")); + Assertions.assertTrue(entries.values().stream().anyMatch(value -> value.contains("server.port=8080"))); + } + } + + 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 Path writePayloadFile(String relativePath, String content) throws Exception { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + return file; + } + + private static CreateResourceBackupRequest sampleRequest() { + CreateResourceBackupRequest request = new CreateResourceBackupRequest(); + request.setIncludeUpgradeHistory(true); + request.setIncludeAuditBackups(false); + request.setRemark("before hardware replacement"); + return request; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java new file mode 100644 index 0000000..da14e8e --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java @@ -0,0 +1,157 @@ +package com.cisd.tms.modules.backup.repository; + +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; +import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper; +import com.cisd.tms.modules.backup.mapper.ResourceRestoreTaskMapper; +import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper; +import com.cisd.tms.modules.backup.repository.impl.ResourceBackupTaskRepositoryImpl; +import com.cisd.tms.modules.backup.repository.impl.ResourceRestoreTaskRepositoryImpl; +import com.cisd.tms.modules.backup.repository.impl.ResourceTaskStepRepositoryImpl; +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import org.mockito.Mockito; + +class ResourceTaskRepositoryTest { + + @Test + void shouldSaveAndQueryBackupTask() { + ResourceBackupTaskMapper mapper = Mockito.mock(ResourceBackupTaskMapper.class); + ResourceBackupTaskRepository repository = new ResourceBackupTaskRepositoryImpl(mapper); + ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity(); + entity.setTaskId("RBKP-001"); + entity.setBackupId("RBACKUP-001"); + entity.setStatus("PENDING"); + entity.setProductType("DIRECT"); + entity.setPackagePath("/home/tms/tmp/resource-backup/RBKP-001/package.tmsbak"); + entity.setManifestSummaryJson("{\"resourceCount\":3}"); + entity.setErrorCode("NONE"); + entity.setRemark("nightly"); + + repository.save(entity); + + Mockito.verify(mapper).insert(entity); + Mockito.when(mapper.selectByTaskId("RBKP-001")).thenReturn(entity); + + ResourceBackupTaskEntity found = repository.findByTaskId("RBKP-001").orElseThrow(); + + Assertions.assertEquals("RBKP-001", found.getTaskId()); + Assertions.assertEquals("RBACKUP-001", found.getBackupId()); + Assertions.assertEquals("/home/tms/tmp/resource-backup/RBKP-001/package.tmsbak", found.getPackagePath()); + Assertions.assertEquals("{\"resourceCount\":3}", found.getManifestSummaryJson()); + Assertions.assertEquals("NONE", found.getErrorCode()); + Assertions.assertEquals("nightly", found.getRemark()); + } + + @Test + void shouldSaveAndQueryRestoreTask() { + ResourceRestoreTaskMapper mapper = Mockito.mock(ResourceRestoreTaskMapper.class); + ResourceRestoreTaskRepository repository = new ResourceRestoreTaskRepositoryImpl(mapper); + ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity(); + entity.setTaskId("RRST-001"); + entity.setPrecheckId("RPRE-001"); + 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.setSourceProductType("DIRECT"); + entity.setTargetProductType("DIRECT"); + entity.setConsistencyReportJson("{\"ok\":true}"); + entity.setErrorCode("NONE"); + + repository.save(entity); + + Mockito.verify(mapper).insert(entity); + Mockito.when(mapper.selectByTaskId("RRST-001")).thenReturn(entity); + + ResourceRestoreTaskEntity found = repository.findByTaskId("RRST-001").orElseThrow(); + + 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("DIRECT", found.getSourceProductType()); + Assertions.assertEquals("DIRECT", found.getTargetProductType()); + Assertions.assertEquals("{\"ok\":true}", found.getConsistencyReportJson()); + Assertions.assertEquals("NONE", found.getErrorCode()); + } + + @Test + void shouldSaveAndQueryTaskStepsByTaskId() { + ResourceTaskStepMapper mapper = Mockito.mock(ResourceTaskStepMapper.class); + ResourceTaskStepRepository repository = new ResourceTaskStepRepositoryImpl(mapper); + ResourceTaskStepEntity entity = new ResourceTaskStepEntity(); + entity.setTaskId("RRST-001"); + entity.setTaskType("RESOURCE_RESTORE"); + entity.setStepNo(1); + entity.setStepCode("STOP_SERVICES"); + entity.setStatus("SUCCESS"); + entity.setLogPath("/home/tms/tmp/resource-restore/RRST-001/steps/01-STOP_SERVICES.log"); + entity.setExitCode(0); + + repository.save(entity); + + Mockito.verify(mapper).insert(entity); + Mockito.when(mapper.selectByTaskIdOrderByStepNo("RRST-001")).thenReturn(List.of(entity)); + + List steps = repository.findByTaskIdOrderByStepNo("RRST-001"); + + 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(0, steps.get(0).getExitCode()); + } + + @Test + void shouldDeclareExplicitSelectColumnsInMappers() throws Exception { + assertSelectSql( + ResourceBackupTaskMapper.class.getMethod("selectByTaskId", String.class), + "tms_resource_backup_task", + "task_id", + "backup_id", + "package_path", + "manifest_summary_json", + "error_code", + "remark" + ); + assertSelectSql( + ResourceRestoreTaskMapper.class.getMethod("selectByTaskId", String.class), + "tms_resource_restore_task", + "task_id", + "precheck_id", + "package_file_id", + "staging_dir", + "runner_state_path", + "consistency_report_json", + "error_code" + ); + assertSelectSql( + ResourceTaskStepMapper.class.getMethod("selectByTaskIdOrderByStepNo", String.class), + "tms_resource_task_step", + "task_id", + "task_type", + "step_no", + "step_code", + "log_path", + "exit_code" + ); + } + + private static void assertSelectSql(Method method, String tableName, String... requiredColumns) { + Select select = method.getAnnotation(Select.class); + Assertions.assertNotNull(select, method.getName() + " should declare @Select"); + String sql = String.join(" ", select.value()); + Assertions.assertFalse(sql.contains("*"), method.getName() + " should not use SELECT *"); + Assertions.assertTrue(sql.contains(tableName), method.getName() + " should target " + tableName); + for (String requiredColumn : requiredColumns) { + Assertions.assertTrue(sql.contains(requiredColumn), method.getName() + " should include column " + requiredColumn); + } + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java new file mode 100644 index 0000000..c7bb674 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java @@ -0,0 +1,117 @@ +package com.cisd.tms.modules.backup.repository; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class ResourceTaskSchemaContractTest { + + @Test + void shouldExposeResourceTaskSchemaAndEnums() throws Exception { + String v6Sql = readClasspathResource("db/migration/V6__add_resource_backup_restore_tables.sql"); + String v1Sql = readClasspathResource("db/migration/V1__tms_schema_full.sql"); + + assertContainsAll(v6Sql, + "CREATE TABLE IF NOT EXISTS tms_resource_backup_task", + "backup_id VARCHAR(64) NOT NULL", + "package_path VARCHAR(1024) NULL", + "manifest_summary_json JSON NULL", + "error_code VARCHAR(64) NULL", + "remark VARCHAR(1024) NULL", + "CREATE TABLE IF NOT EXISTS tms_resource_restore_task", + "precheck_id VARCHAR(64) NOT NULL", + "package_file_id VARCHAR(64) NOT NULL", + "staging_dir VARCHAR(1024) NULL", + "runner_state_path VARCHAR(1024) NULL", + "source_product_type VARCHAR(16) NOT NULL", + "target_product_type VARCHAR(16) NOT NULL", + "consistency_report_json JSON NULL", + "error_code VARCHAR(64) NULL", + "CREATE TABLE IF NOT EXISTS tms_resource_task_step", + "task_type VARCHAR(32) NOT NULL", + "step_code VARCHAR(64) NOT NULL", + "log_path VARCHAR(1024) NULL", + "exit_code INT NULL"); + + assertContainsAll(v1Sql, + "CREATE TABLE IF NOT EXISTS tms_resource_backup_task", + "backup_id VARCHAR(64) NOT NULL", + "package_path VARCHAR(1024) NULL", + "manifest_summary_json JSON NULL", + "error_code VARCHAR(64) NULL", + "remark VARCHAR(1024) NULL", + "CREATE TABLE IF NOT EXISTS tms_resource_restore_task", + "precheck_id VARCHAR(64) NOT NULL", + "package_file_id VARCHAR(64) NOT NULL", + "staging_dir VARCHAR(1024) NULL", + "runner_state_path VARCHAR(1024) NULL", + "source_product_type VARCHAR(16) NOT NULL", + "target_product_type VARCHAR(16) NOT NULL", + "consistency_report_json JSON NULL", + "error_code VARCHAR(64) NULL", + "CREATE TABLE IF NOT EXISTS tms_resource_task_step", + "task_type VARCHAR(32) NOT NULL", + "step_code VARCHAR(64) NOT NULL", + "log_path VARCHAR(1024) NULL", + "exit_code INT NULL"); + + assertEnumConstants( + "com.cisd.tms.modules.backup.enums.ResourceTaskStatus", + "PENDING", + "PRECHECKING", + "PRECHECK_FAILED", + "READY", + "HANDOFF", + "RUNNING", + "VERIFYING", + "SUCCESS", + "FAILED", + "CANCELLED" + ); + assertEnumConstants( + "com.cisd.tms.modules.backup.enums.ResourceTaskType", + "RESOURCE_BACKUP", + "RESOURCE_RESTORE" + ); + assertEnumConstants( + "com.cisd.tms.modules.backup.enums.ResourceControlPhase", + "TMS_PRECHECK", + "HANDOFF_READY", + "RUNNER_OWNED", + "RECONCILE_PENDING", + "DONE" + ); + assertEnumConstants( + "com.cisd.tms.modules.backup.enums.ResourceStepStatus", + "PENDING", + "RUNNING", + "SUCCESS", + "SUCCESS_WITH_WARNINGS", + "FAILED", + "SKIPPED" + ); + } + + private static String readClasspathResource(String path) throws Exception { + ClassPathResource resource = new ClassPathResource(path); + Assertions.assertTrue(resource.exists(), "Missing classpath resource: " + path); + return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + private static void assertContainsAll(String text, String... expectedSnippets) { + for (String expectedSnippet : expectedSnippets) { + Assertions.assertTrue(text.contains(expectedSnippet), "Missing snippet: " + expectedSnippet); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void assertEnumConstants(String className, String... expectedNames) throws Exception { + Class enumClass = Class.forName(className); + Assertions.assertTrue(enumClass.isEnum(), className + " should be an enum"); + Class typedEnumClass = (Class) enumClass.asSubclass(Enum.class); + for (String expectedName : expectedNames) { + Enum.valueOf(typedEnumClass, expectedName); + } + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java new file mode 100644 index 0000000..bdd88d9 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/PayloadArchiveRunnerStepExecutorTest.java @@ -0,0 +1,174 @@ +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.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 shouldExtractPayloadArchiveIntoStagingDirectory() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResourcePackageService packageService = new ResourcePackageServiceImpl( + backupProperties(), + new ResourceEnvelopeCryptoServiceImpl(mockCrypto(), backupProperties()), + objectMapper + ); + Path sourceFile = writeFile("source/application.yml", "server.port=8088"); + 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.toString())), + new CreateResourceBackupRequest() + ).getPackagePath(); + + 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("payload extracted")); + Assertions.assertTrue(Files.exists(taskDir.resolve("staging/payload/payload-manifest.json"))); + Assertions.assertEquals( + "server.port=8088", + Files.readString(taskDir.resolve("staging/payload/resources/TMS_CONFIG/application.yml")) + ); + } + + @Test + void shouldVerifyExtractedPayloadManifest() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResourcePackageService packageService = new ResourcePackageServiceImpl( + backupProperties(), + new ResourceEnvelopeCryptoServiceImpl(mockCrypto(), backupProperties()), + objectMapper + ); + Path taskDir = tempDir.resolve("restore-task-verify"); + Files.createDirectories(taskDir.resolve("staging/payload")); + Files.writeString(taskDir.resolve("staging/payload/payload-manifest.json"), "{\"resources\":[{\"resourceCode\":\"TMS_CONFIG\"}]}"); + Files.writeString(taskDir.resolve("handoff.json"), "{}"); + + PayloadArchiveRunnerStepExecutor executor = new PayloadArchiveRunnerStepExecutor( + new HandoffFileStore(objectMapper), + packageService, + objectMapper + ); + + RunnerStepExecutionResult result = executor.execute(new RunnerStepContext( + "RRST-TASK-20260414-000002", + "CONSISTENCY_VERIFY", + 3, + taskDir, + taskDir.resolve("handoff.json"), + taskDir.resolve("restore-state.json") + )); + + Assertions.assertEquals("SUCCESS", result.getResult()); + Assertions.assertTrue(result.getSummary().contains("resourceCount=1")); + } + + 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; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerTest.java new file mode 100644 index 0000000..293f830 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/ResourceRestoreRunnerTest.java @@ -0,0 +1,148 @@ +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 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()); + } + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/RestoreStateReconcilerTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/RestoreStateReconcilerTest.java new file mode 100644 index 0000000..55071dd --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/RestoreStateReconcilerTest.java @@ -0,0 +1,62 @@ +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 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(); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvokerTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvokerTest.java new file mode 100644 index 0000000..a79984d --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/RunnerCommandInvokerTest.java @@ -0,0 +1,29 @@ +package com.cisd.tms.modules.backup.runner; + +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 RunnerCommandInvokerTest { + + @TempDir + Path tempDir; + + @Test + void shouldExecuteCommandAndAppendLog() throws Exception { + ProcessBuilderRunnerCommandInvoker invoker = new ProcessBuilderRunnerCommandInvoker(); + Path logFile = tempDir.resolve("steps/01-STOP_SERVICES.log"); + + int exitCode = invoker.run( + List.of("/bin/zsh", "-lc", "printf 'hello-runner'"), + logFile, + tempDir + ); + + Assertions.assertEquals(0, exitCode); + Assertions.assertEquals("hello-runner", Files.readString(logFile)); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/RunnerStallDetectorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/RunnerStallDetectorTest.java new file mode 100644 index 0000000..e220803 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/RunnerStallDetectorTest.java @@ -0,0 +1,53 @@ +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; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutorTest.java b/src/test/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutorTest.java new file mode 100644 index 0000000..38d44cc --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/runner/ServiceCommandRunnerStepExecutorTest.java @@ -0,0 +1,145 @@ +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> commands = new ArrayList<>(); + private final List exitCodes = new ArrayList<>(); + + @Override + public int run(List command, Path logFile, Path workingDirectory) { + commands.add(List.copyOf(command)); + if (exitCodes.isEmpty()) { + return 0; + } + return exitCodes.remove(0); + } + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupDownloadServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupDownloadServiceTest.java new file mode 100644 index 0000000..db95335 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupDownloadServiceTest.java @@ -0,0 +1,51 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl; +import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult; +import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; +import java.nio.charset.StandardCharsets; +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.Mockito; + +class ResourceBackupDownloadServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldLoadBackupPackageForBrowserDownload() throws Exception { + Path packageFile = tempDir.resolve("RBKP-20260413-153000-000001.tmsbak"); + Files.writeString(packageFile, "backup-payload", StandardCharsets.UTF_8); + + ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity(); + entity.setTaskId("RBKP-TASK-20260413-000001"); + entity.setPackageName("RBKP-20260413-153000-000001.tmsbak"); + entity.setPackagePath(packageFile.toString()); + + ResourceBackupTaskRepository repository = Mockito.mock(ResourceBackupTaskRepository.class); + Mockito.when(repository.findByTaskId("RBKP-TASK-20260413-000001")).thenReturn(Optional.of(entity)); + + ResourceBackupService service = new ResourceBackupServiceImpl( + Mockito.mock(com.cisd.tms.modules.init.repository.InitTaskRepository.class), + Mockito.mock(com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService.class), + Mockito.mock(com.cisd.tms.modules.backup.packagex.ResourcePackageService.class), + repository, + new ResourceTaskIdGenerator(), + new ResourceBackupProperties() + ); + + ResourceBackupDownloadResult result = service.downloadBackupPackage("RBKP-TASK-20260413-000001"); + + Assertions.assertEquals("RBKP-20260413-153000-000001.tmsbak", result.getFilename()); + Assertions.assertEquals(packageFile.toAbsolutePath().normalize(), result.getPackagePath()); + Assertions.assertEquals(Files.size(packageFile), result.getContentLength()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java new file mode 100644 index 0000000..0300e6c --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java @@ -0,0 +1,111 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse; +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.packagex.ResourcePackageResult; +import com.cisd.tms.modules.backup.packagex.ResourcePackageService; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl; +import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import com.cisd.tms.modules.init.repository.InitTaskRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ResourceBackupServiceTest { + + @Test + void shouldCreateBackupTaskFromLatestSuccessfulInitSnapshot() { + InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class); + ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class); + ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class); + ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class); + + InitTaskEntity initTask = sampleInitTask(); + List discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config")); + ResourcePackageResult packageResult = new ResourcePackageResult(); + packageResult.setPackageName("RBKP-20260413-153000-000001.tmsbak"); + packageResult.setPackagePath("/tmp/resource-backup/RBKP-TASK-20260413-000001/RBKP-20260413-153000-000001.tmsbak"); + packageResult.setPackageSize(2048L); + packageResult.setPackageHash("SM3-HASH"); + packageResult.setStorageEncKeyFingerprint("LOCAL-DEV-STORAGE-KEY"); + packageResult.setAuthenticityKeyFingerprint("LOCAL-DEV-AUTH-KEY"); + packageResult.setManifestSummaryJson("{\"resourceCount\":1}"); + + Mockito.when(initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")).thenReturn(Optional.of(initTask)); + Mockito.when(resourceDiscoveryService.discover(initTask)).thenReturn(discoveredItems); + Mockito.when(resourcePackageService.createPackage( + Mockito.anyString(), + Mockito.anyString(), + Mockito.same(initTask), + Mockito.eq(discoveredItems), + Mockito.any(CreateResourceBackupRequest.class) + )).thenReturn(packageResult); + + ResourceBackupService service = new ResourceBackupServiceImpl( + initTaskRepository, + resourceDiscoveryService, + resourcePackageService, + resourceBackupTaskRepository, + new ResourceTaskIdGenerator(), + new ResourceBackupProperties() + ); + + CreateResourceBackupRequest request = new CreateResourceBackupRequest(); + request.setIncludeUpgradeHistory(true); + request.setIncludeAuditBackups(false); + request.setRemark("before hardware replacement"); + + CreateResourceBackupResponse response = service.createBackupTask(request); + + Assertions.assertTrue(response.getTaskId().startsWith("RBKP-TASK-")); + Assertions.assertTrue(response.getBackupId().startsWith("RBKP-")); + Assertions.assertEquals("SUCCESS", response.getStatus()); + Assertions.assertNotNull(response.getCreatedAt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class); + Mockito.verify(resourceBackupTaskRepository).save(captor.capture()); + ResourceBackupTaskEntity saved = captor.getValue(); + Assertions.assertEquals(response.getTaskId(), saved.getTaskId()); + Assertions.assertEquals(response.getBackupId(), saved.getBackupId()); + Assertions.assertEquals("SUCCESS", saved.getStatus()); + Assertions.assertEquals("ENTERPRISE", saved.getProductType()); + Assertions.assertEquals("RABBITMQ", saved.getMqType()); + Assertions.assertEquals("AAAABBBBXXX", saved.getOrgCode()); + Assertions.assertEquals("before hardware replacement", saved.getRemark()); + Assertions.assertEquals(packageResult.getPackagePath(), saved.getPackagePath()); + Assertions.assertEquals(packageResult.getPackageHash(), saved.getPackageHash()); + Assertions.assertEquals(packageResult.getManifestSummaryJson(), saved.getManifestSummaryJson()); + Assertions.assertEquals(packageResult.getStorageEncKeyFingerprint(), saved.getStorageEncKeyFingerprint()); + Assertions.assertEquals(packageResult.getAuthenticityKeyFingerprint(), saved.getAuthenticityKeyFingerprint()); + } + + private static InitTaskEntity sampleInitTask() { + InitTaskEntity entity = new InitTaskEntity(); + entity.setTaskId("INIT-20260408-000001"); + entity.setTaskType("INIT"); + entity.setStatus("SUCCESS"); + entity.setProductType("ENTERPRISE"); + entity.setMqType("RABBITMQ"); + entity.setOrgCode("AAAABBBBXXX"); + entity.setDeployMode("SINGLE"); + return entity; + } + + private static DiscoveredResourceItem resource(String code, String path) { + DiscoveredResourceItem item = new DiscoveredResourceItem(); + item.setResourceType("FILE"); + item.setResourceCode(code); + item.setResolvedPath(path); + item.setRequired(true); + return item; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckServiceTest.java new file mode 100644 index 0000000..87efa5f --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckServiceTest.java @@ -0,0 +1,178 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.common.config.properties.CisdPresetProperties; +import com.cisd.tms.common.exception.BizException; +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.CreateResourceBackupRequest; +import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +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.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 com.cisd.tms.modules.init.entity.InitTaskEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +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 ResourcePrecheckServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldOnlyVerifyHeaderWithoutDecryptingPayload() { + FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); + ResourceBackupProperties backupProperties = new ResourceBackupProperties(); + backupProperties.setOutputDir(tempDir.toString()); + backupProperties.setStorageEncKeyIndex(7); + backupProperties.setAuthenticitySignKeyIndex(9); + PcieCryptoService pcieCryptoService = mockCrypto(); + ResourcePackageService packageService = new ResourcePackageServiceImpl( + backupProperties, + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, backupProperties), + new ObjectMapper() + ); + ResourcePackageResult packageResult = packageService.createPackage( + "RBKP-TASK-20260414-000001", + "RBKP-20260414-000001", + sampleInitTask(), + List.of(resource("TMS_CONFIG", "/home/tms/config")), + new CreateResourceBackupRequest() + ); + FileRecordEntity fileRecordEntity = new FileRecordEntity(); + fileRecordEntity.setFileId("FILE-20260414-000001"); + fileRecordEntity.setStoragePath(packageResult.getPackagePath()); + Mockito.when(fileRecordRepository.findByFileId("FILE-20260414-000001")).thenReturn(Optional.of(fileRecordEntity)); + + CisdPresetProperties presetProperties = new CisdPresetProperties(); + presetProperties.setProductType("ENTERPRISE"); + ResourcePrecheckService service = new ResourcePrecheckServiceImpl( + fileRecordRepository, + packageService, + new InMemoryResourcePrecheckStateStore(), + new ResourceTaskIdGenerator(), + presetProperties, + backupProperties + ); + + ResourceRestorePrecheckResponse response = service.precheck(sampleRequest()); + + Assertions.assertTrue(response.getSignatureValid()); + Assertions.assertTrue(response.getKeysetMatched()); + Assertions.assertFalse(response.getPayloadDecrypted()); + Assertions.assertInstanceOf(java.util.Map.class, response.getRestoreScopeSummary()); + Assertions.assertTrue(response.getRestoreScopeSummary().toString().contains("resourceCount")); + } + + @Test + void shouldRejectRestoreWhenKeyFingerprintDoesNotMatch() { + FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); + ResourceBackupProperties backupProperties = new ResourceBackupProperties(); + backupProperties.setOutputDir(tempDir.toString()); + backupProperties.setStorageEncKeyIndex(1); + backupProperties.setAuthenticitySignKeyIndex(1); + PcieCryptoService pcieCryptoService = mockCrypto(); + ResourcePackageService packageService = new ResourcePackageServiceImpl( + backupProperties, + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, backupProperties), + new ObjectMapper() + ); + ResourcePackageResult packageResult = packageService.createPackage( + "RBKP-TASK-20260414-000002", + "RBKP-20260414-000002", + sampleInitTask(), + List.of(resource("TMS_CONFIG", "/home/tms/config")), + new CreateResourceBackupRequest() + ); + FileRecordEntity fileRecordEntity = new FileRecordEntity(); + fileRecordEntity.setFileId("FILE-20260414-000002"); + fileRecordEntity.setStoragePath(packageResult.getPackagePath()); + Mockito.when(fileRecordRepository.findByFileId("FILE-20260414-000002")).thenReturn(Optional.of(fileRecordEntity)); + + CisdPresetProperties presetProperties = new CisdPresetProperties(); + presetProperties.setProductType("ENTERPRISE"); + ResourceBackupProperties mismatched = new ResourceBackupProperties(); + mismatched.setStorageEncKeyIndex(3); + mismatched.setAuthenticitySignKeyIndex(5); + ResourcePrecheckService service = new ResourcePrecheckServiceImpl( + fileRecordRepository, + packageService, + new InMemoryResourcePrecheckStateStore(), + new ResourceTaskIdGenerator(), + presetProperties, + mismatched + ); + + BizException exception = Assertions.assertThrows(BizException.class, () -> service.precheck(sampleMismatchRequest())); + Assertions.assertTrue(exception.getMessage().contains("指纹")); + Assertions.assertTrue(exception.getMessage().contains("不匹配")); + } + + private ResourceRestorePrecheckRequest sampleRequest() { + ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest(); + request.setFileId("FILE-20260414-000001"); + return request; + } + + private ResourceRestorePrecheckRequest sampleMismatchRequest() { + ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest(); + request.setFileId("FILE-20260414-000002"); + return request; + } + + 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.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; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java new file mode 100644 index 0000000..23d6e3c --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java @@ -0,0 +1,104 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; +import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.file.entity.FileRecordEntity; +import com.cisd.tms.modules.file.repository.FileRecordRepository; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl; +import com.cisd.tms.modules.backup.support.ResourcePrecheckContext; +import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; +import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore; +import com.cisd.tms.modules.backup.support.file.HandoffFileStore; +import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ResourceRestoreHandoffServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldCreateRestoreTaskAndWriteHandoffFiles() throws Exception { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString()); + properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString()); + FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper()); + ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); + FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); + ObjectMapper objectMapper = new ObjectMapper(); + Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak"); + Files.createDirectories(packagePath.getParent()); + Files.writeString(packagePath, "package-bytes"); + + ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse(); + precheckResponse.setPrecheckId("RPRE-20260413-000001"); + precheckResponse.setBackupId("RBKP-20260413-153000-000001"); + precheckResponse.setSourceProductType("ENTERPRISE"); + precheckResponse.setTargetProductType("ENTERPRISE"); + precheckResponse.setKeysetMatched(true); + precheckResponse.setSignatureValid(true); + precheckResponse.setCompatible(true); + precheckResponse.setRestorePlan(java.util.List.of("STOP_SERVICES", "RESTORE_DATABASES", "START_SERVICES")); + store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse)); + FileRecordEntity fileRecordEntity = new FileRecordEntity(); + fileRecordEntity.setFileId("FILE-20260413-000001"); + fileRecordEntity.setStoragePath(packagePath.toString()); + Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001")) + .thenReturn(java.util.Optional.of(fileRecordEntity)); + + ResourceRestoreService service = new ResourceRestoreServiceImpl( + repository, + fileRecordRepository, + store, + new ResourceTaskIdGenerator(), + new HandoffFileStore(objectMapper), + new ResourceRestoreStateFileStore(objectMapper), + properties + ); + + CreateResourceRestoreRequest request = new CreateResourceRestoreRequest(); + request.setPrecheckId("RPRE-20260413-000001"); + request.setConfirmBackupId("RBKP-20260413-153000-000001"); + request.setConfirmRiskAccepted(true); + + CreateResourceRestoreResponse response = service.createRestoreTask(request); + + Path taskDir = tempDir.resolve("resource-restore").resolve(response.getTaskId()); + Path handoffFile = taskDir.resolve("handoff.json"); + Path stateFile = taskDir.resolve("restore-state.json"); + Assertions.assertTrue(Files.exists(handoffFile)); + Assertions.assertTrue(Files.exists(stateFile)); + + JsonNode handoff = objectMapper.readTree(Files.readString(handoffFile)); + Assertions.assertEquals(response.getTaskId(), handoff.path("taskId").asText()); + Assertions.assertEquals("RBKP-20260413-153000-000001", handoff.path("backupId").asText()); + Assertions.assertEquals("FILE-20260413-000001", handoff.path("packageFileId").asText()); + Assertions.assertEquals(packagePath.toString(), handoff.path("packagePath").asText()); + + JsonNode state = objectMapper.readTree(Files.readString(stateFile)); + Assertions.assertEquals("HANDOFF_READY", state.path("controlPhase").asText()); + Assertions.assertEquals("HANDOFF", state.path("status").asText()); + Assertions.assertEquals("RESTORE_LOCK", state.path("currentStepCode").asText()); + Assertions.assertEquals(1, state.path("currentStepNo").asInt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceRestoreTaskEntity.class); + Mockito.verify(repository).save(captor.capture()); + ResourceRestoreTaskEntity saved = captor.getValue(); + Assertions.assertEquals(stateFile.toString(), saved.getRunnerStatePath()); + Assertions.assertTrue(saved.getStagingDir().contains(response.getTaskId())); + Assertions.assertEquals("HANDOFF", saved.getStatus()); + Assertions.assertEquals(packagePath.toString(), saved.getPackagePath()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreSupportServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreSupportServiceTest.java new file mode 100644 index 0000000..1c1af67 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreSupportServiceTest.java @@ -0,0 +1,241 @@ +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 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; + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java new file mode 100644 index 0000000..c46e67b --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java @@ -0,0 +1,158 @@ +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 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()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStoreTest.java b/src/test/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStoreTest.java new file mode 100644 index 0000000..9e189af --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/support/file/FileSystemResourcePrecheckStateStoreTest.java @@ -0,0 +1,35 @@ +package com.cisd.tms.modules.backup.support.file; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; +import com.cisd.tms.modules.backup.support.ResourcePrecheckContext; +import com.fasterxml.jackson.databind.ObjectMapper; +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 FileSystemResourcePrecheckStateStoreTest { + + @TempDir + Path tempDir; + + @Test + void shouldPersistAndReloadPrecheckContext() { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString()); + FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper()); + + ResourceRestorePrecheckResponse response = new ResourceRestorePrecheckResponse(); + response.setPrecheckId("RPRE-20260413-000001"); + response.setBackupId("RBKP-20260413-153000-000001"); + response.setSourceProductType("ENTERPRISE"); + + store.save(new ResourcePrecheckContext("FILE-20260413-000001", response)); + + ResourcePrecheckContext reloaded = store.find("RPRE-20260413-000001").orElseThrow(); + Assertions.assertEquals("FILE-20260413-000001", reloaded.getFileId()); + Assertions.assertEquals("RBKP-20260413-153000-000001", reloaded.getResponse().getBackupId()); + Assertions.assertEquals("ENTERPRISE", reloaded.getResponse().getSourceProductType()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStoreTest.java b/src/test/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStoreTest.java new file mode 100644 index 0000000..54b75b7 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/support/file/ResourceRestoreStateFileStoreTest.java @@ -0,0 +1,35 @@ +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()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/mk/controller/LmkControllerTest.java b/src/test/java/com/cisd/tms/modules/mk/controller/LmkControllerTest.java new file mode 100644 index 0000000..c6a014c --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/mk/controller/LmkControllerTest.java @@ -0,0 +1,55 @@ +package com.cisd.tms.modules.mk.controller; + +import com.cisd.tms.common.exception.GlobalExceptionHandler; +import com.cisd.tms.modules.mk.service.LmkService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LmkControllerTest { + + @Test + void shouldRejectRecoverPacketsWithPacketIndexesOtherThanOneAndTwo() throws Exception { + LmkService lmkService = Mockito.mock(LmkService.class); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new LmkController(lmkService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + + mockMvc.perform(post("/api/v1/masterKey/recover") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "packets": [ + { + "packetIndex": 1, + "lmk": { + "lmkMac": "1112131415161718", + "components": { + "1": "010203040506", + "2": "0708090a0b0c" + } + } + }, + { + "packetIndex": 3, + "lmk": { + "lmkMac": "1112131415161718", + "components": { + "2": "0708090a0b0c", + "3": "131415161718" + } + } + } + ] + } + """)) + .andExpect(status().isOk()); + + Mockito.verifyNoInteractions(lmkService); + } +}